heap-exploitation

Heap exploitation playbook. Use when targeting ptmalloc2/glibc heap vulnerabilities including UAF, double free, overflow, off-by-one/null, and leveraging tcache/fastbin/unsortedbin attacks for arbitra

SKILL.md

GitHub

SKILL: Heap Exploitation — Expert Attack Playbook

AI LOAD INSTRUCTION: Expert glibc heap exploitation techniques. Covers ptmalloc2 internals, bin structures, tcache mechanics, libc/heap leak methods, and attack selection by glibc version. Distilled from ctf-wiki heap sections, how2heap, and real-world exploitation. Base models often confuse glibc version constraints and miss safe-linking (PROTECT_PTR) introduced in 2.32.

0. RELATED ROUTING

Advanced References


1. PTMALLOC2 STRUCTURE QUICK REFERENCE

malloc_chunk Layout (64-bit)

         chunk pointer (returned by malloc - 0x10)
         ┌──────────────────────────┐
    0x00 │  prev_size (if prev free)│
    0x08 │  size        | A | M | P │  ← P=PREV_INUSE, M=IS_MMAPPED, A=NON_MAIN_ARENA
         ├──────────────────────────┤  ← user data starts here (returned pointer)
    0x10 │  fd (if free)            │  ← forward pointer to next free chunk
    0x18 │  bk (if free)            │  ← backward pointer to prev free chunk
    0x20 │  fd_nextsize (large only)│
    0x28 │  bk_nextsize (large only)│
         └──────────────────────────┘

Bin Types

Bin Size Range (64-bit) Structure LIFO/FIFO
tcache (per-thread) ≤ 0x410 (7 entries per size) Singly linked (next pointer) LIFO
fastbin ≤ 0x80 (default) Singly linked (fd) LIFO
unsortedbin Any freed size Doubly linked circular FIFO
smallbin < 0x400 Doubly linked circular FIFO
largebin ≥ 0x400 Doubly linked + size-sorted Sorted

Key Global Structures

Structure Location Purpose
main_arena libc .data segment Contains bin heads, top chunk, system_mem
mp_ libc .data malloc parameters (tcache settings, mmap threshold)
tcache_perthread_struct Heap (first allocation) Per-thread tcache bins and counts

2. LEAK METHODS

Libc Base Leak

Method Precondition Technique
Unsortedbin fd/bk Free a chunk > tcache range (or fill tcache) fd/bk → main_arena + 0x60 (or +0x70 depending on version) → libc base
Smallbin fd/bk Chunk moved from unsortedbin to smallbin Same as unsortedbin leak
stdout FILE leak Write to _IO_2_1_stdout_ Corrupt _IO_write_base to leak libc data (see IO_FILE)

Heap Base Leak

Method Precondition Technique
Tcache fd pointer Free two tcache chunks, read first's fd fd → heap address (XOR'd in ≥ 2.32)
Fastbin fd Free two fastbin chunks fd → heap address
UAF read Use-after-free on freed chunk Read fd/bk directly

Safe-Linking Decode (glibc ≥ 2.32)

# PROTECT_PTR: fd_stored = (chunk_addr >> 12) ^ real_fd
# To decode: real_fd = fd_stored ^ (chunk_addr >> 12)
# To encode: fd_stored = (chunk_addr >> 12) ^ target_addr

def deobfuscate(stored_fd, chunk_addr):
    return stored_fd ^ (chunk_addr >> 12)

def obfuscate(target, chunk_addr):
    return (chunk_addr >> 12) ^ target

3. ATTACK CATEGORIES BY GLIBC VERSION

glibc < 2.26 (No tcache)

Attack Primitive Needed Result
Fastbin dup Double free Arbitrary allocation
Unsortedbin attack Corrupt unsortedbin bk Write main_arena addr to target (used for __malloc_hook nearby overwrite)
Unlink attack Heap overflow into prev_size + fd/bk Arbitrary write (with known heap pointer)
House of Force Top chunk size overwrite Arbitrary allocation
House of Spirit Write fake chunk header Fastbin allocation at fake chunk
Off-by-one null Null byte overflow into next chunk size Overlapping chunks

