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, *Scan status, 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:

  1. Freed memory gets reused. delete x; auto* y = new T(); very often hands y the slot x just vacated.
  2. The bookkeeping is stored somewhere. Classic allocators (glibc's, the old dlmalloc lineage) 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.

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:

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.

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:


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)#