A modern browser is close to a small operating system. It parses untrusted markup, decodes media, runs a multi-million-line JIT compiler, and executes arbitrary code from any site the moment a tab opens. That makes the JavaScript engine one of the most valuable remote attack surfaces in existence: a single bug in it runs adversary-controlled script with the full power of a native optimizing compiler behind it.
What is striking, once you have done it a couple of times, is how uniform the exploitation is. The engines differ in the details of how they encode a value or lay out an object, but the path from a memory bug to a shell is almost always the same four rungs: a type confusion, the addrof and fakeobj primitives, an arbitrary read/write, and finally code execution. Learn the ladder once and you can read almost any engine writeup. We build it here from the ground up on JavaScriptCore (WebKit) and V8 (Chrome), and then look at why, on a current iPhone, landing the bug is the easy part.
This is the written and generalized version of a talk I gave, in French, at Quarks in the Shell 2023. The original recording is on the talk post. Everything here is public methodology. There is no exploit and no engine 0day in this article.
Why the JavaScript engine
A browser is not one program, it is a pile of them: an HTML and CSS engine, the DOM, a network stack, image and font decoders, and the JavaScript engine sitting in the middle of it all. Any of those is attack surface, but the JavaScript engine is special because the attacker gets to run code there directly, with loops, objects, and timing, rather than coaxing a parser into misbehaving from the outside.
We focus on the two engines that matter most in practice. JavaScriptCore (JSC) is WebKit’s engine, written mostly in C++, shipping not only in Safari but in anything that embeds WebKit, from game consoles to embedded dashboards. V8 is Chrome’s engine, used by Blink (which descends from WebKit’s WebCore) and by Node. SpiderMonkey, Firefox’s engine, follows the same shapes. Because the engines borrowed each other’s design, the techniques port between them with only cosmetic changes.
How values live in memory
Before corrupting anything, we have to know what the raw 64-bit words we will be reading actually mean. Both engines pack every JavaScript value into a machine word, and both do it in a way that lets them tell a pointer from a number without an extra tag byte.
JSC uses NaN-boxing. A JSValue is 64 bits, and the encoding exploits the fact that IEEE-754 doubles have a huge range of bit patterns that are all “NaN”. Roughly:
- A pointer to a heap object (a “cell”) is stored as-is. On 64-bit, valid pointers have their top 16 bits clear, so a small value at the top of the word means “this is a pointer”.
- An int32 is stored with a tag in the high bits, as
0xFFFE000000000000 | value. - A double is stored with a constant offset of
2^49added to its bit pattern, so that every real double lands in a band (top 16 bits between0x0002and0xFFFC) that never collides with the pointer or integer encodings.
A consequence we will lean on: the encoded value 0x0 does not mean the number zero. It is the special “empty” value. The number zero is a double or a tagged integer with its own encoding. So when we read raw memory, a bare zero word is almost never a JavaScript 0. null, true, false, and undefined likewise have their own small fixed encodings.
V8 takes a different route. Small integers (SMIs) are tagged by their low bit, heap object pointers carry a different low-bit tag, and on 64-bit builds V8 uses pointer compression: the high 32 bits of every heap pointer in a given heap are constant, so they are kept once in a base register (the isolate root) and only the low 32 bits are stored in memory. To dereference a compressed pointer you take the stored 32-bit half and add the base from the register. JSC has its own constraint in the same spirit, the gigacage, which confines certain backing stores to a reserved region so a stray pointer cannot reach arbitrary memory. We will not need its internals here, only the awareness that it exists.
The object model and the butterfly
Now the object layout, because that is what we corrupt. Take an ordinary object in JSC. It begins with a structure ID, a number that names the object’s shape: which properties it has, in which order, of which types. Every object that shares a shape shares a structure ID, and the engine reuses it. Add a property, reorder them, or change a type, and the engine mints a new structure for the new shape. Structure IDs used to be handed out linearly, which made them predictable. Modern JSC throws in a few random bits, so you can no longer guess the next one.
After the structure ID and a few flag bytes comes the butterfly pointer. The butterfly is a single pointer that points into the middle of an allocation:
butterfly
|
v
[ ...named properties... ][ length | capacity ][ elem 0 ][ elem 1 ][ ... ]
grow to the LEFT header word grow to the RIGHT
To the right of the pointer live the indexed elements, the things you reach with obj[0], obj[1]. To the left live the out-of-line named properties, the things you set with obj.foo. Right at the boundary sits a header word holding two 32-bit fields packed into 64 bits: the public length (the array’s real length) and the vector length (its allocated capacity).
V8 expresses the same idea differently. The first field of a V8 object is its map pointer, a pointer to a Map object that describes the type and shape, followed by separate pointers to the properties and the elements. Whether the shape is named by a number (JSC’s structure ID) or by a pointer (V8’s map), the role is identical: it is the field that tells the engine “this is what kind of object I am”. Corrupt it and you have lied to the engine about a type. That is the whole game.
The bug that starts everything
Every chain begins with one memory bug in the engine. They come in many flavors, a typing mistake in the JIT optimizer, a botched bounds-check elimination, an out-of-bounds access in an array builtin, but the most productive shape is one that lets us touch a single slot just past the end of an array. Here is why that one extra slot is so valuable.
Lay out an array of doubles and look at what sits next to it in memory:
[ map / structure ][ length | capacity ][ d0 ][ d1 ][ d2 ][ d3 ][ map of the NEXT object ][ ... ]
\______________ our float array ______________/ ^
one slot past the end
The element right after our array’s last double is the header of whatever was allocated next, including its map or structure field. If the bug lets us read or write one element beyond the bounds, we can read and, crucially, overwrite the neighbor’s map. Overwrite it with the map of a different type and the engine now treats that object as something it is not. That is a type confusion, and it is the pivot from a narrow memory bug to a general one. The specific bug only has to get us this far. From here on the recipe is engine methodology, not bug specifics.
addrof and fakeobj
Two primitives turn a type confusion into something you can program against. Neither is normally possible in JavaScript, which is the point. Both exist only because of the bug.
addrof(obj) leaks the address of a JavaScript object. You are never supposed to learn where an object lives, but with the type confusion you can. Keep two arrays, one of objects and one of doubles, side by side. Put the target object into the object array, then use the confusion to make the engine read that array as if it held doubles. Reading element zero now hands you the raw pointer bits of the object, reinterpreted as a floating-point number. Convert that back to an integer and you have the address.
The float-to-integer conversion is just two views over the same bytes:
const buf = new ArrayBuffer(8);
const f64 = new Float64Array(buf);
const u64 = new BigUint64Array(buf);
const ftoi = (f) => { f64[0] = f; return u64[0]; }; // double bits -> integer
const itof = (i) => { u64[0] = i; return f64[0]; }; // integer -> double bits
fakeobj(addr) is the exact inverse. Instead of taking an object and revealing its address, it takes an address and hands you back a JavaScript object located there. You write the address you want as a double into a slot, then use the same confusion to make the engine treat that double as an object pointer. Now you hold a real, usable JS object whose memory you chose, and you can read and set its fields like any other.
// Sketch, not a working exploit: the confusion primitive is engine-specific.
const addr = addrof(victim); // leak an address
const fake = fakeobj(someAddress); // forge an object at an address we control
From fakeobj to arbitrary read and write
addrof and fakeobj are not the goal, they are the tools for building the primitive we actually want: reading and writing any 64-bit word in the address space.
The trick is to hand-build a fake object inside memory you fully control, which is easy because the contents of a float array are entirely yours. You craft a sequence of doubles that, interpreted as an object, looks like an array whose elements pointer is a value you choose. Then you fakeobj it. Reading element zero of that fake object dereferences the pointer you planted, giving you an 8-byte read at any address you like. Writing element zero writes 8 bytes there instead.
// Conceptually:
function read64(where) {
fake_array.set_elements_pointer(where); // forged via fakeobj over controlled doubles
return ftoi(fake_array[0]); // engine dereferences our pointer for us
}
function write64(where, what) {
fake_array.set_elements_pointer(where);
fake_array[0] = itof(what);
}
With read64 and write64 the engine bug is, in effect, fully cashed out. We have arbitrary read and write across the process, subject only to caged regions like the gigacage. Everything from here is about turning memory control into instruction-pointer control.
From read/write to code execution
Arbitrary read/write does not yet run shellcode. We need executable memory we can write to, or a function pointer we can redirect.
For years this was almost free. A JIT compiler has to produce executable code at runtime, so the engine kept memory that was both writable and executable. The classic move was to instantiate a WebAssembly module, which mapped a fresh RWX page. The contents of the module were irrelevant, you just wanted the page to exist. Then you used your write primitive to drop shellcode into it and redirected a JIT-compiled function’s code pointer at your bytes. One call later, your code ran.
That era is over. The mitigation that closed it is W^X: no page is writable and executable at the same time. The free RWX page is gone, and getting code execution after a clean read/write is now the hard part of a browser exploit, not the easy one.
The mitigations that make a working bug only the beginning
This is where a modern target, especially an Apple one, stops being mechanical. A working arbitrary read/write is necessary, but on current iOS it is nowhere near sufficient.
Bulletproof JIT (iOS 10). Apple’s first answer to writing JIT code without a permanent RWX page was to map one physical JIT page through two virtual mappings: one executable, one writable, with the writable mapping placed at an address the attacker is not supposed to know. The idea is that you can execute the code but cannot find where to write it. In practice it was not very durable, since recovering the writable mapping is enough to write your shellcode through it, but it set the direction.
APRR (hardware W^X). Newer Apple silicon enforces the split in hardware, per thread. Even a page that looks RWX is never simultaneously writable and executable when you touch it, because a per-thread permission register gates the write side off at access time. When the runtime legitimately needs to patch JIT code, a routine flips the current thread’s permission to writable, performs the copy, and flips it back. Two design choices make this miserable to abuse. First, the flip is per-thread, so you cannot run another thread into the brief writable window and race the copy. Second, the JIT memcpy is marked always_inline, so there is no tidy function pointer to jump to. It is melted into a much larger function full of other inlined routines. You can reach that big function’s entry, but you then have to survive all the way to the inlined copy at its tail with exactly the right registers set up, and there is a check that the register carrying the permission value was not tampered with along the way. Landing in the middle is a crash, not a primitive.
The split itself is enforced by a small lookup. A page’s rwx bits index into a per-thread APRR register that re-maps them to the effective permissions actually applied, and that mapping is what bakes in W^X: any entry that asks for write and execute comes back without the write bit.
| Page table entry | Index | Effective (APRR) |
|---|---|---|
--- |
0 | --- |
--x |
1 | --x |
-w- |
2 | -w- |
-wx |
3 | --x |
r-- |
4 | r-- |
r-x |
5 | r-x |
rw- |
6 | rw- |
rwx |
7 | r-x |
The two interesting rows are the executable-and-writable ones, -wx and rwx: both lose their w in the effective column. To write into a JIT page the runtime must first flip the thread’s APRR register so that slot maps back to a writable permission, do the copy, and flip it back, which is exactly the window the inlined performJITMemcpy opens and closes.
The commpage. Both attacker and defender care about the commpage, a read-only page mapped into every process that holds frequently used routines and values. It is the Apple analogue of Windows’ KUSER_SHARED_DATA. It is also a quietly revealing artifact: Apple publishes the relevant source, but the entries at offsets 0x110 and 0x118, the ones tied to the APRR machinery, are blanked out, a literal gap in the public code. Even “open” source hides the parts you can only recover by reversing.
PAC. On top of all that, pointer authentication signs pointers with a hardware key, so you cannot forge a usable code pointer from a memory leak alone. And even once you do achieve code execution in the renderer, you are still inside a sandbox. The second half of a real chain is escaping it.
Conclusion
The core of JavaScript engine exploitation is mechanical and remarkably uniform across engines. One memory bug becomes a type confusion, the type confusion builds addrof and fakeobj, those build arbitrary read and write, and read/write becomes, or used to easily become, code execution. The encodings change between JSC and V8, the field that names an object’s type is a number here and a pointer there, but the ladder is the same one every time.
What actually consumes the effort on a modern target is everything wrapped around that core: W^X, bulletproof JIT, APRR, pointer authentication, and the sandbox you still have to climb out of after the renderer falls. Finding and triggering the bug is the part you can teach in an afternoon. Turning it into a reliable exploit on a current iPhone is the part that is genuinely hard, and that asymmetry, a simple core wrapped in years of mitigation, is exactly what makes the work interesting.
By the way, if you would rather watch than read: this material started life as a talk I gave (in French) at Quarks in the Shell 2023. The recording is on the talk post.
