两道有趣的JS代码审计题目
converter
题目是一个网页,如下:
POST两个参数, Input 即输入的数据, converter 代表选择的转换器
分析源码,可以看到有三个 converter ,即代表着三种不同的encode方式,其中 flagConverter 里面有flag
但是限制了 request.body.converter,不能出现 FLAG
if (request.body.converter.match(/[FLAG]/)) { throw new Error("Don't be evil :)"); }
if (request.body.input.length < 20) { throw new Error('Too short :('); }
if (request.body.input.length > 1000) { throw new Error('Too long :('); }这里研究了之后确实不存在突破办法了🤣
分析
注意到真正调用 converter 的代码, 我们输入的两个参数 req.body.converter 和 req.body.input,其中 req.body.converter 是作为 converters 的属性名,即我们可以控制 conveters的任意一个属性(除了这个属性名不能含有 FLAG 之外)
通过
await new Promise代码是在尝试将回调式的异步操作转换为Promise风格的操作,以便使用 await 来等待执行结果,可以忽略
并且我们发现, converters 是一个 const 类型,并且每次执行 encode 操作,都会对 converters 做一次赋值(理论上直接写死就行了吧,没必要每次请求都这么干)
converters['base64'] = base64Converter; converters['scrypt'] = scryptConverter; converters[`FLAG_${request.session.sessionId}`] = flagConverter;
const result = await new Promise((resolve, reject) => { converters[request.body.converter](request.body.input, (error, result) => { if (error) { reject(error); } else { resolve(result); } }); });而我们知道,在JS中,虽然一个对象的属性看上去只有那么点,但是由于JS特有的原型链继承关系,就会导致 Object 对象的属性也被继承过来(因此可以逐一去对这些属性做尝试)
其中这个 __defineSetter__ 属性很有意思,有点类似反射,可以给对象的一个属性做一个代理,正常写法其实是这样的:
const obj = { a: 1};
// 使用 __defineGetter__obj.__defineSetter__('a', function(res) { console.log("get res: " + res);});如果我们定义了 setter ,那么后续赋值的时候, 赋值等式的右边值就会作为 setter 函数的第一个参数传进来
所以,当我们输入 input= FLAG_***SESSION*** (input并没有不允许输入FLAG字符串),converter = __defineSetter__ 时,代码变为:
converters["__defineSetter__"]("FLAG_***SESSION***", (error, result) => { if (error) { reject(error); } else { resolve(result); }});这样当我们重写完对象的 FLAG_***SESSION*** 对应的 setter 时,这个网页先不动!
后续再新开一个网页去访问,此时触发:
converters[`FLAG_${request.session.sessionId}`] = flagConverter;可以看到 flagConverter 就会作为第一个参数传入到函数
(error, result) => { if (error) { reject(error); } else { resolve(result); }}此时 result 的结果自然就是 error , 即 flagConverter.toString() 的结果
回到我们的第一个网页,此时就能看到:
Kantan Calc
这道题目也很有意思,是我见过的JS代码审计中比较新颖的,巧妙地用沙箱逃逸来误导你,实际利用的是JS很常见的一个特性
CTF中的经典计算器前端:
看代码:
app.get('/', function (req, res, next) { let output = ''; const code = req.query.code + ''; console.log(code.length); // 打印输入的代码长度 if (code && code.length < 30) { try { const result = vm.runInNewContext(`'use strict'; (function () { return ${code}; /* ${FLAG} */ })()`, Object.create(null), { timeout: 100 }); output = result + ''; if (output.includes('flag')) { output = 'Error: please do not exfiltrate the flag'; } } catch (e) { output = 'Error: error occurred'; } } else { output = 'Error: invalid code'; }
res.render('index', { title: 'Kantan Calc', output });});关键代码就是在 vm.runInNewContext 那一行,即输入的内容会被当做代码执行,比如输入 2*2
实际在vm中执行的代码就是:
'use strict';( function(){ return 2*2; /* flag{fake_flag} */ })()实际就是一个立即执行函数,但是这里要注意:
vm.runInNewContext('', Object.create(null))Object.create(null) 实际上是创建了一个没有任何原型链的对象,是一个绝对“干净”的对象
因此,最常用的一种沙箱逃逸是没有办法了(我就懒得写了)
解法
在 JavaScript 中,可以通过将函数体转换为字符串来获取函数体:
function a() {}console.log(a+'')// "function a() {}"这样就可以让代码内的flag注释信息打印出来了,但是就算打印出来还是会被后面的if给拦截,这应该怎么办呢?
答案就是数组,利用函数 toString 弱类型转换,返回char数组(绕开if检测),同时保证payload长度小于30
那么我们先直接闭合前面的函数,然后开一个新的函数,即输入:
},function p(){return[...p+1]那么此时:
'use strict';( function(){ return }, function p(){return[...p+1]; /* flag{fake_flag} */ })()那么第二个函数p执行的时候,先执行加法,抓为字符串,然后解构为数组,就绕开了检测
在看wp的时候还发现有另一种思路:
[...arguments[0]+0]})(a=>{实际执行的代码:
'use strict';( function(){ return [...arguments[0]+0]})(a=>{ ; /* flag{fake_flag} */ })()等于先定义了一个函数
( function(){ return [...arguments[0]+0] })然后调用的时候传递了一个参数, (a => {;/*flag{fake_flag}*/}),那么自然这个函数就会被当做参数 arguments 传递进去,同样的道理(不过这种情况会报错了,因为返回的是个数组无法继续函数调用)
总结
这两道题目也都打包Docker镜像了,有需要的可以自取:
docker run -d --restart=always -p 3000:3000 rayepeng/kantan_calc:latestdocker run -d --restart=always -p 3000:3000 rayepeng/convert:latest其实JS的很多特性都是可以用来出题的,但是却很少像PHP那样,有各种变种且好玩的代码审计题,后续准备在这里多思考下,尝试自己也出几道有趣的结合JS特性的审计题目