What Actually Happens When You Call malloc() — Heap Internals Explained
The Question Nobody Asks Until It’s Too Late
You’ve been calling malloc() for years. You know you have to free() what you allocate. But do you know what actually happens between the call and getting back a pointer?
This matters because:
- Memory leaks are diagnosed by understanding the allocator, not by running the program again
- Buffer overflows corrupt the heap metadata, not just your data
- Double-free vulnerabilities work by corrupting the free list
Layer 1: The OS Doesn’t Know About malloc
malloc() is a userspace library function — it’s part of glibc (on Linux) or the C runtime. The OS doesn’t see your malloc() call. It only sees two system calls:
| Syscall | What it does |
|---|---|
brk(addr) | Moves the program break (end of the heap segment) up or down |
mmap(...) | Maps a new anonymous memory region — used for large allocations |
When your program starts, the heap is empty. The first malloc() call causes glibc to call brk() to request a chunk of memory from the kernel — typically 128 KB or more. Subsequent malloc() calls carve out pieces of this pre-fetched chunk without making any syscall at all.
This is why malloc is fast. Most allocations are pure userspace pointer arithmetic.
Layer 2: The Heap Chunk Structure
Every block of memory that glibc manages — whether allocated or free — has a chunk header:
High address
┌──────────────────────────────┐
│ prev_size (8 bytes) │ ← size of previous chunk (if prev is free)
│ size (8 bytes) │ ← size of THIS chunk + 3 flag bits
│ [user data starts here] │
│ ... │
│ [padding to align to 16B] │
└──────────────────────────────┘
Low address
The size field stores the chunk size plus three flag bits packed into the low bits (because chunks are always 16-byte aligned, the low 4 bits are always zero otherwise):
- P bit (bit 0): Previous chunk in use. If clear, the previous chunk is free.
- M bit (bit 1): This chunk was obtained with
mmap(), notbrk(). - A bit (bit 2): This chunk belongs to a non-main arena (for multi-threaded programs).
This is why writing one byte past the end of an allocation (off-by-one) is so dangerous — you corrupt the size field of the next chunk.
Layer 3: Free Lists and Bins
When you call free(ptr), the chunk isn’t zeroed or returned to the OS. It’s placed into a bin — a categorized free list — so it can be reused.
glibc maintains several types of bins:
Fast Bins (≤ 160 bytes)
- Singly-linked LIFO list per size class
- No coalescing (neighboring free chunks are not merged)
- Fastest to allocate from — just pop the head
- Held by:
malloc_state.fastbinsY[]
Unsorted Bin
- When a chunk is freed and isn’t a fastbin, it goes here first
- On the next malloc, glibc scans this bin and coalesces adjacent free chunks
- Think of it as a staging area before proper binning
Small Bins (< 512 bytes)
- Doubly-linked FIFO list per size class
- Chunks ARE coalesced with neighbors before insertion
- O(1) allocation once in the bin
Large Bins (≥ 512 bytes)
- Doubly-linked list, sorted by size within each bin
- Allocation does a best-fit search
Layer 4: What free() Actually Does
free(ptr);
- Compute chunk pointer:
chunk = ptr - 2 * sizeof(size_t)(back up past the header) - Check the M bit — if set, call
munmap()and return - Check if prev chunk is free (P bit clear) → if so, coalesce backward
- Check if next chunk is free → if so, coalesce forward
- If coalesced chunk is large enough, call
brk()to trim the heap (optional) - Otherwise, insert into appropriate bin
This is why double-free is a vulnerability. Calling free(ptr) twice inserts the same chunk into the free list twice. The next malloc() call returns the same pointer to two different callers. Both callers then write to “their” memory — which is the same memory. Classic heap corruption.
Visualizing a Malloc / Free Sequence
Initial heap: [CHUNK A: 64B, in use] [CHUNK B: 128B, in use] [TOP CHUNK: rest]
free(A) → [CHUNK A: 64B, FREE in fastbin] [CHUNK B: 128B, in use] [TOP]
free(B) → coalesce A+B → [CHUNK AB: 192B, FREE in unsorted bin] [TOP]
malloc(100) → carve 100B from AB → [CHUNK X: 112B, in use] [leftover: 80B, free] [TOP]
Practical Takeaways for C Code
// 1. Always check the return value — malloc returns NULL on failure
int *arr = malloc(n * sizeof(int));
if (!arr) { perror("malloc"); exit(1); }
// 2. Calculate size correctly to avoid off-by-one heap corruption
// WRONG: malloc(n) when you need n ints
// RIGHT: malloc(n * sizeof(int))
// 3. Set pointer to NULL after freeing — prevents use-after-free bugs
free(arr);
arr = NULL; // second free(arr) now crashes cleanly instead of silently corrupting
// 4. Use valgrind to detect leaks and heap errors
// valgrind --leak-check=full --track-origins=yes ./your_program
Why Buffer Overflows Are Heap Corruption
char *buf = malloc(16);
strcpy(buf, "This string is longer than 16 bytes!!!");
// Writing past buf[15] overwrites the `size` field of the NEXT chunk header.
// The allocator now has corrupted metadata.
// Next malloc() or free() reads garbage → undefined behavior → crash or exploit.
This is the mechanical reason buffer overflows are so dangerous. It’s not just “bad data” — it’s a corrupted allocator state that makes subsequent memory operations unpredictable.
Summary
| Concept | Detail |
|---|---|
| malloc → OS syscall | Only on first call or large allocations (brk/mmap) |
| Chunk header | 16 bytes of metadata before your pointer |
| free() | Puts chunk in bin, may coalesce neighbors — no zeroing |
| Double-free | Corrupts free list → two callers get same memory |
| Off-by-one | Corrupts next chunk’s size field |
| Fast diagnosis tool | valgrind --leak-check=full |
Found this useful? Explore real student projects on CodexCampus.
Explore the Vault →