Heap allocator awareness
Older MemDiver releases understood heap memory only at the region level. A
mapping named [heap] or [anon:scudo:primary] was tagged heap, and that was
all. Since 0.10.0 MemDiver also reads the allocator’s own structures: chunk
headers, free lists, tcache, arenas and slot metadata. That gives three things
a researcher could not get before:
Chunk context for every byte. For any address you can ask which chunk owns it, whether that chunk is in use or freed, and what the byte is for: header, user data, free-list link, allocator metadata.
Less consensus noise. Allocator fields such as safe-linked tcache
nextpointers, the randomtcache_keyand fd/bk links vary from dump to dump without ever being key material. Consensus now labels them, and can mask them if you ask it to.Residual keys. A key that sits in a freed chunk is stale memory, not live state. Candidates and located-key hits that fall in freed chunks are flagged.
Everything goes through one analysis facade,
memdiver.heap.analyze_heap. The CLI, the web API, the MCP server and
the library all call it, so every surface returns the same answer.
Supported allocators
Analysis levels: L0 detection, L1 attribution of regions to the allocator, L2 chunk walk, L3 free-list walk.
Allocator |
Typical platforms |
Detection signals |
Levels |
What is decoded |
Flags |
|---|---|---|---|---|---|
|
glibc Linux (x86_64, arm64, 32-bit) |
glibc mapped ( |
L0–L3 |
Boundary-tag chunk walk of |
— |
|
embedded heaps, older bionic ( |
content probe only (dlmalloc has no naming convention) |
L0–L2 |
Same boundary-tag walker; bit 2 is |
— |
|
Android ≥ 11, LLVM standalone scudo |
|
L0–L2 |
Primary-region chunks. The packed 64-bit header ( |
partial |
|
musl ≥ 1.2.1 (Alpine and static musl builds) |
musl loader / libc mapped ( |
L0–L2 |
Groups found through the |
— |
|
FreeBSD, Firefox, Redis, pre-11 Android bionic |
|
L0–L1 (+ slot inference) |
Region attribution, plus one slot stride per mapping inferred from the jemalloc 5.x small size-class table. Slots are headerless, in state |
partial |
|
anything else with in-band size words |
weak fallback whenever a memory map exists |
L0–L1 |
Boundary-tag size chains of at least 4 links. State is always |
heuristic |
partial means only part of the allocator’s structures are parsed, so chunks
outside them are not listed. heuristic means chunk boundaries are inferred,
not read from allocator metadata. Both flags are reported in every summary
payload (partial, heuristic, levels). The web UI shows them as badges.
Known limitations
These come from the parser modules (heap/allocators/*.py). Each one produces
a diagnostic instead of a guess.
ptmalloc2. Only the regions named like the main heap (
[heap]; for dlmalloc also[anon:libc_malloc]) are walked unless you select others. Non-main arenas (heap_infoat 64 MiB-aligned anonymous maps) are not decoded, mmapped chunks are not enumerated, andmain_arenais not found in statically linked binaries. An i386 glibc built withMALLOC_ALIGNMENT == 16is not handled. Corrupt headers are skipped by resyncing to the next plausible header, and the diagnostic names the address.scudo.
[anon:scudo:secondary]mappings are listed but theirLargeBlock::Headeris not decoded. BatchClass blocks (ClassId 0) and the per-classTransferBatchfree lists are not walked, so no free lists are produced. A ClassId needs at least two headers on its stride to be walked. Realigned (memalign) chunks may come out asunknown.mallocng. mallocng has no free lists (state lives in the masks), so none are reported. The
malloc_contextis not located.jemalloc. The rtree and the
edata_textent metadata are not walked. A mapping whose stride cannot be inferred is reported as a region only.generic. Nothing about allocation state, free lists or metadata is inferred.
Windows NT / segment heap and macOS libmalloc are not supported.
Fallback
analyze_heap never raises on a parse failure. It tries the detected allocator
first, then the other candidates in score order, and finally generic. Every
attempt is recorded in coverage.analysis (requested, detected, chosen,
fell_back, attempts), and every failed attempt adds a line to
diagnostics. The only error is an unknown allocator name.
Roles and states
Chunk states
State |
Meaning |
|---|---|
|
Allocated and handed to the program |
|
Released to a bin or free list (or marked free in a mask) |
|
Parked in a fast cache (glibc tcache, fastbin). Its in-use bit still says allocated although the program freed it. |
|
Held back from reuse (scudo quarantine) |
|
Boundaries known, state not (jemalloc slots, |
free, cached and quarantined count as freed. Bytes in a freed chunk
are stale.
Byte roles
Each heap byte has exactly one role. The numeric values are stable, because
they are stored in role maps and in heap_roles.npz.
Value |
Role |
Meaning |
|---|---|---|
1 |
|
Allocator header in front of the user bytes (and mallocng’s in-band slot header) |
2 |
|
Bytes handed to the program in an in-use chunk |
3 |
|
fd/bk pointers, tcache |
4 |
|
Stale payload left in a freed chunk |
5 |
|
|
6 |
|
glibc top chunk (the unallocated tail of the heap) |
7 |
|
Bytes of a chunk after its user data |
254 |
|
Consensus only: the dumps disagree about this byte |
255 |
|
Consensus only: no heap coordinate is known here |
Bytes outside every chunk have role none and are left out of role runs.
Workflows
CLI
memdiver heap <action> DUMP takes the same options for every action:
--allocator {auto,ptmalloc2,dlmalloc,scudo,mallocng,jemalloc,generic},
--base-va (for flat raw dumps), --json, -o FILE, and the usual key-material
flags for encrypted .msl files.
# Which allocator is this? Ranked candidates with their evidence.
memdiver heap detect capture.msl
# Overview: chunk counts and bytes per state, size histogram, metadata bytes.
memdiver heap summary capture.msl
# One page of the chunk table, filtered.
memdiver heap chunks capture.msl --state cached --min-size 0x20 --page-size 50
# Who owns this address? Role, offset in chunk and user data, neighbours.
memdiver heap at capture.msl --va 0x55d0c0a012a0
# Walked free lists with cycle / corruption flags.
memdiver heap free-lists capture.msl --kind tcache
# Byte-role runs of a window in a byte view (vas, raw or va).
memdiver heap roles capture.msl --view vas --offset 0x10000 --length 0x2000
Output is a text report by default. --json emits the producer payload
unchanged. See the CLI reference for every flag.
Web UI: heap mode
The mode banner at the top of the workspace now has a third mode, Heap, next to verification and exploration. Heap mode is exploration plus:
a Heap side tab (opened on entering the mode) with the allocator badge, its confidence, the Partial / Heuristic tags, the platform and version hint, other candidates with their evidence, the heap regions (click one to jump there) and chunk counts by state;
a Heap bottom tab with the chunk table (filter by state, size and address range; click a row to jump the hex view to the chunk), a size histogram, the free-list tree, and a chunk inspector with header fields, the user range and the neighbouring chunks;
a hex heap layer that colors every byte by its role and marks chunk starts with a tick, and a heap legend. The Heap layer button in the hex toolbar hides the layer if you want to read the plain bytes.
The allocator selector defaults to Auto-detect. Choosing another allocator discards everything derived from the previous one. See the web UI tour.
The REST routes behind the mode are GET /api/heap/{detect,summary,chunks,chunk-at,free-lists,roles}.
Each takes dump_path, allocator, base_va and the usual key material. Bad
input (unknown allocator, view or state) returns 400 and a missing dump
returns 404.
MCP
Six tools mirror the CLI actions: heap_detect (start here), heap_summary,
heap_chunks, heap_chunk_at, heap_free_lists and heap_roles. See the
MCP reference. A typical agent loop is to run
heap_detect, then heap_summary, then heap_chunk_at on each candidate
offset that search_reduce or locate_key returned.
Library
from memdiver import analyze_heap, HeapRole
layout = analyze_heap("capture.msl") # allocator="auto"
print(layout.allocator.value, layout.confidence, layout.version_hint)
for line in layout.diagnostics:
print("note:", line)
chunk = layout.chunk_at(0x55D0C0A012A0)
if chunk is not None:
print(chunk.state.value, hex(chunk.va), chunk.size, chunk.bin)
# Coalesced (va, length, role) runs over a VA window.
for va, length, role in layout.role_runs(0x55D0C0A00000, 0x55D0C0A04000):
if role is HeapRole.FREE_LINK:
print(f"free-list pointer at {va:#x} (+{length})")
print(layout.role_counts(0x55D0C0A00000, 0x55D0C0A04000))
analyze_heap(source, allocator="auto", *, base_va=None, max_chunks=200_000, regions=None)
accepts a path or an already open DumpSource. regions restricts parsing to
mappings whose name matches one of the given fnmatch patterns ([ is literal,
so "[heap]" works). The surface payloads are also available directly:
memdiver.heap_summary_result(memdiver.ToolSession(), "capture.msl"), and the
same pattern works for the other five producers. To rank allocators without
parsing, call memdiver.detect_allocator(source).
Layouts are cached per dump file (path, mtime, size, region filter and every option), and the cache holds 8 entries. Treat a returned layout as read-only. Encrypted sources are never cached.
Consensus integration
When build_consensus(..., allocator="auto") runs, every dump’s heap is parsed
and its role runs are projected into consensus (slab) coordinates. The
projections are merged per byte:
a byte takes role R when every dump that has a heap role there says R;
when the dumps disagree, the byte is
mixed;agreecounts the dumps whose role matches the anchor dump (dump 0). It works as a per-byte measure of layout stability. A deterministic workload gives full agreement, and allocation jitter givesmixedand lower agreement.
The heap annotation never changes the consensus itself. A failure becomes a
report entry (status: ok, no_va_coordinate, empty_consensus or error).
A corpus of pure raw dumps with no memory map has no virtual addresses to
project through and reports no_va_coordinate. Pass allocator="off" to skip
the annotation entirely.
The pipeline’s consensus stage writes the merged runs and the anchor’s chunk
index to heap_roles.npz beside variance.npy, and adds a "heap" block to
consensus.json. The reduce stages read the file back.
The allocator is chosen with --allocator on the CLI (consensus,
consensus-window, consensus-regions, analyze-candidates, locate-key),
the allocator field on the matching web requests, and the allocator
parameter on the MCP tools (consensus, aligned_window, consensus_regions,
analyze_candidates, locate_key). Values: auto (default), off, or an
allocator name.
The candidate stages (search-reduce, analyze-candidates) take a heap
policy:
Policy |
Effect |
|---|---|
|
Every candidate region gets a |
|
Additionally removes bytes whose consensus role is |
|
Ignores heap roles. The output is exactly what it was before 0.10.0. |
memdiver consensus snapshots/*.msl -o consensus.json --allocator auto
memdiver analyze-candidates snapshots/*.msl --heap-policy mask -o candidates.json
--heap-policy (CLI), heap_policy (web candidates request, MCP
analyze_candidates / search_reduce) select the policy. search-reduce
over a saved --state session carries no heap roles: annotate adds
nothing there and mask is refused. Use analyze-candidates, or a pipeline
run whose consensus stage wrote heap_roles.npz.
residual: true means the region starts in a freed chunk (free, cached
or quarantined). A high-entropy residual is often a real key that the program
freed without wiping, so it is evidence about the key’s lifecycle, not live
state. Masking does not remove residuals. Only allocator-owned bytes are
masked.
Located-key hits
locate-key annotates each hit with its chunk:
{allocator, chunk_va, chunk_size, state, offset_in_chunk, role}. The value is
null when the hit lies outside every chunk or has no virtual address. The
per-dump row carries a heap list parallel to offsets. Annotation is soft: a
parser failure never makes a searched dump unreadable. Pass allocator="off"
to disable it. See Locating a known key.
On every surface the option is --allocator / allocator (default auto).
Real-dump opt-in tests
The unit tests run against hand-built synthetic heaps (tests/fixtures/synth_heap.py).
To check detection and parsing on your own captures, point
MEMDIVER_REAL_HEAP_DUMPS at a directory of dumps:
MEMDIVER_REAL_HEAP_DUMPS=~/captures/heaps pytest tests/test_heap_real_dumps.py -v
Every dump in the directory (.msl, ELF cores, *.gdb_raw.bin /
*.lldb_raw.bin, raw .bin / .dump) is opened. The tests assert that
detection returns candidates, that chunks never overlap and that coverage is
reported. To tighten the assertions, add a sidecar <dump>.expect.json:
{"allocator": "ptmalloc2", "min_chunks": 100}
Without the variable, the tests are skipped.
Troubleshooting
Detected as unknown or generic on a dump you know is glibc / Android.
Detection mostly relies on mapping and module names. .msl files store region
names only when the capture recorded them. Check with memdiver heap detect DUMP and look at the evidence lines. If there are no module or region signals,
the dump has no names. Re-capture with names, or force the allocator with
--allocator ptmalloc2. A fridump import without a .maps sidecar has no names
either.
A flat raw dump gives no chunks. A raw .bin / .dump without a memory
map is read with offset = va - base_va, and base_va defaults to 0. Heap
pointers are absolute virtual addresses, so pass the address of the dump’s
first byte:
memdiver heap summary heap.bin --base-va 0x55d0c0a00000 --allocator ptmalloc2
Without --base-va the pointers land nowhere and the parsers find nothing.
arm64 Android pointers look like 0xb400007a…. Those are Top-Byte-Ignore
tags. The heap layer strips bits 56–63 on arm64 before following a pointer
(memdiver.heap.strip_tag), so you do not need to clean them. If
the target’s architecture is not detected as arm64 (no MSL header, no ELF
machine, no lib64 paths), tags are not stripped. In that case use an .msl or
core file, which records the architecture.
partial or heuristic badge. This is expected for scudo, jemalloc and
generic. Chunks the parser cannot see are not listed, and their bytes have
role none. Read the diagnostics before concluding that a region is unused.
Fell back to another allocator. coverage.analysis.fell_back is true and
a diagnostic such as fell back from ptmalloc2 to generic explains why. This
usually means the heap region was not captured, or its first header is
corrupt.
See also: heap architecture and adding an allocator.