CVE-2026-43716
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.cppSource/JavaScriptCore/runtime/ArrayBuffer.hSource/JavaScriptCore/runtime/JSArrayBuffer.cppSource/JavaScriptCore/runtime/JSArrayBuffer.hSource/JavaScriptCore/runtime/JSArrayBufferPrototype.cppSource/JavaScriptCore/wasm/WasmMemory.hSource/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");
+}