作者 akihi(白帽攻防录讲师),某甲方网络安全工程师,合集 SRC 年度第一、腾讯 SRC 连续三年前十,单漏洞赏金 4w+,擅长 Web/App/PC 客户端漏洞挖掘。专注 Node.js 安全、沙箱逃逸、AI Agent 安全审计。本文基于公开 CVE 信息与技术原理深度复盘,供安全研究与防护参考。
一、漏洞时间线
2026 年 8 月 17 日,vm2 项目发布了安全更新,修复了三个严重的沙箱逃逸漏洞。其中最严重的是 CVE-2026-47686(CVSS 9.9),源于对 ES2022 Error.cause 属性清理不完善。当宿主函数抛出包含主机对象的错误链时,沙箱代码可以获取该引用,进而实现远程代码执行。
| 时间 | 事件 | 影响版本 |
|---|---|---|
| 2026-05-08 | 第一个 vm2 沙箱逃逸漏洞披露 | < 3.11.0 |
| 2026-08-17 | vm2 发布 3.11.6,修复三个漏洞 | < 3.11.6 |
| 2026-08-18 | 技术分析和 PoC 公开 | —— |
| 2026-09-09 | 安全厂商发布威胁情报 | —— |
| 2026-09-24 | 确认影响 AI Agent 框架 | —— |
vm2 是一个流行的 Node.js 沙箱库,被广泛用于代码编辑器、自动化工具和 AI Agent 框架。沙箱逃逸漏洞意味着运行在其中的不可信代码可以突破隔离,访问主机系统。
二、攻击链全景
让我们先用一张流程图还原完整的攻击路径:
攻击者在沙箱中运行不可信 JavaScript 代码 │ ▼ 第一步:触发宿主函数抛出错误 │ 调用宿主提供的函数 │ 函数内部抛出 Error ▼ 第二步:错误对象通过错误链传播 │ Error.cause 包含宿主对象 │ handleException() 清理错误属性 ▼ 第三步:Error.cause 未被清理 │ 清理了 SuppressedError.error │ 清理了 AggregateError.errors │ 但遗漏了 Error.cause ▼ 第四步:沙箱代码获取主机对象引用 │ 通过 error.cause 访问主机对象 │ 拿到主机的函数和对象引用 ▼ 第五步:利用主机对象逃逸沙箱 │ 通过引用调用 require() │ 加载 child_process 模块 ▼ 攻击者在主机上执行任意命令 │ ├─ 读取服务器文件和配置 ├─ 访问内部网络资源 └─ 植入后门持久化关键结论:这个漏洞的核心在于"错误对象清理不完整"。vm2 的 handleException() 函数清理了大部分危险的错误属性,但遗漏了 ES2022 新增的 Error.cause 属性。一个被遗漏的属性,导致整个沙箱隔离被突破。
三、沙箱原理
3.1 什么是沙箱
沙箱(Sandbox)是一种安全隔离机制,允许运行不可信代码而不影响主机系统:
- 代码编辑器:在线运行用户提交的代码
- 自动化工具:运行用户自定义的脚本
- AI Agent:运行 LLM 生成的代码
- 插件系统:运行第三方插件代码
vm2 的设计目标是:在 Node.js 中安全地运行不可信 JavaScript 代码,阻止其访问 process、require 等危险对象。
3.2 vm2 的隔离机制
vm2 使用多种技术实现隔离:
- Proxy 代理:对所有主机对象使用 Proxy 代理,拦截属性访问
- 属性过滤:过滤危险属性(如 process、require、constructor)
- 错误清理:当错误从沙箱传播到主机时,清理错误对象中的危险属性
- 原型链保护:阻止访问对象的原型链
3.3 Error.cause 属性
ES2022 新增了 Error.cause 属性,用于指定错误的原因:
// ES2022 Error.cause try { doSomething(); } catch (err) { throw new Error('操作失败', { cause: err }); } // 错误链: // error.message = '操作失败' // error.cause = 原始错误对象 // 可以通过 error.cause 访问原始错误 // 如果 cause 中包含主机对象: throw new Error('失败', { cause: hostObject }); // 那么沙箱代码可以通过 error.cause 访问 hostObject四、漏洞深度分析
4.1 漏洞位置
漏洞位于 vm2 的lib/setup-sandbox.js文件中的handleException()函数:
// vm2/lib/setup-sandbox.js function handleException(e) { // 清理 SuppressedError if (e instanceof SuppressedError) { e.error = crossCheck(e.error); e.suppressed = crossCheck(e.suppressed); } // 清理 AggregateError if (e instanceof AggregateError) { e.errors = e.errors.map(crossCheck); } // 问题:没有清理 Error.cause! // ES2022 新增的 Error.cause 属性被遗漏了 return e; }4.2 crossCheck 函数
vm2 使用crossCheck()函数清理危险属性:
function crossCheck(value) { // 如果是对象,遍历属性 if (value !== null && typeof value === 'object') { for (const key in value) { // 过滤危险属性 if (dangerousProperties.includes(key)) { delete value[key]; } else { // 递归清理 crossCheck(value[key]); } } } return value; } // 问题:crossCheck 只处理明确传入的对象 // 如果 Error.cause 没有被传入 crossCheck, // 那么 cause 中的主机对象就不会被清理4.3 攻击利用
攻击者利用这个漏洞的步骤:
- 在沙箱中运行代码:攻击者提交恶意 JavaScript
- 触发错误:调用宿主函数,触发抛出包含主机对象的错误
- 获取错误对象:catch 到错误,通过 error.cause 获取主机对象
- 访问危险属性:通过主机对象的引用,访问 require、process 等
- 执行命令:require('child_process').exec('恶意命令')
// PoC 概念性代码 // 在沙箱中运行的恶意代码 try { // 触发宿主函数抛出错误 // 错误中包含主机对象 hostFunction(); } catch (e) { // e.cause 中包含未清理的主机对象 const hostObject = e.cause; // 通过主机对象访问 require const require = hostObject.constructor.constructor('return require')(); // 加载 child_process const { exec } = require('child_process'); // 执行任意命令 exec('whoami', (err, stdout) => { console.log(stdout); }); }4.4 为什么 Error.cause 被遗漏
根本原因:Error.cause 是 ES2022 新增的属性。vm2 的错误清理逻辑是在 ES2022 之前编写的,只考虑了当时存在的错误属性(如 SuppressedError.error、AggregateError.errors)。当 JavaScript 语言新增特性时,沙箱的清理逻辑没有及时更新。
这是一个典型的"语言特性演化"导致的安全漏洞——JavaScript 不断新增特性,沙箱的安全逻辑需要跟上。
4.5 关联漏洞
除了 CVE-2026-47686,vm2 还修复了另一个相关漏洞:
| CVE | CVSS | 描述 |
|---|---|---|
| CVE-2026-47686 | 9.9 | Error.cause 属性未清理,主机对象泄露 |
| CVE-2026-47698 | 8.5 | Function.prototype.call 堆叠绕过原型链保护 |
| CVE-2026-47683 | 7.5 | Buffer 分配限制绕过 |
五、修复方案分析
vm2 在 3.11.6 版本中修复了这些漏洞:
| 修复项 | 修复方式 |
|---|---|
| Error.cause 清理 | 在 handleException() 中增加对 Error.cause 的清理 |
| 递归清理 | 对错误链中的所有对象进行递归清理 |
| 全面过滤 | 对所有错误属性进行过滤,包括未来可能新增的 |
5.1 修复代码
// 修复后的 handleException() function handleException(e) { // 清理 SuppressedError if (e instanceof SuppressedError) { e.error = crossCheck(e.error); e.suppressed = crossCheck(e.suppressed); } // 清理 AggregateError if (e instanceof AggregateError) { e.errors = e.errors.map(crossCheck); } // 修复:清理 Error.cause if (e.cause !== undefined) { e.cause = crossCheck(e.cause); } return e; }5.2 替代方案
由于 vm2 频繁出现沙箱逃逸漏洞,社区建议使用更安全的替代方案:
// 1. 使用 Node.js 内置的 vm 模块 const vm = require('vm'); // 注意:vm 模块不是安全沙箱! // 它只是在 V8 上下文中运行代码 // 2. 使用容器隔离 // Docker 容器是更可靠的隔离方式 docker run --rm untrusted-image // 3. 使用 isolate 模块 // 基于 V8 Isolate 的更严格沙箱 const isolate = require('isolate'); // 4. 明确不要用 vm2 运行不可信代码 // 如果必须用,确保升级到最新版本六、SRC 审计启示录
6.1 沙箱逃逸审计清单
在 SRC 挖洞过程中,针对沙箱逃逸的审计清单:
| # | 检查项 | 检测方法 |
|---|---|---|
| 1 | 是否有沙箱运行不可信代码 | 查找代码执行、插件系统等功能 |
| 2 | 沙箱使用的是什么隔离机制 | 检查是否使用 vm2、isolated-vm 等 |
| 3 | 错误对象是否完全清理 | 测试各种错误类型和属性 |
| 4 | 是否有语言特性被遗漏 | 检查 ES6+ 新特性是否被覆盖 |
| 5 | 是否能访问主机对象引用 | 测试通过错误、原型链等方式访问 |
6.2 JavaScript 沙箱绕过技巧
// 常见的沙箱绕过技巧 // 1. 构造函数访问 (function(){}).constructor('return this')() // 返回全局对象 // 2. 错误对象逃逸 try { throw new Error(); } catch (e) { const host = e.constructor.constructor('return process')(); } // 3. Promise 异步逃逸 Promise.resolve().then(() => { // 异步上下文中可能有不同的隔离 }); // 4. 原型链遍历 obj.__proto__.__proto__.constructor // 访问到 Object 构造函数 // 5. Error.cause 逃逸 throw new Error('test', { cause: hostObject });6.3 AI Agent 安全
| 风险点 | 常见问题 | 防护建议 |
|---|---|---|
| 代码执行 | LLM 生成的代码在沙箱中运行 | 使用容器隔离,不依赖 JS 沙箱 |
| 提示注入 | 恶意输入诱导 LLM 生成恶意代码 | 输入验证和过滤 |
| 工具调用 | Agent 调用危险工具 | 最小权限原则 |
| 数据泄露 | 沙箱逃逸后访问主机数据 | 网络隔离和访问控制 |
七、防护建议
- 升级 vm2:升级到 3.11.6 或更高版本
- 容器隔离:使用 Docker 容器隔离不可信代码
- 最小权限:运行代码的进程使用最小权限
- 网络隔离:限制沙箱的网络访问
- 监控告警:对异常的代码执行行为设置告警
- 定期审计:审计沙箱配置和依赖版本
八、总结
CVE-2026-47686 是一个典型的"语言特性演化"导致的沙箱逃逸漏洞。它提醒我们:JavaScript 沙箱的安全不是一劳永逸的,随着语言不断新增特性,沙箱的隔离逻辑需要持续更新。在 SRC 挖洞实践中,沙箱逃逸是一个高价值攻击面,特别是在 AI Agent 兴起的今天,代码执行安全变得越来越重要。