← All reports

[JSC] Use-after-free after Wasm memory grow via stale pointer folded by DFGConstantFoldingPhase

HighJSC DFG JIT —UAF

CVE: CVE-2026-43663 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected process crash Apple's description: The issue was addressed with improved memory handling. Credit: stratan (@5tratan) of Almamater Technologies, Soyeon Park, Amy Burnett, Khai Tran, sherkito, Kota Toda, HexRabbit (@h3xr4bb1t) and NiNi (@terrynini38514) of DEVCORE Research Team, Using GLM From Z.AI, Tristan Madani (@TristanInSec) from Talence Security, Brian Carpenter

Severity: High | Component: JSC DFG JIT — ConstantFoldingPhase | 13bfbf9 | Bugzilla 312781

High. The compiler baked a heap address into machine code and the runtime was allowed to move it out from under that code — four lines of missing guard, but the guard is the only thing standing between a JIT constant and a freed region. Escalation past "reliable crash" depends on whether the released Wasm memory can be groomed back into the allocator.

An optimizing compiler earns its speed by proving facts at compile time and refusing to re-check them at runtime. That bargain only holds for facts the runtime cannot revise afterward, and a typed array's storage address sat comfortably in the "cannot be revised" column for most of JavaScript's history — a JSArrayBufferView's vector pointer was fixed the moment the buffer was allocated. Resizable ArrayBuffers and growable Wasm memories moved that fact into the mutable column without moving the optimizations that depended on it.

The angle: A page can make optimized JIT code keep writing attacker-chosen doubles into a memory region the engine has already freed.

Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp

// https://bugs.webkit.org/show_bug.cgi?id=125425
break;
}
-
+
+ if (view->isResizableOrGrowableShared()) {
+ // Because resizable and growable-shared views can have their backing store reallocated
+ // by resize() / WebAssembly.Memory.grow(), a folded vector pointer would go stale.
+ break;
+ }
+
m_interpreter.execute(indexInBlock);
eliminated = true;

JSTests/stress/resizable-array-constant-folding.js

+//@ runDefault("--useConcurrentJIT=false")
+const memories = [];
+(function() {
+ while (true) {
+ try {
+ memories.push(new WebAssembly.Memory({initial: 65535, maximum: 65536}));
+ } catch (e) {
+ break;
+ }
+ }
+})();
+
+const memory = new WebAssembly.Memory({initial: 1, maximum: 100});
+const buffer = memory.toResizableBuffer();
+const view = new Float64Array(buffer);
+
+function trigger(val) {
+ view[0] = val;
+}
+
+for (let i = 0; i < 10000; i++)
+ trigger(13.37);
+
+memory.grow(1);
+trigger(1.1);

Two files, and only one of them is production code. The change to JSC::DFG::ConstantFoldingPhase::foldConstants is a four-line early break inserted into the handler that folds GetIndexedPropertyStorage when the abstract interpreter has pinned the base object down to a specific JSArrayBufferView. Before the guard, that handler ran m_interpreter.execute(indexInBlock) and set eliminated = true, retiring the storage-pointer computation in favour of a compile-time constant derived from the view's current vector. After the guard, any view for which isResizableOrGrowableShared() returns true falls out of the fold and takes the ordinary path, where the storage pointer is loaded from the view at runtime on every access.

  Before (resizable view):          After (resizable view):
  GetIndexedPropertyStorage(view)   GetIndexedPropertyStorage(view)
    └─► fold to Constant(0x1234)      └─► isResizableOrGrowableShared()? ──yes──► break
          └─► eliminated = true             └─► node survives to codegen
                └─► codegen: mov rax, 0x1234      └─► codegen: mov rax, [view + vectorOffset]

The comment the patch adds is the whole argument in two lines: resize() and WebAssembly.Memory.grow() can reallocate the backing store, so a folded vector pointer goes stale. Worth noting where the guard lands — directly beneath an existing break that carries a FIXME referencing bug 125425. This folding site already had a known-delicate escape hatch above it; the fix adds a second one alongside.

The other file, JSTests/stress/resizable-array-constant-folding.js, is the regression test, and it is more interesting than most regression tests because it has to defeat an optimization in the Wasm allocator to reproduce at all. It runs with --useConcurrentJIT=false so compilation happens synchronously on the main thread, making the warm-up-then-grow ordering deterministic.

The DFG and abstract interpretation. JSC compiles hot JavaScript through a tier ladder; the DFG is the mid-tier optimizing compiler, which specializes code using runtime profiling plus an abstract interpreter (AI) that propagates what it can prove about each value through the intermediate representation. When AI proves a node's value exactly, downstream phases are entitled to substitute a constant for it.

Constant folding. ConstantFoldingPhase is the pass that cashes those proofs in. Walking each basic block, it asks the abstract interpreter what it knows about each node's operands; when the answer is precise enough, it replaces the node with a constant and marks the original computation eliminated. Its output is trusted verbatim by everything downstream — there is no later pass that re-validates a constant the folding phase emitted.

Typed-array storage. A JSArrayBufferView — the C++ object behind a Float64Array, Uint8Array, and friends — holds a raw vector pointer to the bytes it addresses. Every indexed read and write compiles down to an offset from that base. The DFG models the materialization of that base as a distinct IR node, GetIndexedPropertyStorage, so that a loop touching the same view many times can compute the base once. Separately and independently, the DFG has its own machinery for typed-array lengths and bounds checks; the two are not coupled.

