Heap Havoc picoCTF 2026 Solution

Published: March 20, 2026

Description

A seemingly harmless two-argument program hides a classic stack smash. Download vuln and vuln.c, then overwrite the saved return address to reach the flag routine.

Download vuln and its source code.

Read the source to understand how the two name arguments are handled.

bash
cat vuln.c
bash
chmod +x vuln
  1. Step 1Understand the heap layout
    The program malloc()s two name buffers, then fgets(buf_a, 64, stdin)reads up to 63 bytes into the first one regardless of the buffer's actual size. That mismatch (read length vs. allocation length) is the bug: the extra bytes spill into the next chunk on the heap.
    bash
    cat vuln.c
    bash
    checksec --file=./vuln
    bash
    ./vuln AAAA BBBB  # normal execution
    Learn more

    The heap is the memory region managed by malloc() and free(). Unlike the stack (where memory is automatically reclaimed when functions return), heap memory persists until explicitly freed. When malloc(n) is called multiple times in sequence, the allocator typically places the returned chunks adjacent to each other in memory (with a small header between them).

    A heap buffer overflowoccurs when data written to a heap-allocated buffer extends past the buffer's end and into adjacent memory. Depending on what follows the overflowed buffer, this can overwrite: another heap chunk's content (as here), a heap chunk's metadata header (enabling heap metadata corruption attacks), a function pointer stored on the heap, or a vtable pointer in a C++ object.

    Heap layout is influenced by prior allocations, the allocator implementation (ptmalloc2 in glibc, jemalloc, tcmalloc), and the platform. In CTF challenges the layout is kept simple and predictable. In real exploitation, tools like pwndbg's heap command and heapinfo visualise the heap structure interactively under GDB.

  2. Step 2Determine the overflow size
    The first buffer's size tells you exactly how many bytes are needed before you start overwriting the second buffer. Read vuln.c to find the allocation sizes.
    bash
    # Find the two malloc() calls and their sizes:
    bash
    grep malloc vuln.c
    bash
    # The second buffer follows immediately after the first in heap memory
    Learn more

    The exact number of overflow bytes needed depends on both the allocation size and the heap allocator's alignment requirements. glibc's ptmalloc2 aligns allocations to 16 bytes on 64-bit systems, so a malloc(24) actually rounds up to a 32-byte usable chunk (24 rounded up to a 16-byte multiple) plus the inline 16-byte header. The full padding budget you need before reaching buf_b is therefore 32 bytes of buf_a + header overhead, which is why a 64-byte read from fgetsoverflows comfortably into the second chunk's data.

    Two adjacent chunks from malloc(24) called twice:
    
      +--- chunk A header ---+
      | prev_size  (8 bytes) |
      | size = 0x21          |
      +--- chunk A user data |
      | buf_a[0..23]    (24) |
      | padding         (8)  |  <- alignment fill
      +--- chunk B header ---+   chunk B starts 32 bytes after buf_a
      | prev_size  (8 bytes) |
      | size = 0x21          |
      +--- chunk B user data |
      | buf_b[0..23]    (24) |
      +----------------------+
    
      fgets(buf_a, 64, stdin) accepts 64 bytes:
       buf_a[0..23]  = 24 user bytes
       [24..31]      = 8 bytes (alignment - usually still ours)
       [32..39]      = 8 bytes overwriting chunk B's header
       [40..63]      = 24 bytes overwriting buf_b user data!
    
    So writing exactly 40 chars before "WIN\0" lands "WIN" at
    buf_b[0..3] - and the program later reads buf_b and prints
    the flag because it now matches its expected magic value.

    In this challenge the source code is provided, so reading the malloc() call sizes directly is the fastest approach. In a stripped binary without source, you would use GDB to inspect the heap with pwndbg or GEF's heap chunkscommand, which displays each chunk's address, size, and status (in-use or freed).

    Heap chunk sizes always have their lowest bit set when the chunk is in use (the PREV_INUSE flag in glibc). The chunk size field is at offset -8 from the returned pointer (just before the usable data). For this challenge none of the classical metadata-corruption primitives (House of Force, fastbin dup, tcache poisoning) are needed; the second chunk's data is overwritten directly, and the program reads it as plain data. Those metadata-style attacks come into play when there is no useful target inside the next chunk's data and you have to weaponise the allocator itself; see the heap exploitation guide for the broader catalogue, and the buffer overflow guide for the read-vs-allocation bug pattern.

  3. Step 3Overflow buffer 1 into buffer 2
    Craft a first argument that overflows into the second buffer. The program then uses the overwritten second buffer's value - which you now control - to print the flag or perform a privileged action.
    python
    python3 << 'EOF'
    from pwn import *
    
    p = remote("<HOST>", <PORT_FROM_INSTANCE>)
    
    # First name: overflow to fill first buffer + overflow into second buffer
    # The exact size depends on vuln.c allocation analysis
    first_name = b"A" * 64 + b"WIN"  # overflow into second buffer's content
    
    p.sendlineafter(b"name: ", first_name)
    p.sendlineafter(b"name: ", b"ignored")  # second input doesn't matter
    
    print(p.recvall())
    EOF
    Learn more

    This is a heap-based buffer overflow leading to data corruption. By overflowing the first buffer, you overwrite the bytes of the second buffer with attacker-controlled data. The program then reads and uses the second buffer's content - perhaps printing it, comparing it to a magic value, or using it as a function pointer. Whatever the program does with the second buffer, you now control the outcome.

    In this specific challenge, overwriting the second buffer with a specific string (like WIN or a magic password) triggers the flag-printing code path. In more complex real-world heap exploits, overflowing into a heap chunk's size field enables heap metadata corruption attacks. Overwriting a function pointer stored on the heap achieves arbitrary code execution. Overwriting a vtable pointer in a C++ object is a classic technique in browser exploitation.

    Mitigations against heap overflows include: ASLR (randomises heap base), heap metadata integrity checks (ptmalloc2 checks chunk headers on free), guard pages (memory pages that trap on access placed between allocations), and memory-safe languages. AddressSanitizer (-fsanitize=address) reliably detects heap overflows at runtime and is the standard tool for finding these bugs in development.

Flag

picoCTF{h34p_h4v0c_...}

The two malloc() buffers are adjacent on the heap. Overflowing the first name buffer overwrites the contents of the second buffer. The program then uses the second buffer's now-controlled value, triggering the flag output.

How to prevent this

Adjacent heap allocations do not protect each other. The compiler does not insert canaries between malloc chunks; you have to bound the writes yourself.

  • Bound every read/strcpy/memcpy by the actual allocation size. Track the size alongside the pointer (a struct holding both, or use std::vector / std::string in C++).
  • Run with AddressSanitizer in CI. ASan adds redzones around every heap chunk and traps the moment a write crosses one; this exact bug becomes a clear test failure rather than a silent corruption.
  • Use a hardened allocator (Scudo, hardened_malloc, PartitionAlloc) in production. They isolate allocations into pools and randomize placement, making adjacent-chunk overflows much harder to exploit reliably.

Want more picoCTF 2026 writeups?

Tools used in this challenge

Related reading

What to try next