← WebKit — 2026-W36

97c5e65ca78ea376b4ad55cdbb4cf71ede11a6a5  Use-after-free read in Buffer::takeSlowIndexValidationPath — priorData Vector freed while noCopy blit source is in-flight

severity high class UAF confidence 0.8 signal score 12.07 Source/WebGPU/ patch-now AI review CONFIRMED
Primitive: GPU reads host bytes via a newBufferWithBytesNoCopy MTLBuffer after the backing priorData/Vector is freed
Triage note: For large index/indirect buffer restores the staged copy aliases the caller's bytes through newBufferWithBytesNoCopy and the GPU reads them after commit; the post-commit handler's priorData died on return. Fix hands ownership via WTF::move and parks bytes in m_noCopyStagingSource until finalizeBlitCommandEncoder attaches a completion handler that retains them past GPU read - removes a UAF/OOB read in the GPU process.
Structural signals: bounds_guard (+2.0), uaf_lifetime (+3.57), msg_strong (+3.0), msg_medium (+2.0), area:Source/WebGPU/ (+1.5)

The bug at a glance

A WebGPU GPU-process use-after-free (Source/WebGPU/WebGPU/Buffer.mm, Queue.{h,mm}): a large index/indirect-buffer restore is staged into Metal via newBufferWithBytesNoCopy, which aliases the caller’s Vector<uint8_t> priorData without copying, and the staging command buffer is committed without waiting; but priorData is a local captured by the post-commit handler and is freed when the handler returns, so the GPU reads freed host memory after the commit. The fix hands ownership of the bytes to a new writeBuffer(Buffer&, uint64_t, Vector<uint8_t>&&) overload that parks them (m_noCopyStagingSource) until finalizeBlitCommandEncoder attaches a completed handler that keeps them alive past the GPU read. The diff proves a lifetime defect that is a genuine use-after-free read of host memory by the GPU. Severity high as a GPU-process UAF/OOB read reachable from untrusted WebGPU content; whether reclaimed bytes become observable/controllable is the escalation question (inferred).

newBufferWithBytesNoCopy is the trap: unlike the memcpy staging used below the size threshold, it creates a zero-copy MTLBuffer that merely points at the caller’s memory, so the bytes must outlive the asynchronous GPU read rather than the synchronous call.

Root cause

When restoring a buffer’s prior contents after a validation pass, a post-commit handler calls queue->writeBuffer(*protectedThis, 0, priorData…). writeBuffer stages the host bytes into Metal differently by size: below largeBufferSize it uses newTemporaryBufferWithBytes (a memcpy into a Metal-owned staging buffer, so the encoded copy no longer refers to the source after return); at or above largeBufferSize it uses newBufferWithBytesNoCopy, a zero-copy MTLBuffer that aliases the source bytes, and the staging command buffer is committed without a wait.

Pre-patch sequence: 1. the post-commit handler captures priorData = WTF::move(priorData) (a Vector<uint8_t> local to the handler). 2. It calls queue->writeBuffer(*protectedThis.ptr(), 0, priorData.mutableSpan()) — passing a span (borrow). 3. For a large restore, writeBuffer stages via newBufferWithBytesNoCopy aliasing priorData’s heap buffer and commits without waiting. 4. The handler returns, destroying priorData and freeing its backing allocation. 5. The GPU later executes the staged copy and reads the freed (and possibly reallocated) host bytes — a use-after-free read, writing stale/other heap contents into the destination buffer.

The fix adds an owning overload Queue::writeBuffer(Buffer&, uint64_t, Vector<uint8_t>&&): below largeBufferSize it forwards to the span path (memcpy, safe); at/above it stashes the Vector in m_noCopyStagingSource and stages from m_noCopyStagingSource.mutableSpan(). finalizeBlitCommandEncoder then, before committing, moves m_noCopyStagingSource into a __block Vector retained by an addCompletedHandler on the staging command buffer, so the bytes live until the GPU finishes; if nothing was staged (handler not using noCopy) m_noCopyStagingSource is reset and the completed-handler branch is a no-op. The two post-commit restore handlers now pass WTF::move(priorData) into this overload, handing ownership instead of lending a span.

Key code

Hand ownership of the restore bytes to writeBuffer instead of lending a span that dies with the handler

// restore handler (fixed): move, don't borrow
queue->writeBuffer(*protectedThis.ptr(), 0, WTF::move(priorData));

// Queue::writeBuffer(Buffer&, uint64_t, Vector<uint8_t>&&)
if (data.size() < largeBufferSize) { writeBuffer(buffer, bufferOffset, data.mutableSpan()); return; }
ASSERT(m_noCopyStagingSource.isEmpty());
m_noCopyStagingSource = WTF::move(data);
writeBuffer(buffer, bufferOffset, m_noCopyStagingSource.mutableSpan());
m_noCopyStagingSource = { };

Fix approach

