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.
Setup
Download the game binary and run it locally to learn the controls (movement keys, the solve command, and the obstacles). Get the files from the challenge page on CyLab Security Academy (formerly play.picoctf.org).
Open it in Ghidra to find the grid array, the player coordinate variables, the level counter, and the win() function.
chmod +x gamenc <HOST> <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Find the out-of-bounds write in movement
ObservationThe description says the game draws the player by writing an @ tile into a grid on the stack and never checks that the coordinates stayed inside it. That is an out-of-bounds write, reachable by walking off the edge.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 <- 0x40What 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 cyclic pattern floods a buffer with distinct bytes, but this game only ever writes 0x40 at the player position. There is no overflow that takes arbitrary input, so a cyclic string sent as movement keys changes nothing. Get the grid-to-return-address offset from Ghidra or gdb rather than from a crash.
Tried: Assuming the grid width equals the terminal column count (80) when calculating grid[y][x] offsets
The grid width is a compile-time constant in the binary, not your terminal width. Assume 80 and the stride is wrong, so every offset is wrong and the write lands somewhere else on the stack. Read the array dimension off the local variable in Ghidra, or watch in gdb 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 oversizedx/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 of0x40reaches 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.
Step 2Aim the write at the saved return address
ObservationThe written value is always 0x40, and the only worthwhile target near the grid is the saved return address. Measure the exact frame offset from the grid base to that address in gdb, then aim the single-byte write at it to reach 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 the grid base and the saved return address slot p &grid # or the local's frame offset if the symbol is stripped p $rbp+8 x/gx $rbp+8 # current saved return address # offset = (rbp+8) - grid_base, then translate to (y, x) movesThe write value is fixed at
0x40, so this works when the target byte of the return address (or level counter) needs to become0x40, 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 reacheswin().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 runs inside a subroutine, so at a breakpoint there $rbp+8 is that routine's return address back into main, not main's own. Your offset is off by the size of the inner frame. Break in main's epilogue instead, or walk up with 'info frame' to read main's saved return slot.
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 always stores the literal byte 0x40, whichever key triggered it, because the tile is a hardcoded constant rather than the pressed character. Sending 'A' does not write 0x41. Build the whole exploit around 0x40 being the only value you can write, which is why the target byte has to already be near it in the code segment.
Learn more
Single-byte write is enough here. The saved return address is a code address, and every code address in the same binary shares all of its high bytes because PIE relocates the image as one block. The value already sitting there and the address you want to reach differ only in the low byte or two, so stamping
0x40over a single byte can be enough to redirect the return. That is why the fixed write value is not a real limitation: you are nudging an address, not building one.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-bounds0x40write 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.Step 3Script the moves and read the flag
ObservationThat offset is known now, and it is constant relative to the stack frame. Encode the movement sequence as a fixed keystroke string and send it over the socket with pwntools.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.pythonpython3 - <<'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 PYExpected 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 shifts with environment variables, the path length of the executable, and ASLR, so absolute addresses differ on the remote box even though the offset inside the frame does not. Grid to saved return address is a compile-time layout and stays stable. If anything worked locally and fails remotely, look for a hard-coded absolute address or a move count that quietly depended on ASLR.
Tried: Using io.sendline() instead of io.send() to deliver the move sequence
sendline() appends a newline, and the game reads single keystrokes, so that 0x0a becomes another movement rather than being ignored. The extra step shifts the player and the final write lands one offset off. Use send() so only your key bytes arrive.
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&gridversus$rbp+8in 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).