[JSC] Keep JSWebAssemblyMemory alive from wasm-originated JSArrayBuffers
CVE: CVE-2026-43716 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: The issue was addressed with improved memory handling. Credit: Maher Azzouzi, Tuan and Duc from Calif.io, OpenAI Codex Security - Amy Burnett, Evan Lambert
High. A weak back-pointer was doing duty as a mode selector, so garbage-collecting one object silently rerouted a resize onto an allocator that never reserved the pages it was about to hand out. Script controls the collection timing outright — the added regression test is the trigger.
Resizable ArrayBuffers come in two flavors that share one JavaScript API and almost nothing else underneath. A JS-originated one reserves its full maxByteLength of address space at construction and grows by unprotecting the tail it already owns; a WebAssembly-originated one, handed to script by WebAssembly.Memory.prototype.toResizableBuffer(), is a window onto wasm linear memory whose growth belongs to the wasm allocator. Keeping those two straight requires the buffer to know which world it came from — and before this commit that knowledge lived in a pointer that the garbage collector was free to invalidate.
The angle: A page can drop its WebAssembly.Memory object, keep the buffer it handed out, force a collection, and then grow that buffer through the wrong allocator — producing a script-visible length over memory that was never mapped.
Source/JavaScriptCore/runtime/ArrayBuffer.cpp
Source/JavaScriptCore/runtime/ArrayBuffer.h
Source/JavaScriptCore/runtime/JSArrayBuffer.cpp
Source/JavaScriptCore/runtime/JSArrayBuffer.h
Source/JavaScriptCore/runtime/JSArrayBufferPrototype.cpp
Source/JavaScriptCore/wasm/js/JSWebAssemblyMemory.cpp
Source/JavaScriptCore/wasm/WasmMemory.h
JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js
Patch Details
The change is a re-plumbing of one edge in the object graph, plus the code motion that edge makes possible.
The edge itself: ArrayBuffer loses WeakPtr<Wasm::Memory> m_associatedWasmMemory and its setter ArrayBuffer::setAssociatedWasmMemory(). In their place, the JS-visible wrapper cell JSArrayBuffer gains WriteBarrier<JSWebAssemblyMemory> m_associatedWasmMemoryWrapper, an accessor trio (associatedWasmMemoryWrapper() / setAssociatedWasmMemoryWrapper(VM&, JSWebAssemblyMemory*) / clearAssociatedWasmMemoryWrapper()), and — critically — its first visitChildrenImpl, which appends that field to the marking visitor. JSArrayBuffer previously had no visitChildren of its own; the DECLARE_VISIT_CHILDREN / DEFINE_VISIT_CHILDREN(JSArrayBuffer) pair is new in this commit, and without it the new field would be an untraced pointer rather than an ownership edge.
Before: After:
JSWebAssemblyMemory ──Ref──► Wasm::Memory JSWebAssemblyMemory ──Ref──► Wasm::Memory
│ (GC cell) ▲ │ (GC cell) ▲
│ ┊ WeakPtr │ │
▼ ┊ ▼ │ WriteBarrier
JSArrayBuffer ──raw──► ArrayBuffer┘ JSArrayBuffer ───────┘ (GC-traced)
└──raw──► ArrayBuffer
The edge now runs cell → cell, so a tracing collector sees it; the arrow that used to hang off the native ArrayBuffer and dangle is gone entirely.
The rewiring of the two association points follows: JSWebAssemblyMemory::associateArrayBuffer no longer calls m_buffer->setAssociatedWasmMemory(m_memory.ptr()) before constructing the wrapper — it constructs the JSArrayBuffer first, then calls arrayBuffer->setAssociatedWasmMemoryWrapper(vm, this) when the buffer is resizable non-shared. disassociateArrayBuffer correspondingly clears through the wrapper it already tracks in m_bufferWrapper before dropping it.
With ownership guaranteed, the wasm growth semantics move up a layer. arrayBufferProtoFuncResize in JSArrayBufferPrototype.cpp now checks associatedWasmMemoryWrapper() before anything else and, when set, implements the wasm branch inline: reject shrink with "Cannot shrink WebAssembly memory", reject non-page-multiple lengths with a RangeError naming PageCount::pageSize, and otherwise call jsMemory->memory().grow(vm, PageCount::fromBytes(delta)). The equivalent logic is deleted from ArrayBuffer::resize() — both the page-multiple/shrink validation and the weak-upgrade delegation block — and the function is fenced with RELEASE_ASSERT(!isWasmMemory()) at its head. The specification comment about HostResizeArrayBuffer moves with the logic.
Downstream cleanup: Wasm::Memory no longer needs weak-pointer support, so it reverts from RefCountedAndCanMakeWeakPtr<Memory> to plain RefCounted<Memory> and drops the ThreadSafeWeakPtr include. The debug tripwire checkLifetime() — ASSERT(!refCountDebugger().deletionHasBegun()) — is deleted along with its call sites in adopt() and growSuccessCallback(). The commit message also records that the earlier shipped spot fix from 305413.660@safari-7624-branch, which pinned the WebAssembly.Memory alive for the duration of ArrayBuffer.prototype.resize, is reverted here.
Finally, JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js is the regression test: it creates a memory inside an IIFE, takes its resizable buffer, drops the memory reference, scrubs stale stack roots with a recursive helper, forces two full GCs, and then resizes and writes through the surviving buffer — five times over.
Background
Resizable ArrayBuffers. A resizable ArrayBuffer is one constructed with a maxByteLength option; ArrayBuffer.prototype.resize(n) changes its length in place and typed-array views over it track the current length rather than being detached. In JSC the backing state is ArrayBufferContents with m_hasMaxByteLength set and a RefPtr<BufferMemoryHandle> m_memoryHandle describing the mapping.
The up-front reservation model. For JS-originated resizable buffers, tryAllocateResizableMemory() in ArrayBuffer.cpp reserves the entire maxByteLength of virtual address space through BufferMemoryManager::tryAllocateGrowableBoundsCheckingMemory(), commits only the initial bytes, and marks the remainder inaccessible with OSAllocator::protect(). Growth is therefore cheap and local: unprotect part of a tail the process already owns. Nothing about that path negotiates with an allocator, because the address space was claimed at construction.
Wasm memory modes. A Wasm::Memory is either signaling — a large virtual reservation where hardware traps catch out-of-range accesses — or bounds-checking, where every access carries an explicit length comparison. The commit message states the consequence that matters here: JS-originated resizable buffers reserve their max up front, while wasm-originated ones may be bounds-checking, which do not, or signaling, which do.
toResizableBuffer() and who owns what. WebAssembly.Memory.prototype.toResizableBuffer() hands script an ArrayBuffer view onto a wasm memory's linear memory. JSWebAssemblyMemory::associateArrayBuffer builds the native ArrayBuffer, tags it with makeWasmMemory(), and wraps it in a JSArrayBuffer cell. The sole strong reference to the native Wasm::Memory lives in JSWebAssemblyMemory::m_memory as a Ref — adopt() asserts m_memory->refCount() == 1 immediately after taking it. The native memory object's lifetime is therefore tied to that GC cell and nothing else.
WeakPtr versus Ref. A WeakPtr<T> does not keep its target alive; .get() returns null once the target has been destroyed. Ref and RefPtr are owning references that do.
WriteBarrier<T> and visitChildren. WriteBarrier<T> is JSC's field type for a GC-traced pointer from one cell to another. The owning class must report it during marking — DECLARE_VISIT_CHILDREN on the class, visitor.append(...) in visitChildrenImpl — for the target to be kept alive by that edge. Reference cycles between cells are fine here: a tracing collector reclaims unreachable cycles as a whole, which is why the commit message can describe the new arrangement as a deliberate "GC lifetime cycle".
PageCount. JSC's wasm page abstraction. PageCount::pageSize is 65536 bytes; PageCount::fromBytes() converts a byte delta into pages.
Analysis
The bug is an ownership error whose consequence is not a dangling read but a silent change of implementation: a nullable non-owning pointer was being used as a branch selector, and the branch it fell through to belonged to a different allocator.
script GC ArrayBuffer::resize()
──────── ── ─────────────────────
m = new WA.Memory()
buf = m.toResizableBuffer()
└─ ArrayBuffer.m_associatedWasmMemory = WeakPtr(Wasm::Memory)
drop m ────────────────► JSWebAssemblyMemory unreachable
~Ref<Wasm::Memory> ──► WeakPtr now null
buf.resize(big) ──────────────────────────────► memory = weak.get() → null
(no else branch)
▼ falls through
generic growth path
assumes max was reserved
Walk the columns. The weak edge is installed at toResizableBuffer() time by associateArrayBuffer, pointing from the native ArrayBuffer at the native Wasm::Memory. Script then drops its last reference to the WebAssembly.Memory object while keeping the buffer — an entirely ordinary thing to do, since the buffer is a first-class value with its own API surface. The JSWebAssemblyMemory cell becomes garbage, its Ref<Wasm::Memory> destructs, and the weak pointer the buffer holds goes null. Nothing else in the object graph was keeping that cell alive on the buffer's behalf, because before this commit nothing had a traced edge to it.
The missing invariant is stated plainly enough: an object that delegates an operation to a collaborator must own a reference that keeps the collaborator alive for as long as the operation remains reachable. buf.resize() remains reachable forever; the collaborator did not.
Now the third column. ArrayBuffer::resize() was the single entrypoint for ArrayBuffer.prototype.resize, and its wasm handling was the upgrade-and-delegate block the diff deletes:
RefPtr<Wasm::Memory> memory = m_associatedWasmMemory.get();
if (memory) {
std::ignore = memory->grow(vm, PageCount(newPageCount.pageCount() - oldPageCount.pageCount()));
return deltaByteLength;
}
size_t desiredSize = newPageCount.bytes(); // ← fallthrough on null
There is no else. There is no error return. A null upgrade simply continues into desiredSize and the generic resizable-buffer growth code — code written for buffers produced by tryAllocateResizableMemory(), whose whole maxByteLength mapping was reserved by tryAllocateGrowableBoundsCheckingMemory() at construction and whose growth is an unprotect of an already-owned tail. Per the commit message, a wasm-originated bounds-checking memory never made that reservation.
What makes this worse than a loud failure is that the surviving state looks consistent. The RefPtr<BufferMemoryHandle> m_contents.m_memoryHandle outlives the Wasm::Memory and still reports maximum() as 100 pages, so the sanity check guarding the growth path —
ASSERT(memoryHandle->maximum() >= newPageCount);
— passes cleanly. A declared maximum is being read as evidence that the pages behind it are mapped, which for the wasm allocator's bounds-checking mode it is not. The generic path then advances m_contents.m_sizeInBytes, and with it the buffer's byteLength, over a region whose mapping the wasm allocator never established. Every Uint8Array constructed over that buffer inherits the inflated length. The deleted validation block compounded the reachable shape: the page-multiple and shrink checks were themselves gated on Options::useWasmMemoryToBufferAPIs() and lived inside the same function that no longer knew it was looking at a wasm buffer.
The concrete trigger is the added regression test, and it needs nothing exotic:
- Create
new WebAssembly.Memory({ initial: 1, maximum: 100 })inside an IIFE. - Take
m.toResizableBuffer()into an outer-scopebuf; let the IIFE return somis unreferenced. - Call the recursive
flushStackRoots(64)helper to overwrite stale conservative stack roots that would otherwise pin the memory. fullGC()— twice, with another root flush between, to make collection deterministic rather than hopeful.buf.resize(65536 * 100), then read and write through a freshUint8Array(buf)includingview[view.length - 1] = 0x42.
Each of those steps is ordinary web content. The GC timing is not a race to win but a state to reach, and the test's double-fullGC() shape shows how mechanically it is reached from script.
The fix restores the invariant structurally rather than defensively. The buffer's wrapper cell now holds WriteBarrier<JSWebAssemblyMemory> m_associatedWasmMemoryWrapper and reports it in visitChildrenImpl, so as long as script can name the buffer, the collector marks the JSWebAssemblyMemory through it and the Ref<Wasm::Memory> it owns cannot destruct. The association becomes a genuine cycle between two cells, which a tracing collector handles without help. Because the edge can no longer be null while the buffer is live, the branch is safe to hoist: arrayBufferProtoFuncResize tests associatedWasmMemoryWrapper() first and, when present, handles shrink and page-multiple rejection itself and calls jsMemory->memory().grow(), never entering ArrayBuffer::resize at all. And the fallthrough that caused the bug is not merely made unlikely — it is asserted unreachable by RELEASE_ASSERT(!isWasmMemory()) at the top of ArrayBuffer::resize, which converts any remaining path that reaches the generic growth code with a wasm buffer into an immediate, non-exploitable abort.
A second facet is visible in what the commit deletes. The previously shipped spot fix from 305413.660@safari-7624-branch pinned the WebAssembly.Memory alive only for the duration of ArrayBuffer.prototype.resize, and Wasm::Memory::checkLifetime() existed as a debug tripwire asserting !refCountDebugger().deletionHasBegun() — an assertion that only pays off if something is using a Wasm::Memory whose destruction has begun. Both artifacts circle the same weak upgrade racing the destruction of its sole owner; that facet would land as a use-after-free on a refcounted native object inside grow() rather than a length mismatch. Both sit on the one weak edge this patch removes, which is why both the spot fix and the tripwire could be deleted with it.
The impact is confined to the WebContent process — the JSC heap and wasm linear memory. Apple's advisory describes the outcome as an unexpected Safari crash; the primitive the shape suggests is a length/backing-store mismatch on a script-reachable ArrayBuffer, giving relative out-of-bounds read and write through Uint8Array views at offsets bounded by the memory's declared maximum and therefore substantially attacker-chosen. Converting that into anything outside the renderer still requires a separate sandbox escape.
A weak back-pointer used as a mode selector: when it went null, a wasm-backed buffer silently grew through the generic allocator, whose "max is already reserved" precondition it never satisfied.
Insight
The shipped history is the lesson. The earlier fix pinned the WebAssembly.Memory across the body of resize, and checkLifetime() asserted that no one was touching a memory mid-destruction — a narrowed window and a tripwire aimed at the same edge, neither of which changed the representation that permitted the collaborator to disappear at all. The author's framing in the commit message is the tell that the representation was the actual defect: keeping the weak edge meant "non-wasm ArrayBuffer code has to be aware of this implementation in case the WebAssembly.Memory gets collected", which taxes every future change to that file with reasoning about a collected collaborator. Making the association strong and then asserting the fallback unreachable retires the tax and the bug class together — a scoped protector would have done neither.
Audit directions
- Nullable non-owning references used as branch selectors. Narrow: grep
Source/JavaScriptCore/runtimeandSource/JavaScriptCore/wasmforWeakPtr</Weak<members whose.get()result gates a code path with noelse— start withArrayBuffer::m_wrapper(Weak<JSArrayBuffer>) and theWeak/WeakPtrfields onJSWebAssemblyInstanceandBufferMemoryHandleusers. Wider: the same class appears with any nullable indirection used for dispatch —RefPtrmembers callers may find null,std::optionalcollaborators,dynamicDowncast<>results, cached raw pointers cleared on teardown; the search shape is a conditional whose true-branch delegates to a specialized implementation and whose false-branch is fallthrough into generic code rather than an error return. Widest: the reusable invariant is a reference that may become null through no fault of the caller must never select behavior — it may only gate success versus explicit failure, which carries directly intobase::WeakPtrin Chromium,Weak::upgrade()in Rust, Java'sWeakReference, and JSWeakRef. The question at every rung is the same: if this upgrade returns nothing, does the code fail loudly or quietly do something else? - One API surface over two allocators with different contracts. Narrow: now that
ArrayBuffer::resizehard-asserts!isWasmMemory(), audit the remainingArrayBufferoperations that mutate size or hand out contents on wasm-marked buffers —ArrayBuffer::grow,transfer/transferToFixedLength,detach,refreshAfterWasmMemoryGrow, andSharedArrayBufferContents::grow— checking whether each consultsisWasmMemory()or the newJSArrayBuffer::associatedWasmMemoryWrapper()before applying reservation-dependent logic. Wider: the shape recurs wherever a container abstracts over multiple allocators — checkBufferMemoryHandle's bounds-checking versus signaling users, and any consumer treatingmemoryHandle->maximum()as evidence that the pages behind it are reserved; the tell is a size/capacity accessor being used to authorize an in-place mapping change. Widest: a capacity field records intent, not reservation — never let a max/capacity query stand in for proof that memory is mapped. Applies to any dual-strategy allocator: jemalloc-versus-mmap paths, Rust'sVec::with_capacityversus mmap-backed arenas, Java direct versus heapByteBuffer. - Newly introduced GC edges must be complete and symmetric. Narrow: verify
JSArrayBuffer::m_associatedWasmMemoryWrapperis set on every path that hands script a wasm-originated resizable buffer, not onlyJSWebAssemblyMemory::associateArrayBuffer— traceWebCore::CloneDeserializer::readTerminalinSerializedScriptValue.cpp(named in the commit message) and any structured-clone orJSArrayBuffer::createcaller that can materialize a wasm-backed resizable buffer in a fresh wrapper. A wrapper created without the link would reach the newRELEASE_ASSERT(!isWasmMemory())and abort. Wider: the class covers everyWriteBarrier<T>field added to a JSC cell — confirm each is appended in that class'svisitChildrenImpland cleared at the matching teardown point (heredisassociateArrayBufferviaclearAssociatedWasmMemoryWrapper()); grep forWriteBarrier<declarations in classes whosevisitChildrenImplnever mentions the field name. Widest: an ownership edge added for lifetime reasons must be established at every construction site and released at every teardown site, or the asymmetry resurfaces as either a leak or an unowned pointer — which transfers to V8 handles andTraced<>fields, SpiderMonkeyTraceEdge, and Blink OilpanMember<T>. - Spot fixes and debug tripwires as audit leads, not closure. Narrow: grep JSC and WebCore for the surviving idioms this commit deleted elsewhere —
refCountDebugger().deletionHasBegun()assertions, ad-hocRef/RefPtrlocals introduced purely to keep a collaborator alive across a call (usually with a comment saying so), andprotector-style locals in wasm and typed-array code; for each, ask whether the underlying edge is weak by design. Wider: any// keep alive for the duration ofcomment or scope-local protector paired with a member that isWeakPtr, raw, orThreadSafeWeakPtr— the shape is a protector whose lifetime is one function while the association it protects outlives that function. Widest: scoping a lifetime to a call site narrows the window but does not fix a representation that permits the collaborator to disappear at all, applicable in any refcounted-plus-GC hybrid — Blink'sPersistent/Membersplit, Objective-C__weakwith a temporary strong local, RustArc/Weakupgrades held only across one block. Match tell: a temporary strong reference taken from a weak member, where the same member is dereferenced elsewhere without that protection.