vm沙箱逃逸学习总结
前言
前置知识
沙箱与沙箱逃逸
什么是沙箱(sandbox)?
当我们运行一些可能会产生危害的程序,我们不能直接在主机的真实环境上进行测试,所以可以通过单独开辟一个运行代码的环境,它与主机相互隔离,但使用主机的硬件资源,我们将有危害的代码在沙箱中运行只会对沙箱内部产生一些影响,而不会影响到主机上的功能,沙箱的工作机制主要是依靠重定向,将恶意代码的执行目标重定向到沙箱内部。
沙箱(sandbox)和 虚拟机(VM)和 容器(Docker)之间的区别:
sandbox和VM使用的都是虚拟化技术,但二者间使用的目的不一样。沙箱用来隔离有害程序,而虚拟机则实现了我们在一台电脑上使用多个操作系统的功能。Docker属于sandbox的一种,通过创造一个有边界的运行环境将程序放在里面,使程序被边界困住,从而使程序与程序,程序与主机之间相互隔离开。在实际防护时,使用Docker和sandbox嵌套的方式更多一点,安全性也更高。
常见沙箱逃逸都有啥?
浏览器沙箱逃逸:利用浏览器中的漏洞(如V8/JSC内存漏洞、UAF、类型混淆等)获得沙箱进程权限,进一步利用提权漏洞逃逸到宿主系统。
虚拟机和容器沙箱逃逸:虚拟机逃逸,利用虚拟化软件(如VMware、VirtualBox、QEMU等)漏洞,从虚拟机突破到宿主机系统;容器逃逸,利用容器(如Docker、LXC等)漏洞突破容器边界,访问宿主机资源。
语言解释器沙箱逃逸:Node.js沙箱逃逸,利用vm
模块、Function构造器等机制绕过沙箱,访问process
、require
等敏感对象,执行系统命令;Python沙箱逃逸,利用exec
、eval
、__import__
等内置函数或对象反射实现突破,访问文件或系统命令。
移动端沙箱逃逸:iOS越狱/Android提权,利用内核漏洞或安全机制缺陷突破应用沙箱,获得系统权限。
云环境沙箱逃逸:云函数/FaaS沙箱隔离绕过,针对云平台多租户环境的隔离缺陷,实现跨租户攻击。
这里我们主要讲解语言解释器沙箱逃逸。
语言解释器沙箱的作用:
语言解释器沙箱的主要作用是安全隔离和受控执行,常用于在线编程平台、云函数、插件扩展、教育实验等需要运行不可信代码的场景。
Node.js沙箱逃逸
基础知识
vm模块
node.js是啥?
JavaScript本来用在浏览器前端,后来将Chrome中的v8引擎单独拿出来为JavaScript单独开发了一个运行环境,因此JavaScript也可以作为一门后端语言。
所以Node.js是一个基于 Chrome V8 引擎的JavaScript 运行环境,允许开发者在服务器端运行 JavaScript 代码。
什么是vm模块?
vm模块是node.js内置的一个模块,理论上不能叫做沙箱,它只是Node.js提供给使用者的一个隔离环境
在Nodejs中,我们可以通过引入vm模块来创建一个“沙箱”,但其实这个vm模块的隔离功能并不完善,还有很多缺陷,因此Node后续升级了vm,也就是现在的vm2沙箱,vm2引用了vm模块的功能,并在其基础上做了一些优化。
vm模块的作用
vm模块就是实现一个沙箱,然后我们往沙箱里传入代码,可以执行代码返回结果,同时不会对沙箱外面产生影响。
这里我们可以总结出vm实现的两个要点:
- 传入字符串并将其作为代码执行,得到结果
- 不会对外面造成影响,如同函数一般,将其限制在作用域内,只要最后的运行结果
我们这里用代码理解上面两点
我们现在当前目录创建age.txt,写入
1 |
|
然后创建demo.js
1 |
|
不难发现,成功将txt中的字符串作为代码执行并输出18。
这就是作用里的第一点
但是如果当前作用域下有相同变量名,会发生什么
我们修改代码如下
1 |
|
结果是出现报错
在js中每一个模块都有自己独立的作用域,所以用eval执行字符串代码很容易出现上面的这个问题。
上述提到的方法由于不同模块作用域被限制了使用,那么我们是否可以自己创造作用域呢?
我们可以用new Function做到,new Function的第一个参数是形参名称,第二个参数是函数体。
1 |
|
我们都知道函数内和函数外是两个作用域。
当在函数中的作用域想要使用函数外的变量时,要通过形参来传递,然后返回结果,而这个过程并不会影响函数外的变量。
这就是作用里的第二点。
但是当参数过多时这种方法就变的麻烦起来了,并不实用。
从上面两个执行代码的例子可以看出来vm模块的实际作用:创建一个能够通过传一个字符串就能执行代码,并且还与外部隔绝的作用域。
Nodejs作用域
说到作用域,我们就要了解一下Node中的作用域是怎么分配的(在Node中一般把作用域叫上下文)
我们在写一个Node项目时往往要在一个文件里ruquire其他的js文件,这些文件我们都给它们叫做“包”。每一个包都有一个自己的上下文,包之间的作用域是互相隔离不互通的,也就是说就算我在1.js中require了2.js,那么我在1.js中也无法直接调用2.js中的变量和函数。
举个例子
在同一级目录下,有1.js和2.js两个文件
1.js
1 |
|
2.js
1 |
|
可以发现是undefined
那么我们想2.js中引入并使用y1中的元素应该怎么办呢,Node给我们提供了一个将js文件中元素输出的接口exports
,把1.js修改成下面这样:
1 |
|
我们再运行y2就可以拿到age的值了
我们用图来解释这两个包之间的关系就是

