babygame03 picoCTF 2024 Solution

Published: April 3, 2024

Description

Welcome to BabyGame03. Navigate the map and see what you can find, but be careful, you do not have many moves and some obstacles end the game on collision. The game draws the player by writing the '@' tile into a grid on the stack, and it never checks that your coordinates stayed inside that grid.

Remote + binary

Download the game binary and run it locally to learn the controls (movement keys, the solve command, and the obstacles).

Open it in Ghidra to find the grid array, the player coordinate variables, the level counter, and the win() function.

bash
wget https://artifacts.picoctf.net/c/<id>/game
bash
chmod +x game
bash
nc <HOST> <PORT_FROM_INSTANCE>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Find the out-of-bounds write in movement
    Observation
    I noticed the game description said it 'draws the player by writing the @ tile into a grid on the stack' and 'never checks that your coordinates stayed inside that grid,' which directly indicated an out-of-bounds write vulnerability reachable by moving the player off the edge of the grid.
    The grid is a fixed 2D char buffer on the stack. Each turn the game writes the player tile 0x40 ('@') into grid[player_y][player_x]. Movement updates player_x / player_y with no bounds check, so by walking off the edge you make grid[y][x] alias arbitrary stack memory near the grid. That is a single-byte write-where (the value is always 0x40, but you choose the destination).
    bash
    # In Ghidra, identify the locals: the grid buffer, player_x, player_y, level.
    bash
    # Confirm the write: grid_base + player_y * width + player_x  <-  0x40
    What didn't work first

    Tried: Trying to use pwntools cyclic() to find the offset to the return address rather than computing it from Ghidra locals

    A classic cyclic pattern overwrite floods the buffer with distinct bytes, but the game only ever writes the single byte 0x40 (the '@' tile) at the player position - there is no buffer overflow that accepts arbitrary input. Sending a cyclic string as movement keys has no effect on the write value. You must calculate the grid-to-return-address offset statically in Ghidra or dynamically in gdb, not by crashing with a pattern.

    Tried: Assuming the grid width equals the terminal column count (80) when calculating grid[y][x] offsets

    The actual grid width is a compile-time constant stored in the binary, not the terminal width. Using 80 produces an incorrect stride, so every offset calculation is wrong and the write lands at the wrong stack address. In Ghidra, read the array dimension from the local variable declaration (or confirm with gdb by stepping and watching which address gets written) before doing any arithmetic.

    Learn more

    Why no bounds check is fatal. The address written is computed as grid_base + y*width + x. With negative or oversized x/y, that expression points before or after the grid. The player coordinate variables and the level counter live adjacent to the grid on the same stack frame, and below them sit the saved frame pointer and the saved return address. So a controlled-offset write of 0x40 reaches all of those.

    Getting unlimited moves first. The move budget is checked the same sloppy way. Walking backward off the start of the grid underflows the position and (in this binary) lets you keep moving, so the limited-moves restriction stops mattering once you are off-grid. That gives you as many single-byte writes as the exploit needs.

  2. Step 2
    Aim the write at the saved return address
    Observation
    I noticed the write value is always 0x40 and the only meaningful target near the grid on the stack would be the saved return address, which suggested computing the exact frame offset from the grid base to that address in gdb so we could direct the single-byte write to redirect execution into win().
    Compute the offset from the grid base to the saved return address (gdb: break in the move loop, compare &grid with $rbp+8). Move the player so grid[y][x] lands on a byte of the saved return address, then 'draw' to write 0x40 there. The goal is to patch the return address so that when main returns it lands on the win() call (or on the code that sets the level to its winning value and falls through to win()).
    bash
    # In gdb, find the distances:
    gdb -q ./game
    b *<address inside the move/draw routine>
    run
    # print &grid and the saved return address slot
    p $rbp+8
    x/gx $rbp+8        # current saved return address
    # offset = (rbp+8) - grid_base, then translate to (y, x) moves

    The write value is fixed at 0x40, so this works when the target byte of the return address (or level counter) needs to become 0x40, or when overwriting just the low byte is enough to redirect into the win path. Pick the target byte accordingly; the binary is built so a small patch reaches win().

    What didn't work first

    Tried: Breaking on the main function return in gdb and reading $rbp+8 without first accounting for the inner move/draw stack frame

    The move loop executes inside a subroutine, so at the breakpoint inside that routine $rbp+8 is the saved return address of that subroutine back into main, not the return address of main itself. The offset you measure is therefore wrong by however many bytes the inner frame uses. You need to let the subroutine return and break in main's epilogue, or walk up the frame chain with 'info frame' to read main's saved return address slot directly.

    Tried: Trying to write any byte value other than 0x40 by sending different ASCII keys, expecting the game to draw with the key character

    The draw primitive always stores the literal byte 0x40 regardless of which key triggered it - the game uses a hardcoded tile constant, not the character of the pressed key. Sending 'A' does not write 0x41. The entire exploit must be designed around 0x40 being the only writable value, which is why the target return-address byte must already be close to 0x40 in the binary's code segment.

    Learn more

    Single-byte write is enough here. Stack addresses in a run share most of their high bytes; the return address into main and the win() call site differ only in low bytes. Overwriting one byte with 0x40 is often sufficient to redirect the return, which is why the fixed write value is not a real limitation.

    Two paths to the flag. Some solutions overwrite the level counter (also adjacent to the grid) so the post-game check believes you solved every level and calls win() for you; others overwrite the saved return address directly. Both reduce to the same out-of-bounds 0x40 write primitive. There is also a built-in solve command (the 'p' key in this binary) that auto-clears a level, handy for lining the player up at the right coordinate before the corrupting write.

  3. Step 3
    Script the moves and read the flag
    Observation
    I noticed the offset from the grid to the saved return address was now known and constant relative to the stack frame, which suggested encoding the exact movement sequence as a fixed keystroke string and delivering it over the remote socket with pwntools to reproduce the exploit reliably.
    Encode the navigation as a fixed key sequence: move to the off-grid offset that aliases the return-address byte, issue the draw, then let main return into win(). Drive it with pwntools so it is reproducible against the remote instance.
    python
    python3 - <<'PY'
    from pwn import remote
    
    io = remote("<HOST>", <PORT_FROM_INSTANCE>)
    
    # Each character is one game action. Build this from your gdb offset math:
    # - walk backward to go off-grid (unlimited moves)
    # - position so grid[y][x] == saved return address byte
    # - 'draw'/confirm to write 0x40 there
    moves = "..."   # the exact key sequence derived above
    
    io.send(moves.encode())
    io.interactive()   # main returns into win(), which prints the flag
    PY

    Expected output

    picoCTF{...}
    What didn't work first

    Tried: Testing the offset sequence locally in gdb and confirming win() fires, then running the same script against remote and getting a crash or no flag

    Stack layout is affected by environment variables, the executable path length, and ASLR. The remote instance runs in a different environment than your local machine, so the absolute stack addresses differ even though the relative offset inside the binary's own frame is the same. The offset from grid to the saved return address should be stable (it is a compile-time layout), but if you hard-coded an absolute address anywhere, or if your move count accidentally depended on ASLR, it will work locally and fail remotely. Recheck that every computed value is a frame-relative offset, not an absolute address.

    Tried: Using io.sendline() instead of io.send() to deliver the move sequence

    sendline() appends a newline byte 0x0a after the payload. The game reads single keystrokes, and 0x0a is interpreted as a movement action (down, or confirm, depending on the key map) rather than being ignored. That extra action shifts the player by one step, landing the final write at the wrong offset. Use io.send() so only the intended key bytes are delivered.

    Learn more

    Once the corrupted return fires, win() opens and prints the flag file. If it crashes instead, your offset landed on the wrong byte; re-check &grid versus $rbp+8 in gdb and recompute the move count.

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{gamer_leveluP_...}

The renderer writes the player tile 0x40 into grid[y][x] with no bounds check, so walking off the grid is a single-byte arbitrary write. Aim it at the saved return address (or the adjacent level counter) to redirect main into win(), which prints the flag. The trailing 8-character hex suffix is generated per instance (e.g. 334c3e00 or 84600233).

Key takeaway

Array index confusion becomes an arbitrary write primitive the moment no bounds check guards the index computation. Any C or C++ code that computes a memory destination from attacker-controlled indices without range validation allows writing attacker-influenced bytes to stack or heap locations adjacent to the buffer. Even a single-byte constrained write (as here, always 0x40) is enough to corrupt a return address when the target byte happens to match, and the same primitive underpins heap metadata corruption and format-string indexed writes. Real-world game engines, embedded sensor drivers, and image parsers regularly carry this class of bug.

Related reading

Want more picoCTF 2024 writeups?

Useful tools for Binary Exploitation

What to try next