50724405dcc6e88cfe5b79efa256d9413e65b5d4 drawIndexed validation cache not invalidated on sub-global-max writeBuffer → GPU OOB read
Triage note: indirectIndexedBufferRequiresRecomputation now compares m_indirectCache.indexContentsGeneration; the comment states a writeBuffer to the index buffer whose values stay at/below the validated high-water mark skipped indirectBufferInvalidated(), so a cached clamp verdict was reused against changed index values. RC_REMOVE_SEC_ACCEL_3-gated GPU validation-bypass enabling OOB vertex fetch.
The bug at a glance
The WebGPU Metal backend caches, on the indirect buffer, the verdict of an indexed-indirect draw’s index-clamp validation (Source/WebGPU/WebGPU/Buffer.mm Buffer::indirectIndexedBufferRequiresRecomputation), but that verdict was reached by inspecting a different buffer — the index buffer’s contents. A writeBuffer to the index buffer whose values stay at or below its validated high-water mark skipped indirectBufferInvalidated(), so the stale ‘validation can be skipped’ verdict was reused against changed index values, allowing an indexed-indirect draw to proceed with indices the current validation did not actually cover. The patch threads an indexContentsGeneration through the cache and comparison so any change to the index buffer’s contents forces recomputation. This is a GPU-process validation bypass enabling out-of-bounds vertex fetch; severity high is justified as an OOB read of GPU memory. Escalation to information disclosure depends on reading the OOB-fetched data back, which is inferred.
A cache-coherency bug across two buffers: the skip-validation verdict lives on the indirect-args buffer but was computed from the index buffer’s bytes, and nothing the indirect buffer tracks notices when those index bytes change under it.
Root cause
The invariant is that a cached ’this indexed-indirect draw does not require re-validation’ decision must be invalidated whenever any input it depended on changes — including the index buffer’s contents, which determine the maximum index and thus whether clamping is needed. Buffer::indirectIndexedBufferRequiresRecomputation() compared only terms that describe the indirect buffer itself (indirectOffset, minVertexCount, minInstanceCount, indexType, indexBufferOffsetInBytes, drawType). As the added comment states, ‘Every other term below describes this buffer, but the clamp verdict was reached by reading the index buffer’s values. Those can change without touching anything here: a writeBuffer to the index buffer whose values stay at or below its validated high-water mark skips indirectBufferInvalidated() altogether.’
Pre-patch sequence: 1. An indexed-indirect draw is validated; the clamp verdict (safe to skip re-validation) is recorded via indirectIndexedBufferRecomputed() into m_indirectCache on the indirect buffer. 2. Script issues writeBuffer to the INDEX buffer, changing index values; because the new values stay at/below the index buffer’s previously validated high-water mark, indirectBufferInvalidated() is not called and nothing on the indirect buffer’s cache changes. 3. The same indexed-indirect draw is re-encoded; indirectIndexedBufferRequiresRecomputation() compares its indirect-buffer-only terms, finds them unchanged, and returns false — skipping validation. 4. The draw executes against index values the cached clamp never covered, so indices can now select vertices outside the validated vertex range — an out-of-bounds vertex fetch on the GPU.
The fix adds uint64_t indexContentsGeneration to IndirectArgsCache and to both indirectIndexedBufferRequiresRecomputation() and indirectIndexedBufferRecomputed(); the former returns true when m_indirectCache.indexContentsGeneration != indexContentsGeneration. Callers in Buffer.mm and RenderPassEncoder.mm pass apiIndexBuffer->indexContentsGeneration() (captured into the on-commit handler lambda too). Types: m_indirectCache is a plain struct cached on the Buffer; indexContentsGeneration is a monotonically-changing counter on the index Buffer bumped on content change, so comparing it ties the cached verdict to the exact index-buffer contents generation. Bugclass OOB-read matches: the proven effect is reuse of a stale validation enabling out-of-range index-driven vertex fetch.
Key code
Cached clamp verdict now keyed on the index buffer’s contents generation
#else
// the clamp verdict was reached by reading the index buffer's values; a
// writeBuffer that stays at/below the validated high-water mark skips
// indirectBufferInvalidated(), so the generation must be compared:
if (m_indirectCache.indexContentsGeneration != indexContentsGeneration)
return true;
#endif
return Buffer::indirectBufferRequiresRecomputation(indirectOffset, minVertexCount, minInstanceCount)
|| m_indirectCache.indexType != indexType
|| m_indirectCache.indexBufferOffsetInBytes != indexBufferOffsetInBytes
|| m_indirectCache.drawType != IndirectArgsCache::IndirectIndexedDraw;
Fix approach
The patch adds an indexContentsGeneration field to the IndirectArgsCache struct and threads it through the recompute decision: indirectIndexedBufferRequiresRecomputation() now takes the current index buffer’s generation and returns true (force re-validation) whenever it differs from the cached one, and indirectIndexedBufferRecomputed() records the generation it validated against. Both call sites — Buffer::verifyIndexBufferData’s fast-path check and RenderPassEncoder’s drawIndexedIndirect skip check — pass apiIndexBuffer->indexContentsGeneration(), and the on-commit handler lambda captures it so the committed recompute stores the right generation. The reset path initializes indexContentsGeneration to UINT64_MAX. After the fix, any index-buffer content change (which bumps the generation) invalidates the cached clamp verdict, so the skip-validation fast path collapses to a full recomputation whenever the index bytes differ from those the cache was computed against.
Patch walkthrough
Source/WebGPU/WebGPU/Buffer.h— Adds uint64_t indexContentsGeneration to IndirectArgsCache (with a comment explaining the cross-buffer staleness), and extends indirectIndexedBufferRequiresRecomputation()/indirectIndexedBufferRecomputed() signatures to carry it.Source/WebGPU/WebGPU/Buffer.mm— indirectIndexedBufferRequiresRecomputation returns true when the cached indexContentsGeneration differs from the current one (shipping #else path); indirectIndexedBufferRecomputed stores it; the verifyIndexBufferData fast-path and the cache-reset initializer pass/initialize the generation.Source/WebGPU/WebGPU/RenderPassEncoder.mm— The drawIndexedIndirect path reads apiIndexBuffer->indexContentsGeneration() before the skip check, passes it to requiresRecomputation and to indirectIndexedBufferRecomputed, and captures it in the addOnCommitHandler lambda so the committed recompute records the correct generation.
Background
WebGPU indexed-indirect draw — drawIndexedIndirect fetches draw parameters (index count, base vertex, etc.) from a GPU indirect-args buffer and indices from an index buffer, so the CPU does not know the draw parameters at encode time.
Index-clamp validation — Because indirect draw params and index values are GPU-resident, the Metal backend validates/clamps that index values stay within vertex-buffer bounds (a slow path) and caches when that validation can be skipped.
IndirectArgsCache — Per-Buffer cache recording the parameters of the last validated indexed-indirect draw so an identical subsequent draw can skip re-validation; it lives on the indirect buffer.
indexContentsGeneration — A counter on the index Buffer that advances when its contents change (e.g. via writeBuffer), used to detect that cached, content-derived validation is stale.
writeBuffer high-water mark / indirectBufferInvalidated — Index-buffer writes that keep values within the previously validated maximum did not trigger indirectBufferInvalidated(), so the indirect buffer’s cache was never informed of the content change.
Vulnerability window
- Setup — Script creates vertex, index, and indirect-args buffers and records an indexed-indirect draw so its clamp validation runs and the skip verdict is cached.
- Mutate — Script issues queue.writeBuffer to the index buffer, changing index values while staying at/below the previously validated high-water mark so indirectBufferInvalidated() is skipped.
- Trigger — Re-encode the same drawIndexedIndirect; indirectIndexedBufferRequiresRecomputation returns false on stale terms and validation is skipped.
- OOB fetch — The draw runs against the changed index values the cached clamp never covered, fetching vertices outside the validated range.
Proof of concept
No test ships in the diff. Conceptual trigger (WebGPU): create vertex/index/indirect buffers, record and submit a drawIndexedIndirect so the clamp verdict is cached, then queue.writeBuffer new index values into the index buffer that stay at/below the earlier validated maximum (so the indirect cache is not invalidated), and re-issue the same drawIndexedIndirect; pre-patch the skip-validation path reuses the stale verdict and the GPU fetches vertices using out-of-range indices. Observing the fetched data requires reading rendered output back (inferred).
Exploitation
- Reach — WebGPU (navigator.gpu) is web-reachable; vertex/index/indirect buffers, writeBuffer, and drawIndexedIndirect are standard API usable from script, validated in the GPU process.
- Trigger — Prime the clamp-skip cache with a valid indexed-indirect draw, then mutate index-buffer contents below the high-water mark and redraw to reuse the stale verdict.
- Primitive — GPU-side out-of-bounds vertex fetch: index values that evade the cached clamp drive the vertex shader to read vertex-buffer memory outside the validated range.
- Escalation (open) — Turning the OOB fetch into disclosure requires routing the out-of-range vertex data into readable output (render-to-texture + readback or timing); whether adjacent GPU allocations hold sensitive data and how far OOB the fetch reaches gate escalation (inferred).
Detection & hunting
For defenders and detection engineers:
- Indexed-indirect draw skipping validation after an index-buffer writeBuffer — Instrument the WebGPU Metal backend to log when indirectIndexedBufferRequiresRecomputation returns false despite an intervening index-buffer content change (generation mismatch).
- GPU validation-layer/OOB vertex fetch reports — Run WebGPU CTS/fuzzing of drawIndexedIndirect interleaved with sub-high-water-mark index writes under Metal GPU validation.
Remaining risk in the patch
- Correctness of indexContentsGeneration bumping — The fix relies on the index Buffer’s indexContentsGeneration() advancing on every content change; the generator/bump site is not in this diff, so a writeBuffer path that fails to bump it would leave the same staleness (inferred, unverified here).
- Other cross-buffer cached verdicts — Only the indexed-indirect path is threaded with the generation; if non-indexed indirect or other cached GPU validations also depend on another buffer’s contents, they are not addressed by this diff.
Audit directions
- Other cached GPU validation verdicts — Audit the WebGPU backend for cached ‘skip validation’ decisions whose inputs live on a different resource than the cache, and confirm each depended-on resource has an invalidation/generation hook.
- writeBuffer invalidation paths — Review every Buffer::writeBuffer / content-mutation path to confirm it bumps the relevant generation/invalidates dependent caches, including the sub-high-water-mark case called out in the comment.
- RC_REMOVE_SEC_ACCEL_3-gated GPU hunks — Cross-check other RC_REMOVE_SEC_ACCEL_3 #else branches in WebGPU for validation that ships only in the hardened configuration.