54132281986c805735de81268161f05a70d2abe4 [WebGPU] drawIndexedValidated promotes an unvalidated whole-payload max index as a validated ceiling, causing GPU OOB
Triage note: Replaces m_maxValidated*Index high-water marks with m_minValidatedVertexCount and makes needsIndexValidation revalidate unless maxIndex < min proven vertex count. RC_REMOVE_SEC_ACCEL_3-gated fix to the WebGPU indexed-draw clamp cache that previously let a write keep a stale verdict, permitting an OOB index fetch.
The bug at a glance
A WebGPU validation-cache bug in the Metal backend (Source/WebGPU/WebGPU/Buffer.{h,mm}, GPU process) let a writeBuffer keep a previously-cached “skip the clamp shader” verdict for an indexed draw even after the write placed an index at or above the vertex count that cached draw was proven against, so a subsequent draw fetches a vertex attribute out of bounds. The proven defect is an out-of-bounds GPU-side read of vertex/attribute memory driven by a stale validation verdict; it is a read primitive, not a write. Reachable from untrusted web content through the WebGPU JS API. Severity high as an in-GPU-process OOB read; escalation to cross-buffer information disclosure (rendering OOB bytes to a canvas that JS can read back) is plausible but not proven by the diff (inferred).
The old cache invalidation used index-value high-water marks (the largest index ever validated) as the comparand, but the real safety invariant is phrased in vertex counts (each cached draw was proven to have all indices below a specific vertexCount); comparing a new write against the wrong axis let writes slip through.
Root cause
The clamp shader proves that every index value used by a draw is strictly below the draw’s effective vertexCount, and the result is memoized in m_drawIndexedCache keyed per draw. The invariant the cache must preserve is: a writeBuffer that mutates index-buffer contents may keep cached “safe” verdicts only if the new index values cannot push any cached draw past the vertexCount it was proven against. The pre-patch (RC_REMOVE_SEC_ACCEL_3) path tracked m_maxValidatedUnsignedIndex / m_maxValidatedUshortIndex (high-water marks of validated index values) and in needsIndexValidation returned true only when the write’s max index exceeded that high-water mark.
Pre-patch sequence: 1. a draw is validated; its indices are proven below vertexCount V, and m_maxValidatedUnsignedIndex is raised to the largest index value seen. 2. Because the largest validated index value can be far above V (different cached draws have different V), the high-water mark no longer reflects the smallest proven vertex bound. 3. A writeBuffer lands new index values that are >= some cached draw’s V but still <= m_maxValidatedUnsignedIndex. 4. needsIndexValidation returns false (needsUpdate is false), the stale “skip validation” verdict is kept, and 5. the next draw using that cache entry fetches vertices with an index past its vertexCount — an OOB attribute read.
The fix replaces the value high-water marks with m_minValidatedVertexCount: the smallest vertexCount any live cache entry was proven against (UINT32_MAX when empty, forced to 0 if any entry used a negative baseVertex). needsIndexValidation now returns true if the cache is empty, otherwise true unless the write’s max index stays strictly below m_minValidatedVertexCount for both the uint32 and uint16 interpretations. This is the correct comparand: because the clamp shader accepts an index only if it is below the vertexCount it was proven against, a write whose largest value is below the minimum proven vertex count cannot make any cached draw go out of bounds regardless of where in the buffer it lands.
Key code
needsIndexValidation now compares the write against the minimum proven vertex count, not index-value high-water marks
// post-validation update
m_minValidatedVertexCount = baseVertex < 0 ? 0u : std::min(m_minValidatedVertexCount, vertexCount);
// needsIndexValidation (fixed path)
if (m_drawIndexedCache.isEmpty())
return true;
return maxUnsignedIndex >= m_minValidatedVertexCount || maxUshortIndex >= m_minValidatedVertexCount;
Fix approach
The patch swaps the two index-value high-water marks (m_maxValidatedUnsignedIndex/Ushort) for a single lower bound, m_minValidatedVertexCount, updated on every drawIndexedValidated to the min of its prior value and the just-proven vertexCount (clamped to 0 when a negative baseVertex is in play, which lower-bounds indices in a way the min cannot represent). needsIndexValidation collapses to: empty cache -> must validate; otherwise keep verdicts only if the write’s largest index (either interpretation) is below the smallest proven vertex count. m_minValidatedVertexCount is reset to UINT32_MAX whenever the cache is cleared (capacity overflow) or the buffer is invalidated, keeping the sentinel consistent with an empty cache. The semantic change is that the cache’s revalidation trigger is now expressed in the same unit (vertex count) as its safety proof, instead of a loosely-related index-value ceiling.
Patch walkthrough
Source/WebGPU/WebGPU/Buffer.h— Removes the four high-water-mark members (m_maxUnsignedIndex, m_maxUshortIndex, m_maxValidatedUnsignedIndex, m_maxValidatedUshortIndex) and adds uint32_t m_minValidatedVertexCount{UINT32_MAX}, documented as the smallest vertex count any live cache entry was proven against.Source/WebGPU/WebGPU/Buffer.mm— drawIndexedValidated updates m_minValidatedVertexCount = min(current, vertexCount) (or 0 for negative baseVertex); needsIndexValidation returns true for an empty cache and otherwise only revalidates when maxUnsignedIndex/maxUshortIndex >= m_minValidatedVertexCount; cache-clear-on-overflow and indirectBufferInvalidated reset m_minValidatedVertexCount to UINT32_MAX.
Background
WebGPU clamp shader / draw-index validation — The Metal backend runs a compute pass that checks every index in an indexed draw is below the vertex count before the draw executes, so a malicious index buffer cannot fetch vertices out of bounds. Results are cached to avoid re-running it on every draw.
m_drawIndexedCache — A HashMap keyed by (firstIndex, indexCount, indexType/primitiveOffset, ICB id) whose value records the vertexCount a draw was proven safe against plus a generation; a hit lets RenderPassEncoder skip the clamp shader.
writeBuffer / index contents generation — WebGPU lets JS overwrite buffer contents after validation; such writes must invalidate or re-check cached verdicts because they can change the index values a cached draw relies on.
baseVertex — A per-draw signed offset added to each index; a negative baseVertex lower-bounds the usable index values, which is why the fix forces m_minValidatedVertexCount to 0 in that case (see companion commit 62de38739d77).
RC_REMOVE_SEC_ACCEL_3 disable switch — A compile-time kill switch; code under #ifdef RC_REMOVE_SEC_ACCEL_3 is the pre-patch/disabled-security behavior, and the #else branch is the actual fix.
Vulnerability window
- Setup — Script creates a WebGPU index buffer, issues an indexed draw whose indices are validated by the clamp shader; the verdict is cached with a proven vertexCount V.
- Prime — A draw with a large validated index raises the old high-water mark well above V, decoupling the invalidation threshold from the minimum proven bound.
- Trigger — A writeBuffer overwrites index data with values >= V but <= the high-water mark; needsIndexValidation wrongly returns false and the stale verdict survives.
- Fetch — A redraw reuses the cached “skip validation” entry and the GPU fetches vertex attributes with an index past V — out-of-bounds read.
Proof of concept
No regression test ships in the diff. Conceptual trigger (inferred web path): via the WebGPU JS API, validate an indexed draw to seed m_drawIndexedCache; issue another validated draw with a high index to lift the old high-water mark above the first draw’s vertexCount; then GPUQueue.writeBuffer the index buffer so its max index sits between the min proven vertexCount and the old high-water mark; re-issue the first draw, which now skips revalidation and reads OOB.
Exploitation
- Reach — Untrusted web content obtains a GPUDevice/GPUQueue and builds index + vertex buffers and a render pipeline — all first-party JS, no special permission.
- Trigger — Seed a cached safe verdict, inflate the high-water mark, then writeBuffer malicious indices that evade needsIndexValidation; the clamp shader is skipped.
- Primary primitive — GPU-side out-of-bounds read of whatever follows the vertex buffer in the Metal heap, surfaced into the rendered attributes.
- Secondary — Rendered OOB data can be read back to JS (copyTextureToBuffer / drawn-to-canvas readback), turning the OOB read into an information leak.
- Open question — Escalation beyond disclosure depends on whether adjacent GPU allocations can be groomed to contain pointer-like data and whether the leak is wide/repeatable enough — not established by the diff (inferred).
Detection & hunting
For defenders and detection engineers:
- writeBuffer followed by a skipped clamp-shader draw —
- ICB out-of-bounds read flag —
Remaining risk in the patch
- Dependence on baseVertex handling — m_minValidatedVertexCount is only forced to 0 for negative baseVertex; correctness of the lower-bound case is split with companion commit 62de38739d77, so the two must both be applied — a partial backport leaves the negative-baseVertex path exploitable.
- Shared cache across index types — The single m_minValidatedVertexCount is compared against both the uint32 and uint16 interpretations; it is a conservative lower bound but assumes vertexCount semantics are identical across the index types sharing the cache, which the diff asserts only in prose.
Audit directions
- Other validation memoization in the WebGPU backend —
- writeBuffer invalidation completeness —