[JSC] Use-after-free after Wasm memory grow via stale pointer folded by DFGConstantFoldingPhase
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
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
JSTests/stress/resizable-array-constant-folding.js
Patch Details
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.
Background
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.
Analysis
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:
- Exhaust large virtual reservations so the target memory cannot grow in place.
- Create the small growable memory, take
toResizableBuffer(), build aFloat64Arrayover it. - Run
trigger()10,000 times to force DFG compilation — the fold captures the currentvector. - Call
memory.grow(1), which reallocates and frees the old region. - 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.
Insight
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.
Audit directions
-
Compile-time materialization of a relocatable address. The invariant is that a value may be folded into a constant only if nothing outside the compiled region can change it for the lifetime of that code. Narrow: grep
Source/JavaScriptCore/dfg/andSource/JavaScriptCore/ftl/for every site that reads->vector(),->byteLength(), or->length()off a constantJSArrayBufferViewduring compilation and bakes the result into a node —DFGConstantFoldingPhase.cpp,DFGAbstractInterpreterInlines.h,DFGFixupPhase.cpp, andFTLLowerDFGToB3.cppare the starting points. Match tell: a compile-time load from a heap object followed byinsertConstant/convertToConstant/eliminated = true, with no accompanying check onisResizableOrGrowableShared(), detachment, or a watchpoint. Wider: the same class covers any cached-address optimization whose invalidation relies on a property a newer feature made mutable — Wasm memory base pointers cached in JIT-compiled Wasm code,Structureand butterfly pointers folded before a transition, inline-cache stubs holding raw storage addresses; the shape to notice is a raw pointer captured during compilation and used after a point where script can run. Widest: constant propagation must be gated on immutability, not merely on current knowability — the same question applies to V8's Turbofan folding ofJSArrayBufferbacking stores, SpiderMonkey's IonTypedArrayObjectdata-pointer specialization, and any AOT/JIT system that inlines a pointer from an allocator permitted to relocate (moving GCs, compacting allocators,realloc-based growth). For every folded address: who is allowed to call the relocating API, and is there a watchpoint or recompilation trigger on that call? -
The mirror-image bug: length caching instead of pointer caching. Narrow: enumerate DFG nodes that consume
GetArrayLength/GetTypedArrayByteOffseton views whereisResizableOrGrowableShared()holds, and check whether each guards against shrink as well as growth. Match tell: a bounds check whose limit operand is a constant or a loop-invariant load while the base view is resizable. Verify that bounds-check elimination, LICM of length loads, andGetArrayLengthfolding all re-derive the length after any operation that can run script or resize the buffer. Wider: the same class shows up wherever a container's size is snapshotted before a callback or a growth-capable API call —Vector/Arrayiteration across user callbacks, DOM node-list length caching across mutation,SharedArrayBufferlength reads acrossAtomics.wait. Widest: a bounds check is only sound if its limit is refreshed at least as often as the buffer can change size; this transfers to V8's resizableJSArrayBuffersupport and to Rust'sVecreallocation invalidating raw slice pointers held across apush. -
Does FTL reproduce the same fold independently? The fix lives in one DFG phase, but the abstract interpreter is shared and FTL lowering has its own opportunities to emit a constant storage pointer once AI has proven the base. Narrow: trace
GetIndexedPropertyStorageand other storage-materializing nodes throughFTLLowerDFGToB3.cppand check whether the FTL path consultsisResizableOrGrowableShared()before treating the vector as loop-invariant or letting B3's LICM/CSE hoist it out of a loop. Match tell: a B3 constant, or a value hoisted above any node capable of triggering growth, whose provenance is a compile-time read of a view's data pointer. Ceiling: this rung is bound to JSC's two-tier DFG/FTL split — a fix applied in one tier's phase does not automatically cover the other tier's lowering. That is a WebKit-specific structural hazard rather than a portable principle, and the ladder stops here. -
Who else holds the pre-grow base pointer? An API that relocates a buffer must enumerate and update every cached reference to the old base; any holder it misses becomes a stale pointer of exactly this kind. Narrow: trace what
memory.grow()updates when it takes the copy-and-release path — the view'svector, Wasm instance memory-base registers and globals, any JIT-embedded base — and identify holders updated lazily or not at all. Match tell: a base pointer stored anywhere other than the single canonical location the grow path writes to. Widest: relocation must be accompanied by complete reference fixup, or by a level of indirection that makes fixup unnecessary — the same audit applies to compacting garbage collectors,mremap-based growth in any allocator, and database page-cache relocation. In each case the question is the same: enumerate the caches, then prove the relocating routine touches all of them.