DEF CON CTF Qualifier 2025 - tetrx
The challenge is a stripped binary implementing a game of tetris in the command line. Reversing the binary reveals that the rules deviate from “guideline tetris” in several ways:
- The board itself is 16 columns by 24 rows instead of 10 by 20
- The piece queue is random, without bagging, de-duplication or any hold piece
- The player is asked to provide a 32 bit seed at the start of the game to determine the piece order
- A bug in line clear code prevents two adjacent lines being cleared together; the second line clear will disappear after the next piece is hard dropped
- Zero gravity; pieces only drop on soft or hard drop inputs
Notably, the game’s implementation of piece rotations does follow guideline tetris, including mechanics such as kicks. There is also no 180-degree rotation option unlike other popular tetris clones.
The board is stored as a bitmask uint8_t board[48]
. A 1
bit means the cell
is filled while a 0
bit means the cell is empty. board[0]
respresents the
left half of the bottom row with LSB on the left side of the board, board[1]
is the right half of the bottom row, board[2]
is the left half of the second
row and so on.
The game ends once the user locks a piece in and the next piece that would have spawned in collides with any existing piece. After this, the binary executes the board as shellcode. We know some information about registers when this happens:
r11 = r15 = <board[] on heap>
r13 = "%02hhx" @ 0x302c
r14 = ".o" @ 0x3033
rdi = <jump_table> @ 0x3044
if a piece was recently rotatedrsp[0] = <main+0x529> @ 0x1709 (return address)
There a win()
in the binary, so jumping to <win+0xe> @ 0x1a0e
is enough to
spawn a shell. A typical shellcode will involve selecting one of the registers
above, deriving the address to win()
and finally jumping to/calling win()
.
The ultimate goal is to read the flag file off the local filestystem.
In an ideal scenario, someone’s recipe for a solution might look something like this:
- Write shellcode which will call
win()
- Golf/craft the shellcode in a way that makes it favourable to be obtained in a game of tetris
- Plan out some (if not all) of the piece positions needed to obtain that board state
- Do RNG manipulation to get a good opening piece order for your plan
- Manually play out the rest of the game with a piece order that cannot be manipulated
- Top out and pray for a shell / flag
While discussing solutions after the game ended, it turns out this ideal scenario was far from reality for the solutions that FMC and friends came up with. People would forget one or two steps and overcompensate in some other part of their exploit, usually ending up in playing more tetris than they need to.
Here’s a sampler of the exploits that people (over)cooked:
Oops, you forgot to golf!
0: 49 81 ee 25 16 00 00 sub r14,0x1625 7: 41 ff e6 jmp r14
For FMC’s own solution, while our cracked pwn players were bashing heads on the
other challenges, sleep-deprived @zzoru
and myself
looked at 64-bit math and went “heh this is doable”. Unfortunately, to craft
this shellcode we needed to build two massive overhangs in the 4th and 5th rows
over the course of an extremely long game of tetris. Solving for the moves by
hand order took us over 3h, and was the slowest exploit by far of the ones we
saw.
On the bright side, it’s not like the two of us had other challenges to work on at the time 🥴.
Oops, you forgot to do RNG manipulation!
0: 58 pop rax 1: 66 2d fb fc sub ax,0xfcfb 5: 50 push rax 6: ff e0 jmp rax
I did start with 69 and proceeded to 70 because 69 was bad
@tchen
from [:] (the new merger on the block)
also had a bit too much free time on his hands and decided that it would be fun
to play the game honestly without any RNG manipulation. Impressively, he
still ended up with a shorter exploit than others with similar shellcode.
Oops, you forgot about win()!
0: 4c 87 fe xchg rsi,r15 3: b2 ff mov dl,0xff 5: 31 ff xor edi,edi 7: 0f 05 syscall
Honestly 4 of us playing tetris to race who gets it first was the best part of this […] ctf
There was a [win()]?
Wait, did i actually not notice i already had it and spent like 100 extra moves looking for a piece i didnt even need
@zeski
from organizers gets extra style points
for reading the wrong question and still getting the right answer. Instead of
jumping to win()
, he triggers a read()
into board memory which lets him send
his own execve("/bin/sh")
as a second stage via standard input. Though the
swiss-cheese garbage pile on the left takes away a tiny bit from the elegance of
it all.
Oops, too slow!
0: 66 81 ef 36 16 sub di,0x1636 5: 90 nop 6: ff e7 jmp rdi
This solution from @soon_haari
of Cold Fusion
unfortunately wasn’t the one that flagged for his team because he was 2 minutes
late. However, the remarkably clean garbage pile with its crazy overhangs are
[*chef’s kiss].
Oops, you… Wait no that’s perfect
0: 66 81 ef 36 16 sub di,0x1636 5: 57 push rdi 6: c3 ret
And finally, this solution coming from @T1d
and
@RiK
of RePokemonedCollections is the most “platonic
ideal” of the ones we saw. The exploit itself was the shortest by a long short,
taking only 140 moves, 27 pieces and 2 line clears to get to a shell.