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
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Map the game state and find win()ObservationI 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./gamebashgdb ./gamebash# In GDB: info functions win ; disas win ; disas mainLearn 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 plainwinvariable 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 returnsStep 2
Set the player character to win()'s low byteObservationI 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 insidewin(). Planting0x70is 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.
Step 3
Find the offset from the map base to the saved return addressObservationI 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 &boardbash# x/16xw $rsp (or inspect the frame to locate the saved return address slot)bash# p (long)<ret_addr_slot> - (long)&boardWhat 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 atr*W + cbytes 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 writes0x70there.(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 pressesThe addresses are illustrative; ASLR and the compiler version shift them, so re-read
&boardand the return-address slot in your own GDB session. The relative offset is what carries between runs.Step 4
Overwrite the return address byte and return into win()ObservationI 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.pythonpython3 exploit.pyExpected 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 now
0x70, that address points intowin(), which opensflag.txtand 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.