← WebKit — 2026-W38

34cd00bb83932cecdb288b3f9120760d30998404  Index-validation restore still stages heap priorData through newBufferWithBytesNoCopy

severity high class UAF confidence 0.72 signal score 16.15 Source/WebGPU/ patch-now AI review CONFIRMED
Primitive: GPU-aliased CPU bytes (newBufferWithBytesNoCopy) read after the backing Vector is freed during WebGPU buffer clear/restore
Triage note: RC_REMOVE_SEC_ACCEL_3 brackets the change: the old path staged buffer restore via borrow(getBufferContents) + writeBuffer(Vector&&) through a newBufferWithBytesNoCopy MTLBuffer whose bytes alias a Vector parked in m_noCopyStagingSource, read by the GPU after commit. The fix replaces it with GPU-private snapshotRangeBeforeClear/restoreSnapshotAfterCommit and a bounds-checked copyBuffer (checkedSum, offset>length), plus waitUntilCompletedIfCommitted to avoid wedging the GPU process. Web-reachable GPU-process memory-safety fix; RC macro marker weights it strongly.
Structural signals: checked_int (+4.06), bounds_guard (+3.25), null_guard (+2.59), uaf_lifetime (+4.75), area:Source/WebGPU/ (+1.5)

The bug at a glance

WebGPU buffer clear/restore staged the restore through a Metal newBufferWithBytesNoCopy MTLBuffer that aliased a CPU-side Vector<uint8_t> the GPU read after the command buffer was committed, so the GPU could read heap bytes whose owning Vector had been freed. This is in the GPU process and is web-reachable from WebGPU content. The diff strongly supports a use-after-free / dangling-alias read of GPU-process heap memory (UAF/high is justified); it also fixes a separate GPU-process hang (waitUntilCompleted on never-committed command buffers) and adds bounds checks to the copy path. Reaching RCE from the alias read depends on reclaiming the freed bytes before the GPU reads them, which the diff does not demonstrate, so controlled escalation is (inferred).

To preserve bytes across a buffer clear, the old code read them into a Vector and lent that Vector’s storage to Metal via newBufferWithBytesNoCopy, then relied on a fragile parking slot (m_noCopyStagingSource) to keep the Vector alive until the GPU was done — a classic lend-then-free aliasing hazard.

Root cause

The invariant is that any bytes a Metal command buffer reads must stay valid until the GPU finishes that command buffer. A newBufferWithBytesNoCopy MTLBuffer does not own its bytes; it aliases caller memory, so the caller must keep that memory alive past GPU completion. Buffer::clearIndexBufferForCommandBuffer() and the indirect-validation path both needed to clear a GPU buffer and restore its prior contents after the commit.

Pre-patch sequence: 1. priorData = borrow(*this)->getBufferContents() copies the whole buffer into a CPU-side Vector<uint8_t> (under SUPPRESS_UNCOUNTED_ARG); 2. queue->clearBuffer(m_buffer) clears the GPU buffer; 3. a post-commit handler captures priorData by move and, after [mtlCommandBuffer waitUntilCompleted], calls queue->writeBuffer(buffer, 0, WTF::move(priorData)); 4. Queue::writeBuffer(Buffer&, offset, Vector&&) at or above largeBufferSize moves the Vector into Queue::m_noCopyStagingSource and stages the copy through a newBufferWithBytesNoCopy MTLBuffer that aliases m_noCopyStagingSource’s bytes; 5. that staging command buffer is committed without waiting, and finalizeBlitCommandEncoder() is supposed to attach the parked Vector to the command buffer via an addCompletedHandler so it outlives the GPU read. The window is the gap between committing the aliasing staging buffer and the GPU actually reading it: m_noCopyStagingSource is cleared (m_noCopyStagingSource = {}) right after writeBuffer() returns on the assumption that finalizeBlitCommandEncoder() already took ownership, and any divergence (encoder not holding it, early reset, large indirect restore) leaves the GPU reading freed Vector storage.

The backing is ThreadSafeRefCounted (Buffer, Queue) and protect()/ThreadSafeWeakPtr guard the C++ objects, but none of that owns the raw Vector bytes the no-copy MTLBuffer aliases — the refcounting protects the wrong thing. The fix eliminates the CPU alias entirely: it snapshots the range into a GPU-private MTLBuffer and restores GPU-to-GPU, so no host memory is ever lent to the GPU. A separate correctness bug in Instance::waitForCommandBufferCompletions() — calling waitUntilCompleted on command buffers that were created but never committed (e.g. a submit that failed validation) — would wedge the whole GPU process from the main thread’s releaseGPU() join; that is a hang/DoS, not the memory-safety core.

