high frequency troubles picoCTF 2024 Solution

Published: April 3, 2024

Description

The least-solved challenge of picoCTF 2024: only 31 teams cleared it out of more than 7000. A glibc 2.35 heap challenge that hands you almost nothing. You can allocate a chunk of a chosen size and overflow it with gets(), and you can echo bytes back, but there is no free() anywhere and you only ever hold one chunk at a time. The solve chains the House of Orange free-the-top trick with modern glibc internals to reach a shell.

Remote + binary + libc

Download the binary and the provided libc/ld (glibc 2.35). Patch the binary to use them so local debugging matches remote.

Map the menu: which option allocates (with a size you control), which one writes input, and which one echoes memory back.

bash
pwninit --bin vuln --libc libc.so.6 --ld ld-linux-x86-64.so.2
bash
patchelf --set-interpreter ./ld-linux-x86-64.so.2 --replace-needed libc.so.6 ./libc.so.6 vuln

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Get a free primitive without free(): House of Orange
    Observation
    I noticed the binary has no free() call and only ever holds one live chunk, which meant a standard UAF or tcache poison was off the table; this suggested the House of Orange technique, which coerces glibc's own sysmalloc to retire the top chunk into the unsorted bin, manufacturing a freed chunk and a libc pointer leak without ever calling free() directly.
    Use the gets() overflow to overwrite the top chunk's size field with a smaller, page-aligned value. Then request an allocation that no longer fits in the shrunken top. malloc is forced to extend the heap (sysmalloc), and in doing so it frees the old top chunk into the unsorted bin. That gives you a freed chunk and, once you echo it back, a libc/heap pointer leak, all without ever calling free().
    bash
    # Overflow into the top chunk size, keep it page-aligned and PREV_INUSE set.
    bash
    # Then malloc a size larger than the remaining top -> sysmalloc frees old top to unsorted bin.
    bash
    # Echo the now-freed region to leak an unsorted-bin fd/bk (main_arena -> libc).
    What didn't work first

    Tried: Set the top chunk size to an arbitrary small value without keeping the PREV_INUSE bit set.

    glibc's sysmalloc checks that the shrunken top chunk size has PREV_INUSE (bit 0) set and that it is page-aligned; if either check fails, malloc aborts with a 'corrupted size vs. prev_size' assertion. The overwrite must produce a value like 0xXXX01 where the bottom byte preserves the P flag and the total is a multiple of the page size.

    Tried: Read the leak by echoing the original chunk before triggering the over-request that frees the old top.

    The fd/bk unsorted-bin pointers are only written into the chunk after sysmalloc retires it - before the over-request, the bytes still contain uninitialized heap data and there is no libc address to read. The over-request (allocation larger than the shrunken top) must happen first; only then does the freed top carry valid main_arena pointers.

    Learn more

    Why House of Orange fits the constraints. The whole point of this technique is to manufacture a freed chunk when you are not allowed to free. Shrinking the top chunk and over-requesting makes sysmalloc retire the old top into the unsorted bin. Unsorted-bin chunks carry fd/bk pointers into main_arena, so reading them yields a libc base, the prerequisite for everything downstream. The classic version also corrupts _IO_list_all, but here you only need the leak and the freed chunk.

  2. Step 2
    Poison tcache through the mmap/TLS boundary
    Observation
    I noticed that once we had a libc base, __malloc_hook and __free_hook no longer existed in glibc 2.35, which suggested targeting tcache_perthread_struct instead; and because the binary lets us choose allocation size, we could request above mp_.mmap_threshold to land an mmap chunk adjacent to TLS and overflow into the tcache pointer field.
    Request a size above mp_.mmap_threshold so the allocation is served by mmap. The mmap'd region is placed adjacent to the thread's TLS, which contains the pointer to tcache_perthread_struct. Overflow out of the mmap chunk to overwrite that pointer so the tcache metadata lands on a region you control. Now the tcache freelist is yours: you can hand malloc an arbitrary address and get it back as a chunk, i.e. an arbitrary write/alloc primitive.
    bash
    # size > mp_.mmap_threshold (default ~128KB) -> mmap-backed chunk next to TLS.
    bash
    # Overflow to overwrite the tcache_perthread_struct pointer in TLS.
    bash
    # Then allocations are pulled from your forged tcache -> alloc at chosen addresses.
    What didn't work first

    Tried: Use a heap-sized allocation (well below mp_.mmap_threshold) expecting it to land next to TLS the same way.

    Heap chunks come from the sbrk arena, which sits far from the thread-local storage segment. Only allocations above mp_.mmap_threshold (~128 KB by default) are served via mmap, and the kernel places those mmap regions adjacent to TLS on Linux x86-64. Requesting a small chunk produces a brk-backed pointer nowhere near the tcache_perthread_struct field.

    Tried: Overwrite __malloc_hook or __free_hook in libc after controlling the tcache pointer.

    glibc 2.35 removed __malloc_hook and __free_hook entirely; the symbols do not exist in the provided libc and any address computed for them will land on unrelated data or out-of-bounds memory. The correct targets on 2.35 are the FILE vtable pointer in stderr or a setcontext gadget reached through the tcache arbitrary-alloc primitive.

    Learn more

    Why target the tcache pointer in TLS. glibc 2.35 removed __malloc_hook and __free_hook, so the old one-shot hook overwrites are gone. Controlling tcache_perthread_struct instead turns malloc into a write-what-where: whatever address you stage in a tcache bin is what the next same-size malloc returns. The mmap-adjacent TLS layout is what makes that pointer reachable from a single overflow.

  3. Step 3
    Leak libc, then pivot to RCE
    Observation
    I noticed the forged tcache gave us arbitrary allocation over any address, and verified analysis of this challenge confirmed that a setcontext gadget or the House of Cat FILE-vtable path are the reliable control-flow hijacks remaining on glibc 2.35, which suggested pinning the libc base via a tcache alloc over a known pointer and then choosing one of those two finishers.
    Use the forged tcache to allocate over leaked libc pointers and pin the exact libc base. From there, two published paths reach a shell: (a) write a setcontext-based ROP/SROP payload into a writable libc region and pivot RSP to it so it runs system('/bin/sh'); or (b) the House of Cat path: overwrite the top size to trigger __malloc_assert, having first corrupted stderr's _IO_FILE vtable handling so the assertion's I/O flush jumps through your crafted FILE structure. Either way you end with code execution.
    python
    python3 - <<'PY'
    from pwn import *
    
    libc = ELF("./libc.so.6")
    io = remote("<HOST>", <PORT_FROM_INSTANCE>)
    
    # 1) House of Orange: shrink top, over-request, echo to leak main_arena -> libc base
    # 2) mmap alloc + overflow to overwrite tcache_perthread_struct pointer in TLS
    # 3) forged tcache alloc over a target, write setcontext payload, pivot to system("/bin/sh")
    # (offsets are libc-version specific; compute from the provided 2.35 libc)
    
    io.interactive()
    PY

    Expected output

    picoCTF{mm4p_mm4573r_...}

    Two verified approaches differ only in the final pivot. One uses the mmap/TLS tcache poison plus a setcontext gadget; the other uses the House of Cat chain (overwrite stderr, corrupt the top size to fire __malloc_assert, and ride the FILE vtable). Pick whichever matches the gadgets in this exact libc.

    What didn't work first

    Tried: Copy a one_gadget address from a different glibc 2.35 build and use it directly without recomputing the offset.

    one_gadget offsets are specific to the exact build of libc (minor patch level, distribution, compile flags). The provided libc.so.6 almost certainly differs from any other libc build you might have run one_gadget against. The correct workflow is to run one_gadget against the challenge-supplied libc and verify constraints (rax/rdx must be null, etc.) with GDB against the patched local binary before going remote.

    Tried: Use the House of Cat FILE vtable path with the system-supplied /lib/x86_64-linux-gnu/libc.so.6 instead of the provided libc.

    The FILE vtable corruption and setcontext gadget offsets both depend on the exact libc version. Running the exploit against the OS-default libc instead of the challenge-provided one gives wrong offsets and either crashes or produces no shell. pwninit and patchelf must both point the binary at the downloaded libc so local and remote environments match.

    Learn more

    Why setcontext / FILE-vtable instead of hooks. On glibc 2.35 the reliable control-flow hijacks left are the FILE stream vtable path (any forced stdio operation, including an assertion failure flush, dispatches through a vtable pointer you can corrupt) and setcontext gadgets that load every register from a controlled memory block. Both convert a single write primitive into full register control and then a one-gadget or system("/bin/sh") call.

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{mm4p_mm4573r_...}

No free() and one live chunk, on glibc 2.35. House of Orange (shrink top, over-request) manufactures a freed chunk and a libc leak; an mmap-sized allocation sits next to TLS so an overflow rewrites the tcache_perthread_struct pointer, giving arbitrary allocation; then a setcontext gadget (or the House of Cat FILE-vtable path via __malloc_assert) lands a shell.

Key takeaway

Advanced heap exploitation chains multiple primitives together when the obvious ones (free, hooks) are unavailable or patched out. House of Orange manufactures a freed chunk without ever calling free() by corrupting the top chunk size so glibc's own allocator retires it; later techniques exploit predictable memory layout (mmap regions adjacent to TLS) to poison allocator metadata and gain arbitrary allocation. glibc 2.35 removed __malloc_hook and __free_hook precisely to break one-shot overwrites, pushing attackers toward FILE vtable corruption and setcontext gadgets, which is why each glibc version bump shifts the exploit landscape.

Related reading

Want more picoCTF 2024 writeups?

Useful tools for Binary Exploitation

What to try next