UMDCTF 2026 - roulette [reverse]
Challenge: roulette (Reverse Engineering) Category: reverse
Overview
The binary presents itself as a roulette game. You pick a number, and it tells you if you won. But that's just a facade — the real challenge is a hidden 106-byte input verifier powered by a tiny virtual machine. Each 4-byte chunk of your input is fed through a 27-round VM program that computes a checksum and compares it against a hardcoded target. If all 27 rounds pass, you get the success message and the flag.
The clever solution? Don't reverse-engineer the VM at all. Use GDB with a Python breakpoint script at the critical compare instruction to dynamically extract what each input chunk should be.
Initial Analysis
Running the binary with no arguments shows a simple betting prompt:
1 | $ ./roulette |
The input is parsed via strtol. If you enter
1, you get a "jackpot" message. Enter anything outside 1-36
and it says "invalid." This is all a decoy — none of this path leads to
the flag.
Finding the Real Gate
Looking at sub_401690 (main) in a disassembler, we see
the strtol path branches into the roulette game. But there's another
path: if you provide input longer than a typical number, something
happens. Specifically:
- Address
0x401751: Acmpchecks ifstrlen(input) == 106. If so, execution diverts to a completely different handler — the real verifier. - Address
0x4018fe: The critical compare —cmp (eax ^ edi), target. This is where each round's computed check value is compared. - Address
0x401956: The success / "accepted" path if all checks pass.
So the binary expects exactly 106 bytes of input. Those 106 bytes are processed in 4-byte chunks through 27 rounds of a VM program.
The VM Internals
The VM program is stored at 0x499BE1 and the round
targets at 0x499CE0. Each round runs a small sequence of
bytecode instructions operating on the current 4-byte chunk. The
operations include:
COPY— copy a value between registersXOR— xor two valuesADD— add immediate or registerMUL— multiply by immediateROL/SHR— rotate left or shift right
Each round produces a 32-bit check value in eax. At the
compare site (0x4018fe), the assembly does:
1 | 0x4018fe: cmp (eax ^ edi), target |
Where edi holds the current 4-byte input chunk,
eax is the VM's computed checksum of that chunk, and
target is the round's expected value from the target
table.
The full formula is:
1 | (eax ^ chunk) == target |
So if we can extract both eax and target
for each round, computing the required chunk is trivial.
The Smart Approach: GDB Python Scripting
Rather than fully reverse-engineering the VM interpreter, we can do
something much simpler: set a breakpoint at 0x4018fe and
write a Python GDB script that:
- Reads
eax— the VM-computed checksum for the current round - Reads
edi— our current input chunk (which we control) - Reads the round target from the target table
- Computes
required_chunk = target ^ eax - Patches our input buffer in memory with the correct chunk
- Continues execution to the next round
Since each round's VM computation depends on the input chunk itself, we need to iterate: patch chunk 0, let the VM run round 0, extract the result for round 1, patch chunk 1, and so on through all 27 rounds.
Here's the script:
1 | import gdb |
Running the Script
Save the script as solve_roulette.py and run:
1 | $ gdb -x solve_roulette.py -batch |
The script runs through all 27 rounds. On each iteration, you'll see output like:
1 | [Round 0] |
Flag
1 | UMDCTF{I_R3ALLY-want-to-pl4y-the-p0werball,+but-my-d4d-said-no-so-im-b3tting-ill-win-on-POLYMARKETinstead} |