这个时候就有人会问左上角的global是什么?这里就要说到Node.js中的全局对象了。
刚才我们提到在JavaScript中window
是全局对象,浏览器其他所有的属性都挂载在window
下,
但是Node.js 没有 window 对象,那么在服务端的Nodejs中和window
类似的全局对象叫做global
。在 Node.js 中,最顶层的全局对象是 global,Nodejs下其他的所有属性和包都挂载在这个global对象下。
在global下挂载了一些全局变量,我们在访问这些全局变量时不需要用global.xxx
的方式来访问,直接用xxx
就可以调用这个变量。举个例子,console
就是挂载在global下的一个全局变量,我们在用console.log
输出时并不需要写成global.console.log
,其他常见全局变量还有process(一会逃逸要用到)。
我们也可以手动声明一个全局变量,但全局变量在每个包中都是共享的,所以尽量不要声明全局变量,不然容易导致变量污染。用上面的代码举个例子:
1 |
|
输出:
可以发现我这次在1.js中并没有使用exports
将age导入,并且2.js在输出时也没有用a.age
,因为此时age已经挂载在global上了,它的作用域已经不在1.js中了。
vm沙箱
vm模块的常用API
我们在前面提到了作用域这个概念,所以我们现在思考一下,如果想要实现沙箱的隔离作用,我们是不是可以创建一个新的作用域,让代码在这个新的作用域里面去运行,这样就和其他的作用域进行了隔离,这也就是vm模块运行的原理,先来了解几个常用的vm模块的API。
vm.createContext([sandbox][, options])
在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8为这个沙箱对象在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。
例:const context = vm.createContext({ a: 1 })
vm.runInContext(code, context[, options])
参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同。
例:vm.runInContext('a = a + 1', context)
我们用代码演示一下上面两个api
1 |
|