Resizable and growable buffers. Two comparatively recent additions to the language let a buffer's byte length change after creation: resizable ArrayBuffer (via resize()) and growable SharedArrayBuffer (via grow()). JSArrayBufferView::isResizableOrGrowableShared() reports whether a given view sits on one of these flavours.

WebAssembly.Memory and toResizableBuffer(). A Wasm linear memory is created with an initial and maximum page count, and grow(n) enlarges it. Implementations normally reserve a large contiguous virtual address range up front precisely so that growth can be satisfied by committing more pages in place, leaving the base address untouched. When such a reservation is not available, the implementation falls back to allocating a fresh region, copying the contents across, and releasing the old one. toResizableBuffer() exposes the memory to JavaScript as a resizable buffer, over which ordinary typed arrays can be constructed.

Tiering warm-up. A function has to run many times before the DFG bothers compiling it — hence the 10,000-iteration hot loop that every JSC JIT proof-of-concept opens with.

The missing check is not a bounds check. It is a stability check: the folding phase verified that it knew the view's storage address, and never asked whether it was allowed to keep knowing it.

  Compile time                     Runtime
  ────────────────────────────     ─────────────────────────────────
  AI proves base == view       →   trigger() runs 10,000× on 0xA000
  read view->vector() 0xA000
  fold to Constant(0xA000)         memory.grow(1)
  eliminated = true                  └─► no in-place room
                                     └─► alloc 0xB000, copy, free 0xA000
                                     └─► view->vector = 0xB000
                                   trigger(1.1)
                                     └─► store to 0xA000 + 0  ← freed

Reading the diagram left to right: the compile-time column captures view->vector() once and embeds it as an immediate operand, and nothing in the runtime column is capable of revising that immediate. The grow path does its job faithfully — it updates the view's vector field to the new region — but the DFG-compiled body of trigger() no longer consults that field. The store on the last line targets the pre-grow address, which the engine has already released.

The bounds-check independence is what makes this worse than a stale-and-obviously-wrong pointer. Because the DFG's length and bounds machinery is separate from the storage-pointer fold, the access validates its index against the view's current, grown length and then applies that validated index to the stale base. The check and the pointer come from different eras of the same object. An index that is entirely legitimate with respect to the post-grow buffer is applied to a region that no longer exists — and for indices beyond the old region's extent, the access reaches past the end of a freed allocation entirely.

The regression test's opening block is the part worth internalizing, because it is doing adversarial work rather than test setup:

while (true) {
    try {
        memories.push(new WebAssembly.Memory({initial: 65535, maximum: 65536}));
    } catch (e) {
        break;
    }
}

Each of those memories demands a near-4GB reservation, and the loop keeps allocating until the process refuses. The point is to consume the address space that the next WebAssembly.Memory would otherwise use for its large in-place-growth reservation. With that space exhausted, the small {initial: 1, maximum: 100} memory is created without a generous reservation behind it, so memory.grow(1) has no room to expand in place and is forced down the allocate-copy-release path. The technique converts "growth usually happens in place" from a probabilistic obstacle into a deterministic reallocation.

The sequence from script is short:

  1. Exhaust large virtual reservations so the target memory cannot grow in place.
  2. Create the small growable memory, take toResizableBuffer(), build a Float64Array over it.
  3. Run trigger() 10,000 times to force DFG compilation — the fold captures the current vector.
  4. Call memory.grow(1), which reallocates and frees the old region.
  5. Call trigger() again; the store lands in freed memory.

The fix restores the invariant by refusing to fold at all when the view's buffer belongs to a flavour whose address the runtime may change. Falling through to the unfolded path means GetIndexedPropertyStorage survives into code generation and the base is re-loaded from the view on each access, so the compiled code always reads whatever vector the grow path most recently wrote. The cost is one load per access on resizable views; the benefit is that the compiler stops asserting a fact the runtime has explicit permission to falsify.

What an attacker gets from this depends on what happens to the released region. The observed primitive is a store of an attacker-chosen Float64 at an attacker-chosen index within the old backing store's extent, reachable from ordinary web content, with the symmetric load available through the same view. If the freed region can be reclaimed by attacker-groomed allocations, that same access becomes a controlled relative write into memory the JavaScript type system no longer governs — the conventional starting point for building stronger read/write primitives in the renderer. Absent reclamation, the reliable outcome is the crash Apple's advisory describes. Everything here stays inside the WebContent process: the stale pointer targets a Wasm linear-memory region owned by that same process, and a separate sandbox escape would still be required to reach anything beyond the renderer.

Constant folding proved what the storage pointer was and never asked whether it was allowed to stay that way — the value was knowable, but not immutable.

Feature additions that make a previously-immutable runtime property mutable retroactively invalidate every optimization built on the old assumption — and those optimizations live scattered across compiler phases, not colocated with the feature that broke them. Resizable ArrayBuffer and growable Wasm memory shipped as language features; the compiler passes that had silently depended on storage addresses being fixed were somewhere else entirely, each one a separate discovery. That this particular fold site already carried a FIXME from bug 125425 is the tell: the code was known to be delicate, and the new feature added a second way for it to be wrong.