Description
A heap challenge that manages horse objects with malloc and free. The remove path frees a horse's name buffer but never clears the pointer, giving a use-after-free. The binary ships with glibc 2.33, so tcache safe-linking is active. The intended path is tcache poisoning to overwrite the free GOT entry with system, then freeing a horse whose name is /bin/sh.
Download the binary and the provided libc. Run checksec (note Partial RELRO so the GOT is writable, and confirm glibc 2.33).
Map the menu: add (malloc + read name), remove (free), and any path that reads or uses a horse entry.
wget https://artifacts.picoctf.net/c/503/horsetrack && chmod +x horsetrackchecksec --file=horsetrackpip3 install pwntoolsSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Find the use-after-freeObservationI noticed the remove path frees the horse's name buffer but never clears the table entry, which meant the dangling pointer would be handed back on the next same-size malloc and suggested confirming the UAF primitive before building any further exploit primitives.The horse struct is small (name pointer at 0x0, position at 0x8, an in-use flag at 0xc). remove() calls free() on the name buffer (or the struct) but does not NULL the table entry, so it is a dangling pointer. Because the chunk size lands in a tcache bin, the next allocation of that size reuses it, letting you read and overwrite the freed chunk's contents, including the tcache forward pointer.bash./horsetrackbash# add a horse, remove it, then add/edit to land back on the freed chunkbashobjdump -d horsetrack | grep -B2 'call.*malloc' # read the exact struct/name sizeWhat didn't work first
Tried: Use gdb's heap command or pwndbg's vis_heap_chunks immediately after calling free to confirm the UAF, then re-add a horse expecting it to overwrite the freed chunk.
If you run vis_heap_chunks without attaching before the free call, gdb shows a clean heap with no freed chunks visible because tcache entries are stored in a per-thread structure, not inline. The same-size malloc reuse is real, but you must allocate the new horse with the identical chunk size as the freed one. A name buffer one byte longer falls in a different tcache bin and returns a fresh chunk, missing the UAF entirely.
Tried: Run strings on the binary looking for /bin/sh to check if it is embedded, expecting to jump to it with a one_gadget instead of setting a name buffer.
strings finds any ASCII sequences but does not reveal whether /bin/sh is in a useful, addressable location for this exploit path. one_gadget requires control of rip plus matching register constraints that are unlikely to hold when free is redirected; the intended path writes /bin/sh into a heap name buffer under attacker control and calls system on that pointer, which is simpler and does not depend on one_gadget environment constraints.
Learn more
Why the freed chunk is reachable. glibc's per-thread
tcachepushes a freed chunk (under 0x410) onto a size-bucketed singly linked list and hands it straight back on the next same-sizemallocwithout zeroing. The dangling table entry plus that reuse is the whole primitive: you can both leak the freed chunk's bytes and overwrite itsfdpointer.Step 2
Poison the tcache fd (mind safe-linking)ObservationI noticed the binary ships with glibc 2.33, which introduced safe-linking that mangles the tcache fd as (chunk_addr >> 12) XOR target, and the UAF gave a dangling read to leak the heap address needed to compute the correct mangled pointer before poisoning.glibc 2.32+ mangles the tcache fd as fd = (chunk_address >> 12) XOR target. So to poison a bin to return an arbitrary address you must know the freed chunk's address (a heap leak via the UAF read) and write the mangled value, not the raw target. Aim the poisoned bin at the free GOT entry (the binary is Partial RELRO, so the GOT is writable).pythonpython3 - <<'PY' def mangle(chunk_addr, target): return (chunk_addr >> 12) ^ target # glibc 2.32+ safe-linking # leak a heap address through the UAF read first, then: # poisoned_fd = mangle(freed_chunk_addr, elf.got['free']) PYYou also need a libc leak (read an unsorted-bin chunk's fd/bk via the UAF, or leak a GOT entry) to compute
system's address. The exact offsets are tied to the shipped glibc 2.33; recompute them against the provided libc.What didn't work first
Tried: Skip the heap leak step and write the raw target address (elf.got['free']) directly into the freed chunk's fd pointer, expecting tcache to hand it back.
On glibc 2.32+ safe-linking is always active. Writing a raw address into fd produces a mangled pointer that the allocator interprets as (raw_addr XOR 0), which points somewhere random and typically triggers a malloc abort or segfault before you reach the GOT. You must read the heap address via the UAF first and compute fd = (freed_chunk_addr >> 12) XOR target.
Tried: Use a libc offset computed against the system's installed glibc instead of the shipped libc.so.6, then run the exploit remotely and find that system is at the wrong address.
ASLR randomizes only the base; the internal offsets between symbols depend on the exact build. The remote server runs the provided glibc 2.33 whose symbol offsets differ from a locally installed 2.35 or 2.36 library. Computing system's offset with ldd on the local binary or with a different libc version gives an address that, when written into the GOT, points to garbage and crashes or opens a shell that exits immediately.
Learn more
What safe-linking changes. Before glibc 2.32 you could write a target address straight into a freed chunk's
fdand the next-next allocation would return it. Safe-linking XORs the pointer with(chunk_addr >> 12), so a naive poison yields a corrupted, often-unaligned pointer that aborts. Recovering the heap address via the UAF read and applying the same transform is mandatory on this binary.Step 3
Overwrite free@GOT with system and free a /bin/sh horseObservationI noticed checksec reported Partial RELRO, meaning the GOT is writable, and that free is called with the name buffer pointer as its only argument, which suggested redirecting free@GOT to system and then freeing a horse whose name is /bin/sh to get a shell.Allocate from the poisoned bin until malloc returns a chunk over the free GOT entry, then write system there. Now free() is system(). Create a horse whose name buffer holds the string /bin/sh and free it: free(name) becomes system("/bin/sh"), giving a shell to cat the flag.pythonpython3 - <<'PY' from pwn import * elf = ELF("./horsetrack") libc = ELF("./libc.so.6") io = remote("saturn.picoctf.net", <PORT_FROM_INSTANCE>) # 1) leak heap + libc via the UAF read # 2) tcache-poison (safe-linked) a bin -> elf.got['free'] # 3) allocate over the GOT and write system there # 4) set a horse's name to b"/bin/sh\x00" and free it -> system("/bin/sh") io.interactive() PYExpected output
picoCTF{...}What didn't work first
Tried: Overwrite the malloc GOT entry with system instead of free, then trigger an allocation with a /bin/sh-named horse expecting system to run.
malloc is called with a size argument, not a string pointer, so overwriting malloc with system passes a numeric size (e.g. 0x20) as the first argument to system, not the string /bin/sh. The call does nothing useful or crashes. The correct target is free because free(ptr) passes the name buffer pointer as the first argument, and you control that buffer's contents.
Tried: Write /bin/sh directly into the horse's position or flag field rather than the name buffer, then free the horse struct itself.
The name field is a pointer to a separately allocated buffer; free is called on that pointer, not on the struct itself. Placing /bin/sh bytes in the struct's position or flag field has no effect on what gets passed to free. You must allocate a name buffer containing /bin/sh and ensure the name pointer inside the struct points to it so that free(horse->name) becomes system("/bin/sh").
Learn more
Why free@GOT and not a struct function pointer. This binary's horse struct has no callable pointer to hijack, and glibc 2.34+ removed the malloc/free hooks; on 2.33 the cleanest target is the writable GOT. Redirecting
freetosystemturns the nextfree(name)intosystem(name), and you controlname. See the heap exploitation guide for tcache poisoning and Pwntools for CTF for the harness.
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{...}
UAF (remove frees without NULLing) on glibc 2.33. Leak heap+libc via the dangling read, tcache-poison a bin with the safe-linked fd = (chunk_addr>>12) XOR &free@GOT, allocate over the GOT and write system, then free a horse whose name is /bin/sh to get a shell. Not a struct function-pointer overwrite; offsets are libc-specific.