algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

addrof and fakeobj — the two primitives that own a JS engine

2026-01-113 min readpwn browser

Browser exploitation looks intimidating from the outside — JIT compilers, garbage collectors, hidden classes. But almost every modern JS-engine exploit funnels through the same two primitives, and once you have them the engine is effectively yours:

  • addrof(obj) — leak the in-heap address of a JavaScript object.
  • fakeobj(addr) — make the engine treat memory you control as a JS object.

This series builds toward full arbitrary read/write; this part builds the foundation. Everything else is a way of obtaining, or applying, these two.

targeta modern JS engine (V8 / JSC / SpiderMonkey)
bug
a type confusion between PACKED_DOUBLE and PACKED (object) array elements
goal
addrof + fakeobj

doubles and pointers share storage#

An engine stores array elements differently depending on what's in the array. A [1.1, 2.2] array is PACKED_DOUBLE_ELEMENTS — the backing store holds raw IEEE-754 doubles, inline. A [{}, {}] array is PACKED_ELEMENTS — the backing store holds tagged pointers to objects. Same slot, two interpretations: an 8-byte double, or an 8-byte object pointer.

The entire trick is to make the engine put an object into a slot and then read that slot back as a double (you get the pointer bits → addrof), or write a double and read it back as an object (the engine trusts your bits as a pointer → fakeobj). A type-confusion bug that swaps an array's elements-kind without converting the backing store gives you exactly that swap.

reinterpreting bits#

You move between doubles and integers with an ArrayBuffer aliased two ways:

float <-> int helpersjs
1
2
3
4
5
let buf = new ArrayBuffer(8);
let f64 = new Float64Array(buf);
let u64 = new BigUint64Array(buf);
const ftoi = f => (f64[0] = f, u64[0]);     // double bits -> integer
const itof = i => (u64[0] = i, f64[0]);     // integer -> double bits

the two primitives#

Given a bug that hands you a double-array flt and an object-array obj aliasing the same backing store (the confusion), the primitives fall out:

addrof / fakeobj from the confusionjs
1
2
3
4
5
6
7
8
function addrof(o) {
    obj[0] = o;            // store a real object pointer ...
    return ftoi(flt[0]);   // ... read the same slot as a double -> its address
}
function fakeobj(addr) {
    flt[0] = itof(addr);   // store a controlled address as double bits ...
    return obj[0];         // ... read it back as an object -> a fake object @ addr
}

addrof leaks where an object lives; fakeobj conjures an object out of an address you choose. Point fakeobj at a buffer whose bytes you control and you've forged a fake object header — a fake array with an attacker-chosen length and backing-store pointer, which is the doorway to arbitrary read/write.

the leak that starts every JS pwn
js
1
2
3
let o = {marker: 0x1337};
print("addrof(o) = 0x" + addrof(o).toString(16));
// addrof(o) = 0x000028a4...  -> a real V8 heap pointer, leaked from a double read

why it's always these two#

  • They compose into everything. fakeobj over a controlled buffer forges a TypedArray whose data pointer you set → read/write anywhere. addrof gives you the addresses to aim it at.
  • They're bug-agnostic. A JIT typer bug, an OOB write, a UAF on an array — wildly different bugs all get massaged into "confuse a double array with an object array," because that's the shape that yields these two.
  • They survive most mitigations. Pointer compression and the V8 sandbox change the representation (32-bit compressed pointers, a separate heap cage), but the addrof/fakeobj idea adapts; you're still leaking and forging in-heap references.

The mental model for the whole field: a memory bug in a JS engine is only interesting insofar as it gets you addrof and fakeobj. Reach those two and you stop thinking about the bug — the next two parts are the same regardless of how you got here.