[JSC] Use-after-free after growing a resizable buffer on a WebAssembly memory
CVE: CVE-2026-20664 · Safari 26.4 · Released March 24, 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: Yeonghyeon Choi, Daniel Rhea, Söhnke Benedikt Fischedick (Tripton), Emrovsky & Switch3301, Yevhen Pervushyn
High. The relocation hook updated the buffer's length but not its base — so the post-grow typed array is not detached, not out-of-bounds, just pointed at a released mapping and believing it is now bigger. That is the shape of a read/write primitive, not a crash.
A typed array is a view, but only in the bookkeeping sense: the length is looked up through the buffer, while the data pointer is a private copy baked into the view at construction. That arrangement is safe exactly as long as an ArrayBuffer's storage never moves, which was true of every JS-visible buffer until WebAssembly linear memory started being exposed as one. Once WebAssembly.Memory.prototype.toResizableBuffer() handed script a buffer whose backing mapping can be swapped wholesale on growth, every cached derivation of that base — the buffer's own m_data, and each view's m_vector — became a pointer that has to be told when to move.
The angle: A page that grows a WebAssembly memory it exposed as a resizable buffer gets back a fully valid-looking typed array whose reads and writes land in freed, reclaimable memory.
Source/JavaScriptCore/runtime/ArrayBuffer.cpp
Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
Patch Details
Two production changes and one regression test, at two different levels of the same caching chain.
At the contents level, ArrayBufferContents::refreshAfterWasmMemoryGrow(Wasm::Memory*) previously did two things: rebind m_memoryHandle to memory->handle() and recompute m_sizeInBytes from that new handle. The patch inserts a single line between them — m_data = memory->basePointer() — so that the descriptor's own direct pointer to the memory base follows the handle it was rebound to. Note the ordering: size was already being read from the new handle, which is what made the resulting state internally inconsistent rather than merely stale.
At the buffer level, ArrayBuffer::refreshAfterWasmMemoryGrow(Wasm::Memory*) gains a before/after comparison around the delegation. It snapshots m_contents.data(), calls down into the contents refresh, reads the pointer back, and returns early when the base did not move — signaling-mode memories that commit pages in place take this path and pay nothing. When the base did move, it walks the buffer's incoming-reference list backwards via numberOfIncomingReferences() / incomingReferenceAt(i), dynamicDowncast<JSArrayBufferView>s each cell, and calls the new refreshVector(newData) on every hit.
JSArrayBufferView::refreshVector(void*) is declared in JSArrayBufferView.h and implemented inline in JSArrayBufferViewInlines.h, wrapped in WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN/END for the raw pointer arithmetic. It is guarded by hasVector() — the comment explains why that guard is load-bearing: the notification is delivered over a list whose membership survives detachment, so a view that has already dropped its storage will still be visited and must be skipped rather than handed a freshly computed pointer. For views that do have storage, the new vector is newData + byteOffsetRaw(), stored with m_vector.setWithoutBarrier(...) since the target is raw memory and not a GC-visible cell.
The regression test is worth reading as a triggering recipe in its own right. It first drains the signaling/fast wasm-memory pool with up to 500 WebAssembly.Memory allocations so that the memory it actually cares about is forced into BoundsChecking mode; only then does it build the one-page memory, expose it with toResizableBuffer(), lay a Uint32Array over it, write 0xCAFEBABE at index 0, grow by a page, spray 100 × 64 KiB buffers filled with 0xDEADBEEF, and assert the sentinel is still readable through the view.
Background
WebAssembly linear memory and growth. A WebAssembly.Memory owns a linear memory region that script can enlarge in 64 KiB pages by calling memory.grow(n). JSC backs that region in one of two modes. In signaling (fast) mode, the engine takes a large virtual reservation up front and growth commits additional pages inside it, so the base address is stable for the memory's lifetime. In BoundsChecking mode, the mapping is sized to the current need and growth obtains a different mapping. Signaling-mode memories are expensive in address space, so a process can only hold a limited number of them at once; once that budget is exhausted, subsequent memories fall back to bounds-checking mode. Which mode a given memory lands in is therefore a function of what else the page has already allocated.
BufferMemoryHandle. Each mapping is owned by a refcounted BufferMemoryHandle; tryAllocateResizableMemory constructs one with adoptRef(*new BufferMemoryHandle(...)). ArrayBufferContents::m_memoryHandle holds a reference to the handle for memory-backed buffers, which is what keeps the mapping alive from the ArrayBuffer side.
toResizableBuffer() and ArrayBufferContents. WebAssembly.Memory.prototype.toResizableBuffer() exposes a wasm memory to JavaScript as a resizable non-shared ArrayBuffer; the surrounding code gates this API family on Options::useWasmMemoryToBufferAPIs(). ArrayBufferContents is the storage descriptor behind an ArrayBuffer — it holds m_data (a direct pointer to the base), m_sizeInBytes, m_maxByteLength, and, for memory-backed buffers, m_memoryHandle. m_data is set once at construction: ArrayBufferContents::ArrayBufferContents(void*, size_t, size_t, Ref<BufferMemoryHandle>&&) initializes m_data(data).
JSArrayBufferView::m_vector and view modes. Every typed array and DataView carries its own CagedBarrierPtr<Gigacage::Primitive, void> pointing at the first byte the view covers: the buffer base plus byteOffsetRaw(), computed at construction. Indexed element access reads and writes through m_vector directly rather than re-deriving the address from the buffer on each access. hasVector() distinguishes a view that currently has storage from one that has been detached. A view constructed over an ArrayBuffer uses one of the WastefulTypedArray / DataViewMode families — here ResizableNonSharedAutoLengthWastefulTypedArray — in which the buffer owns the storage and the view does not, and an auto-length view derives its length from the buffer's current byte length at access time. isArrayBufferViewOutOfBounds computes that length from view->possiblySharedBuffer().
Incoming references. An ArrayBuffer tracks the JS cells that reference it, enumerable with numberOfIncomingReferences() and incomingReferenceAt(i). This list exists for garbage-collection purposes — its membership rule is reachability, not subscription.
Analysis
This is a use-after-free through a stale cached base pointer that survived a backing-store relocation, made worse by the fact that the length half of the same description was refreshed correctly.
BoundsChecking wasm memory, memory.grow(1):
handle A (64 KiB) handle B (128 KiB)
base 0x1000_0000 ──released──► base 0x2000_0000
▲ ▲
│ m_vector (stale) │ m_memoryHandle (refreshed)
│ m_data (stale) │ m_sizeInBytes = 131072 (refreshed)
│ │
┌────┴──────────┐ ┌──────┴────────┐
│ Uint32Array │──── length ──►│ ArrayBuffer │
│ (not detached)│ 131072 │ (not detached)│
└───────────────┘ └───────────────┘
Read the diagram top-down: growth on a bounds-checking memory does not extend handle A, it produces handle B at a new address. ArrayBufferContents::refreshAfterWasmMemoryGrow was written as the notification hook for exactly that event, and it did rebind m_memoryHandle to B and recompute m_sizeInBytes from B — but it left m_data pointing at A's base, the value it had received once at construction and never revisited. So the right-hand column of the diagram moved forward and the left-hand column did not.
The rebinding is also what makes the left column dangling rather than merely stale. Assigning the new handle to m_memoryHandle drops the contents' reference to A. When that reference was the last one, A's mapping is released — and now m_data, along with every view's m_vector, addresses freed memory. Nothing in the object graph records this. The buffer is not detached. The views are not detached; hasVector() still reports storage. And the out-of-bounds check does not help either, because isArrayBufferViewOutOfBounds consults the buffer's byte length, which the refresh did update — to B's larger size:
template<typename Getter>
bool isArrayBufferViewOutOfBounds(JSArrayBufferView* view, Getter& getter)
The predicate is asking the right question of the wrong pointer's allocation. It reads the length from the refreshed right-hand column while every actual access reads the base from the unrefreshed left-hand column. The two halves describe different allocations, and the safety check happens to sit on the half that moved.
What falls out is a typed array that presents to script as completely ordinary — live, in-bounds, and, after the grow, one page longer than it used to be — whose element accesses resolve into a released mapping. Concretely, from the script side:
- Allocate enough
WebAssembly.Memoryobjects to exhaust the signaling-mode budget, so the next memory is backed inBoundsCheckingmode. - Create that memory, call
toResizableBuffer(), and construct aUint32Arrayover the resulting buffer. - Call
memory.grow(1). Handle A is released; the view'sm_vectorstill points into it, and the view's length now reflects B. - Allocate to reclaim A's address range with content of the attacker's choosing.
- Read and write through the view's ordinary indexed accessors.
The regression test is steps 1–4 verbatim, with step 5 reduced to a sentinel check: it sprays 100 × 64 KiB buffers filled with 0xDEADBEEF and asserts that ta[0] still reads back 0xCAFEBABE. Before the fix, that assertion is the freed page being observed after someone else took it.
The security consequence is a memory-safety break inside the WebContent process. The engine's assumption is that typed-array bounds checking suffices to confine element access to the buffer's storage, and that holds only when the base pointer and the length describe the same allocation. An attacker who reclaims the released mapping with chosen content could then read and write it through ordinary indexed access on a view the engine still considers valid, plus reach linearly past the old mapping's end because the bounds check uses the larger refreshed length — byte-granular read and write at attacker-chosen offsets over reclaimed memory. That is the customary stepping stone: an info leak of whatever metadata or pointers land in the reclaimed region, and a corruption primitive against the same, from which arbitrary read/write in the renderer could be constructed. Exploitation would remain inside the WebContent sandbox; a separate escape would still be required, and no GPU, Networking, or UI process appears anywhere in this diff.
The fix restores the invariant at both levels. The added m_data = memory->basePointer() makes the contents' own base track the handle it was just rebound to, and the incoming-reference walk propagates the new base to every view's m_vector as newData + byteOffsetRaw() — preserving each view's offset into the buffer while re-anchoring it to the current mapping. The newData == oldData early-out means signaling-mode growth, where the base genuinely never moves, skips the walk entirely.
Growth refreshed the buffer's length from the new mapping and its base from nothing, leaving a live typed array whose bounds check described one allocation and whose accesses hit another, freed one.
Insight
The missing m_data assignment is the smaller half of the story. The larger half is structural: exposing wasm linear memory as a resizable ArrayBuffer converted that backing store from address-stable to relocatable, and in one stroke every pointer derived from the base became a liability. The refresh hook introduced alongside that feature covered the owner object but not the transitive closure of caches — and the level it missed, JSArrayBufferView::m_vector, is precisely the one the language hands directly to script.
Two details about the fix itself are worth carrying forward. It repurposes the GC incoming-reference list as an observer list, and the comment on refreshVector is candid about the mismatch — "an incoming reference from a view to a buffer remains in place even after a view detaches." A registry whose membership rule is liveness rather than subscription will over-deliver to some observers (handled here by the hasVector() guard) and, more dangerously, silently omit any observer that is not a JS cell. And the bug's severity is amplified by the very asymmetry the early-out now guards: because the relocation refreshed the length while leaving the base alone, the pre-fix state was not a detached or out-of-bounds view that fast paths would reject, but a view that looked perfectly valid and slightly larger than its freed storage.
Audit directions
-
Relocatable backing store, cached derivations of its old base. The invariant is that a relocation notification must reach the transitive closure of cached derivations, not just the direct owner. Narrow: enumerate every field in JSC initialized once from
ArrayBufferContents::data()or from base +byteOffsetRaw()and confirm each now has a path fromArrayBuffer::refreshAfterWasmMemoryGrow—m_vectoris covered now, but checkJSDataView, the nativeArrayBufferViewbase address held by WebCore consumers acrosstoWrapped*/possiblySharedImpl()(those are not JS cells and would never appear inincomingReferenceAt), and any cached memory base or size onJSWebAssemblyInstance. Wider: the same class surfaces through other caching mechanisms — vectors baked into JIT-generated code or inline caches for typed-array element access, pointers pinned across a runtime call, and pointers handed to WebGL/WebAudio/WebGPU and retained across a turn. Widest: the principle applies to any system that relaxes an address-stability guarantee — V8's resizableJSArrayBufferbacking store versusJSTypedArraydata pointers, Rust'sVecreallocation invalidating raw pointers taken from it, a database page cache that can move a pinned page. Match tell at each rung: narrow, a member assigned only in a constructor and a detach path with no writer on the growth path; wider, code-search hits where a base pointer is read once and reused after any call that could grow the resource; widest, a commit introducing arefresh/revalidate/rebindhelper — the question is always whether the helper covers the closure or only the owner. -
Notification delivered over a registry whose membership rule is liveness rather than subscription. The invariant is that an observer list must enumerate observers, not reachability edges. Narrow: audit the other consumers of
ArrayBuffer's incoming-reference list inArrayBuffer.cpp— detach, transfer, sharing-mode change — for the same assumption, and specifically ask whether any non-JSCellholder of the buffer's data pointer needs the same notification, since thedynamicDowncast<JSArrayBufferView>in the new loop silently skips everything that is not a JS cell. Wider: the same shape appears anywhere WebKit derives a notification set from a GC or ownership graph rather than an explicit subscriber list —Weak/WeakGCSet-driven invalidation in JSC, and WebCore style/render invalidation walks that assume every interested party is a tree node. Widest: the general "registry-derived-from-graph misses non-graph observers" class holds in any codebase reusing a dependency graph as an event bus — DI containers notifying only registered singletons, ORM change-tracking that misses detached entities, reactive frameworks whose dependency graph excludes manually captured values. Match tell: a notification loop whose body contains a type test or downcast that filters the collection — the filtered-out members are the ones to ask about. -
Partial update of a redundant multi-field description of one resource. The invariant is that fields jointly describing one allocation — base, length, max length, owning handle — must be written atomically with respect to any observer. Narrow: review every mutator on
ArrayBufferContentsandArrayBuffertouching any ofm_data,m_sizeInBytes,m_maxByteLength,m_memoryHandle,m_sharedand verify each writes the whole tuple; the pre-fixrefreshAfterWasmMemoryGrowwrote handle and size but not base, which is exactly what made a dangling pointer look in-bounds. Wider: the class covers any WebKit structure with a derived field cached beside its source — butterfly pointer plus public/vector length inJSObject,StringImpldata pointer plus length, anystd::span-shaped pair rebuilt from two independently updated members. Widest: redundant representations drift, and the drift is dangerous precisely when a safety check reads one half of the tuple and the access reads the other — any buffer/length pair in C, Go slices rebuilt from stale pointers, any capability system storing a bound separately from the object it bounds. Match tell: a function assigning to two or more members of the same struct where a third member is derived from one of them; if the derived member is absent from the assignment list, that is the candidate. -
Completeness of the early-out across the other growth and resize paths. Investigate
Wasm::Memory::growfor every mode in which the base pointer can change and confirm each reachesArrayBuffer::refreshAfterWasmMemoryGrow, and separately check whether a resizable non-shared buffer's shrink /resizepath — as opposed to grow — can relocate or decommit storage while views keep a cached vector. The invariant is WebKit-specific here and does not generalize past the wasm-memory-to-ArrayBuffer seam; the ceiling isBufferMemoryHandle's own lifecycle. Match tell: any handle-replacing or mapping-replacing operation onBufferMemoryHandlenot immediately followed by the refresh call, or a refresh call comparing onlydata()equality while the mapping's extent changed underneath an unchanged base.