CVE-2026-43794
Overview
Background
- WebGPU GPU-process backend
- WebKit routes
GPUDevice.queueoperations from the renderer to a GPU process.Source/WebGPU/WebGPU/Queue.mmis the Metal backend;Source/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cppis the IPC-facing shim that receiveswriteBuffer/writeTexturefrom the renderer. - `newBufferWithBytesNoCopy`
- A Metal API that wraps caller-provided memory in an
MTLBufferwithout copying; theMTLBufferaliases 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;_ArrayLengthhelper bindings are offset bylimits().maxBindingsPerBindGroupto 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.
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
- Delivery
Attacker page uses the WebGPU API (
navigator.gpu), whose queue operations are serviced in the GPU process (web-reachable where WebGPU is enabled). - Trigger no-copy
Issue a
queue.writeTexture/writeBufferof at least 32 MiB so the backend takes thenewBufferWithBytesNoCopypath aliasing the supplied storage. - Free early
Pre-fix, the backing
Vector/SharedMemoryis released as soon as the call returns, while the GPU command is still queued. - 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.
- 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 (
_ArrayLengthbinding bump wrap) can alias binding indices and defeat a bounds check.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifSource/WebGPU/WebGPU/Queue.mm |
modified | |
forSource/WebGPU/WebGPU/RenderPipeline.mm |
modified | |
ifSource/WebGPU/WebGPU/RenderPipeline.mm |
modified |
Files Changed
Source/WebGPU/WebGPU/Queue.mmSource/WebGPU/WebGPU/Queue.swiftSource/WebGPU/WebGPU/RenderPipeline.mmSource/WebGPU/WebGPU/WebGPUExt.hSource/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cpp
Audit Directions
- No-copy backing lifetimeAudit every
newBufferWithBytesNoCopy/writeBufferNoCopycaller for a completion-handler keep-alive of the aliased storage; mismatched thresholds are a prime source of re-regression. - Threshold consistencyVerify all producers/consumers now use
WGPU_LARGE_BUFFER_SIZErather than a local literal, so the “do no-copy” and “keep alive” decisions can never diverge. - Binding-index arithmeticReview other
webBinding/binding-index adjustments and reserved-range offsets for uncheckeduint32_tadditions that could wrap and alias bindings. - Duplicated null checksGrep the GPU-process IPC shims for copy-pasted
convert*guards (!x || !x) that may leave a converted argument unvalidated before use.
Patch
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);
}