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.
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))
}
I chose to use 256 directly because
I was lazyI saw the nameprotectionByte
and thought it might be a value between 0 and 255. A more rigorous approach would be to test for the range of charactersa-zA-Z0-9
.
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;
}
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:
AA
and AB
(both of length 2), its value remains the same, so it's likely related to size, not checksum.A
always corresponds to 235, B
to 232.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.
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.
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))
.
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
.
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
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
.
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;
}
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.
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.
.
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
.
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.