← WebKit — 2026-W39

a778676fb20f35b6928ba0203a11ae2cc72b6cfe  WebGPU drawIndexed validation cache reseeded over recorded same-encoder copy causing GPU OOB read

severity high class OOB-read confidence 0.82 signal score 8 Source/WebGPU/ patch-now AI review CONFIRMED
Primitive: stale draw-indexed validation cache reused while an index-buffer GPU write is pending -> out-of-bounds index/vertex fetch in GPU process
Triage note: Gated by the RC_REMOVE_SEC_ACCEL_3 security-accelerator macro. Buffer now tracks m_pendingIndexWriteCommandEncoders when a buffer with Indirect|Index usage is written, and canSkipDrawIndexedValidation / the cache-store bail out via hasPendingRecordedIndexWrite(), preventing reuse of a cached 'safe' draw-indexed validation when the index contents may have been mutated by a pending GPU write - a classic WebGPU index-validation-bypass leading to OOB access.
Structural signals: uaf_lifetime (+2.2), msg_strong (+3.0), area:Source/WebGPU/ (+1.5), surgical (+1.3)

The bug at a glance

WebGPU’s Metal backend (Source/WebGPU/WebGPU/Buffer.mm, GPU process) caches ’this draw-indexed call is safe’ validation results keyed by the draw parameters. Pre-patch the cache did not account for a pending (recorded but not yet committed) GPU write to an Index/Indirect buffer, so a cached ‘safe’ verdict could be reused after the index contents were mutated, letting a draw-indexed fetch read indices (and thus vertices) out of bounds in the GPU process. This is a WebGPU index-validation bypass yielding an out-of-bounds read (high severity is supported for a GPU-process OOB read / info-leak surface). Promotion to a write or to a concrete cross-boundary leak is (inferred); the diff proves the stale-cache OOB-read, not more.

A performance cache that remembered a buffer was ‘safe to draw’ but forgot that a GPU-side write to that very index buffer was still in flight - validate-once, mutate-after.

Root cause

The violated invariant is that a cached draw-indexed validation result is only reusable while the index buffer’s contents are unchanged since validation. Buffer::canSkipDrawIndexedValidation (Buffer.mm) looks up m_drawIndexedCache by (firstIndex,indexCount,baseVertex,indexType,primitiveOffset,icb) and, if the entry’s indexContentsGeneration matches m_indexContentsGeneration and its vertexCount<=vertexCount, returns the cached ‘safe’ iterator so the expensive per-index bounds validation is skipped. The flaw: a GPU write to a buffer whose usage includes WGPUBufferUsage_Index or WGPUBufferUsage_Indirect could be recorded into a command encoder but not yet committed, so the index bytes the GPU will actually fetch are about to change, yet m_indexContentsGeneration (and the cache) still reflected the old, validated contents.

Pre-patch attacker sequence: 1. create/validate an index buffer so a draw-indexed validation is cached as safe; 2. record a GPU operation that writes new (out-of-range) index values into that Index/Indirect buffer, without committing yet; 3. issue the same draw-indexed call - canSkipDrawIndexedValidation returns the cached iterator because the generation still matches, skipping re-validation; 4. on commit the recorded write lands and then the draw fetches indices from the mutated buffer, dereferencing index/vertex positions beyond the validated bounds - an OOB read in the GPU process.

The fix adds a mutable HashSet m_pendingIndexWriteCommandEncoders and a hasPendingRecordedIndexWrite() query (true when that set is non-empty, via computeSize over live encoders). When a write targets a buffer with Indirect|Index usage, CommandEncoder::trackEncoder records the encoder into that set; the onCommit handler removes the encoder’s uniqueId once the write is committed and invalidates the indirect buffer. canSkipDrawIndexedValidation now returns std::nullopt (forcing full validation) if hasPendingRecordedIndexWrite(), and the cache-store path likewise bails before inserting a ‘safe’ entry while a write is pending. So any outstanding recorded index write disables the skip-validation fast path until it commits, eliminating the stale-cache reuse. (The triage bugclass OOB-read matches the diff; draw-indexed validation protects index-then-vertex fetches, so the exposure is a read, not a write.)

