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

from OOB to arbitrary R/W (and a shell)

2026-01-164 min readpwn browser

addrof/fakeobj are the primitives; a JIT bug is one way to get them. This part is the finish line they all share: parlay them into arbitrary read/write, then into code execution. The first half is mechanical; the second is where the last decade of browser hardening lives.

target
have
addrof + fakeobj (or an OOB array)
step 1
a TypedArray with a backing-store pointer YOU control -> arbitrary R/W
step 2
R/W -> code execution (and the mitigations fighting step 2)

the master primitive: a fake TypedArray#

A TypedArray/DataView is just a header plus a pointer to its backing store. If you can forge that header with a chosen backing-store pointer, every array[i] read/write becomes a read/write at any address you set. With fakeobj you do exactly that:

arbitrary R/W from fakeobjjs
1
2
3
4
5
6
7
// lay out the bytes of a fake TypedArray (map, length, and backing-store ptr)
let container = [ flt_map, ..., itof(0n) /* backingStore */, ... ];
let fake = fakeobj(addrof(container) + HEADER_OFFSET);

function read64(addr)  { container[BS_SLOT] = itof(addr); return ftoi(fake[0]); }
function write64(addr, val) { container[BS_SLOT] = itof(addr); fake[0] = itof(val); }
// move the backing-store pointer, then index the fake array -> R/W anywhere

Rewrite the backing-store slot before each access and the same fake array reads or writes anywhere in the process. That's the arbitrary read/write every JS-engine exploit is built to reach. From here you leak the engine's own pointers, walk structures, and find your code-exec target.

step 2: R/W → code execution#

Historically this was trivial: JITed code lived in RWX pages, so you wrote shellcode straight into a JIT'd function's code and called it. The classic reliable variant used WebAssembly — instantiating a WASM module created an RWX page; overwrite its body via your R/W and jump in.

the old, easy finish (RWX WASM page)js
1
2
3
4
5
let wasm = new WebAssembly.Instance(new WebAssembly.Module(bytes));
let rwx  = read64(addrof(wasm) + WASM_RWX_OFFSET);  // find the RWX page
for (let i = 0; i < shellcode.length; i++)          // overwrite it via arb-write
    write8(rwx + i, shellcode[i]);
wasm.exports.f();                                    // execute

the mitigations that moved the finish line#

Step 1 still works; step 2 is where modern engines fight back, and it's why a present-day browser chain is so much more than "get R/W":

  • W^X for the JIT. RWX JIT pages are gone — code is written through a separate, short-lived mapping and executed read-only. No more "write shellcode into the JIT page." You pivot to a ROP/JOP chain or data-only techniques instead.
  • The V8 heap sandbox ("Ubercage"). Pointers inside the V8 heap are sandboxed — backing-store pointers become offsets within a reserved cage, so a fake TypedArray can only reach inside the cage, not arbitrary process memory. Arbitrary-R/W-in-the-cage no longer means arbitrary-R/W-in-the-process; you now need a second bug (an "out-of-sandbox" primitive) to escape it.
  • Pointer compression changes addresses to 32-bit cage offsets — your addrof/R/W arithmetic adapts to the compressed representation.
  • CFI / CET on the host then constrains the control-flow hijack at the end.
arbitrary R/W, then a shell (pre-sandbox era)
txt
1
2
3
4
[*] fake TypedArray @ cage+0x...   arbitrary R/W online
[*] leaked WASM RWX page @ 0x7f...; wrote 0x2d bytes of shellcode
[*] called exports.f() -> execve("/bin/sh")
segfault{addrof_fakeobj_rw_rce}

the shape of the whole field#

  • The chain is layered now. Bug → addrof/fakeobj → in-cage R/W → sandbox escape → W^X-defeating code-exec. Each mitigation added a stage; none removed the one before it.
  • Data-only is ascendant. Because code-exec is so guarded, many modern chains stay in data: corrupt interpreter state, function pointers the engine itself calls, or WASM/JIT metadata, never executing injected bytes at all.
  • Two bugs are the norm. The sandbox means one memory bug often isn't enough — you bring a renderer bug and a sandbox-escape bug, which is why full chains are team-scale efforts.

That's browser exploitation end to end: a bug becomes addrof/fakeobj, those become a fake TypedArray and arbitrary R/W, and the rest of the chain is a fight against the cage and W^X. The primitives are eternal; the finish line keeps moving, and following where it moves is the whole game.