59b6d9a2d027 executeBundles re-replays bundles containing indirect draws and reconstructs ICBs every call — 30× frame-rate collapse vs direct encoding
Triage note: canSkipDrawIndexedValidation now also requires indexContentsGeneration==m_indexContentsGeneration and drawIndexedValidated carries a validationGeneration; previously the cache compared only vertexCount, so after an index buffer's contents changed to contain out-of-range indices a draw could reuse a stale 'validated' verdict. Also adds overflow-checked bounds in the completion handler and loseTheDevice on clamp-PSO failure. Bundled with a large indirect-draw ICB feature, but the validation-generation change is a genuine WebGPU OOB fix.
The bug at a glance
A stale-validation-cache out-of-bounds bug in the WebGPU implementation (Source/WebGPU, Metal backend), reachable from WebGPU-enabled web content and executed in the GPU process. Buffer::canSkipDrawIndexedValidation memoized a ‘validated’ verdict keyed only on draw parameters and comparing a cached vertexCount; it did not track whether the index buffer’s CONTENTS had changed since validation. After a draw validated an index buffer, rewriting that buffer to contain out-of-range index values let a subsequent indexed draw reuse the stale ‘validated’ verdict and skip the bounds check, so the GPU fetched vertices at out-of-range indices. The diff proves the OOB (a validation-skip after content mutation); high severity is defensible for a GPU-process OOB index/vertex fetch, but the host-memory impact is bounded by Metal’s own argument handling, and escalation beyond a GPU-side OOB read/driver fault is (inferred). A large ICB indirect-draw feature is bundled in the same commit; the security-relevant change is the validation generation.
A validation cache that forgot to invalidate on data change: the verdict was keyed on draw shape and a vertex-count ceiling, so mutating the index buffer’s bytes out from under a ‘validated’ entry reopened the exact hole the validation was meant to close.
Root cause
The invariant WebGPU must enforce is that every index value read during an indexed draw is < vertexCount of the bound vertex buffers. To avoid revalidating identical draws, Buffer memoized results in m_drawIndexedCache keyed by makeKey(firstIndex, indexCount, indexType, primitiveOffset, icb). Pre-patch the cached value was a bare uint32_t vertexCount and canSkipDrawIndexedValidation returned the hit whenever containerIt->value <= vertexCount — i.e. ‘we already proved every index is below some ceiling >= the current vertexCount, so skip’. The flaw: that verdict never recorded WHICH buffer contents it was proven against.
The pre-patch exploit sequence is: 1. create an index buffer whose values are all in range for a vertex buffer of size V; 2. issue drawIndexed, which validates and records m_drawIndexedCache[key] = V (validated); 3. rewrite the index buffer’s bytes (copyBufferToBuffer / mapped write / another encoder) so it now contains indices >= V; 4. issue the same drawIndexed again — canSkipDrawIndexedValidation finds the key, sees cached V <= vertexCount, and returns the skip verdict; 5. the draw executes with unvalidated, out-of-range indices and the GPU fetches vertices past the vertex buffer. The cache keyed on draw shape, not on data, so the content mutation in step 3 was invisible to it.
The fix adds a monotonic generation to Buffer: m_indexContentsGeneration, bumped by indexBufferContentsModified(…) (which also tracks m_indexValueUpperBoundUint/Ushort) and by the buffer-reset path (computeSize bumps both m_contentsGeneration and m_indexContentsGeneration and resets the upper bounds to UINT*_MAX). The cache value becomes DrawIndexValidationCacheEntry { vertexCount, indexContentsGeneration }, and canSkipDrawIndexedValidation now requires containerIt->value.indexContentsGeneration == m_indexContentsGeneration in addition to the vertexCount check, so any content change since validation forces revalidation. drawIndexedValidated gains a validationGeneration parameter stamped into the entry. (The commit also lands an ICB indirect-draw-encoding feature — IndirectDrawData, Device::icbIndirectEncodePipeline and Metal shaders — which is orthogonal to the OOB fix.) Bugclass matches triage (OOB-read).
Key code
canSkipDrawIndexedValidation now also requires an unchanged index-contents generation
- if (containerIt != m_drawIndexedCache.end() && containerIt->value <= vertexCount)
+ if (containerIt != m_drawIndexedCache.end() && containerIt->value.indexContentsGeneration == m_indexContentsGeneration && containerIt->value.vertexCount <= vertexCount)
return containerIt;
return std::nullopt;
Fix approach
The patch makes the validation verdict data-aware. It replaces the cache value type with DrawIndexValidationCacheEntry { vertexCount, indexContentsGeneration } and introduces a per-Buffer m_indexContentsGeneration counter that is incremented every time the index data changes (indexBufferContentsModified) or the buffer is reset (the computeSize path bumps both generations and resets m_indexValueUpperBoundUint/Ushort to their max). canSkipDrawIndexedValidation only returns a cached skip when the entry’s recorded generation equals the buffer’s current generation, so any intervening write forces a fresh bounds validation; drawIndexedValidated stamps the current generation into each new entry. It also tracks the observed maximum index value (m_indexValueUpperBound*) so allIndexValuesBelow(indexType, vertexCount) can cheaply confirm safety, and hardens the clamp-PSO failure path. The semantic change: a ‘validated’ verdict is now scoped to the exact buffer contents it was proven against, not merely to the draw’s shape and a vertex-count ceiling.
Patch walkthrough
Source/WebGPU/WebGPU/Buffer.h— Adds m_contentsGeneration / m_indexContentsGeneration counters, m_indexValueUpperBoundUint/Ushort, accessors contentsGeneration()/indexContentsGeneration(), indexBufferContentsModified() (bumps the generation and widens the upper bounds), and allIndexValuesBelow(); changes drawIndexedValidated’s signature to carry a validationGeneration.Source/WebGPU/WebGPU/BindableResource.h— Replaces the DrawIndexCacheContainer value type uint32_t with struct DrawIndexValidationCacheEntry { vertexCount, indexContentsGeneration }; also adds the (orthogonal) IndirectDrawData struct and IndirectDrawForSlotContainer for the bundled ICB indirect-draw feature.Source/WebGPU/WebGPU/Buffer.mm— canSkipDrawIndexedValidation now also compares indexContentsGeneration; drawIndexedValidated stores DrawIndexValidationCacheEntry{vertexCount, validationGeneration}; the buffer-reset (computeSize) path bumps both generations and resets the index upper bounds to UINT32/UINT16_MAX so nothing is presumed validated after a size/content change.Source/WebGPU/WebGPU/CommandEncoder.mm— skippedDrawIndexedValidation updated to read it->value.vertexCount from the new struct value instead of the old scalar.Source/WebGPU/WebGPU/Device.h / Device.mm— Adds the ICB indirect-encode pipelines/functions (icbIndirectEncodePipeline/Function and the m_icbIndirectEncode*PSO members with their Metal shader) for the bundled indirect-draw ICB feature; not part of the OOB fix but shipped together.
Background
WebGPU indexed draw — drawIndexed reads indices from an index buffer and uses each as an offset into vertex buffers. The spec requires every index to resolve within the bound vertex ranges; the implementation must validate this because the GPU performs the fetch blindly.
Validation cache (m_drawIndexedCache) — A HashMap memoizing that a given draw key was already validated, so hot draw loops avoid re-scanning the index buffer. Correctness depends on invalidating entries whenever anything they assumed changes.
Contents generation counter — A monotonic uint64 bumped on every mutation of a buffer’s (index) contents; comparing the generation stored in a cache entry against the current one detects stale cache hits — the pattern this fix adds.
Metal ICB / indirect command buffer — An Apple Metal object into which draw commands (including indirect draws read from a GPU buffer) are encoded. The bundled feature GPU-encodes indirect draws into ICB slots; relevant here only because it shares the commit.
Index value upper bound — m_indexValueUpperBoundUint/Ushort track the largest index value seen; allIndexValuesBelow(type, vertexCount) lets the validator cheaply assert all indices are in range without rescanning, provided the generation matches.
Vulnerability window
- Setup — Web content creates a WebGPU device, an index buffer with in-range values, and a vertex buffer of size V, and records a render pass drawing indexed.
- Validate — The first drawIndexed validates the indices and caches the verdict keyed on the draw shape.
- Mutate — The index buffer’s contents are rewritten (queue writeBuffer / copyBufferToBuffer / another pass) to contain indices >= V.
- Trigger — The same-shaped drawIndexed is issued again; pre-patch the stale cache reports ‘validated’ and skips revalidation.
- OOB fetch — The GPU executes the draw with out-of-range indices, fetching vertex data past the vertex buffer (GPU-side OOB read).
Proof of concept
No test ships in this slice of the diff (conceptual; web path via WebGPU). Conceptual trigger: create a GPUBuffer index buffer filled with in-range indices and a vertex buffer sized V; submit an indexed draw (populating the validation cache); then rewrite the index buffer with queue.writeBuffer / copyBufferToBuffer so it holds indices >= V; submit the identically-shaped indexed draw again. Pre-patch, canSkipDrawIndexedValidation returns the stale ‘validated’ verdict and the GPU fetches out-of-range vertices.
Exploitation
- Reach — Requires WebGPU enabled; pure web content with a GPUDevice, running the draw path in the GPU process.
- Trigger — Validate a draw, mutate the index buffer to out-of-range values, replay the same draw to hit the stale cache.
- Primary primitive — GPU-side OOB vertex fetch at attacker-controlled index magnitude — a read beyond the vertex buffer within GPU-accessible memory.
- Secondary — OOB-fetched vertex data can flow into shader outputs and rendered pixels, a potential read-back channel; repeatability is trivial (just replay), widening the window.
- Escalation (open) — Open questions gating more than a GPU OOB read / driver fault: whether Metal’s own argument validation bounds the fetch, whether the OOB offset can reach another resource’s mapping, and whether read-back of fetched data leaks host/GPU memory. Not provable from the diff (inferred).
Detection & hunting
For defenders and detection engineers:
- Index buffer written then redrawn same-shape —
- Generation mismatch on cache hit —
- GPU validation-layer OOB —
Remaining risk in the patch
- Generation coverage depends on every mutator — Safety now relies on every index-content mutation calling indexBufferContentsModified or the reset path; any GPU-side or future write path that modifies index bytes without bumping m_indexContentsGeneration silently re-introduces the stale-skip.
- Upper-bound widening only — indexBufferContentsModified only widens m_indexValueUpperBound* (std::max) and never narrows it, so allIndexValuesBelow can become permanently pessimistic after one large write — correctness-safe but the fast-path value may degrade, pushing more draws onto full validation.
- Bundled feature unreviewed here — The ICB indirect-encode shaders (truncated in the diff) add a new path that reads draw counts from app buffers and encodes draws on the GPU; its own bounds handling is outside this OOB fix and not verified from the supplied diff.
Audit directions
- Other WebGPU validation caches —
- indexBufferContentsModified callers —
- Buffer reset paths —
- ICB indirect-draw feature —