glibc 2.26–2.28 (tcache, no key)

Attack Notes
Tcache poisoning Overwrite tcache fd → arbitrary allocation, no size check
Tcache dup Double free into tcache (no double-free detection yet)
All previous attacks Still work, but chunks go to tcache first

glibc 2.29–2.31 (tcache key introduced)

Attack Bypass for tcache key
Tcache dup Corrupt key field (at chunk+0x18) before second free
House of Botcake Double free: one in unsortedbin, one in tcache → overlapping
Tcache stashing unlink Abuse smallbin→tcache refill to get arbitrary chunk

glibc 2.32–2.33 (safe-linking / PROTECT_PTR)

Attack Adaptation
Tcache poisoning Encode target with (chunk_addr >> 12) ^ target
Heap leak required Need heap addr to decode/encode safe-linked pointers
Fastbin dup Same encoding required

glibc ≥ 2.34 (hooks removed)

Change Impact
__malloc_hook removed Cannot overwrite hook for one_gadget
__free_hook removed Cannot overwrite hook
__realloc_hook removed Cannot use realloc trick for one_gadget constraints

Post-2.34 targets: see arbitrary-write-to-rce for _IO_FILE, exit_funcs, TLS_dtor_list, _dl_fini.


4. COMMON VULNERABILITY PATTERNS

Vulnerability Description Exploitation Path
UAF (Use-After-Free) Access chunk after free Read: leak fd/bk; Write: corrupt fd for tcache poisoning
Double Free free() same chunk twice Tcache dup (bypass key) or fastbin dup
Heap Overflow Write past chunk boundary Corrupt next chunk's metadata (size, fd, bk)
Off-by-one One byte overflow Null byte → shrink next chunk size → overlapping chunks
Off-by-null Specifically \x00 overflow Clear PREV_INUSE → trigger backward consolidation
Uninitialized read Read heap memory without clearing Leak fd/bk from recycled chunk

5. TOOLS

# pwndbg heap inspection
pwndbg> heap                      # display all chunks
pwndbg> bins                      # show all bin contents
pwndbg> tcachebins                # tcache status
pwndbg> fastbins                  # fastbin status
pwndbg> unsortedbin               # unsortedbin content
pwndbg> vis_heap_chunks           # visual heap layout
pwndbg> find_fake_fast &__malloc_hook  # find nearby fake fastbin chunks

# how2heap — reference implementations
git clone https://github.com/shellphish/how2heap

# heapinspect
pip install heapinspect
heapinspect <pid>

# pwntools helpers
from pwn import *
libc = ELF('./libc.so.6')
print(hex(libc.symbols['__malloc_hook']))
print(hex(libc.symbols['__free_hook']))

6. DECISION TREE

Heap vulnerability identified
├── What is the primitive?
│   ├── UAF (read + write)
│   │   ├── Can read freed chunk? → Leak libc (unsortedbin) or heap (tcache fd)
│   │   └── Can write freed chunk? → Tcache poisoning / fastbin dup
│   ├── Double free
│   │   ├── glibc < 2.29 → direct tcache dup
│   │   ├── glibc 2.29-2.31 → corrupt tcache key first, or House of Botcake
│   │   └── glibc ≥ 2.32 → need heap leak for safe-linking encode
│   ├── Heap overflow (controlled size)
│   │   ├── Overwrite next chunk size → overlapping chunks → UAF
│   │   └── Overwrite fd directly → arbitrary allocation
│   ├── Off-by-one / off-by-null
│   │   ├── Null byte into size → House of Einherjar (backward consolidation)
│   │   └── One byte into size → shrink chunk, create overlap
│   └── Arbitrary write (from overlap or poisoned allocation)
│       ├── glibc < 2.34 → __malloc_hook / __free_hook → one_gadget
│       ├── glibc ≥ 2.34 → _IO_FILE vtable, exit_funcs, TLS_dtor_list
│       └── Partial RELRO → GOT overwrite
│
├── Need libc leak?
│   ├── Free chunk into unsortedbin (size > 0x410 or fill 7 tcache)
│   ├── Read fd/bk → main_arena offset → libc base
│   └── Alternative: stdout FILE partial overwrite for leak
│
└── Need heap leak? (glibc ≥ 2.32)
    ├── Read tcache fd from freed chunk
    └── Decode: real_addr = stored_fd ^ (chunk_addr >> 12)

