Medium webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to memory corruption
DescriptionA memory corruption issue was addressed with improved memory handling.
ComponentWebKit
Bug ClassUAF
Tracker317317
Fix commit4a92fe2ccba1 (webkit) +54/-9
CISA KEVNot listed
CreditedDung Do (@_piers2) of Calif.io
Disclosed2026-09-14

Background

WebGPU GPU-process backend
WebKit routes GPUDevice.queue operations from the renderer to a GPU process. Source/WebGPU/WebGPU/Queue.mm is the Metal backend; Source/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cpp is the IPC-facing shim that receives writeBuffer/writeTexture from the renderer.
`newBufferWithBytesNoCopy`
A Metal API that wraps caller-provided memory in an MTLBuffer without copying; the MTLBuffer aliases the caller’s storage, which must stay alive until the GPU finishes consuming it.
`WGPU_LARGE_BUFFER_SIZE`
New constant (0x2000000 = 32 MiB, Source/WebGPU/WebGPU/WebGPUExt.h) marking the threshold at or above which the backend uses the no-copy path instead of copying.
`addCompletedHandler` / `onSubmittedWorkDone`
Metal/WebGPU completion callbacks used to extend the lifetime of backing storage until submitted GPU work finishes.
Auto-generated pipeline layout bindings
Device::addPipelineLayouts (RenderPipeline.mm) assigns binding indices; _ArrayLength helper bindings are offset by limits().maxBindingsPerBindGroup to live in a reserved index range.
WebKit lifetime
Backing bytes here are Vector<uint8_t> / SharedMemory (RefPtr), not GC-managed; releasing them while the GPU still reads the aliased storage is a use-after-free.

Root Cause Analysis

The primary defect is a lifetime bug on the Metal no-copy path. In Queue::writeTexture, when noCopy was taken the backend had already created an MTLBuffer via newBufferWithBytesNoCopy aliasing newData’s storage, but the code called finalizeBlitCommandEncoder() and returned without keeping newData alive. Once the function returned, the Vector was destroyed while the GPU still referenced that memory — a use-after-free in which the GPU reads (and the blit consumes) freed heap storage. The same hazard existed in the GPU-process shim RemoteQueue::writeBuffer/writeTexture, which handed the renderer-supplied SharedMemory straight to writeBufferNoCopy/writeTexture and let the data reference drop at return. The patch retains the storage until the GPU is done: writeTexture moves newData into a __block variable released in [m_commandBuffer addCompletedHandler:...], and RemoteQueue adds keepAliveUntilSubmittedWorkDone(...), which for transfers >= WGPU_LARGE_BUFFER_SIZE holds the SharedMemory in backing.onSubmittedWorkDone([data]{ data = nullptr; }). The magic 32 * 1024 * 1024 literals are replaced by the shared WGPU_LARGE_BUFFER_SIZE so the keep-alive threshold matches the no-copy threshold exactly.

The commit also fixes two adjacent defects. In RenderPipeline.mm, the auto-generated-layout code bumped _ArrayLength binding indices with webBinding += limits().maxBindingsPerBindGroup, plain uint32_t arithmetic that can wrap; a wrapped index could alias a real user binding and “defeat the bounds check.” The patch introduces bumpForArrayLength using checkedSum<uint32_t>(...), returning an error ("Binding index overflow in auto-generated layouts") on overflow at both bump sites. In RemoteQueue::writeTexture the patch also corrects a copy-paste logic bug — the guard validated convertedDestination twice (!convertedDestination || !convertedDestination) and asserted the wrong variable — so convertedDataLayout was never actually null-checked before use.

Key insight
The Metal backend wrapped caller memory with newBufferWithBytesNoCopy but then released that memory before the GPU had consumed it, so the GPU read freed storage — a use-after-free — fixed by retaining the backing Vector/SharedMemory via completion handlers keyed to the same large-transfer threshold (plus a checked-arithmetic fix for wrapping array-length binding indices).

