babygame02 picoCTF 2023 Solution

Published: April 26, 2023

Description

An extension of babygame01. Use the L command to set your character to 0x70 (the letter p), then move out-of-bounds before the map array to overwrite a single byte of the saved return address on the stack. This redirects execution into the win() function and prints the flag.

Download the binary and run it locally to understand the game mechanics.

Install pwntools and GDB with pwndbg.

bash
wget https://artifacts.picoctf.net/c/347/game && chmod +x game
bash
pip3 install pwntools

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Map the game state and find win()
    Observation
    I noticed babygame02 is described as an extension of babygame01, and babygame01 already let an out-of-bounds player index clobber an adjacent variable. That suggested the same unchecked-index primitive is present here, but aimed at a higher-value target than a data byte: the saved return address.
    Run the game to learn the controls, then open it in Ghidra or GDB. Locate the win() function (it opens and prints flag.txt) and note its address. The challenge sets up a single-byte overwrite, so the only byte you need is win()'s low byte, which is 0x70 - that is why the intended solution sets the player character to 'p' (0x70).
    bash
    ./game
    bash
    gdb ./game
    bash
    # In GDB: info functions win   ; disas win   ; disas main
    Learn more

    babygame01 and babygame02 share the same bug: the game uses the player's coordinates to index the map array board[player_y][player_x] without a bounds check, so an out-of-range coordinate writes the player's character byte into memory adjacent to the array. In babygame01 the adjacent target was a plain win variable and the default character (@ = 0x40) was already the value the win check wanted.

    What babygame02 escalates. Instead of corrupting a data variable, you aim the same out-of-bounds write at the saved return address on the stack and partially overwrite it so the function returns into win() rather than its real caller. There is no stack canary in this binary, so nothing stops a return-address write.

    (gdb) info functions win
    0x0804...70  win        <- note the low byte: 0x70 = 'p'
    (gdb) disas main          <- find the call after which control returns
  2. Step 2
    Set the player character to win()'s low byte
    Observation
    I noticed the game exposes a command to change the player character, and babygame01's rabbit hole pointed out that pressing the set-character key changes the stamped byte. To write 0x70 over a return-address byte I first have to make 0x70 the byte the game stamps.
    Use the L command to set the player character to 0x70 ('p'). From now on every move stamps 0x70 into board[player_y][player_x], including the out-of-bounds cells you are about to reach. This is the one place babygame02 differs from babygame01, where the default '@' (0x40) was already correct and changing the character was a mistake.
    bash
    # In the running game, press L and supply the character 'p' (0x70)
    bash
    # Confirm the player tile now renders as 'p'
    Learn more

    The single-byte partial overwrite works because win() and the legitimate return address differ only in their low byte. The low byte of any address inside the program image is fixed (it is the page offset), so even if the binary is position-independent you can change just that one byte and reliably land inside win(). Planting 0x70 is therefore enough to redirect the return.

    Why a single byte. The game stamps exactly one character byte per move. Overwriting more than one byte of the return address would require landing the upper bytes too, which the game cannot control move by move. The challenge is designed so the win target is reachable with a one-byte change.

  3. Step 3
    Find the offset from the map base to the saved return address
    Observation
    I noticed that, exactly as in babygame01, the out-of-bounds write reaches adjacent stack memory at a fixed offset from the map array base. To hit the saved return address byte I need that offset so I can translate it into a precise sequence of moves.
    In GDB, break inside the move handler, print the address of the map array (&board) and the address of the saved return address slot (the cell holding the return address for the function whose frame contains board). Subtract to get the byte offset. babygame01 reached its win variable at a negative offset (4 bytes before the array) by walking left off column 0; the saved return address sits at its own fixed offset, so compute it rather than assume a direction.
    bash
    # In GDB, at the move handler:
    bash
    # p &board
    bash
    # x/16xw $rsp        (or inspect the frame to locate the saved return address slot)
    bash
    # p (long)<ret_addr_slot> - (long)&board
    What didn't work first

    Tried: Hardcode absolute stack addresses read from one GDB run into the move math.

    ASLR randomizes the stack base every execution, so absolute addresses change run to run. Only the relative offset between the map array and the saved return-address slot is stable (it is fixed at compile time by the frame layout). Always compute the difference of the two addresses, never rely on either absolute value.

    Tried: Assume each move advances 4 bytes because the cells look like ints.

    Each step changes a coordinate by one cell, and one row step moves WIDTH bytes through the row-major array while one column step moves 1 byte. Decompose the target offset into row and column moves using the actual board width from the binary, not a fixed 4-byte stride.

    Learn more

    C stores board[H][W] in row-major order, so the byte atboard[r][c] lives at r*W + c bytes from the array base. Because the player index is unchecked, you can drive(player_y, player_x) to coordinates whose linear offset equals the distance to the saved return-address byte, and the next stamp writes0x70 there.

    (gdb) p &board
    $1 = (char (*)[W]) 0x7fff...e0d0      <- map base (illustrative)
    (gdb) p (long)<ret_slot> - (long)&board
    $2 = <byte offset>                    <- distance to the saved return address
    
    down_moves  = offset / WIDTH
    right_moves = offset % WIDTH          <- decompose into key presses

    The addresses are illustrative; ASLR and the compiler version shift them, so re-read &board and the return-address slot in your own GDB session. The relative offset is what carries between runs.

  4. Step 4
    Overwrite the return address byte and return into win()
    Observation
    I noticed that once the player character is 0x70 and I know the move count to the saved return address, walking that many out-of-bounds steps lands a 0x70 stamp on the low byte of the return address, so the next function return transfers control into win().
    Drive the game with pwntools: set the character to 0x70 with L, walk the computed out-of-bounds move sequence so the stamp overwrites the saved return address low byte, then let the game function return. Execution jumps into win(), which reads flag.txt on the remote server and prints the flag. Locally you will see 'flag.txt not found', so run the exploit against the remote instance.
    python
    python3 exploit.py

    Expected output

    picoCTF{...}
    # exploit.py
    from pwn import *
    
    p = remote("saturn.picoctf.net", 1337)   # or process("./game") locally
    
    # 1) Set the player character to 0x70 ('p') = win()'s low byte
    p.recvuntil(b"move:")
    p.sendline(b"L")
    p.sendline(b"p")
    
    # 2) Walk the out-of-bounds move sequence computed in GDB so the stamp
    #    lands on the saved return address low byte (counts are build-specific).
    moves = b"a" * RIGHT_MOVES + b"w" * UP_MOVES   # adjust to your offset/direction
    for m in moves:
        p.recvuntil(b"move:")
        p.sendline(bytes([m]))
    
    # 3) Let the function return -> control flows into win()
    p.interactive()
    What didn't work first

    Tried: Send every keystroke at once with a single p.send() burst.

    Sending all moves in one write can outrun the game loop, which consumes input one tick at a time. Moves get dropped and the player ends up at the wrong coordinate, so the stamp misses the return-address byte. Send one move per tick (after the move prompt) so each keystroke registers in the correct board state.

    Tried: Overwrite two or more bytes of the return address to be safe.

    The game only stamps one byte per move and cannot control the upper bytes of the address, so a multi-byte write is neither possible nor needed. win() and the real return target differ only in the low byte, so a single 0x70 stamp is the whole exploit. Trying to write more bytes just walks the index further and segfaults.

    Learn more

    When the function whose frame holds the map array returns, the CPU pops the saved return address you just edited. Because its low byte is now0x70, that address points into win(), which opensflag.txt and prints it. There is no canary check between the overwrite and the return, so nothing intervenes.

    ... game loop runs, you stamp 0x70 over ret low byte ...
    function epilogue:
      leave
      ret            ; pops the edited return address -> win()
    win():
      open("flag.txt"); puts(flag);

    For the heap-side cousin of clobbering a saved pointer (free, realloc, overwrite a function pointer), see Heap exploitation for CTF. For more pwntools idioms (process(), remote(), interactive mode), see Pwntools for CTF.

Interactive tools
  • Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
  • pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.

Flag

Reveal flag

picoCTF{...}

This challenge was not solved during the competition. Follow the steps above to reproduce the solution.

Key takeaway

An out-of-bounds array write whose index the program controls without a bounds check is a write primitive, and where it points decides how serious it is. babygame01 used it to set an adjacent win flag; babygame02 aims the same primitive at the saved return address and uses a single-byte partial overwrite to redirect execution into win(). Partial overwrites are powerful precisely because the low byte of any in-image address is fixed, so one controlled byte is often enough to retarget a return even under ASLR or PIE. The same unchecked-index root cause drives real CVEs in parsers, game engines, and network daemons.

Related reading

Want more picoCTF 2023 writeups?

Useful tools for Binary Exploitation

What to try next