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.
Setup
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.
pwninit --bin vuln --libc libc.so.6 --ld ld-linux-x86-64.so.2patchelf --set-interpreter ./ld-linux-x86-64.so.2 --replace-needed libc.so.6 ./libc.so.6 vulnSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Get a free primitive without free(): House of Orange
ObservationThe binary never calls free() and only ever holds one live chunk, which rules out a normal use-after-free or tcache poison. House of Orange gets around that: it makes glibc's own sysmalloc retire the top chunk into the unsorted bin, producing a freed chunk and a libc pointer leak with no free() anywhere.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.
sysmalloc checks that the shrunken top chunk size keeps the PREV_INUSE bit and stays page-aligned. Fail either and malloc aborts on a corrupted-size assertion. The value you write has to end in 01, preserving that flag, while 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 unsorted-bin fd and bk pointers only appear once sysmalloc retires the chunk. Before the over-request those bytes are uninitialized heap data with no libc address in them. Request more than the shrunken top first; only then does the retired top carry 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
sysmallocretire the old top into the unsorted bin. Unsorted-bin chunks carryfd/bkpointers intomain_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.Step 2Poison tcache through the mmap/TLS boundary
ObservationWith a libc base in hand, the usual hooks are gone: glibc stopped calling __malloc_hook and __free_hook in 2.34, so on this 2.35 libc they are dead weight. Target tcache_perthread_struct instead. Since the binary lets you pick the allocation size, request above mmap_threshold to land an mmap chunk next 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.
Ordinary chunks come from the sbrk arena, which sits far from thread-local storage. Only allocations above the mmap threshold, around 128 KB, are served by mmap, and Linux places those regions next to TLS. A small request gives you a brk-backed pointer nowhere near tcache_perthread_struct.
Tried: Overwrite __malloc_hook or __free_hook in libc after controlling the tcache pointer.
glibc 2.34 stopped calling __malloc_hook and __free_hook, leaving at most inert compat symbols in this 2.35 libc, so writing to them changes nothing no matter how correct your address is. On 2.35 the targets are the FILE vtable pointer in stderr, or a setcontext gadget reached through the arbitrary allocation.
Learn more
Why target the tcache pointer in TLS. glibc stopped calling
__malloc_hookand__free_hookin 2.34, so on this 2.35 libc the old one-shot hook overwrites are dead. Controllingtcache_perthread_structinstead turns malloc into a write-what-where: whatever address you stage in a tcache bin is what the next same-sizemallocreturns. The mmap-adjacent TLS layout is what makes that pointer reachable from a single overflow.Step 3Leak libc, then pivot to RCE
ObservationThe forged tcache allocates over any address you like. On glibc 2.35 the reliable finishers are a setcontext gadget or the House of Cat FILE-vtable path. Pin the libc base with a tcache allocation over a known pointer, then pick one.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.pythonpython3 - <<'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() PYExpected 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 belong to one exact libc build: patch level, distribution, compile flags and all. The provided libc.so.6 will not match whatever you ran it against before. Run it on the supplied libc, then check the register constraints in 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.
Both the FILE vtable path and the setcontext gadget depend on the exact libc version. Run against your system libc instead of the supplied one and the offsets are wrong, so you get a crash or nothing. Point the binary at the downloaded libc with pwninit and patchelf so local matches remote.
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
setcontextgadgets that load every register from a controlled memory block. Both convert a single write primitive into full register control and then a one-gadget orsystem("/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.