Released June 29, 2026

CVE-2026-43716

CVSS 6.5 severity medium CWE-119CWE-416 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected Safari crash

Researchers: Maher Azzouzi, Tuan and Duc from Calif.io, OpenAI Codex Security - Amy Burnett, Evan Lambert

Key insight. The ArrayBuffer<->WebAssembly.Memory association was stored on the non-GC native buffer and thus invisible to the collector, so it could dangle across GC; moving it to a write-barriered JS wrapper (and forbidding native resize of wasm memory) makes the link GC-safe.

Patch Details

diff --git a/JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js b/JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js
new file mode 100644
index 000000000000..efa64566d316
--- /dev/null
+++ b/JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js
@@ -0,0 +1,40 @@
+// Resizing a Wasm-backed resizable ArrayBuffer should work
+// even after the originating WebAssembly.Memory is GC'd.
+
+function flushStackRoots(n) {
+    let a = [n, {}, n + 1, {}, {}];
+    if (n)
+        return flushStackRoots(n - 1);
+    return a;
+}
+
+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();
+
+    if (buf.byteLength !== 65536)
+        throw new Error("bad initial length: " + buf.byteLength);
+    if (!buf.resizable)
+        throw new Error("not resizable");
+
+    buf.resize(65536 * 100);
+
+    if (buf.byteLength !== 65536 * 100)
+        throw new Error("bad resized length: " + buf.byteLength);
+
+    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;
+    if (view[view.length - 1] !== 0x42)
+        throw new Error("write failed");
+}
diff --git a/Source/JavaScriptCore/runtime/ArrayBuffer.cpp b/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
index 84a7fd6b4476..2a4dc89d8557 100644
--- a/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
+++ b/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
@@ -443,19 +443,6 @@ void ArrayBuffer::makeWasmMemory()
     m_isWasmMemory = true;
 }
 
-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
-}
-
 void ArrayBuffer::refreshAfterWasmMemoryGrow(Wasm::Memory* memory)
 {
     ASSERT(isWasmMemory());
@@ -553,10 +540,10 @@ Expected<int64_t, GrowFailReason> ArrayBuffer::grow(VM& vm, size_t newByteLength
     return result;
 }
 
-// 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);
@@ -570,12 +557,6 @@ Expected<int64_t, GrowFailReason> ArrayBuffer::resize(VM& vm, size_t newByteLeng
             return makeUnexpected(GrowFailReason::InvalidGrowSize);
 
         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;
 
@@ -587,17 +568,6 @@ Expected<int64_t, GrowFailReason> ArrayBuffer::resize(VM& vm, size_t newByteLeng
         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);
 
diff --git a/Source/JavaScriptCore/runtime/ArrayBuffer.h b/Source/JavaScriptCore/runtime/ArrayBuffer.h
index 19cdbde7d9c2..d74153fd0e39 100644
--- a/Source/JavaScriptCore/runtime/ArrayBuffer.h
+++ b/Source/JavaScriptCore/runtime/ArrayBuffer.h
@@ -287,7 +287,6 @@ public:
 
     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*);
 
@@ -328,7 +327,6 @@ private:
 public:
     Weak<JSArrayBuffer> m_wrapper;
 private:
-    WeakPtr<Wasm::Memory> m_associatedWasmMemory;
     Checked<unsigned> m_pinCount { 0 };
     bool m_isWasmMemory { false };
     // m_locked == true means that some API user fetched m_contents directly from a TypedArray object,
diff --git a/Source/JavaScriptCore/runtime/JSArrayBuffer.cpp b/Source/JavaScriptCore/runtime/JSArrayBuffer.cpp
index 03413453a597..3a05622c86ab 100644
--- a/Source/JavaScriptCore/runtime/JSArrayBuffer.cpp
+++ b/Source/JavaScriptCore/runtime/JSArrayBuffer.cpp
@@ -27,6 +27,9 @@
 #include "JSArrayBuffer.h"
 
 #include "JSCInlines.h"
+#if ENABLE(WEBASSEMBLY)
+#include "JSWebAssemblyMemory.h"
+#endif // ENABLE(WEBASSEMBLY)
 #include "TypedArrayController.h"
 
 namespace JSC {
@@ -82,5 +85,36 @@ size_t JSArrayBuffer::estimatedSize(JSCell* cell, VM& vm)
     return Base::estimatedSize(cell, vm) + bufferEstimatedSize;
 }
 
+#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);
+
 } // namespace JSC
 
diff --git a/Source/JavaScriptCore/runtime/JSArrayBuffer.h b/Source/JavaScriptCore/runtime/JSArrayBuffer.h
index 86ebc3c902a0..16aaee19929a 100644
--- a/Source/JavaScriptCore/runtime/JSArrayBuffer.h
+++ b/Source/JavaScriptCore/runtime/JSArrayBuffer.h
@@ -27,9 +27,14 @@
 
 #include <JavaScriptCore/ArrayBuffer.h>
 #include <JavaScriptCore/JSObject.h>
