Node.js-vm/vm2沙箱逃逸
0x01 写在前面
该文同时发布在”Zer0day安全”微信公众号,也是我第一次写公众号文章,欢迎师傅们多多关注Zer0day安全
0x02 沙箱逃逸初识
在学习沙箱逃逸之前,需要先明确的一些基本概念
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模块因安全风险不建议用于生产环境
0x03 Node.js的作用域
在Node.js的编程环境中,每个JavaScript文件都被视为一个独立的模块,它们各自拥有自己的私有作用域(或称为上下文)。这意味着,一个模块内部定义的变量、函数等默认情况下是无法被其他模块直接访问的。这种设计保证了模块之间的独立性和封装性,避免了全局命名空间的污染
模块作用域(Module Scope)
每个Node.js文件都被视为一个独立的模块。这些模块拥有它们自己的作用域,也就是说,一个模块中的变量、函数等默认不会影响到其他模块。这种设计使得模块之间天然隔离,减少了相互之间的干扰。
1 | //test1.js |
两包关系如图所示,接下来讲讲图中的global是什么

全局作用域(Global Scope)
在Node.js中,global 对象是一个全局对象,它的属性和方法在所有模块中都是可访问的。这包括了如console、process、Buffer等内置对象,以及任何直接添加到global对象上的自定义属性或方法。
1 | //test1.js |
通过上面的例子可以看到,在输出name时,即使test2.js中没有定义name变量也可以直接使用name进行输出,test1.js中的name也不需要使用exports进行导出,因为此时name已经挂载在global上了
但是,通常不推荐在全局作用域中添加大量自定义变量或函数,因为这可能会导致命名冲突和难以追踪的错误。
0x04 vm模块API
前面已经介绍了 Node.js 的作用域,设想如果创建一个新的作用域,让代码在这个新的作用域里面去运行,就能与其他作用域进行隔离,事实上这就是vm模块运行的原理,首先介绍vm模块常用API
vm.runinThisContext(code)
在当前global(全局上下文)下创建一个作用域,并将接收到的参数当作代码运行。sandbox中可以访问到global中的属性,但无法访问其他包中的属性

1 | const vm = require('vm') |
注意:在 JavaScript 中,赋值操作符不仅会把右边的值赋给左边,它还会返回这个被赋的值,例子中rename的值实际上是vm的运行结果返回
vm.createContext([sandbox])
传入的 sandbox 对象被绑定到 V8 的新 context,在当前global对象之外创建一个全新的作用域,沙箱内运行的代码会将 sandbox 的属性视为全局变量,除非显式传入否则沙箱内无法直接访问 Node.js 的全局对象。配合下面的vm.runInContext()使用
vm.runInContext(code, contextifiedSandbox[, options])
参数为要执行的代码和创建完作用域的沙箱对象,将对象”上下文化”,使其成为独立执行环境的全局对象,在指定沙箱环境中编译并执行 JavaScript 代码,并且参数的值与沙箱内的参数值相同

