← All reports

[JSC] Keep JSWebAssemblyMemory alive from wasm-originated JSArrayBuffers

HighJavaScriptCore — ArrayBuffer / WebAssembly memory seamUAF

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

a012bab | Bugzilla 313473

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

-void ArrayBuffer::setAssociatedWasmMemory(Wasm::Memory* memory)
-{
- // The pointer from a buffer to a memory is only required when the buffer is resizable non-shared,
- // to direct a grow request to the memory (see ArrayBuffer::resize). In other scenarios
- // the pointer is not necessary and we should not be setting it to anything but a nullptr.
- ASSERT(isWasmMemory() && (isResizableNonShared() || !memory));
-#if ENABLE(WEBASSEMBLY)
- m_associatedWasmMemory = memory;
-#else
- UNUSED_PARAM(memory);
-#endif
-}
-
-// Wasm JS API redefines the abstract operation HostResizeArrayBuffer as follows:
-// https://webassembly.github.io/threads/js-api/index.html#abstract-operation-hostresizearraybuffer
Expected<int64_t, GrowFailReason> ArrayBuffer::resize(VM& vm, size_t newByteLength)
{
+ RELEASE_ASSERT(!isWasmMemory());
+
auto memoryHandle = m_contents.m_memoryHandle;
if (!memoryHandle || m_contents.m_shared) [[unlikely]]
return makeUnexpected(GrowFailReason::GrowSharedUnavailable);
@@
deltaByteLength = static_cast<int64_t>(newByteLength) - static_cast<int64_t>(m_contents.m_sizeInBytes);
-#if ENABLE(WEBASSEMBLY)
- if (Options::useWasmMemoryToBufferAPIs()) {
- if (isWasmMemory() && (deltaByteLength < 0 || deltaByteLength % PageCount::pageSize))
- return makeUnexpected(GrowFailReason::InvalidGrowSize);
- }
-#endif
if (!deltaByteLength)
return 0;
@@
if (newPageCount != oldPageCount) {
ASSERT(memoryHandle->maximum() >= newPageCount);
 
-#if ENABLE(WEBASSEMBLY)
- if (Options::useWasmMemoryToBufferAPIs()) {
- // If this is currently associated with a Wasm memory, let the memory do the growing.
- // The memory will call back to our refreshAfterWasmMemoryGrow().
- RefPtr<Wasm::Memory> memory = m_associatedWasmMemory.get();
- if (memory) {
- std::ignore = memory->grow(vm, PageCount(newPageCount.pageCount() - oldPageCount.pageCount()));
- return deltaByteLength;
- }
- }
-#endif
size_t desiredSize = newPageCount.bytes();
RELEASE_ASSERT(desiredSize <= MAX_ARRAY_BUFFER_SIZE);

Source/JavaScriptCore/runtime/ArrayBuffer.h

void NODELETE makeWasmMemory();
inline bool isWasmMemory();
- void NODELETE setAssociatedWasmMemory(Wasm::Memory*);
// When a resizable buffer is associated with a non-shared Wasm memory, this function is called by the memory's growthSuccessCallback.
void refreshAfterWasmMemoryGrow(Wasm::Memory*);
@@
public:
Weak<JSArrayBuffer> m_wrapper;
private:
- WeakPtr<Wasm::Memory> m_associatedWasmMemory;
Checked<unsigned> m_pinCount { 0 };
bool m_isWasmMemory { false };

Source/JavaScriptCore/runtime/JSArrayBuffer.cpp

+#if ENABLE(WEBASSEMBLY)
+JSWebAssemblyMemory* JSArrayBuffer::associatedWasmMemoryWrapper() const
+{
+ return m_associatedWasmMemoryWrapper.get();
+}
+
+void JSArrayBuffer::setAssociatedWasmMemoryWrapper(VM& vm, JSWebAssemblyMemory* wrapper)
+{
+ ASSERT(impl()->isWasmMemory() && impl()->isResizableNonShared());
+ m_associatedWasmMemoryWrapper.set(vm, this, wrapper);
+}
+
+void JSArrayBuffer::clearAssociatedWasmMemoryWrapper()
+{
+ m_associatedWasmMemoryWrapper.clear();
+}
+#endif // ENABLE(WEBASSEMBLY)
+
+template<typename Visitor>
+void JSArrayBuffer::visitChildrenImpl(JSCell* cell, Visitor& visitor)
+{
+ auto* thisObject = uncheckedDowncast<JSArrayBuffer>(cell);
+ ASSERT_GC_OBJECT_INHERITS(thisObject, info());
+ Base::visitChildren(thisObject, visitor);
+#if ENABLE(WEBASSEMBLY)
+ visitor.append(thisObject->m_associatedWasmMemoryWrapper);
+#endif // ENABLE(WEBASSEMBLY)
+}
+
+DEFINE_VISIT_CHILDREN(JSArrayBuffer);

Source/JavaScriptCore/runtime/JSArrayBuffer.h

+#if ENABLE(WEBASSEMBLY)
+ JSWebAssemblyMemory* associatedWasmMemoryWrapper() const;
+ void setAssociatedWasmMemoryWrapper(VM&, JSWebAssemblyMemory*);
+ void clearAssociatedWasmMemoryWrapper();
+#endif // ENABLE(WEBASSEMBLY)
+
DECLARE_EXPORT_INFO;
+
+ DECLARE_VISIT_CHILDREN;
@@
ArrayBuffer* m_impl;
+ // For resizable non-shared Wasm buffers, this points back to the owning JSWebAssemblyMemory so
+ // that ArrayBuffer.prototype.resize can delegate to Wasm::Memory::grow.
+#if ENABLE(WEBASSEMBLY)
+ WriteBarrier<JSWebAssemblyMemory> m_associatedWasmMemoryWrapper;
+#endif // ENABLE(WEBASSEMBLY)

Source/JavaScriptCore/runtime/JSArrayBufferPrototype.cpp

size_t newByteLength = static_cast<size_t>(newLength);
+
+#if ENABLE(WEBASSEMBLY)
+ // Wasm JS API redefines the abstract operation HostResizeArrayBuffer as follows:
+ // https://webassembly.github.io/threads/js-api/index.html#abstract-operation-hostresizearraybuffer
+ //
+ // Further, WebAssembly-originated resizable ArrayBuffers must defer resizing to the backing
+ // WebAssembly memory for correct handling of refreshing bounds-checking memories.
+ if (auto* jsMemory = thisObject->associatedWasmMemoryWrapper()) {
+ size_t oldByteLength = thisObject->impl()->byteLength();
+ if (newByteLength < oldByteLength)
+ return throwVMRangeError(globalObject, scope, "Cannot shrink WebAssembly memory"_s);
+ if (newByteLength % PageCount::pageSize)
+ return throwVMRangeError(globalObject, scope, makeString("WebAssembly memory cannot be resized to new byte length "_s, newByteLength, " because it is not a multiple of "_s, PageCount::pageSize));
+ size_t delta = newByteLength - oldByteLength;
+ if (delta) {
+ auto result = jsMemory->memory().grow(vm, PageCount::fromBytes(delta));
+ if (!result)
+ return throwVMRangeError(globalObject, scope, makeString("ArrayBuffer resize failed with new byte length "_s, newByteLength));
+ }
+ return JSValue::encode(jsUndefined());
+ }
+#endif
+
if (!thisObject->impl()->resize(vm, newByteLength))

Source/JavaScriptCore/wasm/js/JSWebAssemblyMemory.cpp

m_buffer->makeWasmMemory();
- if (m_buffer->isResizableNonShared())
- m_buffer->setAssociatedWasmMemory(m_memory.ptr());
 
auto* arrayBuffer = JSArrayBuffer::create(vm, globalObject->arrayBufferStructure(m_buffer->sharingMode()), m_buffer.get());
+ if (m_buffer->isResizableNonShared())
+ arrayBuffer->setAssociatedWasmMemoryWrapper(vm, this);
@@
if (!m_buffer->isShared())
m_buffer->detach(vm);
- m_buffer->setAssociatedWasmMemory(nullptr);
m_buffer = nullptr;
+ if (auto* wrapper = m_bufferWrapper.get())
+ wrapper->clearAssociatedWasmMemoryWrapper();
m_bufferWrapper.clear();

Source/JavaScriptCore/wasm/WasmMemory.h

-#include <wtf/RefCountedAndCanMakeWeakPtr.h>
+#include <wtf/RefCounted.h>
#include <wtf/RefPtr.h>
#include <wtf/TZoneMalloc.h>
-#include <wtf/ThreadSafeWeakPtr.h>
@@
-class Memory final : public RefCountedAndCanMakeWeakPtr<Memory> {
+class Memory final : public RefCounted<Memory> {
@@
void registerInstance(JSWebAssemblyInstance&);
 
- void checkLifetime() { ASSERT(!refCountDebugger().deletionHasBegun()); }
-

JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js

+for (let iter = 0; iter < 5; ++iter) {
+ let buf;
+ (function () {
+ let m = new WebAssembly.Memory({ initial: 1, maximum: 100 });
+ buf = m.toResizableBuffer();
+ })();
+ flushStackRoots(64);
+ fullGC();
+ flushStackRoots(64);
+ fullGC();
+
+ buf.resize(65536 * 100);
+
+ let view = new Uint8Array(buf);
+ for (let i = 0; i < view.length; i += 4096) {
+ if (view[i] !== 0)
+ throw new Error("non-zero at " + i);
+ }
+ view[view.length - 1] = 0x42;

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.

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 Refadopt() 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.

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:

  1. Create new WebAssembly.Memory({ initial: 1, maximum: 100 }) inside an IIFE.
  2. Take m.toResizableBuffer() into an outer-scope buf; let the IIFE return so m is unreferenced.
  3. Call the recursive flushStackRoots(64) helper to overwrite stale conservative stack roots that would otherwise pin the memory.
  4. fullGC() — twice, with another root flush between, to make collection deterministic rather than hopeful.
  5. buf.resize(65536 * 100), then read and write through a fresh Uint8Array(buf) including view[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.

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.