A new move-taking overload becomes the owner of the restore bytes for their whole GPU lifetime. Below largeBufferSize it defers to the existing span overload (Metal memcpys, so no aliasing survives the call). At/above it parks the Vector in the Queue member m_noCopyStagingSource and stages from that member’s span; finalizeBlitCommandEncoder (which runs before the staging command buffer is committed, since Metal only accepts completed handlers on uncommitted buffers) moves m_noCopyStagingSource into a __block-captured Vector kept alive by an addCompletedHandler, releasing it only when the GPU signals completion. If the staged copy was never actually encoded, m_noCopyStagingSource is still non-empty on return and simply reset. The two buffer-restore post-commit handlers switch from priorData.mutableSpan() to WTF::move(priorData), so ownership — not a dangling span — is what reaches the zero-copy staging path.

Patch walkthrough

  • Source/WebGPU/WebGPU/Buffer.mm — Both large index/indirect restore post-commit handlers change queue->writeBuffer(*protectedThis.ptr(), 0, priorData.mutableSpan()) to WTF::move(priorData), with comments noting the restore is staged through a newBufferWithBytesNoCopy MTLBuffer read after commit.
  • Source/WebGPU/WebGPU/Queue.h — Declares the writeBuffer(Buffer&, uint64_t, Vector<uint8_t>&&) overload and adds the m_noCopyStagingSource member holding the bytes a pending zero-copy staged copy aliases.
  • Source/WebGPU/WebGPU/Queue.mm — Implements the move overload (memcpy path below largeBufferSize, park-and-stage at/above) and extends finalizeBlitCommandEncoder to move m_noCopyStagingSource into a __block Vector retained by an addCompletedHandler on the command buffer before commit.

Background

newBufferWithBytesNoCopy vs newTemporaryBufferWithBytes — The former wraps existing host memory in an MTLBuffer without copying (the bytes must outlive the GPU’s use); the latter memcpys into Metal-owned storage. WebGPU picks between them by size (largeBufferSize), which is why only large restores are vulnerable.

Post-commit / completed handler — addPostCommitHandler runs after a command buffer is committed; addCompletedHandler runs after the GPU finishes. Metal rejects completed handlers on already-committed buffers, so the fix attaches the retaining handler in finalizeBlitCommandEncoder before commit.

priorData restore — The bytes of a buffer’s prior contents, re-uploaded after a validation/clamp pass; for large index/indirect buffers (unbounded below maxBufferSize) this restore is large enough to hit the zero-copy staging path.

WTF::move ownership transfer — Passing Vector<uint8_t>&& lets the Queue take ownership and keep the allocation alive for the GPU read, versus a std::span that only borrows and dangles when the source Vector is destroyed.

m_noCopyStagingSource — A Queue member parking the aliased bytes between writeBuffer staging and finalizeBlitCommandEncoder; it bridges the gap until a completed handler can hold them for the command buffer’s GPU lifetime.

Vulnerability window

  1. Setup — Web content drives an index/indirect-buffer validation that saves and later restores the buffer’s prior contents (priorData) in a post-commit handler.
  2. Stage — The restore is large (>= largeBufferSize), so writeBuffer stages it via newBufferWithBytesNoCopy aliasing priorData and commits without waiting.
  3. Free — The post-commit handler returns and priorData is destroyed, freeing the aliased host buffer while the GPU copy is still pending.
  4. UAF read — The GPU executes the staged copy and reads the freed (possibly reallocated) bytes, writing them into the destination buffer.

Proof of concept

No test ships in the diff. Conceptual trigger (inferred web path): via WebGPU, drive an indexed or indirect draw whose validation path saves and restores a large (>= largeBufferSize) index/indirect buffer, forcing the restore through the newBufferWithBytesNoCopy staging route; pre-patch the restored bytes are freed before the GPU reads them. Winning the window reliably requires the freed Vector to be reclaimed with attacker data before the staged copy executes.

Exploitation

  1. Reach — Unprivileged WebGPU content in the GPU process sizes the index/indirect buffer at/above largeBufferSize to select the zero-copy staging path.
  2. Trigger — Invoke the validation/restore path so the restore is staged via newBufferWithBytesNoCopy and the backing Vector is freed on handler return.
  3. Primary primitive — GPU reads use-after-free host memory; the freed bytes are copied into a GPU buffer the attacker can subsequently read back.
  4. Secondary — If the freed allocation is reclaimed by other GPU-process data before the GPU read, the restore leaks that data into the buffer — an information-disclosure read of reclaimed heap.
  5. Open question — Reliable escalation needs grooming the GPU-process heap so the freed Vector is promptly reused with sensitive/pointer-bearing content and the read window held open — not demonstrated by the diff (inferred).

Detection & hunting

For defenders and detection engineers:

  • Large restore via noCopy staging —
  • ASAN heap-use-after-free on GPU read —

Remaining risk in the patch

  • Single pending staging slot — m_noCopyStagingSource holds exactly one pending buffer and is guarded only by ASSERT(m_noCopyStagingSource.isEmpty()); if two writeBuffer(&&) calls stage noCopy copies before finalizeBlitCommandEncoder runs, the second overwrites the first in release builds, re-freeing/leaking the first’s bytes.
  • Reset-after-stage assumption — The overload resets m_noCopyStagingSource to {} immediately after the inner writeBuffer, assuming staging synchronously either consumed the bytes into the command buffer (left empty) or did not encode a copy (still full). If a staging path defers the alias past this point without finalize draining it, the reset re-opens the UAF.

Audit directions

  • All newBufferWithBytesNoCopy callers —
  • Span vs owning overloads —

Before / after

Loading diff…