#c #systems-programming #memory-management #os #pointers

What Actually Happens When You Call malloc() — Heap Internals Explained

· 11 min read

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:

SyscallWhat 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(), not brk().
  • 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);
  1. Compute chunk pointer: chunk = ptr - 2 * sizeof(size_t) (back up past the header)
  2. Check the M bit — if set, call munmap() and return
  3. Check if prev chunk is free (P bit clear) → if so, coalesce backward
  4. Check if next chunk is free → if so, coalesce forward
  5. If coalesced chunk is large enough, call brk() to trim the heap (optional)
  6. 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

ConceptDetail
malloc → OS syscallOnly on first call or large allocations (brk/mmap)
Chunk header16 bytes of metadata before your pointer
free()Puts chunk in bin, may coalesce neighbors — no zeroing
Double-freeCorrupts free list → two callers get same memory
Off-by-oneCorrupts next chunk’s size field
Fast diagnosis toolvalgrind --leak-check=full

Found this useful? Explore real student projects on CodexCampus.

Explore the Vault →