COMPFEST CTF 2026 · Reverse Engineering
chall.exe is a 3,072 byte PE32 that barely does anything on its own. It allocates RWX memory, copies 674 bytes into it and calls the result.
That little blob is the actual challenge. It starts in x86, escapes into x86-64 through Heaven’s Gate, decrypts the next x86 stage and switches back. Then it does it again.
i spent much less time on the crypto than on making sure i had Binary Ninja in the right architecture. The same bytes look valid in both modes. Wrong mode, plausible assembly, complete bullshit.
This was also my first time using Binary Ninja Cloud. i honestly expected a restricted demo. It handled the challenge just fine.
What the blob does
1 | chall.exe (PE32) |
The service throws 16 random bytes at us and expects another 16 bytes as lowercase hex. Once the mixer is translated, all of the architecture hopping can stay in the write-up. The solver is plain Python.
Starting with the boring part
i started with the original chall.exe. No patching and no extracted stage yet.
There is one relevant function at 0x401000:
| Address | What happens |
|---|---|
0x40102a |
VirtualAlloc(0, 0x2a2, 0x3000, 0x40) |
0x401065 |
copy 0x2a2 bytes from 0x402000 |
0x40107d |
input pointer into ECX, output pointer into EDX |
0x40108c |
call the new allocation |
0x4010a3 |
compare 16 output bytes with memcmp |
The complete wrapper: allocate, copy, call, compare.
The .data section starts at file offset 0x600 and virtual address 0x402000. The first 0x2a2 bytes are the blob.
Directly behind the blob sits this:
1 | input: 9c41e07db2f5361a8ad30c47e961b5f2 |
This is great. The challenge ships its own known-answer test.
i used that as my stop sign for the live service. As long as my Python did not produce 023a...f80f, it was wrong. There is no reason to debug this through a socket.
The original challenge hash is:
1 | 493d571c6c2b15f8815d21c2e9cad9d711d8b9f9bc2966a44e41efdf31675bc9 |
The first retf
The blob entry is normal x86:
1 | 00000000 push ebx |
call leaves 0x12 on the stack. The code turns that into 0x17, pushes CS + 0x10 and executes retf.
So 0x17 is not more x86. This is the classic Heaven’s Gate switch into 64-bit mode.
The x86 bootstrap stops at 0x402016. Do not let the disassembler continue in x86 mode.
The seven bytes at 0x17 are the whole joke:
1 | 48 8d 05 64 02 00 00 |
As x86:
1 | dec eax |
As x86-64:
1 | lea rax, [rip + 0x264] ; blob + 0x282 |
In x86, 0x48 is dec eax. In x86-64 it is the REX.W prefix for the following lea.
The wrong listing did not contain invalid instructions. It just slowly stopped making sense. Loading the same bytes with an x86-64 entry at 0x402017 resolved the first lea to the scratch area at 0x402282. That was the confirmation i needed.
Binary Ninja Cloud does not take raw blobs
Binary Ninja Cloud wants PE, ELF, Mach-O or BNDB. It does not want 674 raw bytes.
i could have analyzed five little slices and adjusted the addresses in my head. No thanks. The stages share scratch memory and the 64-bit code uses RIP-relative addressing, so the original layout is useful.
build_analysis_containers.py puts the entire blob into a minimal one-section PE at 0x402000.
Only three things differ between the containers:
- PE32 or PE32+
- the entry RVA
- the filename
The bootstrap and stage 1 containers use the untouched bytes. Stages 2 through 4 use the output from decrypt_stage.py. No patched opcodes and no edited screenshots.
| Entry | Architecture | What it shows |
|---|---|---|
0x402000 |
x86 | bootstrap |
0x402017 |
x86-64 | stage 1 |
0x4020d4 |
x86 | decrypted stage 2 |
0x402196 |
x86-64 | decrypted stage 3 |
0x402230 |
x86 | decrypted stage 4 |
This also keeps the scratch area in the correct place:
| Range | Size | Use |
|---|---|---|
[0x000, 0x017) |
0x17 |
x86 bootstrap |
[0x017, 0x0d4) |
0xbd |
x86-64 stage 1 |
[0x0d4, 0x196) |
0xc2 |
x86 stage 2 |
[0x196, 0x230) |
0x9a |
x86-64 stage 3 |
[0x230, 0x282) |
0x52 |
x86 stage 4 |
[0x282, 0x2a2) |
0x20 |
pointers, left, right |
x86-64 decrypts the first x86 stage
Stage 1 starts at 0x17.
It stores the input and output pointers from ECX and EDX, then reads the request as two little-endian 64-bit values. Those become left and right in my code. Register names are not useful names.
The decryptor itself is a 64-bit LCG:
1 | state = rol64( |
It decrypts 0xc2 bytes starting at 0xd4. The loop stops at 0x196, exactly where the next mode starts.
Stage 1 also performs the first request-dependent operation:
1 | left ^= 0xA6F1C0D93B5E2748 |
Then another far return subtracts 0x10 from the code segment and we are back in x86.
Decrypt loop on the left, shared state and the return to x86 on the right.
This is where i separated unpacking from solving. The LCG reveals the next code stage. The XOR modifies the request. They happen next to each other in assembly, but they have no business living in the same Python function.
The carry i lost in stage 2
The decrypted x86 stage uses call and pop to find its base. Right after that is the part i got wrong:
1 | mov eax, dword [left] |
i translated it one instruction at a time. Low 32 bits looked right, high 32 bits did not. i had thrown away half of mul.
mul ecx writes its result to EDX:EAX. The following add and adc add all 64 bits to left.
So the operation is:
1 | left = ( |
Afterwards, two shld instructions form a 64-bit rotate:
1 | right = rol64(right, 13) |
Then a 32-bit LCG decrypts 0x9a bytes at 0x196:
1 | state = 0x5F3A19C7 |
The stage ends in another retf, back to x86-64.
Raw disassembly wins here. mul, add, adc and both shld instructions are all visible.
The last two stages
Stage 3 is x86-64 again and finishes the mixer:
1 | right = rol64((right + left) & MASK64, 29) |
It also decrypts the last 0x52 bytes at 0x230:
1 | state = 0xB5297A4D2C1F60E9 |
The two constants traded places compared with stage 1. i kept both loops separate in the script because making one clever generic LCG here only makes it easier to pass the wrong arguments.
Mixer at the top, decrypt loop on the right, final trip back to x86 at the bottom.
Stage 4 is x86 and finally writes the answer:
1 | output0 = left ^ right |
The addition is another add and adc pair. Both values go out little-endian.
Then ebp, edi, esi and ebx are popped. Those match the four pushes at blob entry. A normal ret takes us back to the wrapper.
Two results, four restored registers and one completely normal ret.
What remains for the solver
The live service does not care how its code was packed. It only needs this:
1 | from struct import pack, unpack |
The LCGs are gone because they only reveal code. transform() contains only operations involving the request.
Checking my work
decrypt_stage.py extracts the blob, writes every decrypted stage and runs the test vector from the PE:
1 | python3 decrypt_stage.py |
The relevant assertion is:
1 | assert transform(bytes.fromhex( |
This caught my broken mul translation. It would also catch all of these:
- entering x86-64 at
0x18and losing theREX.Wbyte at0x17 - translating the stage 2
mulas a 32-bit result - losing the carry from
addintoadc - packing the final values in the wrong byte order
The same goes for the Binary Ninja containers:
1 | python3 build_analysis_containers.py |
The service
There is not much left. The service asks for a CTFd token and prints a request:
1 | CTFd access token: ... |
solve.py parses that request, calls transform() and sends the hex result:
1 | request = bytes.fromhex(request_hex) |
Run it like this:
1 | CTFD_TOKEN='...' python3 solve.py |
After all that, the network part is two lines.
Scripts and hashes
Everything needed to reproduce it:
And the hashes:
1 | chall.exe |
Flag
1 | COMPFEST18{0nly_th3_av4t4r_m4st3r3d_4ll_th3m_b1ts_iR2Yth2DpF0F8hzD} |
i liked this one. Basic crypto, tiny solver, horrible disassembly if you miss one architecture switch.
0x48 at offset 0x17 is basically the challenge. x86 sees dec eax. x86-64 sees the prefix that makes the next instruction work.
Binary Ninja will happily show either version. Choosing the correct one is still your job.