Medium CVSS 4.3 webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentJSC
Sub-componentJSC Runtime
Bug ClassUAF
Tracker306136
Fix commit6b357f32c607 (WebKit/WebKit) +54/-1
CWECWE-787, CWE-120 (Out-of-bounds write, Buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L
CISA KEVNot listed
CreditedYeonghyeon Choi, Daniel Rhea, Söhnke Benedikt Fischedick (Tripton), Emrovsky & Switch3301, Yevhen Pervushyn
Disclosed2026-03-24

Background

Resizable ArrayBuffer over wasm memory
WebAssembly.Memory.toResizableBuffer() exposes growable wasm memory as an ArrayBuffer whose backing can move when the memory grows.
Typed-array m_vector cache
A JSArrayBufferView caches its buffer’s data pointer (plus byteOffset) in m_vector for fast indexed access.
Incoming references
An ArrayBuffer tracks the views referencing it; the fix uses this list to update each view after a grow.
Memory grow / reallocation
Growing wasm memory can move the backing store to a new base address, invalidating any cached pointer into the old store.

Root Cause Analysis

A WebAssembly.Memory can be exposed to JS as a resizable ArrayBuffer (toResizableBuffer), and typed-array views (JSArrayBufferView) over an ArrayBuffer cache the buffer’s backing data pointer in their m_vector field for fast element access. When the underlying wasm memory grows, its base data pointer can move (the memory is reallocated/remapped), and ArrayBufferContents is updated to the new base.

Pre-patch, growing the wasm memory did not refresh the cached m_vector pointers of the views attached to that buffer, so every typed array over the resizable buffer kept pointing at the OLD backing store – a stale/dangling data pointer. Reading or writing such a view after the grow accesses freed or moved memory: a use-after-free / out-of-bounds access.

The fix adds JSArrayBufferView::refreshVector(newData), which (when the view still hasVector()) recomputes m_vector as newData + byteOffsetRaw() and stores it without a write barrier; ArrayBuffer, after a wasm-memory grow, walks its incoming references (numberOfIncomingReferences/incomingReferenceAt), downcasts each to JSArrayBufferView, and calls refreshVector(newData) so every view is repointed at the new base, while ArrayBufferContents::refreshAfterWasmMemoryGrow sets m_data = memory->basePointer().

The restored invariant is that after a wasm memory grows, all typed-array views over its resizable buffer observe the buffer’s new base pointer rather than a stale one.

Key insight
Growing a WebAssembly.Memory exposed as a resizable ArrayBuffer moved the backing store but did not refresh the data pointers cached in its typed-array views, leaving them dangling; refreshing every view’s m_vector to the new base after a grow fixes it.

Attack Path

  1. Get a resizable buffer over wasm memory Create a WebAssembly.Memory with room to grow and obtain memory.toResizableBuffer(); allocate a typed array (e.g. Uint32Array) over it, caching the current data pointer.
  2. Grow the memory Call memory.grow(n), which moves the backing store to a new base pointer and frees/unmaps the old one.
  3. Leave views stale Pre-patch, the typed array’s cached m_vector still points at the old (freed/moved) backing store.
  4. Access via the stale view Read/write ta[i]; the access dereferences the stale data pointer – a use-after-free / out-of-bounds in WebContent, groomable by allocating over the freed region.

Impact Assessment

A stale-pointer use-after-free / out-of-bounds: growing a wasm memory left typed-array views pointing at the old backing store, reachable from ordinary JS over a resizable buffer in the WebContent process. The attacker controls the grow and can reallocate the freed region, making this a strong, groomable read/write primitive rather than a mere crash. Confined to WebContent; rated medium.

Changed Functions

FunctionChangeNotes
for
JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
modified

Files Changed

  • JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
  • Source/JavaScriptCore/runtime/ArrayBuffer.cpp
  • Source/JavaScriptCore/runtime/JSArrayBufferView.h
  • Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h

Audit Directions

  • Other buffer-base changes
    grep JSC runtime for places that change ArrayBufferContents::m_data / basePointer (detach, transfer, resize, shared-memory remap) and confirm all incoming views are refreshed.
  • Cached m_vector consumers
    Audit JSArrayBufferView fast paths that read m_vector without revalidating against the buffer after operations that can move backing store.
  • Resizable/growable buffer APIs
    Review ArrayBuffer.prototype.resize, SharedArrayBuffer.grow, and toResizableBuffer paths for the same view-refresh requirement.
diff --git a/Source/JavaScriptCore/runtime/ArrayBuffer.cpp b/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
index 14777a6a36a0..749112a9e540 100644
--- a/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
+++ b/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
@@ -459,7 +459,20 @@ void ArrayBuffer::setAssociatedWasmMemory(Wasm::Memory* memory)
 void ArrayBuffer::refreshAfterWasmMemoryGrow(Wasm::Memory* memory)
 {
     ASSERT(isWasmMemory());
+
+    void* oldData = m_contents.data();
     m_contents.refreshAfterWasmMemoryGrow(memory);
+    void* newData = m_contents.data();
+    if (newData == oldData)
+        return;
+
+    // JSArrayBufferViews (typed arrays) effectively cache their buffer's data pointer.
+    for (size_t i = numberOfIncomingReferences(); i--;) {
+        JSCell* cell = incomingReferenceAt(i);
+        auto* view = dynamicDowncast<JSArrayBufferView>(cell);
+        if (view)
+            view->refreshVector(newData);
+    }
 }
 
 void ArrayBuffer::setSharingMode(ArrayBufferSharingMode newSharingMode)
@@ -764,6 +777,7 @@ void ArrayBufferContents::refreshAfterWasmMemoryGrow(Wasm::Memory* memory)
     ASSERT(isResizableNonShared());
     // If the memory is BoundChecking, the memory's handle is replaced with a different one when it grows.
     m_memoryHandle = memory->handle();
+    m_data = memory->basePointer();
     m_sizeInBytes = m_memoryHandle->size();
 #else
     UNUSED_PARAM(memory);
diff --git a/Source/JavaScriptCore/runtime/JSArrayBufferView.h b/Source/JavaScriptCore/runtime/JSArrayBufferView.h
index 26c7ba00a807..ba3021e58933 100644
--- a/Source/JavaScriptCore/runtime/JSArrayBufferView.h
+++ b/Source/JavaScriptCore/runtime/JSArrayBufferView.h
@@ -364,7 +364,7 @@ private:
     JS_EXPORT_PRIVATE ArrayBuffer* slowDownAndWasteMemory();
     static void finalize(JSCell*);
     void detachFromArrayBuffer();
-
+    void refreshVector(void* newData);
 
 protected:
     friend class LLIntOffsetsExtractor;
diff --git a/Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h b/Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
index 1fff67ddbef4..50dc90a0291b 100644
--- a/Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
+++ b/Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
@@ -132,6 +132,21 @@ inline RefPtr<ArrayBufferView> JSArrayBufferView::toWrappedAllowSharedAndResizab
     return nullptr;
 }
 
+WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN
+
+inline void JSArrayBufferView::refreshVector(void* newData)
+{
+    // We ensure that the vector is really there because these notifications are delivered to
+    // incoming references of a buffer, and an incoming reference from a view to a buffer remains in
+    // place even after a view detaches.
+    if (hasVector()) {
+        void* newVectorPtr = static_cast<uint8_t*>(newData) + byteOffsetRaw();
+        m_vector.setWithoutBarrier(newVectorPtr);
+    }
+}
+
+WTF_ALLOW_UNSAFE_BUFFER_USAGE_END
+
 template<typename Getter>
 bool isArrayBufferViewOutOfBounds(JSArrayBufferView* view, Getter& getter)
 {
diff --git a/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js b/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
new file mode 100644
index 000000000000..697eb051d2a6
--- /dev/null
+++ b/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
@@ -0,0 +1,24 @@
+//@ requireOptions("--useWasmMemoryToBufferAPIs=true")
+
+import * as assert from '../assert.js'
+
+const memories = [];
+for (let i = 0; i < 500; i++) {
+    try {
+        memories.push(new WebAssembly.Memory({ initial: 1, maximum: 100 }));
+    } catch (e) { break; }
+}
+
+const memory = new WebAssembly.Memory({ initial: 1, maximum: 10 });
+const buffer = memory.toResizableBuffer();
+const ta = new Uint32Array(buffer);
+ta[0] = 0xCAFEBABE;
+
+memory.grow(1);
+
+for (let i = 0; i < 100; i++) {
+    const arr = new ArrayBuffer(65536);
+    new Uint32Array(arr).fill(0xDEADBEEF);
+}
+
+assert.eq(ta[0], 0xCAFEBABE);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js b/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
new file mode 100644
index 000000000000..697eb051d2a6
--- /dev/null
+++ b/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
@@ -0,0 +1,24 @@
+//@ requireOptions("--useWasmMemoryToBufferAPIs=true")
+
+import * as assert from '../assert.js'
+
+const memories = [];
+for (let i = 0; i < 500; i++) {
+    try {
+        memories.push(new WebAssembly.Memory({ initial: 1, maximum: 100 }));
+    } catch (e) { break; }
+}
+
+const memory = new WebAssembly.Memory({ initial: 1, maximum: 10 });
+const buffer = memory.toResizableBuffer();
+const ta = new Uint32Array(buffer);
+ta[0] = 0xCAFEBABE;
+
+memory.grow(1);
+
+for (let i = 0; i < 100; i++) {
+    const arr = new ArrayBuffer(65536);
+    new Uint32Array(arr).fill(0xDEADBEEF);
+}
+
+assert.eq(ta[0], 0xCAFEBABE);
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.