Key code

Pending-write gate on the validation-skip cache and the encoder tracking on index/indirect writes (Buffer.mm)

// canSkipDrawIndexedValidation:
if (hasPendingRecordedIndexWrite())
    return std::nullopt;
...
// on a recorded write to an Index/Indirect buffer:
if (m_usage & (WGPUBufferUsage_Indirect | WGPUBufferUsage_Index))
    CommandEncoder::trackEncoder(commandEncoder, m_pendingIndexWriteCommandEncoders);
...
bool Buffer::hasPendingRecordedIndexWrite() const
{
    return computeSize(m_pendingIndexWriteCommandEncoders, m_device.get());
}

Fix approach

The patch makes the draw-indexed validation cache aware of in-flight index-buffer mutations. A new mutable HashSet m_pendingIndexWriteCommandEncoders records every command encoder that has a recorded-but-uncommitted write to this buffer when the buffer’s usage is Index or Indirect; hasPendingRecordedIndexWrite() reports whether any such write is outstanding (filtering to live encoders via computeSize). Both the cache read (canSkipDrawIndexedValidation) and the cache write bail when a pending write exists, so no ‘safe’ verdict is produced or reused across a pending mutation. The onCommit handler removes the encoder from the set once its write is committed (and invalidates the indirect buffer), re-enabling the fast path. The hot path collapses to: skip validation only when there is provably no outstanding recorded index write; otherwise fall back to full per-draw index validation.

Patch walkthrough

  • Source/WebGPU/WebGPU/Buffer.h — Declares bool hasPendingRecordedIndexWrite() const and adds the mutable HashSet<uint64_t> m_pendingIndexWriteCommandEncoders member tracking encoders with recorded index/indirect writes.
  • Source/WebGPU/WebGPU/Buffer.mm — Adds the early-out if (hasPendingRecordedIndexWrite()) return std::nullopt; in canSkipDrawIndexedValidation and a matching bail in the cache-store path; on a recorded write to an Index/Indirect buffer calls CommandEncoder::trackEncoder into the new set; the onCommit handler removes the encoder’s uniqueId; and defines hasPendingRecordedIndexWrite() in terms of computeSize over the set.

Background

WebGPU draw-indexed validation — Before a drawIndexed, the backend must ensure every index the GPU will fetch stays within the bound vertex/index buffers; this per-index bounds check is expensive, so results are cached keyed by draw parameters and reused when the buffer contents are unchanged.

m_drawIndexedCache / indexContentsGeneration — A HashMap of cached validation verdicts; each entry stores the generation (m_indexContentsGeneration) at which it was validated. Reuse is only valid while the stored generation equals the current generation - i.e. the index bytes have not changed.

WGPUBufferUsage_Index / _Indirect — Buffer usage flags marking a buffer as holding draw indices or indirect draw arguments. Writes to such buffers can change what the GPU fetches, so they must invalidate any cached ‘safe’ draw validation.

Recorded vs. committed GPU work / onCommit handler — Metal command encoders record work that executes later at commit time. A write recorded into an encoder has not yet taken effect, so validation must treat the buffer as about-to-change until the addOnCommitHandler callback fires and the write is live.

CommandEncoder::trackEncoder / uniqueId — Helper that records a command encoder into a HashSet keyed by the encoder’s uniqueId, and computeSize counts only still-live encoders; used here to know whether any pending index write is outstanding for this buffer.

