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

JIT type confusion — when the optimizer believes a lie

2026-01-133 min readpwn browser

Part 1 assumed a type confusion fell out of the sky. This part is where it usually comes from on a modern engine: not the interpreter, but the optimizing JIT. The compiler reasons about types and ranges to delete "redundant" checks — and if you can make its reasoning wrong, it deletes a check that was load-bearing.

target
component
the optimizing JIT (V8 TurboFan / JSC DFG-FTL)
bug
a typer/range miscalculation -> an eliminated bounds check
yield
OOB read/write on a JS array's backing store

how the JIT earns its bugs#

To make hot JavaScript fast, the optimizer speculates: it watches the types you actually pass, assumes they'll keep coming, and compiles specialized code guarded by cheap checks. Crucially, it runs a typer that tracks the possible range/type of every value, and a bounds-check elimination pass that removes CheckBounds nodes it can prove are always in range.

That proof is only as sound as the typer. If a single operation's modeled range is wrong — too wide, too narrow, missing a corner like -0 or NaN — the optimizer can "prove" an index is in bounds when it isn't, and drop the check. The compiled code then indexes the backing store with no guard at all.

the shape of a typer bugjs
function evil(x) {
    let a = [1.1, 2.2, 3.3, 4.4];
    // some operation the typer mis-models, producing an index the optimizer
    // believes is 0..3 but can actually be larger:
    let i = miscomputed_index(x);       // typer thinks: range [0,3]
    a[i] = 1.1;                         // CheckBounds removed -> OOB write
    return a;
}
for (let k = 0; k < 100000; k++) evil(ok); // warm it up: force JIT compilation
evil(trigger);                              // now run the unguarded path

Real instances of the pattern read like a museum of subtle arithmetic:

  • CVE-2018-17463 — V8's JSCreateObject / Object.create typer returned a type that let later range analysis go wrong.
  • The Math.expm1 -0 bug — the typer modeled the result as plain Number and missed that it could be -0, breaking downstream range math.
  • Array.prototype length-tracking bugs — the optimizer's view of an array's length drifting from reality after a side-effecting callback.

Different functions, identical consequence: a CheckBounds removed on the strength of a range the typer got wrong.

from OOB to the primitives#

The eliminated check gives you an out-of-bounds read/write on a backing store. You groom the heap so that just past your victim array sits another array's length field or a sibling array of a different elements-kind. Now:

  • OOB-write the neighbour's length → a giant array → relative R/W over the heap;
  • or OOB across a PACKED_DOUBLE and a PACKED (object) array so the two alias — which is exactly the float/object confusion that builds addrof/fakeobj.
OOB write -> corrupt a neighbour's lengthjs
oob[ LEN_SLOT_OF_NEIGHBOUR ] = itof(0x1000n);   // neighbour.length := huge
// neighbour is now an OOB array: relative read/write across the V8 heap
a removed bounds check, an out-of-bounds array
txt
1
2
3
[*] sprayed arrays; victim @ ...e8, neighbour length @ ...f0
[*] typer bug fired: a[8] write with no CheckBounds
[*] neighbour.length = 0x1000  -> addrof/fakeobj online

the defenders' moves#

  • Verifier / typer hardening. Each of these bugs is met with a typer fix, plus fuzzers (Fuzzilli) hunting the next range error. It's a grind, not a wall.
  • Hardening the consequence. V8 added bounds checks that resist typer lies in sensitive spots, and the heap sandbox (next part) limits what an OOB even reaches.
  • JIT mitigations broadly — but the typer is enormous surface, and a single wrong range is still a full compromise.

The lesson that makes JIT bugs feel different from heap bugs: you're not corrupting memory directly, you're corrupting the compiler's beliefs, and letting it corrupt memory for you with the bounds check it was sure it didn't need. Get the OOB, reach the float/object confusion, and part 3 turns it into read/write over the entire address space.