Key code

Replace CPU-aliased Vector restore with a GPU-private snapshot buffer

-    SUPPRESS_UNCOUNTED_ARG Vector<uint8_t> priorData = borrow(*this)->getBufferContents();
+    id<MTLBuffer> priorData = snapshotRangeBeforeClear(queue, 0, m_buffer.length);
...
id<MTLBuffer> Buffer::snapshotRangeBeforeClear(Queue& queue, NSUInteger offset, NSUInteger size)
{
    if (offset >= m_buffer.length)
        return nil;
    size = std::min<NSUInteger>(size, m_buffer.length - offset);
    if (!size)
        return nil;
    id<MTLBuffer> snapshot = m_device->safeCreateBuffer(size, MTLStorageModePrivate);
    if (!snapshot)
        return nil;
    queue.copyBuffer(m_buffer, offset, snapshot, 0, size);
    return snapshot;
}

Fix approach

The restore no longer round-trips through host memory. snapshotRangeBeforeClear() allocates a GPU-private (MTLStorageModePrivate) MTLBuffer and uses the new bounds-checked Queue::copyBuffer() to blit the live range into it before the clear; restoreSnapshotAfterCommit() adds a post-commit handler that, holding the Buffer and Queue alive via protect(), blits the snapshot back GPU-to-GPU after completion. Queue::copyBuffer() validates offsets with checkedSum<uint64_t> and rejects anything exceeding source/destination length. The entire Queue::writeBuffer(Buffer&, Vector&&) overload and Queue::m_noCopyStagingSource parking slot are deleted, so no newBufferWithBytesNoCopy alias of host memory is created on this path at all. Separately, waitUntilCompletedIfCommitted() skips command buffers below MTLCommandBufferStatusCommitted so an uncommitted buffer can no longer wedge the GPU work-queue thread. The hot clear/restore path collapses to: GPU-private snapshot -> clear -> post-commit GPU-to-GPU restore, with the main thread no longer able to deadlock on uncommitted work.

Patch walkthrough

  • Source/WebGPU/WebGPU/Buffer.h — Forward-declares Queue and declares snapshotRangeBeforeClear(Queue&, offset, size) and restoreSnapshotAfterCommit(CommandBuffer&, Queue&, snapshot, offset), the GPU-private snapshot/restore API replacing the Vector-based restore.
  • Source/WebGPU/WebGPU/Buffer.mm — Implements the two new methods; both clear/restore sites (clearIndexBufferForCommandBuffer and the indirect-validation path in takeSlow…Path) swap the borrow(getBufferContents) Vector for a snapshot MTLBuffer and call restoreSnapshotAfterCommit, which protects Buffer/Queue and blits back after waitUntilCompleted.
  • Source/WebGPU/WebGPU/Instance.mm — Adds waitUntilCompletedIfCommitted() and uses it in waitForCommandBufferCompletions() so Metal command buffers created-but-never-committed are skipped instead of blocking forever and wedging the GPU process.
  • Source/WebGPU/WebGPU/Queue.h — Removes the writeBuffer(Buffer&, offset, Vector&&) overload and the m_noCopyStagingSource member, and declares copyBuffer(source, srcOffset, dest, dstOffset, size).
  • Source/WebGPU/WebGPU/Queue.mm — Deletes the no-copy staging logic in finalizeBlitCommandEncoder() and the Vector&& writeBuffer overload, and implements the bounds-checked copyBuffer() that blits buffer-to-buffer via the blit encoder.

Background

newBufferWithBytesNoCopy — A Metal API that wraps caller-supplied host memory as an MTLBuffer without copying; the buffer aliases that memory, which the caller must keep valid until the GPU finishes using it.

MTLStorageModePrivate — A storage mode for GPU-only memory not CPU-mappable; a snapshot in private storage cannot alias host bytes, removing the lifetime coupling to a CPU Vector.

Blit command encoder / copyFromBuffer — Metal’s GPU-side memory-copy mechanism; copying buffer-to-buffer on the GPU avoids ever exposing contents to host memory.

Post-commit handler / waitUntilCompleted — A callback run after a command buffer is submitted; waitUntilCompleted blocks until the GPU finishes, but only ever completes for a buffer that was actually committed.

