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. Get the files from the challenge page on CyLab Security Academy (formerly play.picoctf.org).
Connect to the live challenge instance at tethys.picoctf.net <PORT_FROM_INSTANCE>.
chmod +x heap0 && \
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.
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.
Step 1Measure the gap
Observationheap0.c makes two consecutive malloc() calls, putting your buffer 32 bytes ahead of safe_var on the heap. The distance between them is the overflow offset.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 0x21 (0x20 total + PREV_INUSE) | | user data buffer+0 .. buffer+15 | <- our buffer +--------- chunk for safe_var --------+ | prev_size buffer+16 .. buffer+23 | (metadata) | size buffer+24 .. buffer+31 | (metadata, 0x21) | safe_var buffer+32 ... | <- target +-------------------------------------+ | top chunk (rest of heap arena) | +-------------------------------------+ Both requests are small, so each lands in glibc's minimum 32-byte chunk. That makes the distance from the buffer pointer to the safe_var pointer exactly 0x20 = 32 bytes: bytes 17-32 of an overflow trample safe_var's own chunk header, and byte 33 is the first byte of safe_var itself. 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 bytessize(with the low 3 bits encoding flags like PREV_INUSE). Chunks are 16-byte aligned with a 32-byte minimum, so a small request still costs a 32-byte chunk. The distance from one user pointer to the next is the total size of the first chunk, which is why the two heap pointers here sit 32 bytes apart even though neither string is anywhere near that long.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 overflow
ObservationThe write option calls scanf with %s and no length limit into a buffer that sits 32 bytes before safe_var. Send 33 or more non-whitespace characters and the extra bytes land in safe_var.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 intosafe_var, overwriting the"bico"string that sits adjacent on the heap. Oncesafe_varno longer equals"bico",check_win()reveals the flag. See the heap exploitation guide and the buffer overflow guide for the broader memory-corruption picture.bashAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWhat didn't work first
Tried: Sending a short string (say 16 A's) because the buffer looked small
The two allocations are 32 bytes apart, so anything under 32 characters stays inside the buffer's chunk and safe_var's chunk header. safe_var keeps its "bico" value and option 4 still tells you that you lose. Send at least 33 characters so a byte of your input lands on safe_var itself.
Tried: Sending input that contains a space (e.g. 'AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA')
scanf with %s stops at the first whitespace. A space mid-input ends the read early, writes far fewer bytes than you meant, and leaves safe_var alone. Send an unbroken run of non-whitespace characters.
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 has only 32 bytes before safe_var begins, but scanf will happily write a 100-byte string into it, overflowing into whatever sits next on the heap - in this casesafe_var, which starts with the string"bico".The win condition is
strcmp(safe_var, "bico") != 0: the flag prints whensafe_varno longer equals"bico". Writing 33 or more characters overwrites the first byte ofsafe_varwithA(0x41), making it"Aico"instead of"bico". That corrupted string fails the strcmp check, triggering the win.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.scanf("%s")is notoriously unsafe - it is effectively the same asgets()for string reading, since neither performs bounds checking. Safer alternatives includefgets(buf, sizeof(buf), stdin)which limits the read to a specified length, orscanf("%32s", ...)which takes an explicit width limit in the format string.Step 3Print the flag
Observationcheck_win() gates the flag on a strcmp against safe_var. Once the overflow has changed it, option 4 passes the guard.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.bashnc 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 runs check_win(), which compares safe_var right away. Without the option 2 overflow first, safe_var still holds its initial value, the comparison matches, and the program tells you that you lose. Overflow, then check.
Tried: Trying to overflow the buffer locally and expecting the same flag as the remote instance
Locally the overflow corrupts safe_var and check_win() fires, but the local binary prints a placeholder rather than the real flag. Only the live instance holds it, so run the finished exploit against the remote connection.
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. Many browser exploits use heap corruption as a key step, and the closely related heap over-read is what Heartbleed (OpenSSL) abused to dump adjacent heap memory.
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
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.
- Replace unbounded reads with size-limited alternatives: use
fgets(buf, sizeof(buf), stdin)orscanf("%32s", buf)(explicit width), neverscanf("%s", buf),gets(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.