Released August 17, 2026

CVE-2026-43794

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

Overview

Impact:

Description: Processing maliciously crafted web content may lead to memory corruption

Researchers: Dung Do (@_piers2) of Calif.io

Key insight. A no-copy WebGPU upload aliased caller storage the GPU read asynchronously without keeping it alive, and an array-length binding index was bumped by an unchecked uint32 add that could wrap onto a user binding — retaining the storage until GPU completion and using checkedSum close both.

Patch Details

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);
 }
 

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-43794
Bugzilla 317317
Component WebKit
Bug Class UAF
Severity medium
Commit 4a92fe2ccba1be5f…
Advisory Apple Advisory

Terminology

  • newBufferWithBytesNoCopy — A Metal API that wraps existing memory as an MTLBuffer without copying; the backing storage must outlive the GPU’s use of the buffer.
  • _ArrayLength bindings — Auto-generated bindings placed above user bindings (offset by maxBindingsPerBindGroup) to hold array lengths; their index must not collide with user bindings.
  • checkedSum / overflow — Overflow-checked addition; without it the binding-index bump can wrap and alias a user binding.

Root Cause Analysis

This fixes two memory-safety bugs in the WebGPU (Metal) backend: a use-after-free of buffer storage in Queue::writeTexture’s no-copy path, and an integer-overflow in auto-generated pipeline-layout binding indices. (1) writeTexture no-copy: for a large upload it creates an MTLBuffer with newBufferWithBytesNoCopy that ALIASES the caller’s newData storage rather than copying it; the GPU reads that storage asynchronously. Pre-patch, newData was allowed to be destroyed when writeTexture returned, so the aliased storage could be freed while the GPU was still consuming it — a use-after-free of GPU-visible memory. The fix moves newData into a __block Vector<uint8_t> and releases it only in the command buffer’s addCompletedHandler, keeping the storage alive until the GPU has finished. (2) addPipelineLayouts array-length bindings: auto-generated ‘_ArrayLength’ bindings are placed at webBinding + limits().maxBindingsPerBindGroup to sit in a reserved range above user bindings; pre-patch this uint32 addition was unchecked, so a large webBinding could overflow and wrap the array-length binding index back down to ALIAS a real user binding, defeating the bounds check that separates array-length metadata from user bindings. The fix computes the bumped index with checkedSum<uint32_t> and rejects the layout (‘Binding index overflow in auto-generated layouts’) on overflow. The restored invariants are that no-copy GPU buffers keep their backing storage alive until the GPU is done, and that array-length binding indices cannot wrap to collide with user bindings.

Attack Path

1. Trigger a no-copy texture upload

Use WebGPU writeTexture with data large enough to take the newBufferWithBytesNoCopy path that aliases the caller’s storage.

2. Free the aliased storage

Let the source Vector be destroyed while the GPU still reads the aliased buffer — a use-after-free of GPU-visible memory.

3. (Or) overflow a binding index

Alternatively, craft a pipeline layout whose ‘_ArrayLength’ binding index plus maxBindingsPerBindGroup overflows uint32 and wraps onto a user binding.

4. Corrupt GPU-process memory

Either the freed-buffer read or the aliased binding defeats a safety invariant, corrupting memory in the WebGPU/GPU path.

Impact Assessment

Two memory-corruption bugs in the WebGPU (GPU-process) path reachable from WebGPU content: a use-after-free of GPU-visible buffer storage, and an integer-overflow that aliases an array-length binding onto a user binding. The advisory rates it memory corruption; both are controllable primitives in the graphics path.

Audit Directions

noCopy buffer lifetimes

Audit WebGPU Queue/Buffer for newBufferWithBytesNoCopy (and other no-copy/aliasing) uses that don’t retain the backing storage until a command-buffer completion handler.

Binding-index arithmetic

Grep the WebGPU layout code for binding/index computations (offsets by maxBindingsPerBindGroup, strides) using unchecked integer arithmetic that could wrap and alias.

Changed Functions

Function File Change Note
Queue::writeTexture (noCopy path) Source/WebGPU/WebGPU/Queue.mm modified Moves newData into a __block Vector retained until the command buffer’s completion handler, keeping the newBufferWithBytesNoCopy-aliased storage alive until the GPU consumes it (fixing a UAF).
Device::addPipelineLayouts (_ArrayLength binding) Source/WebGPU/WebGPU/RenderPipeline.mm modified Computes webBinding + maxBindingsPerBindGroup with checkedSum<uint32_t> and rejects on overflow, so an array-length binding index cannot wrap to alias a user binding.

Files Changed

Source Files

  • 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