只读代理保护的是"对象图",不是"权限图"。你能保证攻击者改不了属性,保证不了他调用一个属性之后,代码跑在谁的进程里。
vm2 CVE-2026-92939:一个 crypto.setEngine 如何击穿 Node.js 沙箱
TL;DR
- 2026-09-17,VulnCheck 披露 vm2 沙箱逃逸漏洞 CVE-2026-92939(GHSA-46pr-c5wc-xffx):3.11.3 ~ 3.11.6 受影响,3.11.7 修复。CVSS v3.1 9.9、v4.0 9.4,CWE-114(Process Control)。
- 利用条件低得离谱:沙箱里只需要暴露一个
cryptobuiltin,不需要fs、process、module、child_process、worker_threads、vm、inspector中任何一个。 - 根因:vm2 用递归只读代理把宿主 crypto 模块喂给 NodeVM——只读代理防住了"属性篡改",却防不住"方法调用"。而
crypto.setEngine()的每个可调用导出,都以宿主进程权限执行。 - 利用链一句话:
crypto.setEngine("/path/to/evil.so")→ OpenSSL 请求操作系统动态加载器dlopen该文件 → so 的构造函数(__attribute__((constructor)))在宿主进程内执行 → 此时引擎符号校验才拒绝,但已经晚了——原生代码已经在跑。 - vm2 在 2023 年曾因逃逸漏洞潮宣布停止维护,这次 3.11.7 的补丁说明它仍被广泛部署、仍有人接手修补。但结论没变:进程内 JS 沙箱不是安全边界,进程隔离才是。要跑真正不可信的代码,用
isolated-vm(V8 Isolate)或容器/microVM。
一、背景:为什么 2026 年还有人在用 vm2
vm2 是 Node.js 生态最著名的进程内 JavaScript 沙箱,API 长这样:
// 典型用法:跑一段"不可信"的用户代码
const { NodeVM } = require("vm2");
const vm = new NodeVM({
console: "inherit",
sandbox: {}, // 注入宿主对象
require: {
external: true,
builtin: ["crypto"], // ← 注意:允许沙箱 require 内置 crypto 模块
},
});
vm.run(`
const hash = require("crypto").createHash("sha256");
hash.update("hello");
console.log(hash.digest("hex"));
`, "user-code.js");它的使用场景在 2026 年不但没有消失,反而变多了:
┌──────────────────────────────────────────────────────────┐
│ 谁在进程内跑"别人的 JS"? │
├──────────────────────────────────────────────────────────┤
│ SaaS 插件系统 :用户上传自定义脚本扩展工作流 │
│ 低代码平台 :表达式/脚本节点由租户编写 │
│ AI Agent 运行时 :LLM 生成的 JS 代码在本地验证执行 │
│ 规则引擎 :风控/营销规则允许运营写 JS 片段 │
│ 在线判题/Playground:教学场景执行用户提交的代码 │
└──────────────────────────────────────────────────────────┘历史包袱要交代清楚:2023 年 vm2 连续被爆出多个逃逸漏洞(经典的 Proxy get trap 绕过、Error.prepareStackTrace 滥用等),作者公开宣布放弃维护,建议迁移到 isolated-vm。此后仍有大量项目带着 vm2 上生产——npm 每周数百万下载不是虚数。这次 CVE 的存在本身说明:3.11.3~3.11.6 这些"停维后仍在流转"的版本,修复窗口有多大。
二、漏洞本体:受影响版本与评分
| 项目 | 内容 |
|---|---|
| CVE | CVE-2026-92939 |
| GHSA | GHSA-46pr-c5wc-xffx |
| 受影响版本 | vm2 >= 3.11.3, < 3.11.6+1(即 3.11.3 ~ 3.11.6) |
| 修复版本 | 3.11.7 |
| CWE | CWE-114: Process Control |
| CVSS v3.1 | 9.9 Critical(AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H) |
| CVSS v4.0 | 9.4 Critical |
| 披露渠道 | VulnCheck,报告者 Forrof,2026-09-17 |
| 利用前提 | NodeVM 配置暴露 crypto builtin(或 sandbox 注入宿主 crypto);攻击者能让一个原生库文件落盘 |
CVSS 向量里 S:C(Scope: Changed)是关键——逃逸类漏洞的标志:影响越过沙箱边界,波及宿主进程。PR:L 说明攻击者只需要普通沙箱代码执行权(这通常就是产品功能本身)。
三、原理深拆:只读代理防得住什么,防不住什么
3.1 vm2 的隔离模型
vm2 的隔离建立在三个机制上:
┌─────────────────────────────────────────────────────────────┐
│ 宿主 Node.js 进程 │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ vm2 NodeVM 沙箱 │ │
│ │ │ │
│ │ 1. 新的 V8 Context → 全局对象与宿主分离 │ │
│ │ 2. 代理化的 require → builtin 按白名单放行 │ │
│ │ 3. 递归只读代理(Proxy) → 注入宿主对象"防篡改" │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ sandbox.crypto ≈ Proxy(crypto) { │ │ │
│ │ │ get: 只读返回,禁止 set/delete │ │ │
│ │ │ 递归:子对象也套代理 │ │ │
│ │ │ } │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ 方法调用(无拦截) │
│ ┌───────▼────────┐ │
│ │ 宿主 crypto 模块 │ ← 可调用导出以宿主权限执行 │
│ └───────┬────────┘ │
│ ┌───────▼────────┐ │
│ │ OpenSSL 引擎 │ ← setEngine 触发 dlopen │
│ └────────────────┘ │
└─────────────────────────────────────────────────────────────┘设计者假设:把宿主对象用只读代理包一层,沙箱代码就"看得到、改不了"。这个假设对属性成立,对方法调用不成立——crypto.createHash(...) 是宿主函数在宿主进程里跑,代理层根本不在调用路径上。只要白名单里出现任何一个"能产生宿主侧副作用"的方法,只读代理就是装饰品。
3.2 crypto.setEngine 是什么
crypto.setEngine() 是 Node.js 对 OpenSSL ENGINE API 的直接暴露,用于让 OpenSSL 用自定义引擎实现加解密(历史上用于硬件加速卡)。它的函数签名:
crypto.setEngine(engine[, flags])
// engine: string | Engine —— 字符串形式就是一个 "引擎 ID" 或 **共享库文件路径**
// flags: 数字,控制接管哪些方法(ENGINE_METHOD_ALL 等)注意 Node.js 官方文档对 setEngine 的描述中有一句关键的话:当传入字符串时,它会被 OpenSSL 当作引擎标识符或共享库的路径处理。OpenSSL 内部会走 ENGINE_by_id() → dlopen() 这条路。
3.3 利用链:dlopen 的构造函数先于一切校验执行
攻击完整时序:
攻击者控制沙箱内 JS(产品功能,如"用户自定义脚本")
│
▼
① 载荷落盘:恶意 .so 已随插件包/上传文件写到磁盘
(无需 fs 权限——文件是宿主应用自己写下去的)
│
▼
② require("crypto") ← 白名单允许,正常放行
│
▼
③ crypto.setEngine("/tmp/.cache/libx.so")
│ 只读代理:不拦截调用 ✗
│ 宿主侧:OpenSSL ENGINE_by_id("…/libx.so")
▼
④ OS 动态加载器 dlopen("/tmp/.cache/libx.so")
│
│ ELF 加载 → 运行 .init/.init_array
▼
⑤ 库构造函数 __attribute__((constructor)) 执行
★ 此刻:宿主进程内,任意原生代码
│
▼
⑥ OpenSSL 校验 ENGINE 符号 → 发现不是合法引擎 → 加载失败
(报错返回,但 ⑤ 已经发生,报错只是"事后通知")这个利用的精妙之处全在 ④→⑤→⑥ 的顺序:dlopen 的语义是"加载即执行初始化代码",而"这是不是一个合法 OpenSSL 引擎"的校验发生在加载之后。所以即使 OpenSSL 最终拒绝了这个引擎、setEngine 抛出错误,宿主进程里的原生代码也已经跑完了——构造函数里可以做任何事:反弹 shell、改内存、安装钩子、把进程凭证读走。
换一种说法:校验逻辑试图在"效果已发生"之后拦截"原因",这类时序错误在安全设计里有个通用名字——TOCTOU 的近亲,只不过这里不是 time-of-check,而是 order-of-check 被塞反了。
3.4 与 2023 年逃逸潮的范式对比
| 维度 | 2023 年经典逃逸 | CVE-2026-92939 |
|---|---|---|
| 根因 | vm2 内部代理/异常处理逻辑缺陷(如 Proxy trap 绕过) | 暴露的宿主 API 本身具有宿主级副作用 |
| 技巧 | 精心构造的 JS 对象图操作 | 一行 crypto.setEngine(path) |
| 需要的能力 | 纯 JS,无需额外条件 | 纯 JS + 载荷文件已在磁盘 |
| 防御思路 | 补 vm2 内部实现 | 收敛 builtin 白名单 / 换进程级隔离 |
范式变了:2023 年的问题可以靠"把 vm2 写对"解决(理论上);这次的教训是——只要把一个拥有宿主级副作用的标准库函数交给沙箱,任何进程内隔离框架都救不了你。同类风险面还包括但不限于:任何接受路径并触发加载/解析的 API、任何回调宿主层的 API。白名单审计的正确粒度是"方法级副作用评审",而不是"模块级信任"。
四、修复与防御清单
4.1 立刻能做的
- 升级 vm2 到 3.11.7(
npm ls vm2确认版本,注意锁文件里的间接依赖)。 - 重新审视 builtin 白名单:把
crypto从require.builtin里去掉,除非业务真的需要在沙箱内做哈希/加密——需要的话,改为在宿主侧提供一个收敛过的桥接函数(只暴露createHash/randomBytes这类无加载语义的方法):
// ❌ 危险配置:整模块直接暴露
const vm = new NodeVM({
require: { builtin: ["crypto"] },
});
// ✅ 收敛配置:宿主侧包一层最小 API
const { createHash, randomBytes, timingSafeEqual } = require("crypto");
const vm = new NodeVM({
sandbox: {
safeCrypto: { // 无 setEngine、无任意路径语义
createHash: (alg) => createHash(String(alg).slice(0, 32)),
randomBytes: (n) => randomBytes(Math.min(Number(n) || 0, 1024)),
},
},
require: { builtin: [] }, // builtin 全关
});- 审计沙箱代码的来源:如果沙箱执行的是用户/插件/AI 生成的代码,攻击者让文件落盘的路径几乎一定存在(上传、npm 依赖、缓存目录)。按"载荷必然可达"来设计防御,不要赌攻击者写不进磁盘。
4.2 中期:把"不可信代码"挪出进程
进程内沙箱(vm2、嵌套 V8 Context)的定位应该是"防误用",不是"防恶意"。真正的恶意代码隔离,边界必须落在进程/内核层:
隔离强度阶梯(低 → 高):
vm2 / new Function → 防误用,不防恶意(本文 CVE 证明)
isolated-vm (V8 Isolate) → 独立堆 + 受控桥接,无宿主对象泄漏面
worker 进程 + rlimit → 进程边界,可杀可限
容器 / gVisor / Firecracker → 内核级边界,文件系统/网络/系统调用全收口isolated-vm 的思路是给每个"不可信执行单元"一个独立 V8 Isolate,宿主与沙箱之间只传递显式序列化的数据(ExternalCopy),不存在共享对象代理——本文这种"代理防篡改但方法仍宿主执行"的结构性盲区从模型上就不存在。
配合容器跑更稳:--cap-drop=ALL、只读根文件系统、noexec 挂载上传目录(载荷落盘了也无法被 dlopen 执行)、seccomp 拦截 ptrace/mount。noexec 是这个 CVE 的一个高性价比缓解项——dlopen 一个 noexec 分区上的 so 会直接失败。
4.3 检测侧
- 审计日志里盯
setEngine调用:生产环境沙箱内出现setEngine基本可以按入侵处理。 - eBPF/auditd 监控 Node 进程的非常规
dlopen(来源路径在 tmp/cache/上传目录)。 - 依赖扫描中把
vm2 < 3.11.7标红。
五、写在最后:AI 时代沙箱使用率还在涨
vm2 的使用场景里,2026 年增长最快的一类是 AI Agent 运行时——LLM 生成的代码需要在本地验证执行。这和本站之前讨论过的 AI CLI Agent 供应链投毒、Docker AI Agent 隔离治理 是同一条主线:模型的输出是不可信输入,执行它的环境必须按对抗标准设计。
一个 crypto.setEngine,一行代码,宿主沦陷。沙箱这件事,从来没有"看起来隔离了"和"真的隔离了"之间的中间态。