7. CONFIRMING THE FINDING

Step Question What it proves
1 Which exact glibc version does the target link? every technique below is version-conditional
2 What is the bug class: overflow, UAF, double-free, unlink, overlap? the primitive's parameter
3 Was the type confusion or overlap demonstrated, not merely crashed on? a crash is a DoS
4 Did the primitive give an aimed read or write, confirmed by a read-back? the aim
5 Was there a control run without the triggering input? causality
6 Which glibc mitigations are present: tcache key, safe-linking, checks? they decide which technique exists
7 Did the primitive reach the goal? an intermediate result must be labelled

A demonstrated overlapping allocation or an aimed write, with the glibc version and its mitigations recorded. A malloc(): corrupted top size abort is a crash, not a primitive.


8. EXECUTION PRIMITIVES

A heap finding is proven by a demonstrated overlap or a read-back-verified aimed write, with the glibc version and mitigation state recorded, and a control run. A heap abort is a DoS.

7.1 The version and mitigation gate

# EVERYTHING in this file is conditional on these two things. Read them first.
BIN=./target
echo "=== the glibc the target will actually load ==="
ldd "$BIN" | grep libc
"$(ldd "$BIN" | awk '/libc/{print $3}')" 2>/dev/null | head -1
echo
echo "=== the local libc's version, which must match what you test against ==="
ldd --version | head -1
echo
echo "=== THE VERSION-CONDITIONAL MITIGATIONS, checked on the symbols, not assumed ==="
LIBC=$(ldd "$BIN" | awk '/libc/{print $3}')
for s in __free_hook __malloc_hook __tcache_key __libc_argv _IO_2_1_stdout_; do
  printf '  %-24s ' "$s"
  nm -D "$LIBC" 2>/dev/null | grep -q "$s" && echo "PRESENT" || echo "absent  <-- the technique that needs it is NOT available"
done
echo
echo "=== the version gate table ==="
cat <<'VERSIONS'
  < 2.26          : no tcache. Use fastbin dup, unsafe unlink, house of force/spirit.
  2.26 - 2.28     : tcache present, NO key field. tcache dup / tcache poisoning - the easiest era.
  >= 2.29         : tcache KEY added -> a double-free inside tcache is detected.
                    Also SAFE-LINKING: fd is `ptr >> 12`-protected -> a plain fake-fd no longer works.
                    Unsorted-bin attack still available; `_IO_FILE` attacks remain.
  >= 2.32         : the `_IO_FILE` vtable check. A fake vtable must live within the vtable section.
  >= 2.34         : __free_hook and __malloc_hook REMOVED. Retarget to _IO_FILE, __exit_funcs,
                    TLS dtor_list, or a C++ vtable.
  >= 2.35/2.36    : the vtable check covers more paths; check the exact table's bounds.
VERSIONS
echo
echo "=== THE CONTROL for the gate: run the technique on a libc OUTSIDE its version range ==="
echo "  it must fail. If a tcache-dup works against 2.31 unmodified, your harness is loading a"
echo "  different libc than you think - verify with LD_DEBUG=libs, not with LD_PRELOAD."
LD_DEBUG=libs "$BIN" 2>&1 | grep -i "calling init" | head -5

The version and hazard gate must be read from the symbols, not assumed. A tcache-dup working against 2.31 unmodified means the harness is loading a different libc than you think.

7.2 The overlap demonstration, which must be positive

# THE DEMONSTRATION THAT CONVERTS A BUG INTO A PRIMITIVE: two live allocations sharing memory.
from pwn import *
context.log_level = 'error'

