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). Get the files from the challenge page on CyLab Security Academy (formerly play.picoctf.org).
Map the menu: add (malloc + read name), remove (free), and any path that reads or uses a horse entry.
chmod +x horsetrackchecksec --file=horsetrackpip3 install pwntoolsSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Find the use-after-free
ObservationThe remove path frees the horse's name buffer and never clears the table entry, so the dangling pointer comes back on the next same-size malloc. Confirm that use-after-free before building anything on top of it.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.
Visualize the heap without attaching before the free and gdb shows a clean layout with no freed chunks, because tcache entries live in a per-thread structure rather than inline. The same-size reuse is real, but the new horse has to match the freed chunk's size exactly. A name buffer one byte longer falls into a different bin and returns a fresh chunk, missing the bug 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 ASCII sequences without telling you whether the string sits somewhere addressable for this path. one_gadget needs control of rip plus register constraints unlikely to hold when free is redirected. The intended route writes /bin/sh into a heap name buffer you control and calls system on that pointer, which is simpler and free of 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 2Poison the tcache fd (mind safe-linking)
ObservationThe binary ships glibc 2.33, where safe-linking mangles the tcache forward pointer against the chunk's own address. The use-after-free gives a dangling read that leaks the heap address needed to compute the correct mangled value 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.
From glibc 2.32 onward, safe-linking is always on. Write a raw address into the forward pointer and the allocator demangles it into something random, usually aborting inside malloc or segfaulting before you reach the GOT. Read the heap address through the use-after-free first, then compute the mangled value from the freed chunk's own address.
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 offsets between symbols belong to the exact build. The remote server runs the supplied glibc 2.33, whose offsets differ from a locally installed 2.35 or 2.36. Compute system's offset against the wrong library and the GOT entry points at garbage, crashing or opening a shell that exits at once.
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 3Overwrite free@GOT with system and free a /bin/sh horse
Observationchecksec reports Partial RELRO, so the GOT is writable, and free receives the name buffer pointer as its only argument. Redirect free's GOT entry to system, then free a horse whose name is /bin/sh.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 receives a size, not a string pointer, so redirecting it to system passes a small number as the first argument rather than a command. The call does nothing useful, or crashes. free is the right target, because it passes the name buffer pointer as its 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, and free is called on that pointer rather than on the struct. Putting the string into the struct's position or flag field changes nothing about what free receives. Allocate a name buffer holding /bin/sh and make sure the struct's name pointer points at it.
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.