Vulnerability window

  1. Setup — From WebGPU JS, create an index buffer (usage Index/Indirect) and issue a drawIndexed so a ‘safe’ validation verdict is cached keyed by the draw parameters.
  2. Trigger — Record a GPU write that stores out-of-range index values into the same buffer, without committing the encoder yet.
  3. Pre-patch flaw — Re-issue the same drawIndexed; canSkipDrawIndexedValidation returns the cached iterator (generation still matches), skipping re-validation of the now-stale indices.
  4. Fault — On commit the write lands and the draw fetches indices/vertices beyond the validated range - an OOB read in GPU-process memory.
  5. Post-patch — hasPendingRecordedIndexWrite() forces full validation (returns std::nullopt / skips caching) while any recorded index write is outstanding, so stale verdicts are never reused.

Proof of concept

No test ships in the diff. Conceptually (WebGPU JS): create a GPUBuffer with INDEX (and/or INDIRECT) usage, populate it with in-range indices and run a drawIndexed so the backend caches a safe verdict; then in a command encoder record a buffer write (or copyBufferToBuffer) that overwrites indices with out-of-range values, and before/while that is pending re-run the same drawIndexed with identical parameters so the cached validation is reused; on submit the GPU fetches the mutated, unvalidated indices OOB. The exact Metal fetch path and whether the leaked bytes are observable are (inferred).

Exploitation

  1. Reach — Reach the WebGPU Metal backend from JS via GPUDevice/GPUQueue/GPURenderPass drawIndexed and buffer-write APIs - standard web-content WebGPU surface into the GPU process.
  2. Trigger — Seed a cached draw-indexed validation, then record (but delay committing) a write that places out-of-range indices in the Index/Indirect buffer, and replay the identical draw to hit the stale cache.
  3. Primitive — Primary primitive is an out-of-bounds index fetch, which in turn drives an out-of-bounds vertex read; the attacker controls the injected index values and thus the OOB offset.
  4. Escalation (inferred) — Open questions gating a real leak: whether the OOB-fetched vertex data is renderable back to a readable surface/canvas (an info-leak of adjacent GPU memory), and whether indirect-draw arguments can be similarly staled to amplify the read - the diff proves the bypass, not the exfiltration path.

Detection & hunting

For defenders and detection engineers:

  • GPU-process OOB-read crashes in drawIndexed — ASan/Metal validation-layer faults during indexed draws immediately following a recorded write to an Index/Indirect buffer in the same submission.
  • Validation-skip after buffer write — Instrument canSkipDrawIndexedValidation to log a skip occurring while an encoder has an uncommitted write to the same buffer - the exact pre-patch condition.
  • Index buffers with Indirect|Index usage written mid-encode — Flag WebGPU command streams that write a buffer and reuse it as the index source for an identical repeated drawIndexed within the same uncommitted encoder.

Remaining risk in the patch

  • onCommit removal vs. aborted/failed encoders — The pending-write set is cleared in the addOnCommitHandler callback; if an encoder is destroyed or its submission fails without that handler running as expected, the set could stay populated (over-conservatively disabling the cache) or, in a reuse case, be cleared early - the handler’s guarantees are not fully shown in the diff.
  • computeSize semantics not in diff — hasPendingRecordedIndexWrite() relies on computeSize filtering to live encoders; computeSize’s full body (and how it treats dead WeakPtrs) is only partly shown, so whether a stale uniqueId can linger is unverified from here.

Audit directions

  • Other validation caches in Buffer.mm — Review the m_skippedValidationCommandEncoders and indirect-buffer caches for the same ‘validated verdict reused across a pending write’ gap.
  • Generation-counter invalidation coverage — Audit every path that mutates index/vertex buffer contents to confirm it bumps m_indexContentsGeneration or is otherwise covered by the pending-write set, not just direct CPU writes.
  • Indirect draw argument buffers — Check whether indirect draw-count/argument buffers have an analogous pending-write stale-validation path beyond the index case handled here.
  • trackEncoder lifetime — Verify every write path to Index/Indirect buffers registers via trackEncoder and that the onCommit removal cannot be skipped (e.g. encoder reuse, aborted submits) leaving the set stuck or prematurely cleared.

Before / after

Loading diff…