Cyandev CTF 2024 Write-up

2024-01-01

很久沒打 CTF 了,以前主要都以挑戰 Binary / Crypto 為主,前幾天剛好在 X 看到有 Web 前端的 CTF 挑戰,首次嘗試挑戰一下。

Challenge 1 - Check In

隨意輸入一些文字並 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))
}

Console 執行結果

我選擇直接使用 256 是 因為懶 看到 protectionByte 的命名,認為它可能是一個範圍在 0 至 255 之間的數值。較嚴謹的做法是針對 a-zA-Z0-9 範圍字符進行測試。

Challenge 2 - Bytecode

在分析 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

從這些結果,我們可以推斷出幾點信息:

  1. 第一個 Byte 的值會根據輸入 flag 的長度而變化。例如,當輸入為 AAAB(長度均為 2)時,其值保持不變,因此可以初步推斷它與 size 相關,而非 checksum。
  2. 每個後續 Byte 似乎都對應於單獨的字符,且沒有前後干涉或加密。例如,A 始終對應於 235,B 始終對應於 232。
  3. 字母表並非連續的,而是有其獨特的 Mapping 關係。

初步得知以上關鍵訊息後,可以繼續往下看接下來將發生些甚麼。

if (mangledBuffer.length === 0) {
    return false;
}

這可以得知,基本上有 input 字元,mangledBuffer 的 length 就一定大於 0。

HumbleVM

成功通過上一步的 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)) 的執行結果。

loadCode Console

結果返回了 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。

Console dumpCode

在執行最終 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 調試,可以大幅增加解題的效率。

不知道有沒有錯漏的內容,及其他更精彩的解法,希望各位多多指教!

🎉  感謝您的閱讀,不仿看看我的其他文章,也歡迎在 XGitHub 上交流。