vm.runInNewContext(code[, sandbox][, options])
creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。
例:vm.runInNewContext('a = a + 1', { a: 1 })
vm.runInThisContext(code[, options])
在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。sandbox中可以能访问和修改全局变量,但不会污染外部作用域(这里的“外部作用域”指的不是“全局作用域”,而是当前函数或模块的局部变量作用域)
- 能访问和修改全局变量(如
global.foo
、全局对象、Node.js 内建全局)。 - 不能访问当前函数或模块里的局部变量,也不能声明变量后让外部直接访问。
举例,
1 |
|

- vm.Script类
vm.Script
是 Node.js vm
模块中的一个类。它代表一段预编译的 JavaScript 脚本,你可以用它在不同的上下文(沙箱)中多次、高效地执行同一段代码。和 eval
不同,vm.Script
支持预编译与复用,也不绑定当前作用域。
1 |
|
例,
1 |
|
vm沙箱逃逸
原理
沙箱逃逸的前提是:我们可以从沙箱内部通过属性或者方法或者其他方式访问到沙箱外部
我们执行m+n这个表达式:
1 |
|
成功的在沙箱中执行了脚本,并且返回了执行的结果。
这个环境中上下文有三个对象:
this
指向传入的 sandbox 对象。你传给vm.createContext
的对象就是沙箱的“全局对象”,里面的属性(m、n)就是沙箱中的变量,而this
在沙箱代码中指向这个对象,你可以通过m
,n
,this.m
,this.n
在沙箱里访问这两个变量。m等于数字1
n等于数字2
一般进行沙箱逃逸最后都是进行rce,那么在Node里要进行rce就需要procces了。在获取到process对象后,我们就可以用require来导入child_process,再利用child_process执行命令。
但process挂载在global上,我们上面说了在creatContext
后是不能访问到global的,所以我们最终的目标是通过各种办法将global上的process引入到沙箱中。
我们可以使用传入的对象(比如this)来引入沙箱外部模块,从而拿到process,比如(code参数最好用反引号包裹,这样可以使code更严格便于执行):
1 |
|
那么我们是怎么实现逃逸的呢,首先我们提到过this指向的是当前传递给createContext
的对象,这个对象是不属于沙箱环境的,我们通过这个对象获取到它的构造器,再获得一个构造器对象的构造器(此时为Function的constructor),最后的()
是调用这个用Function的constructor生成的函数,最终返回了一个process对象。
下面这行代码也可以获得process对象:
1 |
|
this.toString获取到一个函数对象,this.toString.constructor获取到函数对象的构造器,构造器中可以传入字符串类型的代码,然后再执行,即可获得process对象。
那么问题就来了!
1、为什么不直接使用{}.toString.constructor('return process')()
,却要使用this呢?
{}的意思是在沙箱内声明了一个对象。
这两个的一个重要区别就是,{}是在沙盒内的一个对象,而this是在沙盒外的对象(注入进来的)。沙盒内的对象即使使用这个方法,也获取不到process,因为它本身就没有process。
2、m和n也是沙盒外的对象,为什么不用m.toString.constructor('return process')()
呢?
因为数字、字符串、布尔等这些都是primitive types,他们的传递其实传递的是值而不是引用,所以在沙盒内虽然你也是使用的m,但是这个m和外部那个m已经不是一个m了,所以也是无法利用的。
所以,我们将mn改成其他类型就可以利用了:context:{m: [], n: {}, x: /regexp/}
那么我们就可以总结一下沙箱绕过的核心原理:
只要在沙箱内部,找到一个沙箱外部的对象,借助这个对象内的属性即可获得沙箱外的函数,进而绕过沙箱。
利用方式
方法1,利用Function构造函数沙箱逃逸,执行命令
利用引用类型:
1 |
|
上面通过新建了一个 process,将通过x.tostring.constructor拿到的process模块,这时的sandbox中的数值是任意的引用类型
然后通过拿到的process.mainMoudule.require方法,拿到c’child_process’子模块,然后通过exec.Sync方法执行我们的命令
这样就成功的利用了沙箱逃逸实现了任意命令执行,虽然出现了乱码,但是不影响我们的命令执行
利用this(这里与原理里的实例一致):
1 |
|
与利用引用类型不同的是通过 this方法来实现的,将x换位了this,那么这时,sandbox中的值就不要求是一些类型的引用,可以是任意的数值了
方法2:利用argument.callee.caller实现
现在如果有一个这样的一个框架如下所示:
1 |
|
在 JavaScript 中,this 关键字的值取决于函数的执行上下文。
在全局作用域中,this 通常指向全局对象(如浏览器环境中的 window 对象,Node.js 环境中的 global 对象)。但是,在使用 Object.create(null) 创建的对象上下文中,this 将为 null。
const sandbox = Object.create(null);
Object.create(null) 是一个创建一个新对象的方法:
在 JavaScript 中,Object.create(null) 会创建一个纯净的对象,它没有继承自 Object.prototype 或任何其他原型对象,因此不会拥有默认的原型方法和属性。这样的对象通常被称为“空对象”或“纯净对象”。
在这个纯净对象 sandbox 上下文中,由于没有原型链,它的 this 值将为 null。
这时,上面使用this和引用类型的来访问外部模块的方法就没有办法生效了,那么我们就可以通过callee和caller来进行实现
我们先介绍一下callee和caller这两个属性:
callee 和 caller 都是已经被废弃的属性
callee,会指向调用函数本身
caller,会指向谁调用你的函数
arguments.callee.caller,是一个函数中的内置对象的属性,它可以返回函数的调用者。
沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法。
这种情况下也是一样的,我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,这时我们定义的函数的arguments.callee.caller就会指向外部(指向沙箱外的调用者),然后我们在沙箱内就可以进行逃逸了。
具体实现:
1 |
|
分析一下上面代码,我们在沙箱内先创建了一个对象,并且将这个对象的toString方法进行了重写,
注意沙箱外在console.log中将字符串与运行结果进行了拼接,这样沙箱中代码的最终结果会转换成字符串,沙箱内的a.toString 方法就被外部的toString方法调用
然后通过arguments.callee.caller
获得到沙箱外的toString方法,利用toString方法的构造函数返回了process(这里就和第一种方法原理一致了),再调用process进行rce。
简单地说,沙箱外在console.log中通过字符串拼接的方式触发了这个重写后的toString函数,而重写的函数里又通过指向其触发者,拿到沙箱外的对象,进一步实现命令执行。
方法3:利用ES6的 proxy 模式来劫持外部get操作
上面方法2的前提是打印时,需要与一个字符串拼接的行为,如果沙箱外没有执行字符串的相关操作来触发这个toString,并且也没有可以用来进行恶意重写的函数,怎么逃逸呢
那么我们可以用Proxy
来劫持属性
Proxy可理解为:在目标对象前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,相当于对外界的访问进行过滤和改写。
详见:Proxy 和 Reflect一个 Proxy 对象包装另一个对象并拦截诸如读取/写入属性和其他操作,可以选择自行处理它 - 掘金
例如,拦截读取属性行为:
1 |
|
逃逸方式
1 |
|
触发利用链的逻辑就是我们在get:
这个钩子里写了一个恶意函数,当我们在沙箱外访问proxy对象的任意属性(不论是否存在)这个钩子就会自动运行,实现了rce。
如果沙箱的返回值返回的是我们无法利用的对象或者没有返回值应该怎么进行逃逸呢?
我们可以借助异常,把我们沙箱内的对象抛出去,如果外部有捕捉异常的(如日志)逻辑,则也可能触发漏洞:
1 |
|
这里我们用catch捕获到了throw出的proxy对象,在console.log时由于将字符串与对象拼接,将报错信息和rce的回显一起带了出来。
vm2沙箱逃逸
vm2
通过上面几个例子可以看出来vm沙箱隔离功能较弱,有很多逃逸的方法,所以第三方包vm2在vm的基础上做了一些优化
我们这里可以安装vm2包
1 |
|
vm2出现过多次逃逸的问题,所以现有的代码被进行了大量修改,为了方便分析需要使用较老版本的vm2,但github上貌似将3.9以前的版本全都删除了,所以我这里也找不到对应的资源了,代码分析也比较麻烦,直接移步链接:
vm2实现原理分析-安全客 - 安全资讯平台 (anquanke.com)
这里我们就不进行分析了,直接给出漏洞及利用方式
CVE-2019-10761
该漏洞要求vm2版本<=3.6.10
1 |
|
这个链子在p牛的知识星球上有,很抽象,沙箱逃逸说到底就是要从沙箱外获取一个对象,然后获得这个对象的constructor属性。
这条链子获取沙箱外对象的方法是 在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,我们正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,我们在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象。
举个例子:
假设当前环境下最大递归值为1000,我们通过程序控制递归999次(注意这里说的递归值不是一直调用同一个函数的最大值,而是单次程序内调用函数次数的最大值,也就是调用栈的最大值):
1 |
|
CVE-2021-23449
这个漏洞在snyk解释是原型链污染导致的沙箱逃逸,但p牛在知识星球里发了其实是另外的原因
Sandbox Bypass in vm2 | CVE-2021-23449 | Snyk
poc:
1 |
|
import()在JavaScript中是一个语法结构,不是函数,没法通过之前对require这种函数处理相同的方法来处理它,导致实际上我们调用import()的结果实际上是没有经过沙箱的,是一个外部变量。 我们再获取这个变量的属性即可绕过沙箱。 vm2对此的修复方法也很粗糙,正则匹配并替换了\bimport\b
关键字,在编译失败的时候,报Dynamic Import not supported错误。
另外一个trick
1 |
|
在vm2的原理中提到vm2会为对象配置代理并初始化,如果对象是以下类型:
就会return Decontextify.instance
函数,这个函数中用到了Symbol全局对象,我们可以通过劫持Symbol对象的getter并抛出异常,再在沙箱内拿到这个异常对象就可以了
其他CVE
多名安全研究人员先后发现了VM2中的多个沙箱逃逸漏洞,分别是Seongil Wi发现的CVE-2023-29017漏洞和SeungHyun Lee发现的CVE-2023-29199、CVE-2023-30547漏洞。攻击者利用这些漏洞可以绕过沙箱环境的限制运行恶意代码。CVE-2023-29017漏洞影响vm2 3.9.14之前版本,VM2已在3.9.15版本中修复了该漏洞。
这里提一下CVE-2023-30547
CVE-2023-30547漏洞是由来自韩国科学技术院(KAIST)的SeungHyun Lee发现的,该漏洞属于异常处理漏洞,允许攻击者在handleException()内引发未处理的主机异常,CVSS评分9.8分。
handleException()函数负责处理沙箱中的异常以预防主机信息泄露。但如果攻击者设置一个定制的getPrototypeOf()代理处理器来抛出未处理的主机异常,handleException()函数就无法处理该异常。那么攻击者就可以访问主机函数,即绕过沙箱的限制实现逃逸,并可以在主机环境内执行任意代码。
vm2 在 3.9.16 版本及以下存在一个漏洞,攻击者可以在 handleException()
中触发未处理的宿主异常,从而逃逸沙盒并在宿主上下文中执行任意代码。
1 |
|
参考文献
NodeJS VM沙箱逃逸_let sandbox = object.create(null); let context = v-CSDN博客
nodejs vm/vm2沙箱逃逸分析 - zpchcbd - 博客园
vm2再爆沙箱逃逸漏洞 - 嘶吼 RoarTalk – 网络安全行业综合服务平台,4hou.com
Sandbox Escape in vm2@3.9.16 · GitHub --- Sandbox Escape in vm2@3.9.16 · GitHub