heap 0 picoCTF 2024 Solution

Published: April 3, 2024

Description

Are overflows just a stack concern?

Local + remote

Download the heap0 binary and source, then review how the write option copies input onto the heap.

Connect to the live challenge instance at tethys.picoctf.net <PORT_FROM_INSTANCE>.

bash
wget https://artifacts.picoctf.net/c_titan/31/heap0 && \
chmod +x heap0 && \
wget https://artifacts.picoctf.net/c_titan/31/heap0.c && \
nc tethys.picoctf.net <PORT_FROM_INSTANCE>

Menu overview

  • 1. Print heap (shows your buffer).
  • 2. Write to buffer (overflow opportunity).
  • 3. Print safe_var (starts as "bico", the target to corrupt).
  • 4. Print flag (only works once safe_var no longer equals "bico").

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it

This is the first heap exploitation challenge. Once you master this basic heap overflow (corrupting a string guard variable), continue to heap 1 (overwriting with a specific string), heap 2 (function pointer hijacking), and heap 3 (use-after-free). The Buffer Overflow and Binary Exploitation guide explains tcache poisoning and heap exploitation fundamentals in depth.

  1. Step 1
    Measure the gap
    Observation
    I noticed the source file heap0.c showed two consecutive malloc() calls placing a 32-byte buffer immediately before safe_var on the heap, which suggested the distance between them was the exact overflow offset needed to corrupt safe_var.
    Reading heap0.c reveals your buffer is allocated just before safe_var, with 32 bytes between them. safe_var is initialized to "bico"; the flag prints once safe_var no longer equals "bico". Overflowing with 33 or more characters will overwrite safe_var, corrupting the "bico" string.
    Learn more

    The heap is the region of memory used for dynamic allocations (via malloc, calloc, new). Unlike the stack (which is managed automatically), heap memory is manually allocated and freed by the programmer. The C runtime maintains the heap as a series of chunks, where each chunk has a header storing its size and status, followed by the user data.

    Heap layout right after the program's two malloc()s on glibc:
    
      +--------- chunk for buffer ----------+
      | prev_size  (8 bytes, usually 0)     |
      | size       0x31  (0x30 total + PREV_INUSE)|
      | user data buffer[0..31]             |   <- our 32-byte buffer
      +--------- chunk for safe_var --------+
      | prev_size  (unused while in use)    |
      | size       0x31                     |
      | safe_var   (the guard, 8 bytes)     |   <- target
      +-------------------------------------+
      | top chunk (rest of heap arena)      |
      +-------------------------------------+
    
    The "32-byte gap" is the user-data span of buffer's chunk.
    scanf("%s", input_data) reads until whitespace, writing past
    the 32-byte boundary with no length check. Any input longer
    than 32 bytes spills into safe_var's chunk and overwrites the
    "bico" string stored there. Once safe_var != "bico", check_win
    prints the flag.

    When two heap allocations happen in sequence, glibc's ptmalloc places their chunks contiguously inside the "top chunk" of the arena. There is no guard page between them, no canary, and no metadata between chunks while they're both in use. That adjacency is the key insight: overflowing the first allocation corrupts the second. This is fundamentally the same as a stack buffer overflow, but on the heap.

    The ptmalloc chunk header is 16 bytes on 64-bit glibc: 8 bytes prev_size + 8 bytes size (with the low 3 bits encoding flags like PREV_INUSE). So a 32-byte user request becomes a 48-byte chunk total. The header eats space whether you read or write to it; only the user data region is meant to be touched.

    The 32-byte gap tells you exactly how many bytes to write before reaching safe_var. In a real exploit, you might not have the source code and would need to determine this offset experimentally (by writing increasing amounts of data and observing when safe_var changes) or by reading the binary in a disassembler to find the allocation sizes.

    Heap layout can vary between systems due to alignment, debug allocators, and allocator implementation differences. Always test your exploit on the same environment as the target - a heap overflow that works locally may fail remotely if the heap layout differs.

  2. Step 2
    Trigger the overflow
    Observation
    I noticed that the write option used scanf("%s", input_data) with no length limit and a 32-byte buffer, which suggested sending 33 or more non-whitespace characters would overflow past the buffer boundary and corrupt the adjacent safe_var on the heap.
    Use option 2 and enter at least 33 characters (33+ As). scanf("%s", input_data) reads until whitespace with no length limit, so any input longer than 32 bytes spills directly into safe_var, overwriting the "bico" string that sits adjacent on the heap. Once safe_var no longer equals "bico", check_win() reveals the flag. See the heap exploitation guide and the buffer overflow guide for the broader memory-corruption picture.
    bash
    AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
    What didn't work first

    Tried: Sending exactly 32 A's to fill the buffer

    32 A's fill the buffer perfectly but do not overflow into safe_var at all. scanf appends a null byte at position 32, which is still inside the buffer's user-data region. safe_var remains "bico" and the win condition is never triggered. You need at least 33 characters so the 33rd byte lands in safe_var's chunk.

    Tried: Sending input that contains a space (e.g. 'AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA')

    scanf("%s", ...) stops reading at the first whitespace character. A space in the middle of your input causes scanf to terminate early, writing far fewer bytes than intended and leaving safe_var untouched. Use a continuous string of non-whitespace characters such as 33 or more A's with no spaces.

    Learn more

    scanf("%s", ...) reads characters until it hits whitespace (space, tab, newline) and appends a null byte, with no length limit. The buffer here is only 32 bytes, but scanf will happily write a 100-byte string into it, overflowing into whatever sits next on the heap - in this case safe_var, which starts with the string "bico".

    The win condition is strcmp(safe_var, "bico") != 0: the flag prints when safe_var no longer equals "bico". Writing 33 or more characters overwrites the first byte of safe_var with A (0x41), making it "Aico" instead of "bico". That corrupted string fails the strcmp check, triggering the win.

    The choice of A as filler is a CTF convention. The hex value of A is 0x41, making filled buffers visually distinctive in hex dumps (you'll see rows of 41 41 41 41...). This makes it easy to spot exactly where your input landed in memory. Similarly, B (0x42) is used for a second buffer when you need to distinguish two inputs.

    scanf("%s") is notoriously unsafe - it is effectively the same as gets() for string reading, since neither performs bounds checking. Safer alternatives include fgets(buf, sizeof(buf), stdin) which limits the read to a specified length, or scanf("%32s", ...) which takes an explicit width limit in the format string.

  3. Step 3
    Print the flag
    Observation
    I noticed check_win() gates the flag behind a strcmp(safe_var, "bico") check, which suggested that after successfully overwriting safe_var via the overflow, selecting option 4 would now pass the guard condition and reveal the flag.
    Now that safe_var has been overwritten and no longer equals "bico", option 4 succeeds. The program calls check_win(), which uses strcmp(safe_var, "bico") to verify the guard variable has been corrupted before revealing the flag.
    bash
    nc tethys.picoctf.net <PORT_FROM_INSTANCE>
    Write 33+ bytes via option 2 (overwriting safe_var's "bico"), then select option 4 to read the flag.
    What didn't work first

    Tried: Selecting option 4 before overflowing the buffer

    Option 4 calls check_win(), which immediately runs strcmp(safe_var, "bico"). If you haven't yet used option 2 to overflow the buffer, safe_var still holds its initial value "bico" and the strcmp returns 0, so the program prints "Sorry, you lose!" and reveals nothing. You must overflow first via option 2, then call option 4.

    Tried: Trying to overflow the buffer locally and expecting the same flag as the remote instance

    Running the local binary with the overflow will corrupt safe_var and trigger check_win(), but the local binary prints a placeholder or a different flag string than the remote server holds. The actual CTF flag is only served by the live picoCTF instance. Always run the final exploit against the remote nc connection to get the scoreable flag.

    Learn more

    A guard variable (like safe_var) is a program variable whose value controls access to sensitive functionality. The pattern of "check a guard, then reveal a secret" is the simplest form of access control logic. In real applications, guard variables might represent authentication state, license flags, or feature enable/disable conditions.

    This challenge shows why memory safety is critical: if an attacker can corrupt any memory - not just return addresses - they can subvert the program's security logic. A guard variable that should only be modifiable through legitimate authentication can be bypassed entirely if the attacker can write to its memory address through an overflow.

    Heap overflows are used in real-world exploits to corrupt heap metadata (the allocator's bookkeeping structures), function pointers stored on the heap, C++ vtable pointers, and security-sensitive flags - exactly as in this challenge. High-profile vulnerabilities like HeartBleed (OpenSSL) and many browser exploits involve heap corruption as a key step.

    Languages with automatic memory management (Java, Python, Go, Rust) eliminate most heap overflow vulnerabilities because array bounds are checked at runtime and manual memory management is restricted or absent. This is why memory-safe languages are increasingly recommended for security-critical code.

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{my_first_heap_overflow_0c47...}

Corrupting safe_var away from "bico" unlocks option 4, printing the flag above.

Key takeaway

Heap overflows follow the same logic as stack overflows: consecutive allocations sit adjacent in memory, so writing past the end of one chunk corrupts the next. The allocator places no guard between live chunks, meaning any unbounded write function (scanf without a width, gets, strcpy) can silently trample adjacent heap objects including security flags, function pointers, C++ vtable pointers, and allocator metadata. High-profile real-world vulnerabilities like Heartbleed and the majority of browser sandbox escapes involve some form of heap corruption as the initial primitive.

How to prevent this

Heap overflows happen when a write extends past the chunk boundary. The fix is bounds checking, not allocator tweaks.

  • Replace unbounded reads with size-limited alternatives: use fgets(buf, sizeof(buf), stdin) or scanf("%32s", buf) (explicit width), never scanf("%s", buf), gets(buf), or strcpy(dst, src). memcpy needs a length you can prove is <= the destination capacity.
  • Compile with -fsanitize=address in CI. AddressSanitizer catches every heap overflow at the moment it happens with a clear stack trace; ship without ASan but never test without it.
  • Use a memory-safe language (Rust, Go) for any code path that handles untrusted input. C's ~70% share of memory-safety CVEs is the single biggest exploit category in the industry.

Related reading

Want more picoCTF 2024 writeups?

Tools used in this challenge

Do these first

What to try next