Description
Plan the fastest pizza drone routes and snag a slice of the flag. Download router plus city1.map, city2.map, and city3.map, then optimize the delivery path.
Setup
chmod +x router./router city1.mapSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Identify the OOB write in the reroute command
ObservationThe reroute command takes a signed integer index with no bounds check. A negative value walks backward past the route list and into the neighboring heap allocations.The binary has areroute <id> <new_index>command that stores a signed integer index without bounds checking. A negative index writes out-of-bounds on the heap, allowing you to corrupt adjacent heap metadata and overwrite heap pointers. This is the core vulnerability.bashchmod +x routerbash./router city1.mapbash# Commands available: route, reroute, replay, receipt, dispatch, finishWhat didn't work first
Tried: Try using a positive index with reroute to see if it can reach the finish callback forward in memory.
A positive index writes inside the route list itself, never into the chunk holding the callback. That chunk was allocated earlier and sits at a lower address, so only a negative index reaches it. Positive values corrupt route data and change nothing about control flow.
Tried: Run the binary under checksec or file and assume it has no PIE because the binary looks small.
Even a small binary built with PIE is fully position-independent, and checksec says so. The load address changes every run under ASLR, so an address copied from one GDB session fails against the remote instance, where the base is different.
Learn more
An out-of-bounds (OOB) write occurs when a program writes to memory outside the bounds of its intended buffer. Unlike a stack buffer overflow (which overwrites local variables and return addresses), this challenge's OOB write targets the heap - the dynamic memory region used for
malloc()allocations.The vulnerability here is a signed/unsigned confusion bug: the
reroutecommand accepts an integer index and uses it without bounds checking. Concretely, supposeroute_listsits at heap address0x55a0c0and the C code writes toroute_list[idx]whereidxis a 64-bit signed integer the user supplied. Passidx = -16:- The compiler emits something like
mov [rax + rdi*8], rsi, whererdiholds the index. -16in two's complement is0xFFFFFFFFFFFFFFF0. Multiplied by 8 (entry size), it's0xFFFFFFFFFFFFFF80, i.e. -128.- Adding that to the base
0x55a0c0wraps below it: the store lands at0x55a040, which is the previous heap allocation.
That previous allocation is the
finishcallback structure. So a negative index lets you stomp on adjacent heap metadata or function pointers before the route array - exactly what we exploit below.This class of vulnerability is common in real-world software. Notable examples include CVE-2021-3156 (a heap-based buffer overflow in sudo that uses an off-by-one write to corrupt adjacent memory) and many CVEs in network protocol parsers. The lack of bounds checking is particularly dangerous in C/C++ where the language provides no automatic protection - unlike Python, Rust, or Java which raise exceptions or refuse to compile unsafe accesses.
- The compiler emits something like
Step 2Leak PIE base and heap address
ObservationThe binary is PIE, so every address is randomized at runtime. Get a live binary pointer out of the replay and receipt commands before attempting any overwrite.Use thereplay <id>command to leak a binary address at heap offset +0x2260 (PIE base). Use thereceipt <id>command to leak a heap pointer. Compute PIE base from the leaked address.pythonpython3 << 'EOF' from pwn import * p = remote("<HOST>", <PORT_FROM_INSTANCE>) # or: p = process(["./router", "city1.map"]) # Trigger route allocation p.sendlineafter(b"> ", b"route 0 1") # create a route entry # Leak PIE base via replay command (reads heap + 0x2260) p.sendlineafter(b"> ", b"replay 0") leak_data = p.recvline() pie_leak = int(leak_data.split()[-1], 16) pie_base = pie_leak - 0x2260 # adjust offset from binary analysis log.info(f"PIE base: {hex(pie_base)}") # Leak heap pointer via receipt command p.sendlineafter(b"> ", b"receipt 0") heap_data = p.recvline() heap_leak = int(heap_data.split()[-1], 16) heap_base = heap_leak - 0x??? # adjust offset log.info(f"Heap base: {hex(heap_base)}") EOFWhat didn't work first
Tried: Use strings or objdump to find a hardcoded PIE address and skip the replay leak entirely.
PIE loads the binary at a random base each run, so no address in the file matches a runtime one. strings and objdump report file-relative offsets, not live addresses. Without a runtime pointer from replay, there is no base to compute.
Tried: Assume the heap offset is always +0x2260 and skip re-deriving it on the actual binary.
That offset belongs to this exact build and its allocation sequence. A recompile or a different libc rearranges the heap and moves the pointer to another slot. Dump the heap in GDB, find a value inside the text segment, and compute the offset from that session's route list.
Learn more
PIE (Position-Independent Executable) is a security feature that randomises the base address where the binary is loaded in memory at runtime (ASLR - Address Space Layout Randomisation applied to the executable itself). Without a leak, an attacker cannot predict the addresses of functions, gadgets, or data structures.
An information leak (or memory disclosure vulnerability) is a bug that causes the program to output memory contents it shouldn't. In this challenge, the
replaycommand reads a value from a specific heap offset and prints it; that value happens to contain a pointer into the binary's code section. Since the binary and heap are loaded at fixed offsets relative to each other (their layout within a process is deterministic even if the base addresses are random), knowing one pointer leaks the randomisation for both.Where the +0x2260 number came from. The offsets in this walkthrough are specific to this build of
router. Re-derive them on a fresh binary with GDB:- Break on
routehandling so the heap structure has been allocated. Note the base frominfo proc mappingsorp $rebase(0). - Dump the heap region:
x/256gx $heap_base(where$heap_baseis the address printed when an earlymallocreturned). - Spot the slot whose value lies inside the binary's text segment - that's the leakable PIE pointer. Subtract its base address from the value to get the in-binary offset (the constant you hardcode into the exploit), and subtract the heap base from its slot to get +0x2260.
- For the
finishcallback at +0x430: break ondispatch, single-step until you see a call through a heap-stored pointer, and note the offset fromroute_list's base.
The pattern of "leak an address to defeat ASLR, then use it to compute target addresses" is the standard approach in modern exploitation. Most modern exploits require at least one information leak before they can compute reliable ROP chain addresses or overwrite function pointers. For more on the ASLR/PIE pieces see ASLR and PIE bypass for CTF; for the broader heap-corruption playbook see Heap exploitation for CTF.
- Break on
Step 3Overwrite the finish callback at heap offset +0x430
ObservationThe finish callback lives on the heap at a fixed offset from the route list. A negative index puts the out-of-bounds write on exactly that slot, and the dispatch call goes wherever you point it.Using the OOB write viareroutewith a negative index, overwrite thefinishcallback function pointer stored at heap offset +0x430 with the address of the win function or a one_gadget. Then calldispatchto trigger the overwritten callback.pythonpython3 << 'EOF' from pwn import * p = remote("<HOST>", <PORT_FROM_INSTANCE>) # After leaking addresses, compute the target and payload win_addr = pie_base + 0x???? # address of win/print_flag function # Negative index for OOB write - reroute <id> <negative_idx> # The exact offset depends on heap layout analysis p.sendlineafter(b"> ", b"reroute 0 -<OFFSET>") # Write win_addr bytes into the finish callback slot at +0x430 # Trigger the overwritten callback p.sendlineafter(b"> ", b"dispatch") print(p.recvall()) EOFExpected output
picoCTF{...}What didn't work first
Tried: Use one_gadget on the router binary itself to find a magic gadget and jump there directly.
one_gadget searches libc for sequences that reach execve with the right register state, and those live in the C library's wrapper chain. This binary has nothing comparable. Target its own win or print_flag function, adding the offset Ghidra gives you to the recovered base.
Tried: Compute the negative reroute index from the +0x430 offset alone without accounting for the route_list base.
The index is relative to the route list's runtime address, not to the start of the heap. Compute it as the distance between the callback and the route list divided by 8, which needs both addresses from a live dump. Using the raw structure offset ignores the heap distance and lands the write somewhere else entirely.
Learn more
Function pointer overwrites are one of the most powerful heap exploitation primitives. When an attacker can overwrite a function pointer stored on the heap (e.g. a callback, a vtable entry, or a registered handler), the next call to that function executes the attacker's chosen address instead. This redirects control flow without touching the return address, bypassing stack canaries entirely.
The
dispatchcommand that triggers the overwrittenfinishcallback is the equivalent of the "trigger" step in any use-after-free or heap corruption exploit: you first set up the corruption, then trigger the code path that dereferences the corrupted pointer. The window between corruption and trigger is where real-world mitigations like pointer authentication codes (PAC on ARM) or safe unlinking checks would intervene.One-gadget (also called "magic gadget") refers to a specific location in libc that, when jumped to directly, executes
execve("/bin/sh")with the right register/environment conditions. Tools like theone_gadgetRuby gem enumerate these addresses in a given libc. Unlike a full ROP chain, a one-gadget lets you win with a single pointer overwrite, if the environmental constraints at the call site are met. When constraints don't match, you fall back to a real ROP chain - see ROP chains without libc.Porting this exploit to a new build. The hardcoded numbers (
0x2260,0x430, plus the win-function offset) will drift if the binary is recompiled. The fix is mechanical:- Open the binary in Ghidra, find the global named something like
route_list,g_routes, or whatever the data section names suggest. - Read its raw address from
objdump -s -j .data router(or.bss, depending on initialisation). - Subtract the binary's base from the leaked pointer to recover the live PIE base, then add the win-function offset (also from Ghidra) to get the destination address.
- Re-derive the OOB index by computing
(target_slot - route_list) / 8with the base from your heap dump.
- Open the binary in Ghidra, find the global named something like
Interactive tools
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
Flag
Reveal flag
picoCTF{...}
This flag could not be verified. The binary reads /flag.txt from the live instance, so there is no offline derivation, and no independent solve publishes the value. The site's earlier value was a guess and has been withdrawn. Run the exploit against your own instance to get yours.