Another Onion
Challenge Data
| Field | Value |
|---|---|
| CTF | DiceCTF 2026 Quals |
| Challenge | Another Onion |
| Category | Reversing |
| Flag | dice{5p33d_1s_a1s0_imp0rtant_1n_r3ver5e_3ngineer1ng} |
Challenge Description
Another Onion was an ELF that kept rewriting itself in stages. Each stage read 2 bytes from stdin, modified a fixed mmap region at 0x90000000, decrypted the next block, and jumped to it. In the real instance there were 256 stages, so the complete key ended up being 512 bytes long.
Solving all 256 stages at once was not practical. The useful approach was to solve only the next pair of bytes on each iteration and keep moving forward that way.
Reconnaissance
The first step was to check what repeated between stages. This pattern appeared in the binary.
4004be: call read@plt
4004cf: call read@plt
...
4005a0: movzx eax,BYTE PTR [rip+...]
4005a7: xor al,BYTE PTR [rip+...]
Each stage consumed exactly 2 bytes and prepared the next one. The important detail was that execution depended completely on the state left by all previous stages: low executable memory, the mmap region, the stack, and the current frame.
Trying to solve everything statically was not worth it. It was better to treat each stage as an independent problem and capture a valid state to solve only the next pair.
Analysis
The strategy ended up being to stop the process right after the current stage's read and use that snapshot of the state to test the 0x0000..0xffff possible pairs offline.
The solver can be summarized with this loop.
for stage_idx in range(len(prefix) // 2, total_pairs):
resume = trace_last_read(binary, bytes(prefix), layout)
low, mmap, stack = snapshot_stage(binary, bytes(prefix), resume, layout, stem)
meta = parse_stage_meta(entry, resume, expected_next, disasm, layout)
pair = solve_pair(meta, low, mmap, stack, stack_qword0, layout, expect)
prefix += pair
The brute force only worked if the snapshot was close enough to the real execution.
- Low executable image.
- Fixed
mmapregion. - Exact stack window.
- Enough metadata to reconstruct
rbp,rsp, and the return to the stage.
Checking only three bytes of the next block produced false positives. The useful validation was a masked signature of the next stage's prologue.
EXPECT_STAGE_HEX = "0f280d0000000048c7c0f0ffffff0f2815"
EXPECT_STAGE_MASK_HEX = ("ff" * 3) + ("00" * 4) + ("ff" * 10)
The four wildcard bytes corresponded to the RIP-relative displacement, which changed between stages even though the general pattern stayed the same.
Solution
The complete flow ended up being this.
- Run the binary with the already solved prefix.
- Trace the last
readiteration corresponding to the current stage. - Run it again and dump low memory,
mmap, and stack right at that return. - Disassemble the captured stage to extract metadata.
- Test the 65536 possible pairs offline.
- Keep the pair that decrypted the next block correctly.
- Repeat until the full key was recovered.
In practice, the replay had to be very faithful. The later stages barely tolerated anything wrong in the frame, so capturing the exact stack was what finally stopped the solver from drifting.
First I tested it against an instance that had already expired and recovered this token.
28d5de4e7fde4f5d5a2ace2ecc17f59f
Then I repeated the same process against a live instance and got the correct token.
9e8067aa95d9277bf2a47b90cd1dcd87
Once I had that, the only thing left was to send it to the service and get the final flag. The stable solve came from working stage by stage.
Flag
dice{5p33d_1s_a1s0_imp0rtant_1n_r3ver5e_3ngineer1ng}