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.
wget https://artifacts.picoctf.net/c/347/game && chmod +x gamepip3 install pwntoolsSolution
Walk me through it- Step 1Understand the game state structureRun the game and explore movement controls. Use GDB to map out the stack frame: player x/y coordinates and any adjacent function pointers.bash
./gamebashgdb ./gamebash# In GDB: disas main, break *<address>, run, info frameLearn more
The game maintains a player structure on the stack (or as a global). Typical fields include:
player_x- current column positionplayer_y- current row positionboard[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 atboard[r][c]lives at offsetr*W + cbytes from the base of the array. The game uses the player's coordinates as the index, soboard[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 theplayer_x < WIDTH && player_y < HEIGHTguard.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
flagjust 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. - Step 2Identify the movement vulnerabilityTest 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 observepythonpython3 -c "print('l' * 100)" | ./gameLearn 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_ywithout checkingplayer_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 posThe compiler lowers
board[y][x] = '@'to*((char*)board + y*WIDTH + x) = '@'. Withyuncapped, moving down past rowHEIGHT-1walks off the array into adjacent stack variables - and since the game also reads fromboard[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. - Step 3Calculate the offset to the function pointerUsing 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 &boardbash# p &func_ptr (or name of the function pointer variable)bash# p (long)&func_ptr - (long)&boardLearn 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_counterThe addresses above are illustrative. ASLR randomizes stack addresses each run and the exact frame layout depends on the compiler version, so re-run
info frameandp &board/p &flag_counterlocally 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
intat offset 200 has its bytes laid out as[byte0][byte1][byte2][byte3], where byte0 is the LSB. Writing a single0x40at offset 200 leaves the upper three bytes as their initial zero, giving the int value0x00000040 = 64, which is the win value the comparison is checking against. - Step 4Trigger the overwritten function pointerDrive 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 firesLearn 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.