PartitionAlloc, Explained Through the Attacks It Stops#
Audience: C++ programmers who know pointers, new/delete, objects, vtables, and
lifetimes — but don't do exploitation. The goal is that by the end you can read any PA
mitigation and say exactly which step of exactly which attack it breaks.
Why this document exists: when a normal C++ dev reads "PartitionAlloc encodes the freelist pointer" they have no way to judge whether that's worth a single CPU cycle. The feature only means something if you can see the attack on the other side of it. So every section here pairs real attacker code — the code an exploit developer would actually write — with the PA mechanism that turns that code into a crash. Read the attack first, then watch the mechanism break it.
Version caveat. The mechanism → attack mapping is durable. Exact struct offsets, pool names,
*Scanstatus, build defaults, and the set of carve-outs are HEAD- and config-sensitive — verify those against the current tree (reference/partition_allocator/). The PA code snippets below are condensed from the notes in this directory (security.md,freelist_design.md,backup_ref_ptr.md) to fit on a page; they are faithful in mechanism, lightly edited in spelling.
The one idea everything else hangs on#
A memory-safety bug is not automatically "the attacker wins." That's the single biggest gap between how a C++ dev reads a sanitizer report ("use-after-free, undefined behavior, probably a crash") and how an attacker reads it ("a foothold I can amplify").
Exploitation is a ladder:
a BUG ─► a PRIMITIVE ─► a STRONGER PRIMITIVE ─► CONTROL of execution
(unsafe op) (repeatable (read/write anywhere) (run code of my choice)
capability)
Almost none of PA's mitigations prevent the bug. They each break one rung so the bug can't climb. That reframing is the whole document: for every feature, ask "which rung did this snap?"
Part I — The vocabulary the exploit world assumes#
If these five sections click, the rest is easy.
I.1 The heap in 90 seconds#
new/delete don't talk to the OS per object. An allocator grabs big regions of memory and
carves them into slots. When you delete, the slot isn't returned to the OS — it goes on
a free list so the next new of a similar size can reuse it. The allocator also keeps
bookkeeping: how big each slot is, which slots are free, the links of the free list.
Two facts the attacker lives on:
- Freed memory gets reused.
delete x; auto* y = new T();very often handsythe slotxjust vacated. - The bookkeeping is stored somewhere. Classic allocators (glibc's, the old
dlmalloclineage) store it inline, immediately next to your object — the 8–16 bytes before your buffer hold the chunk size and free-list links. That physical adjacency is a vulnerability: a buffer overflow reaches the allocator's own brain. (PA does not do this — hold that thought.)
I.2 Primitives — the real currency#
A bug is just a location where the program is unsafe. A primitive is a repeatable capability the attacker squeezes out of it. The four that matter:
| Primitive | What it means in C++ terms | Why it's gold |
|---|---|---|
| Read primitive | "I can read N bytes from an address I influence" | Leak a real pointer → see through ASLR (I.5) |
| Write primitive | "I can write a value to an address I influence" | Overwrite a function pointer, a length, a refcount |
| Allocation primitive | "I can make new return an address I chose" |
Place a controlled object on top of a victim, or the stack |
| Info leak | A read primitive used specifically to learn addresses | Defeats randomization; prerequisite for the rest |
Exploitation = turn a weak bug into one of these, then turn that into control of the program counter (where the CPU executes next). Mitigations win by making a rung impossible: the bug can't become a primitive, or the primitive can't reach the program counter.
I.3 Reuse, spray, and grooming — why freeing then allocating is dangerous#
Suppose a bug leaves a dangling pointer: an object was freed, but some T* still points
at its slot. By itself that's UB. The attack, in code:
Widget* w = new Widget(); // slot S; w aims at S
delete w; // S goes on the free list — but 'w' still aims at S (dangling)
// The attacker now races to refill slot S with bytes they choose:
auto* groom = new ControlledBuffer(/* same size class as Widget */); // very often == slot S
attacker_fill(groom, chosen_bytes); // now S holds attacker-chosen bytes
Now the dangling pointer points at memory whose contents the attacker controls. The old object's "fields" are now attacker-chosen bytes.
- Heap spray: do that allocation many times so the outcome is reliable instead of racy.
- Grooming / "Feng Shui": issue a careful sequence of allocations/frees to force a
specific slot to be reused, or to force two objects to sit adjacent. In a browser the
attacker drives all of this through the script and content the renderer parses — that's
why browsers are the battleground; the adversary gets to run allocation logic inside your
process. Throughout this doc, when you see a
new/deletein attacker code, picture a JavaScript line (new ArrayBuffer(32),arr.length = 0) that bottoms out in thatnew/delete.
The takeaway for a C++ dev: where you read "freed but still referenced" or "wrote one element too far" as undefined behavior — a standards abstraction that'll "probably just crash" — the attacker reads the very same bug as a concrete, repeatable capability: their opportunity to decide what bytes a live pointer sees. Same line of code, two completely different readings.
I.4 From corrupted bytes to hijacked execution — the vtable bridge#
You know virtual dispatch: an object with virtual methods starts with a hidden vptr
pointing at the class vtable (an array of function pointers); a virtual call reads a
pointer out of that table and jumps to it. Mechanically a call like obj->render() is roughly:
(*obj->vptr[INDEX_OF_render])(obj); // read a function pointer FROM MEMORY, then jump to it
That "read a function pointer from memory, then jump" is the bridge. If the attacker controls the object's bytes (via I.3 reuse) or can corrupt the vptr (via a write primitive), they point it at a fake vtable full of addresses they picked. The very next virtual call jumps wherever they want. That is how "I control some heap bytes" becomes "I control the program counter." Length fields and stored data pointers are the other common targets — corrupt a length to get an out-of-bounds read/write; corrupt a stored pointer to redirect where the program reads/writes.
I.5 Why they can't just inject code — and what they do instead#
So the attacker controls the program counter (I.4). Why isn't it over? Because two defenses stand between "I control where the CPU jumps" and "I run my code," and every modern exploit is shaped by getting around them.
The first wall is W^X / DEP: a page of memory is either writable or executable, never both. The attacker can write machine code into a heap buffer, but that page isn't executable, so the CPU refuses to run it — and they can't flip it to executable either, because the syscall to do that is blocked by the sandbox. Injecting code is simply off the table.
Their answer is to stop trying to supply code and instead reuse the code already mapped
executable — libc, the binary itself. Those pages are executable; the attacker just can't write
them, which is fine, because they don't need to. The trick is ROP (return-oriented programming):
scattered through that existing code are short instruction snippets that happen to end in a ret —
call each one a gadget. A ret pops an address off the stack and jumps to it. So if the attacker
controls the stack (via a write primitive), they can lay out a list of gadget addresses; each
gadget does a tiny bit of work, hits its ret, and the ret launches the next one. The surprising
part — and the reason ROP ended W^X as a complete defense — is that a large enough pile of these
borrowed snippets is Turing-complete: chain them and you can compute anything, never having
written a single byte of your own code. (One subtlety: hijacking a vptr redirects the program
counter, but the ROP chain lives on the stack, so the first gadget is usually a stack pivot
that repoints the stack at attacker-controlled memory; then the chain runs.) You don't need ROP's
internals to follow this document — treat it as one black-box rung: "vtable overwrite → ROP" just
means "redirect a code pointer, then run a chain of borrowed snippets."
That leaves the second wall, ASLR: libraries, the stack, and the heap are loaded at addresses randomized each run, so the attacker doesn't know where any of those gadgets actually are. The answer here is an info leak — a read primitive that reveals one real pointer. Subtract its known offset within its module and you recover the module's base address; now every gadget's runtime location is computable.
This is why a read primitive is worth as much as a write primitive. A full exploit usually needs both: a leak to defeat ASLR and learn the addresses, and a write/control step to actually redirect execution. Keep that in mind below — several PA features are aimed squarely at denying the leak.
Part II — Each attack method, mechanically, and what PA does to it#
Each subsection has the same five beats: the bug (ordinary C++ you might write), the attacker's code (what they do with it), the payoff (the primitive they get), what PA does (the real mechanism, in PA's own code), and residual / pivot (where the attacker goes when that door closes). The verdict labels are: Dead (this rung is gone), Hindered (more attempts / a narrower precondition needed), Untouched (PA never sees it), Residual (constrained but live), Not PA's job (delegated to another layer — see Part IV).
Quick-reference matrix#
| Attack | The rung PA snaps | Verdict | Where the attacker pivots |
|---|---|---|---|
| Overflow → allocator-metadata corruption | Metadata is moved out-of-line, behind guard pages | Dead | Corrupt a neighboring object instead |
Freelist poisoning → arbitrary new |
Free-list link is encoded + shadowed + bounds-checked | Dead | Get a write some other way |
| Linear overflow → adjacent object | Bucketing fixes neighbor's size; MTE faults on byte 1 | Hindered | Overwrite neighbor's length / ptr / vptr |
| Wild / region-crossing overflow | Guard pages fault | Dead | Stay inside one slot span |
| Use-after-free → type confusion | BRP quarantines the slot if a raw_ptr still aims at it |
Dead (covered ptrs) | Find a UAF whose live ref is not raw_ptr |
| Double-free | Free-list consistency/shadow check | Hindered | Need a validation-bypassing free |
| Type confusion (logic bug, no reuse) | — | Untouched | PA never sees it |
| Heap grooming / spray | Free-list shuffle + partition/bucket isolation | Hindered | More attempts; same-partition same-size spray |
| Pointer redirection / leak | Pool/cage range check; BRP/MTE on the read | Partial | Redirect within the region |
| Cross-cache / page reuse | Quarantine vs. decommit policy | Residual | Force slot-span decommit + cross-type reuse |
| Control-flow hijack (ROP/JOP) | — (CFI/PAC/CET/V8 sandbox) | Not PA's job | Different mitigation stack |
II.1 Linear heap overflow → adjacent-object corruption#
The bug (ordinary C++ you've written):
struct Header { // a perfectly normal object: a size + a pointer
size_t length;
char* data;
};
char* buf = new char[16]; // slot A (16-byte bucket)
Header* h = new Header{}; // slot B — grooming (II.7) put it right after A
h->length = 8;
h->data = small_backing_store;
memcpy(buf, input, input_len); // BUG: input_len can exceed 16; the tail spills into slot B
What that looks like in memory — the overflow runs straight off the end of buf into the
neighbor's fields:
memory addresses increasing ───────────────────────────────────────────►
┌───────────────────────────┬─────────────────┬─────────────────┐
│ buf (slot A · 16 B) │ h->length (8B) │ h->data (8B) │
└───────────────────────────┴─────────────────┴─────────────────┘
│←──── slot A ─────────────→│←──────── slot B (Header) ────────→│
│←─ legal write: 16 bytes ─→│
│←──────── memcpy(buf, input, 32) copies this whole span ──────→│
└─ OVERFLOW: 16 attacker-chosen bytes
land on h->length and h->data
The attacker's code. They don't think in char[16]; they think in byte offsets relative to
the overflow source. They lay out the exact bytes that will land on the neighbor's fields:
// The attacker controls 'input'. They build it to overwrite buf, then keep going
// 16 more bytes into slot B, landing precisely on h->length and h->data.
struct OverflowPayload {
uint8_t filler[16]; // exactly fills buf (slot A)
size_t new_length; // lands on h->length
char* new_data; // lands on h->data
};
OverflowPayload p{};
memset(p.filler, 'A', sizeof p.filler);
p.new_length = 0x7fffffff; // make the victim think it owns ~2GB
p.new_data = reinterpret_cast<char*>(0xDEAD); // aim the victim at an address of our choosing
deliver(buf, &p, sizeof p); // the vulnerable memcpy copies all of this
The payoff. Later, innocent code trusts those fields:
void Flush(Header* h) {
process(h->data, h->length); // reads/writes h->length bytes starting at h->data
} // both are now attacker-chosen → arbitrary read/write
A 16-byte overflow has become an arbitrary read/write primitive. (Or the neighbor is a C++ object with a vptr at offset 0 → the same overflow overwrites the vptr → I.4 → control.)
What PA does to it. PA doesn't stop the memcpy. It removes the escalation paths the
attacker assumed, and on MTE hardware it stops the write at byte 1:
- Out-of-line metadata (II.2) means the bytes after
bufare another object's fields, never the allocator's bookkeeping — so you can't escalate via the allocator, only via a neighbor object. - Bucketing:
bufand its neighbor share a size class, so the attacker can only place a same-bucket victim next door — they can't conjure an arbitrary-sized juicy object on demand. - MTE (where enabled): adjacent slots carry different tags, so the first out-of-bounds byte
faults before it ever reaches
h->length. PA tags on a 16-byte granule:
cpp
// security.md — MTE config
constexpr size_t kMaxMemoryTaggingSize = 1024; // tag slots ≤ 1 KB
constexpr uint64_t kMemTagGranuleSize = 16u; // neighbor slot carries a different tag
// The CPU compares the pointer's tag against the granule's tag on every store;
// the spill from slot A into slot B is a tag mismatch → synchronous fault.
Residual / pivot. With MTE off, overwriting a live neighbor's fields survives. This is the main surviving spatial path; it just requires grooming (II.7) to place a same-bucket object with a length, a pointer, or a vptr immediately after the overflow source. Verdict: Hindered.
II.2 Overflow → allocator-metadata corruption — the "House of ___" family#
First, the world this attack lives in (skip if you've done CTFs). When exploit people say "the
heap," they usually mean glibc's allocator — the malloc behind every normal Linux program,
internally called ptmalloc. It's the canonical target because it's everywhere and because it keeps
its bookkeeping inline, right next to your data (I.1). A little vocabulary makes the rest readable:
a chunk is one allocation plus the small header glued to its front; when you free a chunk it
gets parked on a free list, and glibc calls those lists bins (the "fastbin," the "unsorted bin,"
and so on). Over the last ~20 years a whole craft grew up around corrupting that inline header and
those bin links — a named catalog of recipes that CTF players drill. You do not need to learn the
catalog. The only reason it's in this doc is to show you the attack surface PA was built to
delete: once you've seen it, "PA moves metadata out of line" stops being a throwaway phrase and
becomes "PA erases twenty years of exploitation technique in one design decision."
Why classic allocators are vulnerable. As just said, glibc stores a chunk's size and free-list (bin) links inline, right before the user data. In C++ terms the heap really looks like this:
// glibc's view of memory around YOUR object:
struct malloc_chunk {
size_t prev_size; // metadata
size_t size; // metadata: chunk size + flag bits <-- attacker target
// --- the pointer malloc() returns points HERE ---
struct malloc_chunk* fd; // when free: forward link <-- attacker target
struct malloc_chunk* bk; // when free: backward link <-- attacker target
// ... your data ...
};
So the same overflow as II.1, if the neighbor happens to be a chunk header, rewrites the allocator's own brain. This is the whole game in one picture — where the metadata lives decides whether an overflow can reach it:
glibc — metadata INLINE, interleaved with your objects (reachable):
…[hdr: size,links][ object A ][hdr: size,links][ object B ]…
└─ overflow off A ─────────────►✗ corrupts B's header
→ House of ___ → arbitrary malloc
PartitionAlloc — metadata OUT-OF-LINE, fenced behind guard pages (unreachable):
[guard][ slot-span metadata ][guard][ object A ][ object B ]…[ object N ][guard]
▲ all bookkeeping lives here └─ overflow off A ──► reaches object B ONLY
(a guard page faults there is NO allocator metadata down here
before you can touch it) to corrupt — pivot back to II.1
The attacker's code. That named catalog — "House of Force," "House of Spirit," "unlink,"
"unsorted-bin attack," "fastbin dup" (the "House of ___" names are an homage to the 2005 Phrack
article The Malloc Maleficarum) — is all the same move: overflow the neighbor's size/bin
links so the allocator's next operation does something insane, most usefully handing back an
attacker-chosen address from the next malloc.
// "House of Force"-style, schematically: corrupt the top chunk's size to ~SIZE_MAX,
// then request a huge size so malloc's cursor lands exactly where we want.
size_t* top_size = neighbor_chunk_size_field(); // reached via an overflow like II.1
*top_size = (size_t)-1; // top chunk now "owns" all of memory
void* target = (void*)(stack_return_address - 0x20);
size_t evil = (uintptr_t)target - (uintptr_t)heap_top - HEADER;
malloc(evil); // moves the allocator's cursor to 'target'...
void* here = malloc(0x20); // ...and THIS malloc returns 'target' — an allocation primitive
// now writing into 'here' overwrites a return address on the stack → I.5
What PA does to it. PA stores page- and bucket-level bookkeeping in a separate metadata region at the head of each super page, fenced by guard pages. A data overflow physically cannot reach it:
Super page layout (security.md):
Offset Content Security purpose
0x00000 Guard page (4 KB) catch underflow
0x01000 Metadata page SlotSpanMetadata, PartitionPageMetadata — OUT OF LINE
0x02000 InSlotMetadataTable
0x03000 Guard pages (4–8 KB) fence the metadata off from data
0x04000 Slot spans (your objects) <-- all attacker-reachable overflows live down here
...
0x1F0000 Guard pages (16 KB) catch overflow off the end
The metadata isn't near your object at all — it's behind a guard page. There is no
neighbor_chunk_size_field() to reach. The one piece of metadata that is in-slot — the free-list
link living inside a free slot — is hardened separately (II.4).
Residual / pivot. None, for this family. Your glibc muscle memory does not transfer; there is no "corrupt allocator state → primitive" move. Pivot back to II.1 (neighbor-object corruption). Verdict: Dead.
II.3 Use-after-free → type confusion — the canonical browser bug#
This is the one to understand cold; it's the bread-and-butter of renderer exploits, and the attack PA's headline feature (BRP) exists to kill. We'll write the whole exploit out.
The bug. A real object with virtual methods, freed while a pointer to it survives:
struct AudioNode {
virtual void Process(); // vtable slot 0 — the first 8 bytes of the object are the vptr
virtual ~AudioNode();
double gain; // offset 8
AudioNode* next; // offset 16
}; // sizeof == 32 → lands in the 32-byte bucket
AudioNode* w = new AudioNode(); // slot S; w->vptr points at AudioNode's real vtable
// ... a lifetime bug elsewhere (an event handler outlives its node, say) ...
delete w; // slot S is freed; but 'w' is still reachable and used later
The attacker's code. Three pieces: a fake object whose layout matches AudioNode, a fake vtable
of addresses they chose, and a spray loop to win the reuse race. Picture each new as a JS
allocation the renderer performs on the attacker's behalf.
// (1) The fake vtable: an array of function pointers WE pick. Slot 0 is what Process() will call.
// Addresses come from a prior info leak (I.5) that defeated ASLR.
uintptr_t g_fake_vtable[8];
g_fake_vtable[0] = leaked_base + OFFSET_STACK_PIVOT; // first ROP gadget / pivot
// (2) The replacement object. Its job: put our chosen vptr at offset 0, where AudioNode::vptr lives.
struct FakeNode {
uintptr_t fake_vptr; // offset 0 -> read as w->vptr
uint8_t pad[24]; // fill out the rest of the 32-byte slot
};
// (3) Spray: refill the freed slot S many times so reuse is reliable, not racy.
// Every sprayed object is 32 bytes — the SAME bucket as AudioNode — so one of them
// is very likely to be handed slot S.
constexpr size_t kSpray = 0x2000;
for (size_t i = 0; i < kSpray; ++i) {
auto* f = new FakeNode(); // JS: new Uint8Array(32) etc.
f->fake_vptr = reinterpret_cast<uintptr_t>(g_fake_vtable);
// (keep references so nothing gets freed back)
}
// At this point slot S almost certainly holds a FakeNode: its first 8 bytes == &g_fake_vtable.
The payoff. The program makes one more virtual call through the stale pointer:
w->Process();
// expands to: (*w->vptr[0])(w)
// w->vptr == g_fake_vtable (attacker bytes now sitting in slot S)
// vptr[0] == OFFSET_STACK_PIVOT (attacker-chosen)
// → the CPU jumps into the attacker's ROP chain. Program counter is theirs. (I.4 → I.5)
A lifetime mistake became full control of execution. The whole dance, on a timeline:
sequenceDiagram
autonumber
participant P as Program
participant S as Slot S (heap)
participant A as Attacker (script)
P->>S: new AudioNode() — real vptr at offset 0
Note over P,S: a lifetime bug fires…
P->>S: delete w (S → free list; but w still points at S)
A->>S: spray new FakeNode() ×0x2000 (same 32B bucket)
Note over S: a FakeNode lands in S; offset 0 now = fake_vtable ptr
P->>S: w->Process() → call (vptr[0])(w)
S-->>P: vptr[0] = attacker-chosen gadget
Note over P: program counter hijacked → ROP (I.5)
What PA does to it — the headline defense, mechanically. BackupRefPtr (BRP / "MiraclePtr")
keeps a reference count in the slot's InSlotMetadata that counts surviving raw_ptr<T>
references. The crux is the free() path: if any raw_ptr still aims at the slot, PA refuses to
put the slot back on the free list — it quarantines it — so the attacker's spray can never land
there.
// backup_ref_ptr.md — the free() path, condensed.
bool InSlotMetadata::ReleaseFromAllocator(...) {
CountType old = count_.fetch_and(~kMemoryHeldByAllocatorBit, std::memory_order_release);
if (!(old & kMemoryHeldByAllocatorBit)) // already freed once?
DoubleFreeOrCorruptionDetected(...); // -> immediate crash (this is also II.5)
static constexpr CountType mask = kPtrCountMask | kUnprotectedPtrCountMask;
if ((old & mask) == 0) // NO raw_ptr still references the slot...
return true; // -> OK to reclaim onto the free list
CheckDanglingPointersOnFree(old); // ...but a raw_ptr DOES still reference it:
return false; // -> DO NOT reclaim. Quarantine slot S.
}
If AudioNode* w had been a raw_ptr<AudioNode>, then at delete w the count was still nonzero,
ReleaseFromAllocator returned false, and slot S was withheld from the free list. The
attacker's new FakeNode() spray therefore cannot occupy S, and the final w->Process()
dereferences quarantined/poisoned memory → a safe, controlled crash instead of a hijack. This is
deterministic — there is no probability for the attacker to grind down with more spray. The
single decision that kills the attack:
flowchart TD
F["free slot S"] --> C{"any raw_ptr<T> still
references S? (count != 0)"}
C -->|no| R["reclaim: S returns to the free list"]
C -->|yes| Q["quarantine: S withheld from the free list"]
R --> SPRAY["attacker spray reuses S
→ type confusion → control"]
Q --> SAFE["later use of w hits poison
→ safe, controlled crash"]
style SPRAY fill:#5a1f1f,stroke:#e06c6c,color:#fff
style SAFE fill:#1f4a2a,stroke:#5fbf7f,color:#fff
The attacker wants the left branch; a single surviving raw_ptr forces the right one.
Two more layers stack on top:
- Partition isolation independently blocks the cross-type version: S can only be reused inside
its own partition, so you can't refill an AudioNode slot with an object from another partition.
- Bucketing forces the replacement to be the same size (32 bytes) as AudioNode.
Residual / pivot (the live fight — see Part III). BRP only arms for raw_ptr. If the surviving
reference is a plain AudioNode* that was never rewritten, or a pointer hidden in an
integer/handle, a pointer-to-pointer, a union, or a deliberate perf carve-out — the count stays
zero, the quarantine never triggers, and this exploit works exactly as written above. So the
attacker's job collapses to bug selection: find the UAF whose live reference isn't a raw_ptr.
Verdict: Dead for covered pointers.
II.4 Freelist poisoning → an arbitrary-allocation primitive#
The most "magic"-looking attack to a non-security dev, so here's the mechanism in full.
How a free list actually works. It's a singly linked list threaded through the free slots themselves — each free slot's first word holds a pointer to the next free slot:
head ─► [free slot]──next──► [free slot]──next──► [free slot] ─► null
In code, a naive allocator's hot path is literally:
void* Allocate() {
void* slot = freelist_head;
freelist_head = *reinterpret_cast<void**>(slot); // "next" is read straight out of the slot
return slot;
}
The attacker's code. With a UAF-write or an overflow (II.1) into a free slot, overwrite its
"next" word to point at a target address X. Two allocations later, the allocator hands back X:
char* freed = get_dangling_into_a_free_slot(); // a slot currently ON the free list
void* target = (void*)stack_return_address; // X = wherever we want to write
// A free slot's first 8 bytes ARE the freelist 'next' pointer. Overwrite them:
*reinterpret_cast<void**>(freed) = target; // poison: next -> X
(void)operator new(SLOT_SIZE); // pops 'freed', sets head = X
void* here = operator new(SLOT_SIZE); // pops X → returns 'target' as if fresh memory
// 'here' now points at the stack. Whatever a constructor writes here overwrites the return address.
Now new returns a pointer into memory the attacker chose → arbitrary write / object planting.
Classic allocators trusted that "next" pointer blindly. The poisoning, before and after:
flowchart LR
subgraph Normal["normal free list"]
direction LR
h1["head"] --> a1["slot A"] --> b1["slot B"] --> n1["null"]
end
subgraph Poisoned["after attacker overwrites A's 'next'"]
direction LR
h2["head"] --> a2["slot A"] -. forged 'next' .-> x2["X = stack /
any address"]
end
style x2 fill:#5a1f1f,stroke:#e06c6c,color:#fff
(glibc later added "safe-linking," which XORs the pointer with a heap-derived secret — the same idea PA had earlier.)
What PA does to it. PA never stores a raw "next" pointer. It stores it encoded as a pool-relative offset, byte-swapped, with an inverted shadow copy, and validates on every pop. Three independent checks must pass, or the process aborts:
// freelist_design.md — decode + validate, condensed.
// (a) The link is a byte-swapped, pool-relative OFFSET, not an address.
FreelistEntry* EncodedPoolOffset::Decode(size_t slot_size) const {
PoolInfo pool = GetPoolInfo(SlotStart::Unchecked(this));
uintptr_t off = ReverseBytes(encoded_); // undo the bswap transform
// CHECK 1: the offset must not set any bit inside the pool base mask.
// An attacker's arbitrary X almost always does -> abort. A corrupted link
// CANNOT escape the pool, and as a raw pointer the bswapped value is non-canonical.
if (off & pool.base_mask & ~kPtrTagMask)
FreelistCorruptionDetected(slot_size);
return reinterpret_cast<FreelistEntry*>(pool.base | off);
}
// (b) A shadow copy must still be the exact inverse, and the target must be in-bounds.
static bool IsWellFormed(const FreelistEntry* here, const FreelistEntry* next) {
bool shadow_ok = here->encoded_next_.Inverted() == here->shadow_; // CHECK 2
bool not_in_metadata = (next_addr & kSuperPageOffsetMask) >= PartitionPageSize();
bool same_super_page = (here_addr & kSuperPageBaseMask) == // CHECK 3
(next_addr & kSuperPageBaseMask);
return shadow_ok & same_super_page & not_in_metadata;
}
Walk the attacker's *freed = target through this: they wrote a raw address into the
encoded_next_ word, but PA will ReverseBytes it and require it to be a small in-pool offset
(fails CHECK 1), they didn't update the inverted shadow_ word (fails CHECK 2), and target (the
stack) isn't in the same super page (fails CHECK 3). Any one failure → FreelistCorruptionDetected
→ abort. Even a perfect forgery (fix all three) only lets the attacker pick another legitimate
slot within that one super page — never an arbitrary X. The gauntlet every popped link runs:
flowchart TD
POP["pop a free slot:
decode its 'next' link"] --> C1{"CHECK 1
decoded offset stays
inside the pool?"}
C1 -->|no| AB["FreelistCorruptionDetected()
→ immediate abort"]
C1 -->|yes| C2{"CHECK 2
shadow == inverse
of encoded link?"}
C2 -->|no| AB
C2 -->|yes| C3{"CHECK 3
target in the same
super page?"}
C3 -->|no| AB
C3 -->|yes| OK["accept — but at best another
real slot in THIS span,
never an arbitrary X"]
style AB fill:#1f4a2a,stroke:#5fbf7f,color:#fff
style OK fill:#3a3a1f,stroke:#bfae5f,color:#fff
Residual / pivot. The arbitrary-new primitive is gone. Stop trying to steer the allocator;
obtain a write some other way.
Verdict: Dead.
II.5 Double-free#
The bug + the attacker's code. The same slot is freed twice, so it lands on the free list
twice. The allocator can then hand the same slot to two different news — instant aliasing /
type confusion — or the duplicate-link state sets up the poisoning of II.4:
delete p; // slot S -> free list
// ... a second path believes it owns p too ...
delete p; // S -> free list AGAIN; the list now contains S twice
auto* a = new Foo(); // returns S
auto* b = new Bar(); // ALSO returns S — 'a' and 'b' alias the same bytes as different types
// writing through 'a' (a Foo) reinterprets bytes that 'b' reads as a Bar -> type confusion -> I.4
What PA does to it. Two independent checks fire on the second free:
// security.md — immediate free-list check on the free path:
PA_CHECK(entry != freelist_head); // re-freeing the current head is caught instantly
// backup_ref_ptr.md — and BRP's allocator bit catches it even across the freelist:
CountType old = count_.fetch_and(~kMemoryHeldByAllocatorBit, ...);
if (!(old & kMemoryHeldByAllocatorBit)) // bit already clear == this slot was already freed
DoubleFreeOrCorruptionDetected(...); // -> PA_IMMEDIATE_CRASH()
The same free-list shadow/consistency machinery from II.4 also trips when a slot is re-inserted into a list it's already on, and BRP's quarantine perturbs the timing the attacker relies on.
Residual / pivot. You'd need a free that bypasses validation — freeing an interior/aliased pointer that canonicalizes to a different slot, or a race that double-frees inside the check window. Verdict: Hindered.
II.6 Type confusion without reuse (a pure logic bug)#
The bug + the attacker's code. A bad cast, a mis-tagged union, or a variant read as the wrong
type — no free, no reallocation. The same live bytes are simply interpreted as two types:
union Value {
double number; // attacker writes here as a JS number...
ScriptObject* object; // ...and a type-confusion bug reads it back here as a pointer
};
Value v;
v.number = bit_cast<double>(uintptr_t{0x4141414141414141}); // attacker-chosen "pointer"
v.object->Call(); // the engine treats attacker-controlled bits as a real object* -> I.4
What PA does to it. Nothing. PA only ever sees a size and a partition; it has no notion of your C++ types, and nothing was ever freed or reallocated, so the allocator was never on the path.
Residual / pivot. This is precisely where pure type confusion lives in a PA world. The defense is in the language / compiler / codebase (and the V8 sandbox), not the allocator. Verdict: Untouched.
II.7 Heap grooming / spray — the reliability layer#
Not an attack by itself; it's how II.1 and II.3 are made reliable. The attacker needs the right object in the right slot, but the heap is noisy and shared, so they shape it deliberately.
The attacker's code. A classic "defragment, free a hole, refill it" groom:
// 1. Spray many same-bucket objects to defragment the bucket into a clean run of slots.
std::vector<Filler*> pin;
for (int i = 0; i < 50'000; ++i) pin.push_back(new Filler()); // Filler is the victim's size
// 2. Free a contiguous group to open a predictable hole.
for (int i = 1000; i < 1010; ++i) { delete pin[i]; pin[i] = nullptr; }
// 3. Allocate the VICTIM — it drops into the hole we just opened.
Victim* v = make_victim();
// 4. Free one neighbor and immediately refill with a controlled-contents object,
// so 'v' now sits next to bytes we own (sets up II.1) or on top of a slot we'll dangle (II.3).
delete pin[1005];
auto* controlled = new ControlledBuffer(); // lands adjacent to / on top of v
What PA does to it. - Free-list shuffle: PA can randomize the order slots come off a freshly provisioned span, so reuse isn't a clean LIFO/FIFO the groom can predict — this raises the number of attempts needed. - Partition + bucket isolation: the groom object must be interchangeable with the victim — same partition and same size class. You can't groom with arbitrary-sized sprays, which removes a lot of convenient primitives.
Residual / pivot. Grooming still works with volume and a same-bucket controllable-contents object (an array or string in the victim's bucket). Budget extra attempts; cheap against an auto-restarting target, expensive against a one-shot renderer. Verdict: Hindered.
II.8 Pointer redirection & information leak#
The attacker's code (two flavors).
// Redirection: corrupt a STORED pointer so the program reads/writes somewhere else.
backing_store->data_ptr = (char*)leak_target; // via II.1 overflow or II.3 reuse
read_back(backing_store); // program now reads from leak_target for us
// Leak: read where you shouldn't, recover a real address, defeat ASLR (I.5).
uintptr_t leaked = *reinterpret_cast<uintptr_t*>(stale_or_oob_pointer);
uintptr_t lib_base = leaked - KNOWN_OFFSET; // now we know where the gadgets are
What PA does to it.
- Pools / address-space cages: certain pointers live in a known reserved region; PA can
range-check them (the same pool.base_mask test as II.4), so a corrupted pointer aimed
outside the cage is detectable.
- BRP neutralizes the UAF-read leak for covered pointers (you read poison/quarantined memory,
not stale or attacker-reallocated data); guard pages / MTE catch the out-of-bounds read.
Residual / pivot. Caging bounds the range, not the aim — redirecting to another address
inside the same region passes the check. Leak via same-bucket adjacent reads or a non-raw_ptr
UAF read. (Note: leaking any caged pointer also reveals the cage base, which is itself useful.)
Verdict: Partial.
II.9 Cross-cache / page reuse (advanced — worth knowing)#
The idea + the attacker's code. BRP reserves the slot. But if you can force the object's entire slot span / OS page to be released and then re-provisioned under a different bucket or partition, a stale pointer ends up aliasing a different-type object — sidestepping per-bucket reuse rules and, potentially, BRP's slot-level quarantine:
// Free everything in the victim's slot span so the span goes fully empty...
for (auto* o : everything_in_that_span) delete o;
// ...apply memory pressure so PA DECOMMITS the now-empty span (a page-level path, not the slot path)
exhaust_memory_until_decommit();
// ...then allocate a DIFFERENT bucket/partition so the same virtual range is re-provisioned for it.
auto* other = new DifferentType[...]; // may reuse the decommitted range -> stale ptr now aliases it
What PA does. Partitions and the decommit policy govern when this is possible; the surface is the interaction between how long BRP holds a quarantined slot and when empty slot spans get decommitted under pressure. It's deliberately constrained but not eliminated.
Residual / pivot. Genuinely a residual; version-sensitive and worth auditing against the current decommit + refcount-lifetime code. Verdict: Residual.
II.10 Control-flow hijack — the boundary (not PA's job)#
Once the attacker has a write primitive aimed at a code pointer (a return address on the stack, or a vptr per I.4), the actual hijack + ROP chain is governed by a different defense stack:
// The fake vtable from II.3 / the poisoned return address from II.4 leads here:
uintptr_t rop[] = {
pop_rdi_ret, (uintptr_t)"/bin/sh", // borrowed gadget #1: load an argument
system_addr, // borrowed gadget #2: call into existing code
/* ... */
};
// PAC/CET/CFI are what decide whether this chain even gets to run.
- PAC — signs return/code pointers so tampering is detected (ARM; watch PACMAN-style speculative recovery).
- CET — shadow stack + indirect-branch tracking (x86).
- V8 sandbox / CFI — keeps JS-heap corruption from naming native pointers.
PA's only contribution is indirect: by making the corruption in II.1–II.8 harder, it raises the cost of reaching this step. Treat "heap integrity" and "control flow" as two separate problems; PA sells you the first. Verdict: Not PA's job.
Part III — Defeating BRP, in plain terms (where the fight actually is)#
BRP (II.3) is the deterministic centerpiece, so the cat-and-mouse concentrates there. Four lines of attack, each shown as the move that re-enables the II.3 exploit verbatim.
1. Find an uncovered reference. BRP only arms for raw_ptr<T> — only those bump the count. Any
of these keep the count at zero, so ReleaseFromAllocator returns true, the slot is freed
normally, and the II.3 spray lands:
Widget* a = w; // a plain raw pointer never rewritten to raw_ptr — count stays 0
uintptr_t b = (uintptr_t)w; // pointer hidden in an integer / handle table
Widget** c = &a; // pointer-to-pointer
union { Widget* p; uint64_t bits; } d{ .p = w }; // pointer in a union member
raw_ptr<Widget, DisableDanglingPtrDetection> e = w; // a deliberate perf carve-out
Defender's counter: the long campaign to rewrite the whole codebase to raw_ptr/raw_ref.
2. Use a bug class BRP doesn't cover. BRP is temporal only. It does nothing about out-of-bounds (II.1) — an overflow into a live neighbor never touches a refcount. Spatial safety is a different effort (bounds-checked spans), not BRP.
3. Attack the reference count itself. The count lives in in-slot metadata, near the data (end of slot, by default). An adjacent overflow (II.1) that reaches it can forge a zero count → the slot is freed while still referenced — the attacker has manufactured a UAF — or overflow/wrap the count:
// Overflow from the neighboring slot all the way to this slot's trailing InSlotMetadata:
InSlotMetadata* meta = end_of_victim_slot(); // count_ lives here
meta->count_ = 0; // forge "no references" -> next free() reclaims it
// Now free the victim through the normal path; BRP sees count==0 and hands slot S to the freelist.
// We've created a dangling raw_ptr that BRP THINKS is safe. II.3 resumes.
PA's defense is the cookie check guarding that field — corrupt the count without fixing the
cookie and the next Acquire/Release/free aborts:
// backup_ref_ptr.md — validated on every refcount op:
uint32_t CalculateCookie() { return (uint32_t)(uintptr_t)this ^ kCookieSalt; } // 0xc01dbeef
void CheckCookieIfSupported() { PA_CHECK(brp_cookie_ == CalculateCookie()); }
The field's exact placement is version-sensitive; its integrity is a standing target. Defender's counter: hardening (cookie, and moving metadata out-of-line where feasible).
4. Drop the count to zero while keeping a usable alias. BRP releases the slot when the count hits zero. If the attacker releases every counted reference through one path while retaining an uncounted alias (per #1) through another, the slot frees and II.3 is back on:
raw_ptr<Widget> counted = w; // bumps the count
Widget* alias = w; // does NOT (uncovered, per #1)
counted = nullptr; // count -> 0; BRP reclaims slot S
// 'alias' still points at S, which is now reusable. Spray (II.3) and go.
Part IV — The boundary: what PA delegates, and to whom#
PA is one layer in a stack. Knowing the seams tells you where attacks go when PA closes a door:
- Out-of-bounds / spatial safety: bounds-checked spans + compiler bounds-safety. PA's deliberate gap; their job. (This is the II.1 residual and BRP's blind spot, Part III #2.)
- Control flow (ROP/JOP): PAC / CET / V8 CFI. §II.10.
- JS-heap ↔ native-pointer confusion: the V8 sandbox (offset-based references). §II.6; separate cage.
- Cheap broad spatial+temporal net: MTE — closes the II.1 residual and immediate-reuse UAF,
near-free, ARM-only. (See
mte_integration.md; PA's MTE history is uneven —mte_comparison.md.) - Blast-radius containment: Site Isolation (one process per site) + the renderer sandbox (seccomp-bpf, etc.) — even a successful corruption must still escape the sandbox to matter.
- Uninitialized-memory reads: usually nobody, by default, for performance (zero-on-alloc is typically off). A standing residual — and why an info leak (II.8) is often the cheapest first step.
Synthesis in one paragraph#
In PartitionAlloc the allocator-state attacks (the entire "House of ___" / freelist-poisoning world,
II.2/II.4) are simply gone — bookkeeping is out-of-line behind guard pages, and the free-list
link is encoded, shadowed, and bounds-checked. The spatial attacks (II.1) survive only in their
mildest form, overwriting a neighboring object's fields, and only where MTE is off. The
temporal attacks (use-after-free → type confusion, II.3) survive only where the still-live
reference managed to escape raw_ptr (Part III), which is why bug-hunting in modern Chrome is
really raw_ptr-coverage-hunting. And everything past the memory write — redirecting a code
pointer, building a ROP chain, escaping the sandbox (II.10) — is a different fight against a
different stack (PAC/CET/CFI/site isolation), one PA only makes harder to reach, never fights
directly. Read any PA mitigation through this lens and you can name the exact rung it snaps.
See also (in this directory)#
security.md— the full feature list with PA's own code (GigaCage, guard pages, scribbling, …)freelist_design.md— pool-offset encoding, bswap transform, shadow-entry validation (II.4)backup_ref_ptr.md—InSlotMetadata, reference counting, dangling-pointer detection (II.3)mte_integration.md/mte_comparison.md— the hardware net for II.1 and immediate-reuse UAFgigacage.md— pool/cage layout behind the range checks (II.8)architecture.md— super pages, slot spans, buckets (the layout every attack above assumes)