1 | const util = require('util') |
vm.runInNewContext(code,[, sandbox][, options])
creatContext和runInContext的结合版,传入要执行的代码和沙箱对象
1 | const util = require('util') |
vm.Script类
Node.js 内置 vm 模块提供的类,用于编译但不执行 JavaScript 代码,编译后的脚本可以被多次执行,并且允许自定义全局对象和执行环境
new vm.Script(code, options):code是不绑定于任何全局对象的,它仅仅绑定于每次执行它的对象
script.runInContext([contextifiedSandbox[, options]]): 在指定的上下文中执行预编译的脚本
script.runInNewContext([sandbox[, options]]): 创建一个新的上下文并在其中执行脚本
1 | const util = require('util') |
0x05 vm沙箱逃逸
一般进行沙箱逃逸最后的目的都是进行rce,在Node中进行rce就需要获取process对象,用require来导入child_process,再利用child_process执行命令,这就是Node中最常规的rce过程
但process挂载在global上,而在creatContext后是不能访问到global的,所以我们最终的目标是通过各种办法将global上的process引入到沙箱中:
.constructor.constructor / .toString.constructor
1 | const vm = require("vm") |
成功输出了env内容,为什么能在vm.runInNewContext中拿到process呢,this指向的是this指向的是当前传递给runInNewContext的对象,该对象不属于沙箱环境内部,利用.constructor获取该对象的构造器Function,再利用.constructor获取Function的构造器,由这层继承关系Function.constructor位于global中,利用其构造返回process的函数,最后通过()调用获取process对象
类似的,this.toString.constructor也能获取到Function.constructor完成获取目的,完成rce:
1 | const vm = require("vm") |
除了this还有什么能在沙箱中通过继承关系逃逸出来呢?可以尝试利用沙箱中的变量吗
1 | const inspect = require('util').inspect; |
以上三个变量都逃逸失败**”ReferenceError: process is not defined”,**因为数字,字符串,布尔(包括)这些都是primitive类型(原始类型,也称基本数据类型),在传递的过程中是将值传递过去而不是引用(类似于函数传递形参),因此沙盒内使用的a,b,c只是它们的值而不是其本身,相当于在沙箱中直接声明,这是没有办法利用的
1 | const inspect = require('util').inspect; |
但是可以将其设为[] , {} 这样的对象实例类型,内置构造/自定义函数
1 | const inspect = require('util').inspect; |
arguments.callee.caller
下面的例子中this为null,并且也没有其他可以引用的对象
1 | const vm = require('vm'); |
但是在函数中存在内置对象的属性arguments.callee.caller,它可以返回函数的调用者
arguments:一个类数组对象,存在于每一个正在执行的函数内部,包含传递给该函数的所有参数
arguments.callee:rguments 对象的一个属性,它指向当前正在执行的函数本身
arguments.callee.caller:这是 Function 对象的一个属性(caller),指向调用当前函数的那个函数,如果当前函数是在全局作用域被调用的(没有父函数),返回 null
利用好arguments.callee.caller完成沙箱外对象的获取
1 | const vm = require('vm'); |
前面演示的沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,这种情况下其实也是一样的,只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了,比如说例子中console.log通过字符串拼接的方式触发toString()函数,我们就对res的toString()进行重写触发rce
Proxy劫持属性
触发利用链的逻辑就是我们在get:这个钩子里写了一个恶意函数,当我们在沙箱外访问proxy对象的任意属性(不论是否存在)这个钩子就会自动运行,实现了rce
1 | const vm = require("vm"); |
异常抛出
如果沙箱的返回值返回的是我们无法利用的对象或者没有返回值,可以借助异常,将沙箱内的对象抛出去,在外部输出:
1 | const vm = require("vm"); |
抛出后catch捕获到了throw出的proxy对象,在console.log时由于将字符串与对象拼接,将报错信息和rce的回显一起带了出来,所以说结合题目提供源码具体分析应对
0x06 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执行的入口,导出了NodeVM,VM这两个沙箱环境,还有一个VMScript实际上是封装了vm.Scriptsadbox.js:针对global的一些函数和变量进行了hook,比如setTimeout,setInterval等
vm2相比vm做了很大的改进,其中之一就是利用了es6新增的 proxy 特性,从而拦截对诸如 constructor 和 proto 这些属性的访问
1 | const {VM, VMScript} = require("vm2"); |
其中 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并抛出异常,接着在沙箱内拿到这个异常对象
1 | Symbol = { |
has方法未代理
漏洞原理:箱中自己定义了Object.prototype.has()方法,在该方法里面通过获取t变量(也就是主键)的构造器,然后再返回process对象,通过 "" in Buffer.from 来触发has方法来实现返回process对象
1 | const {VM} = require('vm2'); |
prepareStackTrace攻击
vm2版本<3.9.10
覆盖prepareStackTrace导致沙箱逃逸,直接给自定义了一个变量LocalWeakMap存储了WeakMap方法对应的get和set,这样即使对WeakMap.prototype.set重写了,最后set的时候也是调用的是localWeakMap.Set
1 | const { set } = WeakMap.prototype; |
vm2沙箱逃逸CVE
具体参考https://www.cnblogs.com/zpchcbd/p/16899212.html
CVE-2019-10761
vm2版本<=3.6.10
漏洞原理:在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象
1 | ; |
CVE-2021-23449
vm2版本<3.9.5
漏洞原理:import()在JavaScript中是一个语法结构而非函数,没法通过之前对require这种函数处理相同的方法来处理它,导致实际上调用import()的结果实际上是没有经过沙箱的外部变量,再获取这个变量的属性即可绕过沙箱
vm2对此的修复方法也很粗糙,正则匹配并替换了\bimport\b关键字,在编译失败的时候,报Dynamic Import not supported错误
1 | let res = import('./foo.js') |
CVE-2022-36067
vm2版本<3.9.11
漏洞原理:在3.9.10中并没有对Error做相关的限制,导致可以重新定义一个Error来绕过对LocalErrorprepareStackTrace的操作,就是我们通过实例化Error对象就可以获得栈的情况,其中prepareStackTrace()函数定义了如何对异常的栈的处理,我们这边进行重写,因为栈中不仅包含了沙箱的栈还包含了其他作用域下的栈,那么思路就出来了,我们只需要通过遍历栈中的对象,拿到全局作用域下的process即可进行逃逸
1 | globalThis.OldError=globalThis.Error; |
CVE-2026-22709
vm2 <= 3.10.0
vm2使用Promise 的then和catch 方法时,对回调函数的清理存在缺陷: localPromise.prototype.then 的回调被正确清理 , 但 globalPromise.prototype.then/catch 的回调未被清理 ,async 函数返回的是 globalPromise 对象,而非 localPromise ,可以通过 async 函数配合异常处理,获取宿主环境的对象引用,进而实现沙箱逃逸
1 | const { VM } = require("vm2"); |
更多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