def overlap_demo(alloc, free, show, payload):
    """The shape: allocate A, allocate B, free A, cause a SECOND allocation path to return A's chunk
    while A is still referenced, then write through A and read through the OTHER reference."""
    a = alloc(0x40, b'A'*0x40)         # the still-referenced chunk
    guard = alloc(0x40, b'G'*0x40)     # a neighbour, so the layout is observable
    b = alloc(0x40, b'B'*0x40)
    free(a)                             # A is freed while its handle is live
    c = alloc(0x40, b'C'*0x40)          # the same chunk is handed out again
    print("=== THE DEMONSTRATION, not a crash ===")
    print("  1. `a` and `c` must be the SAME address (the overlap)")
    print("  2. writing through `a` and reading through `c` must SHOW the write")
    print("  3. THE CONTROL: a run WITHOUT the free must NOT show the overlap")
    payload(a, b'OVERWRITTEN-BY-A')     # write through the stale handle
    print("  through c:", show(c, 16))     # must reflect A's write
    return

print("=== WHAT COUNTS, in order of strength ===")
for s, d in [("overlapping allocations, demonstrated by address equality",
              "the strongest: two live handles, one memory"),
             ("a write through one visible through the other",
              "the primitive, with a read-back"),
             ("a controlled out-of-bounds write, aimed at a neighbouring chunk's size or fd",
              "a write primitive, needing an aim proof"),
             ("an aimed write verified by a READ-BACK of a distinctive value",
              "the aim; a corrupting write is not aimed"),
             ("a crash or an abort (corrupted top size, double free detected)",
              "a DoS, and it must be labelled as one")]:
    print("  %-62s -> %s" % (s, d))
print()
print("=== THE CONTROL, which is the whole point ===")
print("  run the SAME sequence with the `free` removed. The address equality must disappear.")
print("  if the addresses are equal anyway, your allocator harness is not doing what you think.")

The control is the same sequence with the free removed. If the addresses match anyway, the harness is not doing what you think, and the overlap demonstration is void.

7.3 The aim, proven by read-back

print("=== AN AIMED WRITE NEEDS A READ-BACK, AND A KNOWN OFFSET ===")
print("  1. compute the OFFSET from the corrupted chunk to the target, from the actual layout")
print("     (use the chunk header addresses you printed, not the offsets from a blog post)")
print("  2. write a DISTINCTIVE value, e.g. 0xdeadbeefcafe1234")
print("  3. READ THE TARGET BACK and require that value")
print("  4. THE CONTROL: the same sequence without the corrupting write must leave it unchanged")
print()
print("=== the OFFSETS THAT MATTER, and where to get them ===")
for s in ["the next chunk's header: prev_size/size at +0x0/+0x8 from the chunk's user pointer - 0x10",
          "the tcache fd/next after the key was added: user pointer + 0x0 is `next`, +0x8 is `key`",
          "safe-linking: the stored fd is `target >> 12`, so a fake fd must be `target ^ (chunk>>12)`",
          "an unsorted-bin fd/bk pair: +0x0 fd, +0x8 bk, and both must be consistent or the unlink checks"]:
    print("  -", s)
print()
print("=== the THREE glibc checks that produce the aborts people misreport as findings ===")
for s, d in [("`free(): invalid next size`", "a size field was corrupted; the write landed, but the aim was not proven"),
             ("`malloc(): corrupted top size`", "the top chunk's size was corrupted; a DoS"),
             ("`free(): double free detected in tcache 2`", "the tcache KEY caught it; the technique is out of range for this version"),
             ("`malloc_consolidate(): unaligned fastbin chunk`", "fastbin corruption; again a DoS unless the aim is shown")]:
    print("  %-46s -> %s" % (s, d))

A corrupting write is not an aimed write. The three glibc aborts are frequently misreported as findings, and each one is a DoS unless the aim is proven with a read-back.

7.4 The leak, and the goal

cat <<'LEAKS'
LEAK SOURCES, in the order of how much they cost the defender to close
  an unsorted-bin chunk's fd/bk after a free into the unsorted bin    -> a libc address
  a tcache chunk's `next` after safe-linking                          -> a HEAP address (not libc)
  a stdout FILE's _IO_write_base after a partial write                 -> a libc address
  an uninitialised chunk reused for a struct with a pointer            -> whatever was there
  a chunk placed in a live object's field by a type confusion          -> an object address

