Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

只读代理保护的是"对象图",不是"权限图"。你能保证攻击者改不了属性,保证不了他调用一个属性之后,代码跑在谁的进程里。

vm2 CVE-2026-92939:一个 crypto.setEngine 如何击穿 Node.js 沙箱

TL;DR

  • 2026-09-17,VulnCheck 披露 vm2 沙箱逃逸漏洞 CVE-2026-92939GHSA-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)。
  • 利用条件低得离谱:沙箱里只需要暴露一个 crypto builtin,不需要 fsprocessmodulechild_processworker_threadsvminspector 中任何一个。
  • 根因: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 长这样:

javascript
// 典型用法:跑一段"不可信"的用户代码
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 年不但没有消失,反而变多了:

text
┌──────────────────────────────────────────────────────────┐
│                谁在进程内跑"别人的 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 这些"停维后仍在流转"的版本,修复窗口有多大。

二、漏洞本体:受影响版本与评分

项目内容
CVECVE-2026-92939
GHSAGHSA-46pr-c5wc-xffx
受影响版本vm2 >= 3.11.3, < 3.11.6+1(即 3.11.3 ~ 3.11.6)
修复版本3.11.7
CWECWE-114: Process Control
CVSS v3.19.9 Critical(AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
CVSS v4.09.4 Critical
披露渠道VulnCheck,报告者 Forrof,2026-09-17
利用前提NodeVM 配置暴露 crypto builtin(或 sandbox 注入宿主 crypto);攻击者能让一个原生库文件落盘

CVSS 向量里 S:C(Scope: Changed)是关键——逃逸类漏洞的标志:影响越过沙箱边界,波及宿主进程。PR:L 说明攻击者只需要普通沙箱代码执行权(这通常就是产品功能本身)。

三、原理深拆:只读代理防得住什么,防不住什么

3.1 vm2 的隔离模型

vm2 的隔离建立在三个机制上:

text
┌─────────────────────────────────────────────────────────────┐
│                        宿主 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 用自定义引擎实现加解密(历史上用于硬件加速卡)。它的函数签名:

javascript
crypto.setEngine(engine[, flags])
// engine: string | Engine  —— 字符串形式就是一个 "引擎 ID" 或 **共享库文件路径**
// flags:  数字,控制接管哪些方法(ENGINE_METHOD_ALL 等)

注意 Node.js 官方文档对 setEngine 的描述中有一句关键的话:当传入字符串时,它会被 OpenSSL 当作引擎标识符或共享库的路径处理。OpenSSL 内部会走 ENGINE_by_id()dlopen() 这条路。

3.3 利用链:dlopen 的构造函数先于一切校验执行

攻击完整时序:

text
攻击者控制沙箱内 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 立刻能做的

  1. 升级 vm2 到 3.11.7npm ls vm2 确认版本,注意锁文件里的间接依赖)。
  2. 重新审视 builtin 白名单:把 cryptorequire.builtin 里去掉,除非业务真的需要在沙箱内做哈希/加密——需要的话,改为在宿主侧提供一个收敛过的桥接函数(只暴露 createHash/randomBytes 这类无加载语义的方法):
javascript
// ❌ 危险配置:整模块直接暴露
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 全关
});
  1. 审计沙箱代码的来源:如果沙箱执行的是用户/插件/AI 生成的代码,攻击者让文件落盘的路径几乎一定存在(上传、npm 依赖、缓存目录)。按"载荷必然可达"来设计防御,不要赌攻击者写不进磁盘。

4.2 中期:把"不可信代码"挪出进程

进程内沙箱(vm2、嵌套 V8 Context)的定位应该是"防误用",不是"防恶意"。真正的恶意代码隔离,边界必须落在进程/内核层:

text
隔离强度阶梯(低 → 高):

vm2 / new Function      →  防误用,不防恶意(本文 CVE 证明)
isolated-vm (V8 Isolate) →  独立堆 + 受控桥接,无宿主对象泄漏面
worker 进程 + rlimit     →  进程边界,可杀可限
容器 / gVisor / Firecracker → 内核级边界,文件系统/网络/系统调用全收口

isolated-vm 的思路是给每个"不可信执行单元"一个独立 V8 Isolate,宿主与沙箱之间只传递显式序列化的数据(ExternalCopy),不存在共享对象代理——本文这种"代理防篡改但方法仍宿主执行"的结构性盲区从模型上就不存在。

配合容器跑更稳:--cap-drop=ALL、只读根文件系统、noexec 挂载上传目录(载荷落盘了也无法被 dlopen 执行)、seccomp 拦截 ptrace/mountnoexec 是这个 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,一行代码,宿主沦陷。沙箱这件事,从来没有"看起来隔离了"和"真的隔离了"之间的中间态。

参考资料

上次更新于: