Cyandev CTF 2024 Write-up

2024-01-01

I haven't played CTF for a long time. Previously, I mainly focused on challenges related to Binary and Crypto. A few days ago, I saw a Web frontend CTF challenge on X, and decided to give it a try for the first time.

Challenge 1 - Check In

After entering some text and clicking Submit, an alert popped up saying The flag is wrong!.

I opened Chrome DevTools and used the Search in all files feature in the Sources tab to search for the alert string. I found the file white-box-[hash].js and noticed a string check in the checkFlag1 function.

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;
}

I quickly realized that a wasm module was loaded. Initially, I planned to disassembly the .wasm file, but then realized the logic wasn’t too complex: it takes a protectionByte, passes it to the getFlag1 function, and returns a realFlag. So I changed my strategy to try and brute force the input.

I set a Breakpoint after the initRust call and then executed the following script in the Console to get the 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 execution result

I chose to use 256 directly because I was lazy I saw the name protectionByte and thought it might be a value between 0 and 255. A more rigorous approach would be to test for the range of characters a-zA-Z0-9.

Challenge 2 - Bytecode

While analyzing the JavaScript code for Challenge 1, I noticed that the code for Challenge 2 was also included in the same file, so I was able to directly read the checkFlag2 function's code.

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;
}

Preliminary Analysis of mangle

The mangle function seems to transform the input flag. To understand its behavior, I set a Breakpoint after executing mangle and tested it with different inputs.

const mangle = (0,_rust_sdk__WEBPACK_IMPORTED_MODULE_1__/* .mangle */ .dW)
console.log(mangle('A'), mangle('AA'), mangle('AB'), mangle('ABC'))

The output was:

Uint8Array [171, 235] <- A
Uint8Array [168, 235, 235] <- AA
Uint8Array [168, 235, 232] <- AB
Uint8Array [169, 235, 232, 233] <- ABC

From these results, we can infer a few points:

  1. The first Byte's value changes based on the input flag’s length. For example, when the input is AA and AB (both of length 2), its value remains the same, so it's likely related to size, not checksum.
  2. Each subsequent Byte seems to correspond to an individual character without interference or encryption from before or after. For example, A always corresponds to 235, B to 232.
  3. The alphabet is not continuous but has its unique mapping relationship.

After knowing these key messages, we can continue to see what happens next.

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

This indicates that basically, if there is any input character, the length of mangledBuffer will definitely be greater than 0.

HumbleVM

After successfully passing the size verification, we come to the following code.

const vm = new _rust_sdk__WEBPACK_IMPORTED_MODULE_1__ /* .HumbleVM */ .Z3();

This creates an instance of HumbleVM. Since I was not sure of its specific functionality, I first searched online for any information or open-source code related to HumbleVM, but found none. So, I preliminarily concluded that this is a custom module.

Breaking Down mangledBuffer.subarray(0, 1)

Continuing with the analysis, we come to the vm part of the code.

vm.loadCode(Uint8Array.from([3, 162, 4, 0]));
if (!vm.run(mangledBuffer.subarray(0, 1))) {
    return false;
}

Since the function of the Array in loadCode was unclear, I set a Breakpoint after loadCode and observed the execution result of vm.run(mangledBuffer.subarray(0, 1)).

loadCode Console

The result returned false, and considering that the first Byte of mangledBuffer is related to the size of the flag, I tried a brute force approach to determine which Byte value would return true.

for (let i = 0; i < 256; i++) {
    if (vm.run(new Uint8Array([i]))) console.log(i)
}

// 162

Therefore, we know that the first Byte should be 162.

Next, we need to determine the length of the flag so that the first Byte becomes 162 after mangle.

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

Entering AAAAAAAA makes the first Byte 162, so we can determine that the length of the flag is 8.

Breaking Down mangledBuffer.subarray(0, 2)

After the above analysis, we successfully passed the first checkpoint and then came to the next one.

vm.loadCode(Uint8Array.from([1, 2, 1, 4, 155]));
if (!vm.run(mangledBuffer.subarray(0, 2))) {
    return false;
}

This code is similar to the previous step, but with an additional Byte passed. We can use the brute force method again to determine the second Byte.

for (let i = 0; i < 256; i++)
    if (vm.run(new Uint8Array([162, i]))) console.log(i)

// 154

Breaking Down mangle

After initially analyzing mangle, we were not clear about the actual alphabet mapping. To address this, we can create a Rainbow table to find out which Byte corresponds to which character.

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)
}

The output was:

170 '\x00'
171 '\x01'
168 '\x02'
169 '\x03'
...
154 '0'
155 '1'
152 '2'
153 '3'
...
221 'w'
210 'x'
211 'y'
208 'z'
...

According to the results, we know that the solved second Byte 154 corresponds to the first character of the flag 0.

Breaking Down mangledBuffer

Next is the key part of the challenge, requiring analysis of the entire 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;
}

Brute Force Solution?

Although we can use brute force to determine the first character of the flag and know the total length is 8, in theory, we could solve the entire challenge by brute force (requiring 8^255 attempts), right?

Strictly speaking, yes, if you're in a time-sensitive CTF competition, this is indeed a strategy. But I think it doesn't seem like the author's intention, there must be other more interesting solutions, so I continued with the code analysis.

Analyzing loadCode

Through experimentation, I found that each loadCode replaces the previous code.

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

Then, I noticed a correlation between the values in loadCode and the input values. This made me think of the connection of this question with WebAssembly, where each Byte code might correspond to an Assembly instruction, like CMP, SUB, JE, etc.

I tried modifying the arguments of loadCode and the corresponding input, further experiments confirmed this.

vm.loadCode(Uint8Array.from([1, 2, 1, 4, 155]));
vm.run(new Uint8Array([162, 154])); // true

Just as I was wondering how to test each Byte Code for the corresponding instruction, I discovered the function vm.dumpCode in the Console by typing vm..

Console dumpCode

After executing the final loadCode and setting a Breakpoint, I ran vm.dumpCode(), which gave the following output:

[
    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 🎉 Based on these instructions, we can calculate the complete 8 Bytes:

[154, 208, 201, 155, 234, 192, 218, 158]

Comparing these values with the previously created Rainbow table, we find the final flag is 0zc1@jp4.

Conclusion

First, thanks to Cyandev for creating this interesting CTF challenge which provided a perfect and fun ending to 2023, and for introducing us to him and his work. I recommend checking it out if you're interested.

A small suggestion is that this challenge could be completely solved by brute force, I wonder if it was intentionally designed this way by the author to accommodate participants of different levels. But overall, it was an interesting and rewarding challenge.

This is my first time participating in a web-related CTF, so I've documented it in detail. I believe that being proficient in using Chrome DevTools for setting breakpoints and debugging can significantly increase the efficiency of solving problems. I wonder if there are other more brilliant solutions or any mistakes I've made, and I look forward to exchanging ideas with everyone.

🎉  Thanks for reading and hope you enjoy it. Feel free to check out my other posts and find me on X and GitHub!