Description
Exploit a use-after-free vulnerability. nc mercury.picoctf.net PORT
Setup
Download the binary and analyze it.
Install pwntools.
Find the port on the instance launch panel and substitute it for <PORT_FROM_INSTANCE>.
wget <url>/vulnchmod +x vulnchecksec vulnpip install pwntoolsSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Identify the use-after-free vulnerabilityObservationI noticed the challenge description explicitly calls out a use-after-free vulnerability and the binary name is 'vuln', which suggested disassembling it to find where a heap pointer is freed without being nulled and then reused through the dangling reference.Disassemble vuln (32-bit x86, little-endian). It allocates a user struct on the heap with two 4-byte fields: a function pointer (whatToDo) and a username pointer. The 'I' (inquire about deletion) path frees the struct without nulling the global pointer, leaving a dangling reference.bashobjdump -d vuln | grep -A20 '<main>'bashnm vuln | grep haha # find the win symbol hahaexploitgobrrrWhat didn't work first
Tried: Run strings vuln hoping to find the win function name and its address directly.
strings prints hahaexploitgobrrr as a symbol name string but gives no numeric address. Addresses are only available after linking and must be read with nm or readelf -s; strings only scans for printable character sequences and has no concept of the symbol table.
Tried: Use Ghidra to look for a buffer overflow or stack smash rather than a heap UAF.
Ghidra decompiles the allocation and free paths clearly, but if you focus on stack frames you will not see a vulnerability there - the struct is heap-allocated and there is no classic overflow. The bug is a freed-but-not-nulled heap pointer, which only becomes visible when you trace the doProcess indirect call back to user_ptr after the free.
Learn more
A use-after-free (UAF) bug occurs when the program continues to use a pointer after the memory it points to has been freed. The freed memory can be reclaimed by a subsequent allocation of the same size; whatever the new owner writes there shows through the dangling pointer. See heap exploitation for the broader playbook.
Step 2
Find the function-pointer fire siteObservationI noticed the user struct contains a whatToDo function pointer field, which suggested searching for the indirect call instruction in the disassembly to confirm exactly when and where the program dereferences that pointer so we know when our overwrite will trigger.The function pointer fires in the main loop: after every menu action, main calls doProcess(user), which dereferences user_ptr->whatToDo and calls it. Because user_ptr is never nulled after free, the dangling pointer is live on every iteration. Confirm the indirect call in objdump: it appears as call DWORD PTR [eax] (32-bit, not QWORD).bashobjdump -d vuln | grep -B2 -A1 'call.*\['What didn't work first
Tried: Search for call instructions using grep 'call' without filtering for indirect calls through a register or memory operand.
Plain grep 'call' matches hundreds of direct call sites (PLT stubs, library calls) and buries the one indirect call DWORD PTR [eax] that is the actual fire site. Filtering for 'call.*\[' isolates only the indirect call-through-pointer patterns, making the dangerous site visible.
Tried: Assume the function pointer fires immediately when the menu option is chosen, without looking for a secondary dispatch function.
The pointer does not fire inside the menu branch itself. It fires in doProcess(), which main() calls after every branch returns. Without tracing the post-branch call, you would think the UAF window is too narrow and miss that every subsequent menu iteration is also a viable trigger.
Step 3
Reclaim the chunk, overwrite the fn pointerObservationI noticed the 'L' (leave message) path allocates an 8-byte buffer, the same size as the freed user struct, which suggested that tcache's LIFO policy would return the freed chunk to the 'L' path and let us overwrite whatToDo with the address of hahaexploitgobrrr.Send 'I' and confirm with 'Y' to free the user struct. Then send 'L' to leave a message: this calls malloc(8), which tcache hands back the just-freed chunk. Writing p32(win) + p32(0) overwrites whatToDo with the win address. On the very next main-loop iteration, doProcess(user) calls user->whatToDo() and lands in hahaexploitgobrrr.pythonpython3 - <<'EOF' from pwn import * elf = ELF('./vuln') win = elf.sym['hahaexploitgobrrr'] p = remote('mercury.picoctf.net', <PORT_FROM_INSTANCE>) p.sendlineafter(b'>', b'S') # leak: prints hahaexploitgobrrr address p.sendlineafter(b'>', b'I') # inquire about deletion: free(user); ptr not nulled p.sendlineafter(b'(Y/N)?', b'Y') p.sendlineafter(b'>', b'L') # leave message: malloc(8) reuses freed chunk p.sendlineafter(b'message:', p32(win) + p32(0)) # main loop calls doProcess(user) -> user->whatToDo() -> hahaexploitgobrrr print(p.recvall(timeout=2).decode(errors='ignore')) EOFExpected output
picoCTF{d0ubl3_j30p4rdy_...}What didn't work first
Tried: Pack the win address with p64(win) instead of p32(win) when writing the reclaim payload.
checksec reports this is a 32-bit ELF, so pointers are 4 bytes wide, not 8. p64(win) writes 8 bytes for the address alone, overflowing the 8-byte chunk and corrupting adjacent heap metadata. The exploit either crashes with a segfault in malloc's internal unlink or writes into the wrong field entirely. Use p32(win) + p32(0) to exactly match the two 4-byte fields of the user struct.
Tried: Send the 'L' (leave message) option before sending 'I' + 'Y' to free the struct, thinking the allocation order does not matter.
The tcache reuse only works if the chunk is freed first. Sending 'L' before 'I'+'Y' allocates a fresh message chunk at a different address; the user struct is still live and its whatToDo field is untouched. The free must happen before the reclaim allocation so tcache's LIFO policy returns the same chunk to the 'L' path.
Learn more
Step-by-step heap state. This is a 32-bit binary, so the user struct is 8 bytes (two 4-byte pointers: whatToDo and username). The message buffer from 'L' is also 8 bytes, landing in the same tcache bin:
(1) [S] Subscribe leak: user_ptr already allocated; hahaexploitgobrrr addr printed (2) [I + Y] Inquire/delete: free(user); heap: [size=0x10 | fd: NULL] <- now in tcache[0x10] user_ptr STILL POINTS HERE (dangling) (3) [L] Leave message: msg = malloc(8); read(fd, msg, 8); // p32(win)+p32(0) tcache[0x10] LIFO -> returns the same chunk heap: [size=0x10 | whatToDo: <win> | username: 0] user_ptr->whatToDo == win (aliased) (4) main loop: doProcess(user) -> user->whatToDo() jumps to hahaexploitgobrrr -> flagWhy p32? The binary is 32-bit (confirmed by checksec), so pointers are 4 bytes.
p32(win)packs the win address as a little-endian 4-byte value, followed by 4 null bytes for the username pointer field. The program reads exactly 8 bytes into the message slot, overwriting both fields of the now-freed user struct.
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{d0ubl3_j30p4rdy_...}
After free(), the memory can be reclaimed by the next malloc of the same size. Writing a function address there overwrites the struct's function pointer through the dangling reference.