THE CONTROL FOR A LEAK: the value must be CONSISTENT with the allocator's known layout.
  a 'libc leak' that is not in the mapped libc range (`/proc/<pid>/maps`) is not a libc leak.
  CHECK IT against the process's own maps - that check is the control, and it is cheap.

THE GOAL, which must be stated:
  a leak alone                              -> an info-disclosure or ASLR-bypass finding
  an overlap without an effect              -> an intermediate result
  an aimed write                            -> a write primitive; say what it wrote
  control flow redirected                   -> the full finding
'GOAL' is not implied by 'heap corruption'. The reader needs the impact.
LEAKS
echo
echo "=== the maps check, which validates a leak ==="
python3 - <<'PY'
from pwn import *
context.log_level='error'
p = process('./target'); p.recvuntil(b'leak: ')
addr = int(p.recvline().strip(), 16)
maps = open(f'/proc/{p.pid}/maps').read()
p.close()
inside = any(int(l.split('-')[0],16) <= addr < int(l.split('-')[1],16) and '/libc' in l
             for l in maps.splitlines())
print(f"  leaked {hex(addr)} inside a libc mapping: {inside}")
print("  -> FALSE here means the 'libc leak' is not a libc address. That is the control.")
PY

The /proc/<pid>/maps check validates a leak and it is cheap. A "libc leak" that is not in a libc mapping is not a libc leak.

7.5 The end-to-end harness

python3 - <<'PY'
print("=== HEAP EXPLOITATION ACCEPTANCE CHECKLIST ===")
CHECKS = [
 ("the EXACT glibc version is recorded, read from `ldd`/`nm`, not assumed",
  "every technique here is version-conditional"),
 ("the presence of __free_hook, __malloc_hook, the tcache key, and safe-linking was CHECKED",
  "the version table decides which technique is even available"),
 ("LD_DEBUG=libs confirmed which libc the process actually loads",
  "a harness can silently load a different libc"),
 ("the bug class is named: overflow, UAF, double-free, unlink, overlap",
  "the primitive's parameter"),
 ("the OVERLAP was demonstrated by ADDRESS EQUALITY between two live handles",
  "not by a crash"),
 ("a write through one handle was shown VISIBLE through the other",
  "the primitive, with a read-back"),
 ("a CONTROL run with the free removed did NOT show the overlap",
  "if it still does, the harness is wrong"),
 ("an aimed write was verified by reading back a DISTINCTIVE value",
  "a corrupting write is not aimed"),
 ("every leak was validated against the process's OWN /proc/<pid>/maps",
  "a 'libc leak' outside a libc mapping is not one"),
 ("the GOAL is stated: leak only, overlap, aimed write, or control-flow redirect",
  "'heap corruption' does not state the impact"),
 ("glibc aborts are labelled as DoS rather than reported as primitives",
  "the four common aborts are all crashes"),
]
for n, how in CHECKS: print("  [ ] %-66s -> %s" % (n, how))
print()
print("=== THE REPORT SHAPE ===")
print("  environment : glibc version, confirmed by LD_DEBUG, and the mitigation state")
print("  bug         : the class, the trigger, and the chunk sizes involved")
print("  primitive   : the overlap or the aimed write, with its read-back")
print("  control     : the same sequence without the trigger")
print("  leak        : the address, its source, and the maps validation")
print("  goal        : leak / overlap / write / control, stated explicitly")
PY

Environment, bug, primitive, control, leak, goal. The abort-vs-primitive distinction in the checklist is what keeps a DoS from being reported as a compromise.


9. EVIDENCE STANDARD — HEAP ARTEFACTS

Item Why
The exact glibc version, confirmed with LD_DEBUG=libs every technique is version-conditional
The symbol presence check: __free_hook, __malloc_hook, the tcache key the version table's prerequisites
The bug class and the chunk sizes involved the primitive's parameters
The overlap's address equality between two live handles the demonstration, not a crash
The write-through visibility through the other handle the primitive, with a read-back
The control run with the trigger removed causality
The aimed write's read-back of a distinctive value a corrupting write is not aimed
The leak's address, its source, and the /proc/<pid>/maps validation a leak outside a libc mapping is not a libc leak
The goal: leak only, overlap, aimed write, or control-flow redirect the impact, which the class does not state
The glibc abort message, if any, labelled as a DoS the four common aborts are crashes

