< All Writeups tetrx - DC33 Quals by 4yn on 5/1/2025 tags: pwn misc Tetris board = shellcode and the many paths leading to /bin/sh

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 rotated
  • rsp[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:

  1. Write shellcode which will call win()
  2. Golf/craft the shellcode in a way that makes it favourable to be obtained in a game of tetris
  3. Plan out some (if not all) of the piece positions needed to obtain that board state
  4. Do RNG manipulation to get a good opening piece order for your plan
  5. Manually play out the rest of the game with a piece order that cannot be manipulated
  6. 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()]? psyduck
Wait, did i actually not notice i already had it and spent like 100 extra moves looking for a piece i didnt even need psyduck

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… Nevermind 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.