WGPU largeBufferSize — The threshold above which the old Queue::writeBuffer(Vector&&) staged via a no-copy alias instead of memcpy’ing into a Metal-owned staging buffer — exactly the large-restore case this path hit.

protect()/ThreadSafeWeakPtr — WebGPU’s thread-safe refcount helpers that keep Buffer/Queue C++ objects alive across async handlers; they do not keep raw Vector storage alive, which is why the old alias could dangle.

Vulnerability window

  1. Setup — WebGPU content creates a large GPU buffer used as an index/indirect buffer bound into a render pass.
  2. Trigger — The slow index/indirect validation path detects unverified data and must clear then restore the buffer, taking the large-restore branch.
  3. Alias — Pre-patch, prior bytes are copied into a Vector and lent to the GPU via a newBufferWithBytesNoCopy MTLBuffer, with m_noCopyStagingSource meant to keep them alive.
  4. Free — The Vector/parking slot is released while the committed staging command buffer’s GPU read is still pending, leaving the MTLBuffer aliasing freed heap bytes.
  5. GPU read — The GPU reads the aliased (now freed/possibly reclaimed) memory when executing the restore copy.

Proof of concept

No regression test ships in the diff. Conceptual trigger (web path, inferred): via the WebGPU API create a buffer at or above the large-buffer threshold, bind it as an index/indirect buffer, and issue a draw whose indirect/index data fails validation so takeSlowIndexValidationPath()/the indirect path runs the clear-and-restore; the large restore exercises the old newBufferWithBytesNoCopy alias. Timing the handler so the aliased Vector is freed and its bytes reclaimed before the GPU read is what would turn the dangling read into controlled data, and is not demonstrated.

Exploitation

  1. Reach — WebGPU content (GPUDevice/GPUQueue) in a WebContent process driving the GPU process; WebGPU must be enabled for the origin.
  2. Trigger — Force the slow index/indirect validation clear-restore on a large buffer so the no-copy aliasing restore path executes.
  3. Primitive (primary) — GPU-process dangling read: the GPU reads a region of host heap whose owning Vector has been freed — an information/corruption primitive inside the GPU process.
  4. Primitive (secondary) — The restore writes whatever the GPU read back into the MTLBuffer, so reclaimed heap contents can be laundered into a web-observable GPU buffer (read-back), and the freed-then-reclaimed bytes could steer subsequent GPU reads.
  5. Open question (gating) — Whether the free-to-GPU-read window can be widened/repeated enough to reclaim the bytes with attacker-controlled data, and whether that steers anything beyond buffer contents, gates escalation past an info leak (inferred).

Detection & hunting

For defenders and detection engineers:

  • newBufferWithBytesNoCopy on transient storage — Flag any Metal newBufferWithBytesNoCopy whose backing is a stack/heap Vector not provably alive until GPU completion; audit finalizeBlitCommandEncoder parking patterns.
  • waitUntilCompleted on uncommitted buffers — Watch for GPU-process hangs; assert command buffer status >= Committed before waitUntilCompleted.
  • Unbounded copy offsets — Verify copyBuffer/clearBuffer offset+size against buffer length with overflow-checked arithmetic (checkedSum).

Remaining risk in the patch

  • Snapshot allocation failure fallback — safeCreateBuffer may return nil (returns nil snapshot), in which case the prior contents are simply not restored; the diff does not show whether losing the restore has any correctness/security consequence for subsequent reads of the cleared buffer.
  • copyBuffer silent no-op on invalid args — copyBuffer returns early (ASSERT_NOT_REACHED in release) on overflow/length failure, so a bad range produces a silent partial restore rather than an error path; downstream assumptions about the buffer being fully restored are not verified in the diff.

Audit directions

  • Other no-copy stagers — Grep WebGPU for newBufferWithBytesNoCopy and writeTexture noCopy tails; each needs a GPU-private snapshot or a provably-outliving owner.
  • Clear/restore pairs — Review every clearBuffer()+addPostCommitHandler restore for host-memory aliasing and for correct bounds on the restored range.
  • Command buffer lifecycle — Audit all waitUntilCompleted callers (Instance/Queue/Device) for never-committed buffers that could deadlock the GPU work-queue thread.
  • Blit bounds — Check all blit-encoder copy/fill call sites for checkedSum-style overflow and length validation like the new copyBuffer().

Before / after

Loading diff…