Report the primitive and the read-back: "the target links glibc 2.31-0ubuntu9.9, confirmed with LD_DEBUG=libs, and nm -D shows __free_hook and __malloc_hook PRESENT with the tcache key and safe-linking also present, so the tcache-dup technique is out of range and the technique used is an unsorted-bin attack. The bug is a use-after-free of a 0x90-sized chunk in the note editor's delete path. The overlap was demonstrated by address equality: the stale handle and the newly-allocated chunk both report 0x55d3c1a2b2a0, and a write of OVERWRITTEN-BY-A through the stale handle is visible when read through the new handle, and a control run with the free removed reports two distinct addresses, so the free is causal. The aimed write wrote 0xdeadbeefcafe1234 to __free_hook and the read-back returned that value, and the leak of 0x7f9a1c2d3e50 was validated as inside the libc mapping in the process's own /proc/<pid>/maps. The goal reached is control-flow redirection via __free_hook, which glibc 2.34 and later remove, so the finding is scoped to 2.31 and earlier", never "the binary has a heap overflow".

False positives - do not report these

Observation Why it is not a finding
malloc(): corrupted top size or any glibc abort a DoS; the four common aborts are crashes
free(): double free detected in tcache 2 the tcache key caught it; out of range for that version
A crash in the allocator reported as corruption without a demonstrated effect it is a crash
"libc leak" not validated against /proc/<pid>/maps it may not be a libc address
A tcache chunk's next reported as a libc leak it is a heap address
An overlap where the control (no free) also overlaps the harness is wrong
An unverified offset taken from a write-up rather than measured the layout is build-specific
A technique for a version the target does not run the nm -D check rules it out
An aimed write with no read-back a corrupting write is not aimed
A heap bug at __free_hook on glibc >= 2.34 the symbol is removed
'heap corruption' with no stated goal the impact is missing

An overlap demonstrated by address equality, a read-back-verified aimed write, and a maps-validated leak. A glibc abort and an unvalidated leak are this family's two standard non-findings.


10. REMEDIATION REFERENCE — HEAP HARDENING ASSESSMENT

  1. Record the exact glibc version confirmed with LD_DEBUG=libs, and check the relevant symbols with nm -D, before choosing a technique - a harness silently loading a different libc invalidates the whole result.
  2. Check for __free_hook, __malloc_hook, the tcache key, and safe-linking explicitly, because the version table decides which technique exists at all - a technique outside its version range is not a finding.
  3. Demonstrate an overlap by address equality between two live handles, never by a crash - the four common glibc aborts are DoS results and must be labelled as such.
  4. Always run the control with the triggering operation removed, and require the overlap to disappear - a control that still overlaps means the harness is wrong rather than the target vulnerable.
  5. Prove every aimed write by reading back a distinctive value - a corrupting write that aborts the allocator is not an aimed write.
  6. Validate every leak against the process's own /proc/<pid>/maps - a "libc leak" outside a libc mapping is a heap or stack address, and the check is cheap.
  7. Measure offsets from the actual layout in your run rather than copying them, because a chunk offset is build-specific - a published offset rarely transfers.
  8. State the goal explicitly: leak only, overlap, aimed write, or control-flow redirect - "heap corruption" does not tell the reader the impact.
  9. Scope the finding to the glibc version range on which the technique works, and say which version closes it - __free_hook findings cease to apply at 2.34, and the reader's remediation depends on that.
  10. Report the chunk sizes and the allocation pattern, because the sizes determine whether glibc's tcache or fastbin paths apply - a 0x20 chunk and a 0x410 chunk behave differently.
  11. Note the deployment reality: many targets run a fixed libc in a container, so the version scope is the finding's real boundary - a finding that applies to the target's image is actionable and one that applies to a neighbouring version is not.

11. RELATED SIBLINGS - LOAD TOGETHER