Skip to main content

Horsetrack picoCTF 2023 Solution

The remove path frees a name buffer without clearing the pointer, so poison the tcache and swap free@GOT for system.

Published: April 26, 2023Updated: August 25, 2026

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.

bash
chmod +x horsetrack
bash
checksec --file=horsetrack
bash
pip3 install pwntools

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Find the use-after-free
    Observation
    The 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
    ./horsetrack
    bash
    # add a horse, remove it, then add/edit to land back on the freed chunk
    bash
    objdump -d horsetrack | grep -B2 'call.*malloc'   # read the exact struct/name size
    What 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 tcache pushes a freed chunk (under 0x410) onto a size-bucketed singly linked list and hands it straight back on the next same-size malloc without zeroing. The dangling table entry plus that reuse is the whole primitive: you can both leak the freed chunk's bytes and overwrite its fd pointer.

  2. Step 2Poison the tcache fd (mind safe-linking)
    Observation
    The 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).
    python
    python3 - <<'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'])
    PY

    You 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 fd and 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.

  3. Step 3Overwrite free@GOT with system and free a /bin/sh horse
    Observation
    checksec 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.
    python
    python3 - <<'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()
    PY

    Expected 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 free to system turns the next free(name) into system(name), and you control name. 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.

Key takeaway

Use-after-free bugs come from freeing a heap allocation while keeping a live pointer to it, which the allocator then hands to the next allocation. glibc's tcache recycles the freed chunk immediately, so writing through the stale pointer overwrites the new allocation's metadata, the free list's forward pointer included. Poison that pointer and a later malloc returns an address of your choosing, which is a write anywhere in memory, the Global Offset Table included.

Related reading

Useful tools for Binary Exploitation

Where to go next