+#include <JavaScriptCore/WriteBarrier.h>
 
 namespace JSC {
 
+#if ENABLE(WEBASSEMBLY)
+class JSWebAssemblyMemory;
+#endif // ENABLE(WEBASSEMBLY)

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-43716
Bugzilla 313473
Component JSC
Bug Class UAF
Severity medium
Commit a012babd4f1611a4…
Advisory Apple Advisory

Terminology

  • Resizable buffer over wasm memory — toResizableBuffer/useWasmMemoryToBufferAPIs exposes growable wasm memory as a resizable ArrayBuffer whose resize is routed to the memory.
  • GC tracing / write barrier — References between GC objects must be stored on GC-visible cells with a write barrier so the collector keeps the referent alive; a raw pointer on a non-GC object is invisible to GC.
  • Native ArrayBuffer vs JSArrayBuffer — ArrayBuffer is the native backing object; JSArrayBuffer is its GC-visible JS wrapper cell.
  • HostResizeArrayBuffer — The abstract operation resizing a buffer; for wasm-backed buffers it must delegate growth to the wasm memory.

Root Cause Analysis

When the useWasmMemoryToBufferAPIs feature exposes a WebAssembly.Memory as a resizable non-shared ArrayBuffer, the buffer and the wasm memory must stay associated so a JS-side resize is routed to the memory’s grow (which reallocates the backing store). Pre-patch, the native ArrayBuffer held that association itself via m_associatedWasmMemory (set through ArrayBuffer::setAssociatedWasmMemory), and ArrayBuffer::resize, on a wasm-backed buffer, fetched RefPtrWasm::Memory from it and called memory->grow. Storing the buffer->memory link on the native ArrayBuffer (which is not a GC object and is not traced) meant the association was not GC-visible: after a garbage collection the link could go stale / the memory be reclaimed or left in an inconsistent state, and a later resize (the added test resizes a resizable buffer after GC) would operate on a dangling or wrong memory – a use-after-free / crash. The fix moves the association onto the GC-visible JS wrapper: JSArrayBuffer gains m_associatedWasmMemoryWrapper with associatedWasmMemoryWrapper()/setAssociatedWasmMemoryWrapper(vm, wrapper) using a write barrier (.set(vm, this, wrapper)), so the JSWebAssemblyMemory is properly kept alive and traced by the collector; the native ArrayBuffer::setAssociatedWasmMemory is removed, and ArrayBuffer::resize now RELEASE_ASSERTs !isWasmMemory() (native resize must never touch a wasm memory – growth goes through the JS wrapper/memory path instead). The restored invariant is that the buffer<->wasm-memory association is owned by a GC-traced wrapper so it cannot dangle across GC, and native ArrayBuffer resize is never applied to wasm memory.

Attack Path

1. Expose wasm memory as a resizable buffer

With useWasmMemoryToBufferAPIs, create a WebAssembly.Memory and obtain its resizable non-shared ArrayBuffer, associating buffer and memory.

2. Drop strong references and GC

Remove other references to the wasm memory / wrapper and force garbage collection, so the untraced native association can go stale.

3. Resize after GC

Call the resizable buffer’s resize/grow; pre-patch it dereferences the now-dangling associated Wasm::Memory.

4. Use-after-free / crash

The resize operates on freed or inconsistent memory state in the WebContent process.

Impact Assessment

A lifetime/GC bug: the buffer->wasm-memory association lived on an untraced native object, so after GC a resizable-buffer resize could touch a dangling memory – a use-after-free reachable from JS in the WebContent process. The attacker controls GC timing and the resize, making it a groomable primitive though the observed effect is a crash. Confined to WebContent; rated medium.

Audit Directions

Untraced cross-object pointers

grep JSC runtime for raw/RefPtr members on native (non-GC) objects that point at GC cells (wrappers, memories) and should instead live on the wrapper with a write barrier.

Wasm memory <-> buffer paths

Audit resize/grow/detach/transfer for resizable and shared buffers backed by wasm memory to confirm associations survive GC and native fast paths are gated by isWasmMemory().

resize-after-GC

Review other resizable-buffer operations for correctness when the last non-GC reference is dropped and a collection intervenes.

Changed Functions

Function File Change Note
ArrayBuffer::setAssociatedWasmMemory (removed) / resize Source/JavaScriptCore/runtime/ArrayBuffer.cpp modified Removes the native buffer’s untraced wasm-memory pointer and adds RELEASE_ASSERT(!isWasmMemory()) in resize so native resize is never used for wasm memory.
JSArrayBuffer::associatedWasmMemoryWrapper / setAssociatedWasmMemoryWrapper Source/JavaScriptCore/runtime/JSArrayBuffer.cpp added Stores the buffer<->memory association on the GC-visible JS wrapper via a write-barriered m_associatedWasmMemoryWrapper.set(vm, this, wrapper), so the JSWebAssemblyMemory is kept alive and traced.
m_associatedWasmMemoryWrapper (member) / decls Source/JavaScriptCore/runtime/JSArrayBuffer.h modified Adds the traced wrapper member and accessors; JSWebAssemblyMemory/WasmMemory headers wired accordingly.

Files Changed

Source Files

  • 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/WasmMemory.h
  • Source/JavaScriptCore/wasm/js/JSWebAssemblyMemory.cpp

Test Files

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

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js b/JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js
new file mode 100644
index 000000000000..efa64566d316
--- /dev/null
+++ b/JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js
@@ -0,0 +1,40 @@
+// Resizing a Wasm-backed resizable ArrayBuffer should work
+// even after the originating WebAssembly.Memory is GC'd.
+
+function flushStackRoots(n) {
+    let a = [n, {}, n + 1, {}, {}];
+    if (n)
+        return flushStackRoots(n - 1);
+    return a;
+}
+
+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();
+
+    if (buf.byteLength !== 65536)
+        throw new Error("bad initial length: " + buf.byteLength);
+    if (!buf.resizable)
+        throw new Error("not resizable");
+
+    buf.resize(65536 * 100);
+
+    if (buf.byteLength !== 65536 * 100)
+        throw new Error("bad resized length: " + buf.byteLength);
+
+    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;
+    if (view[view.length - 1] !== 0x42)
+        throw new Error("write failed");
+}