很久沒打 CTF 了,以前主要都以挑戰 Binary / Crypto 為主,前幾天剛好在 X 看到有 Web 前端的 CTF 挑戰,首次嘗試挑戰一下。
隨意輸入一些文字並 Submit 後,彈出了 The flag is wrong!
的 alert。
開啟 Chrome DevTools,在 Sources
頁使用 Search in all files
功能來搜尋 alert 字串。發現了 white-box-[hash].js
文件,並注意到在 checkFlag1
中進行了字串檢查。
async function checkFlag1(flag) {
await (0,_rust_sdk__WEBPACK_IMPORTED_MODULE_1__/* .initRust */ .yT)();
if (!/^[a-zA-Z0-9!]{8}$/.test(flag)) {
return false;
}
const protectionByte = flag.charCodeAt(0);
const realFlag = (0,_rust_sdk__WEBPACK_IMPORTED_MODULE_1__/* .getFlag1 */ .S7)(protectionByte);
return realFlag.length > 0 && flag === realFlag;
}
async function submitFlag1(flag, username) {
if (!await checkFlag1(flag)) {
return false;
}
await (0,_backend_api__WEBPACK_IMPORTED_MODULE_3__/* .submitFlag */ .W)(1, username, flag);
return true;
}
大概看了一下發現是載入了 wasm 的模組。本來打算直接反組譯 .wasm
文件,但意識到邏輯其實不太複雜:取一個 protectionByte
,然後將其傳給 getFlag1
函數,並返回 realFlag
。於是我改變策略,嘗試直接改變輸入以進行暴力破解。
我在 initRust
調用後設置了一個 Breakpoint,然後在 Console 執行以下 script 來獲得 flag v85t7z!b
。
const getFlag1 = (0,_rust_sdk__WEBPACK_IMPORTED_MODULE_1__/* .getFlag1 */ .S7)
for (let i = 0; i < 256; i++) {
if (getFlag1(i).length > 0)
console.log(getFlag1(i))
}
我選擇直接使用 256 是
因為懶看到protectionByte
的命名,認為它可能是一個範圍在 0 至 255 之間的數值。較嚴謹的做法是針對a-zA-Z0-9
範圍字符進行測試。
在分析 Challenge 1 的 JavaScript 代碼時,我注意到 Challenge 2 的代碼也包含在同一文件中,因此可以直接閱讀 checkFlag2
函數的代碼。
async function checkFlag2(flag) {
await (0, _rust_sdk__WEBPACK_IMPORTED_MODULE_1__ /* .initRust */ .yT)();
const mangledBuffer = (0, _rust_sdk__WEBPACK_IMPORTED_MODULE_1__ /* .mangle */ .dW)(flag);
if (mangledBuffer.length === 0) {
return false;
}
const vm = new _rust_sdk__WEBPACK_IMPORTED_MODULE_1__ /* .HumbleVM */ .Z3();
try {
vm.loadCode(Uint8Array.from([3, 162, 4, 0]));
if (!vm.run(mangledBuffer.subarray(0, 1))) {
return false;
}
vm.loadCode(Uint8Array.from([1, 2, 1, 4, 155]));
if (!vm.run(mangledBuffer.subarray(0, 2))) {
return false;
}
vm.loadCode(Uint8Array.from([1, 1, 4, 208, 1, 4, 201, 1, 4, 155, 1, 2, 1, 4, 235, 1, 4, 192, 1, 3, 200, 4, 18, 1, 3, 8, 4, 150]));
if (!vm.run(mangledBuffer)) {
return false;
}
} finally {
vm.free();
}
return true;
}
mangle
mangle
函數的作用看起來是將輸入的 flag 進行轉換。為了深入了解它的行為,我在執行 mangle
之後設置了一個 Breakpoint,並用不同的輸入進行測試。
const mangle = (0,_rust_sdk__WEBPACK_IMPORTED_MODULE_1__/* .mangle */ .dW)
console.log(mangle('A'), mangle('AA'), mangle('AB'), mangle('ABC'))
輸出結果如下:
Uint8Array [171, 235] <- A
Uint8Array [168, 235, 235] <- AA
Uint8Array [168, 235, 232] <- AB
Uint8Array [169, 235, 232, 233] <- ABC
從這些結果,我們可以推斷出幾點信息:
AA
和 AB
(長度均為 2)時,其值保持不變,因此可以初步推斷它與 size 相關,而非 checksum。A
始終對應於 235,B
始終對應於 232。初步得知以上關鍵訊息後,可以繼續往下看接下來將發生些甚麼。
if (mangledBuffer.length === 0) {
return false;
}
這可以得知,基本上有 input 字元,mangledBuffer
的 length 就一定大於 0。
成功通過上一步的 size 驗証後,我們來到了以下的程式碼。
const vm = new _rust_sdk__WEBPACK_IMPORTED_MODULE_1__ /* .HumbleVM */ .Z3();
這裡創建了一個 HumbleVM
的 Instance。由於不確定其具體功能,因此我先在網上找找有沒有 HumbleVM
的相關訊息或者開源程式碼,答案是沒有。因此,我初步判斷這是一個自定義的 Module。
mangledBuffer.subarray(0, 1)
繼續深入分析,我們來到了 vm
部分的代碼。
vm.loadCode(Uint8Array.from([3, 162, 4, 0]));
if (!vm.run(mangledBuffer.subarray(0, 1))) {
return false;
}
由於對 loadCode
中的 Array 作用不明,我在 loadCode
之後設置了一個 Breakpoint,觀察 vm.run(mangledBuffer.subarray(0, 1))
的執行結果。
結果返回了 false
,考慮到 mangledBuffer
的第一個 Byte 與 flag 大小相關,我嘗試采用暴力方法來確定哪個 Byte 值會返回 true
。
for (let i = 0; i < 256; i++) {
if (vm.run(new Uint8Array([i]))) console.log(i)
}
// 162
因此,我們知道第一個 Byte 應為 162
。
接下來,我們需要確定 flag 的長度,使得在 mangle
之後第一個 Byte 變為 162
。
const mangle = (0,_rust_sdk__WEBPACK_IMPORTED_MODULE_1__/* .mangle */ .dW)
for (let i = 1; i <= 100; i++)
if (mangle('A'.repeat(i)).at(0) === 162)
console.log(i)
// 8
輸入 AAAAAAAA
使得第一個 Byte 變為 162
,因此可以確定 flag 的長度為 8
。
mangledBuffer.subarray(0, 2)
經過上述分析,我們成功地通過了第一個檢查點,接著來到了下一關。
vm.loadCode(Uint8Array.from([1, 2, 1, 4, 155]));
if (!vm.run(mangledBuffer.subarray(0, 2))) {
return false;
}
這段代碼與前一步類似,只是多傳遞了一個 Byte 。我們可以再次使用暴力方法來確定第二個 Byte 。
for (let i = 0; i < 256; i++)
if (vm.run(new Uint8Array([162, i]))) console.log(i)
// 154
mangle
在早前初步分析過 mangle
之後,我們對實際的字母表映射尚不清楚。為此,我們可以通過創建一個 Rainbow table 來找出每個 Byte 對應的字元。
const mangle = (0,_rust_sdk__WEBPACK_IMPORTED_MODULE_1__/* .mangle */ .dW)
for (let i = 0; i < 256; i++) {
const flag = String.fromCharCode(i)
const key = mangle(flag).at(1)
if (key)
console.log(key, flag)
}
輸出結果如下:
170 '\x00'
171 '\x01'
168 '\x02'
169 '\x03'
...
154 '0'
155 '1'
152 '2'
153 '3'
...
221 'w'
210 'x'
211 'y'
208 'z'
...
根據輸出結果,我們知道解出的第二個 Byte 154
對應於 flag 的第一個字符 0
。
mangledBuffer
接下來是挑戰的關鍵部分,需要對整個 flag 進行分析。
vm.loadCode(Uint8Array.from([1, 1, 4, 208, 1, 4, 201, 1, 4, 155, 1, 2, 1, 4, 235, 1, 4, 192, 1, 3, 200, 4, 18, 1, 3, 8, 4, 150]));
if (!vm.run(mangledBuffer)) {
return false;
}
儘管我們可以使用暴力方法來確定 flag 的第一個字符,並且知道 flag 的總長度為 8,理論上可以通過暴力方法解決整個挑戰(需要的嘗試次數為 8^255
)對吧?
嚴格來說是對的,如果你正在參加分秒必爭的 CTF 比賽,這的確是一個策略。但我認為這似乎不像是題目作者的初衷,必定有其他更有趣的解法,因此我繼續進行代碼分析。
loadCode
通過實驗,我發現每次 loadCode
都會替換之前的代碼。
vm.loadCode(Uint8Array.from([3, 162, 4, 0]));
vm.run(new Uint8Array([162])); // 162
vm.loadCode(Uint8Array.from([1, 2, 1, 4, 155]));
vm.run(new Uint8Array([162])); // error: Uncaught data pointer is out of bounds
vm.loadCode(Uint8Array.from([3, 162, 4, 0]));
vm.run(new Uint8Array([162])); // 162
接著,我注意到 loadCode
中的值與輸入值之間存在關聯。這讓我想到了這道題目與 WebAssembly 的關聯,每個 Byte 代碼可能對應一個 Assembly 指令,如 CMP
, SUB
, JE
等。
我嘗試自行修改 loadCode
的 arguments 和相應的 input,進一步的實驗證實了這一點。
vm.loadCode(Uint8Array.from([1, 2, 1, 4, 155]));
vm.run(new Uint8Array([162, 154])); // true
正當我在煩腦如何測試每個 Byte Code 所對應的指令時,我在 Console 輸入 vm.
後,發現了 vm.dumpCode
的 function。
在執行最終 loadCode
並設置 Breakpoint 後,我執行了 vm.dumpCode()
,得到了以下的輸出:
[
NextDataCell,
NextDataCell,
AssertEq(
208,
),
NextDataCell,
AssertEq(
201,
),
NextDataCell,
AssertEq(
155,
),
NextDataCell,
Add(
1,
),
AssertEq(
235,
),
NextDataCell,
AssertEq(
192,
),
NextDataCell,
Sub(
200,
),
AssertEq(
18,
),
NextDataCell,
Sub(
8,
),
AssertEq(
150,
),
]
BINGO 🎉 根據這些指令,我們可以計算完整的 8 個 Byte:
[154, 208, 201, 155, 234, 192, 218, 158]
將這些數值與之前創建的 Rainbow table 對照,我們得出最終的 flag 為 0zc1@jp4
。
首先感謝 Cyandev 製作這個有趣的 CTF 挑戰讓大家在 2023 年尾畫上完滿且有趣的句點,透過這活動首次認識到他和他的作品,大家有興趣也可以去看看。
有個小小的意見,就是這次的題目能夠完全通過暴力方法破解,不知道作者的原意是否為了讓不同程度的參加者而故意設計的,但總括而言,這是一個有趣並令人有所得著的挑戰。
這次是我第一次參與 Web 相關的 CTF 挑戰,因此我詳細記錄了解題過程。我認為能夠熟練使用 Chrome DevTools 來進行 Breakpoint 調試,可以大幅增加解題的效率。
不知道有沒有錯漏的內容,及其他更精彩的解法,希望各位多多指教!