Attack Path

  1. Delivery Attacker page uses the WebGPU API (navigator.gpu), whose queue operations are serviced in the GPU process (web-reachable where WebGPU is enabled).
  2. Trigger no-copy Issue a queue.writeTexture/writeBuffer of at least 32 MiB so the backend takes the newBufferWithBytesNoCopy path aliasing the supplied storage.
  3. Free early Pre-fix, the backing Vector/SharedMemory is released as soon as the call returns, while the GPU command is still queued.
  4. GPU consumes freed memory The in-flight blit/write reads (and may write through) the now-freed storage, and the renderer can reallocate that memory in the interim.
  5. Primitive Use-after-free / memory corruption in the GPU process (the advisory’s “memory corruption” impact); controlled reuse of the freed region to influence GPU DMA is inferred, not shown by the diff. A parallel integer-overflow path (_ArrayLength binding bump wrap) can alias binding indices and defeat a bounds check.

Impact Assessment

Runs in the GPU process; reachable from unprivileged renderer web content via the WebGPU IPC surface. The diff substantiates a use-after-free: large no-copy transfers alias caller storage that was released before GPU consumption, matching the advisory’s strong “memory corruption” impact (not merely a crash). The companion checkedSum change substantiates an integer overflow in binding-index assignment that could alias bindings and bypass a bounds check, and a null-check logic bug on convertedDataLayout was fixed. Confirmed: a GPU-process memory-safety hole; the degree of attacker control over the corrupted contents is inferred, not proven by the diff. Gating: WebGPU must be enabled, and the UAF path requires transfers at/above the 32 MiB threshold.

Changed Functions

FunctionChangeNotes
if
Source/WebGPU/WebGPU/Queue.mm
modified
for
Source/WebGPU/WebGPU/RenderPipeline.mm
modified
if
Source/WebGPU/WebGPU/RenderPipeline.mm
modified

Files Changed

  • Source/WebGPU/WebGPU/Queue.mm
  • Source/WebGPU/WebGPU/Queue.swift
  • Source/WebGPU/WebGPU/RenderPipeline.mm
  • Source/WebGPU/WebGPU/WebGPUExt.h
  • Source/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cpp

Audit Directions

  • No-copy backing lifetime
    Audit every newBufferWithBytesNoCopy / writeBufferNoCopy caller for a completion-handler keep-alive of the aliased storage; mismatched thresholds are a prime source of re-regression.
  • Threshold consistency
    Verify all producers/consumers now use WGPU_LARGE_BUFFER_SIZE rather than a local literal, so the “do no-copy” and “keep alive” decisions can never diverge.
  • Binding-index arithmetic
    Review other webBinding/binding-index adjustments and reserved-range offsets for unchecked uint32_t additions that could wrap and alias bindings.
  • Duplicated null checks
    Grep the GPU-process IPC shims for copy-pasted convert* guards (!x || !x) that may leave a converted argument unvalidated before use.
diff --git a/Source/WebGPU/WebGPU/Queue.mm b/Source/WebGPU/WebGPU/Queue.mm
index 0bf08a83aa85..90851766fcfd 100644
--- a/Source/WebGPU/WebGPU/Queue.mm
+++ b/Source/WebGPU/WebGPU/Queue.mm
@@ -48,7 +48,7 @@
 
 namespace WebGPU {
 
-constexpr static auto largeBufferSize = 32 * 1024 * 1024;
+constexpr static auto largeBufferSize = WGPU_LARGE_BUFFER_SIZE;
 constexpr bool skipMemoryAttribution = true;
 
 WTF_MAKE_TZONE_ALLOCATED_IMPL(Queue);
@@ -1225,8 +1225,16 @@ void Queue::writeTexture(const WGPUImageCopyTexture& destination, std::span<uint
         return;
     }
 
-    if (noCopy)
+    if (noCopy) {
+        if (!newData.isEmpty()) {
+            // The MTLBuffer above was created with newBufferWithBytesNoCopy and aliases newData's storage; keep that storage alive until the GPU has consumed it.
+            __block Vector<uint8_t> retainedNewData = WTF::move(newData);
+            [m_commandBuffer addCompletedHandler:^(id<MTLCommandBuffer>) {
+                retainedNewData = { };
+            }];
+        }
         finalizeBlitCommandEncoder();
+    }
 }
 
 void Queue::setLabel(String&& label)
