Description
Are overflows just a stack concern?
Setup
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>.
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 (the target we'll zero out).
- 4. Print flag (only works once safe_var == 0).
Solution
Walk me through itThis is the first heap exploitation challenge. Once you master basic overflow-to-zero here, 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.
- Step 1Measure the gapReading heap0.c reveals your buffer is allocated just before safe_var, with 32 bytes between them. Overflowing with exactly 32 characters will zero safe_var.
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 0x21 (0x20 user + flags)| | user data buffer[0..31] | <- our 32-byte buffer +--------- chunk for safe_var --------+ | prev_size (unused while in use) | | size 0x21 | | 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. fgets(buffer, 33, ...) writes 32 bytes + a NUL terminator. That NUL lands in the FIRST byte of safe_var's chunk header prev_size (or directly on safe_var, depending on layout) and since safe_var was already 0/aligned, the byte we care about gets cleared.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 bytessize(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 whensafe_varchanges) 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.
- Step 2Trigger the overflowUse option 2 and enter exactly 32 characters (32
As).fgets(buf, 33, stdin)reads up to 32 bytes plus a null terminator, so writing 32 chars consumes every slot and lands the null at byte 33 - the first byte ofsafe_var- clearing it. See the heap exploitation guide and the buffer overflow guide for the broader memory-corruption picture.bashAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALearn more
The null terminator trick works because
fgets()always appends a null byte (\0) after the input. If you write exactly 32 characters, fgets writes 32 bytes of input followed by a null byte - 33 bytes total. The 33rd byte (the null) lands at the first byte ofsafe_var, zeroing it.This is subtly different from classic buffer overflows: you're not trying to overwrite a return address or function pointer with a useful value. You're just relying on the incidental null terminator to zero the adjacent variable. This makes it a "gentler" overflow - no need for shellcode or ROP chains, just careful byte counting.
The choice of
Aas filler is a CTF convention. The hex value ofAis0x41, making filled buffers visually distinctive in hex dumps (you'll see rows of41 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.fgets()is considered safer thangets()precisely because it takes a size argument and won't read more than the specified number of bytes. However, "won't overflow the specified buffer" is not the same as "won't overflow adjacent heap objects" - if the size argument is wrong or the heap layout puts sensitive data right after the buffer, even fgets-limited input can be dangerous. - Step 3Print the flagNow that safe_var is zeroed, option 4 succeeds. The program checks the guard variable before revealing the flag.bash
nc tethys.picoctf.net <PORT_FROM_INSTANCE>Write 32 bytes via option 2, then select option 4 to read the 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.
Flag
picoCTF{my_first_heap_overflow_0c47...}
Zeroing safe_var unlocks option 4, printing the flag above.
How to prevent this
How to prevent this
Heap overflows happen when a write extends past the chunk boundary. The fix is bounds checking, not allocator tweaks.
- Pass the actual buffer size to every read:
fgets(buf, sizeof(buf), stdin), nevergets(buf)orstrcpy(dst, src).memcpyneeds a length you can prove is <= the destination capacity. - Compile with
-fsanitize=addressin 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.