diff --git a/Source/WebGPU/WebGPU/Queue.swift b/Source/WebGPU/WebGPU/Queue.swift
index ef2827dc6aff..d9838d068a28 100644
--- a/Source/WebGPU/WebGPU/Queue.swift
+++ b/Source/WebGPU/WebGPU/Queue.swift
@@ -25,8 +25,9 @@
 
 public import Metal
 internal import WebGPU_Internal.Queue
+import WebGPU_Private.WebGPUExt
 
-private let largeBufferSize = 32 * 1024 * 1024
+private let largeBufferSize = Int(WGPU_LARGE_BUFFER_SIZE)
 
 // FIXME: Eventually all these "thunks" should be removed.
 // swift-format-ignore: AlwaysUseLowerCamelCase
diff --git a/Source/WebGPU/WebGPU/RenderPipeline.mm b/Source/WebGPU/WebGPU/RenderPipeline.mm
index 8b446ce17590..9d40a2908f85 100644
--- a/Source/WebGPU/WebGPU/RenderPipeline.mm
+++ b/Source/WebGPU/WebGPU/RenderPipeline.mm
@@ -982,6 +982,13 @@ NSString* Device::addPipelineLayouts(Vector<Vector<WGPUBindGroupLayoutEntry>>& p
     for (auto& bindGroupLayout : pipelineLayout.bindGroupLayouts) {
         auto& entries = pipelineEntries[bindGroupLayout.group];
         HashMap<String, uint64_t> entryMap;
+        // Wrapping the bump would let the array-length entry alias a user binding and defeat the bounds check.
+        auto bumpForArrayLength = [&](uint32_t webBinding) -> std::optional<uint32_t> {
+            auto checked = checkedSum<uint32_t>(webBinding, limits().maxBindingsPerBindGroup);
+            if (checked.hasOverflowed())
+                return std::nullopt;
+            return checked.value();
+        };
         for (auto& entry : bindGroupLayout.entries) {
             auto visibility = convertVisibility(entry.visibility);
             auto stage = visibility / 2;
@@ -991,7 +998,10 @@ NSString* Device::addPipelineLayouts(Vector<Vector<WGPUBindGroupLayoutEntry>>& p
             uint32_t webBinding = entry.webBinding;
             if (auto& entryName = entry.name; entryName.length()) {
                 if (entryName.endsWith("_ArrayLength"_s)) {
-                    webBinding += limits().maxBindingsPerBindGroup;
+                    auto bumped = bumpForArrayLength(webBinding);
+                    if (!bumped)
+                        return @"Binding index overflow in auto-generated layouts";
+                    webBinding = *bumped;
                     isArrayLength = true;
                 }
             }
@@ -1010,7 +1020,10 @@ NSString* Device::addPipelineLayouts(Vector<Vector<WGPUBindGroupLayoutEntry>>& p
             WGPUBufferBindingType bufferTypeOverride = WGPUBufferBindingType_Undefined;
             if (auto& entryName = entry.name; entryName.length()) {
                 if (isArrayLength) {
-                    webBinding += limits().maxBindingsPerBindGroup;
+                    auto bumped = bumpForArrayLength(webBinding);
+                    if (!bumped)
+                        return @"Binding index overflow in auto-generated layouts";
+                    webBinding = *bumped;
                     bufferTypeOverride = static_cast<WGPUBufferBindingType>(WGPUBufferBindingType_ArrayLength);
                     auto shortName = entryName.substring(2, entryName.length() - (sizeof("_ArrayLength") + 1));
                     if (auto it = entryMap.find(shortName); it != entryMap.end())
diff --git a/Source/WebGPU/WebGPU/WebGPUExt.h b/Source/WebGPU/WebGPU/WebGPUExt.h
index 5fec3ec7cceb..fcdd849578ed 100644
--- a/Source/WebGPU/WebGPU/WebGPUExt.h
+++ b/Source/WebGPU/WebGPU/WebGPUExt.h
@@ -41,6 +41,13 @@
 #define WGPU_FUZZER_ASSERT_NOT_REACHED(...) WTFLogAlways(__VA_ARGS__)
 #endif
 
+// Threshold above which the Metal backend uses newBufferWithBytesNoCopy in writeBuffer / writeTexture
+// and aliases the caller's storage rather than copying. Callers passing transfers >= this size MUST
+// keep the source bytes alive until the GPU has consumed them (e.g. via addCompletedHandler).
+// Value is 32 * 1024 * 1024; written as a single integer literal so Swift's clang macro importer
+// picks it up as `WGPU_LARGE_BUFFER_SIZE` rather than skipping it.
+#define WGPU_LARGE_BUFFER_SIZE 33554432
+
 #include <optional>
 #include <simd/simd.h>
 #include <wtf/MachSendRight.h>
diff --git a/Source/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cpp b/Source/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cpp
index 89b29f347c47..5d54a354824c 100644
--- a/Source/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cpp
+++ b/Source/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cpp
@@ -35,12 +35,24 @@
 #include <WebCore/SharedMemory.h>
 #include <WebCore/WebGPUBuffer.h>
 #include <WebCore/WebGPUQueue.h>
+#include <WebGPU/WebGPU.h>
+#include <WebGPU/WebGPUExt.h>
 #include <wtf/TZoneMallocInlines.h>
 
 namespace WebKit {
 
 WTF_MAKE_TZONE_ALLOCATED_IMPL(RemoteQueue);
 
+// For transfers at or above WGPU_LARGE_BUFFER_SIZE the backend uses newBufferWithBytesNoCopy and aliases `data`'s mapping; keep it alive until the GPU has consumed the bytes. Smaller transfers are copied into a Metal buffer synchronously, so `data` can be released as soon as we return.
+static void keepAliveUntilSubmittedWorkDone(WebCore::WebGPU::Queue& backing, RefPtr<WebCore::SharedMemory>&& data)
+{
+    if (!data || data->size() < WGPU_LARGE_BUFFER_SIZE)
+        return;
+    backing.onSubmittedWorkDone([data = WTF::move(data)]() mutable {
+        data = nullptr;
+    });
+}
+
 RemoteQueue::RemoteQueue(WebCore::WebGPU::Queue& queue, WebGPU::ObjectHeap& objectHeap, Ref<IPC::StreamServerConnection>&& streamConnection, RemoteGPU& gpu, WebGPUIdentifier identifier)
     : m_backing(queue)
     , m_objectHeap(objectHeap)
@@ -98,7 +110,9 @@ void RemoteQueue::writeBuffer(
         return;
     }
 
-    protectedBacking()->writeBufferNoCopy(*convertedBuffer, bufferOffset, data->mutableSpan(), 0, std::nullopt);
+    Ref backing = m_backing;
+    backing->writeBufferNoCopy(*convertedBuffer, bufferOffset, data->mutableSpan(), 0, std::nullopt);
+    keepAliveUntilSubmittedWorkDone(backing, WTF::move(data));
     completionHandler(true);
 }
 
@@ -128,15 +142,17 @@ void RemoteQueue::writeTexture(
     auto convertedDestination = objectHeap->convertFromBacking(destination);
     ASSERT(convertedDestination);
     auto convertedDataLayout = objectHeap->convertFromBacking(dataLayout);
-    ASSERT(convertedDestination);
+    ASSERT(convertedDataLayout);
     auto convertedSize = objectHeap->convertFromBacking(size);
     ASSERT(convertedSize);
-    if (!convertedDestination || !convertedDestination || !convertedSize || !data || data->size() <= WebGPU::maxCrossProcessResourceCopySize) {
+    if (!convertedDestination || !convertedDataLayout || !convertedSize || !data || data->size() <= WebGPU::maxCrossProcessResourceCopySize) {
         completionHandler(false);
         return;
     }
 
-    protectedBacking()->writeTexture(*convertedDestination, data ? data->mutableSpan() : std::span<uint8_t> { }, *convertedDataLayout, *convertedSize);
+    Ref backing = m_backing;
+    backing->writeTexture(*convertedDestination, data->mutableSpan(), *convertedDataLayout, *convertedSize);
+    keepAliveUntilSubmittedWorkDone(backing, WTF::move(data));
     completionHandler(true);
 }
 
Loading diff…