62de38739d779e25aecb7a5ee4569a1c60e7d84b baseVertex missing from draw-validation cache key bypasses index clamp shader causing GPU OOB
Triage note: Adds baseVertex to DrawIndexCacheContainerKey/makeKey and requires baseVertex>=0 to skip the clamp shader; the comment explains a negative baseVertex constrains indices to >= -baseVertex which the cached value doesn't capture. RC_REMOVE_SEC_ACCEL_3-gated WebGPU validation bug allowing OOB index fetch.
The bug at a glance
A WebGPU validation-cache keying bug (Source/WebGPU/WebGPU/BindableResource.h, Buffer.{h,mm}, RenderPassEncoder.mm, GPU process): the indexed-draw “skip the clamp shader” cache key omitted baseVertex, so a verdict proven for a non-negative baseVertex could be reused for a draw with a negative baseVertex, whose indices must additionally be >= -baseVertex to stay in bounds — a lower bound the buffer never tracks. The result is a GPU-side out-of-bounds read of vertex memory below the buffer. Reachable from untrusted web content via the WebGPU API. Severity high as an OOB read; escalation to info disclosure via canvas readback is plausible but unproven by the diff (inferred). This is a companion to 54132281986c.
The validation proof only ever establishes an upper bound on index values (indices < vertexCount); a negative baseVertex silently adds a lower-bound requirement (indices >= -baseVertex) that the cache key didn’t record, so the cache conflated two draws with different safety preconditions.
Root cause
For an indexed draw, the effective vertex a shader reads is index + baseVertex. The clamp shader / allIndexValuesBelow machinery only ever proves an upper bound: all index values are below effectiveMinVertexCount (= minVertexCount - baseVertex). When baseVertex >= 0 this is sufficient because index values are unsigned so the lower bound is trivially 0. When baseVertex < 0, index + baseVertex can go negative and read before the vertex buffer, so the draw is additionally safe only if every index is >= -baseVertex — a lower-bound constraint.
Pre-patch sequence: 1. a draw with baseVertex = 0 is validated and cached under a key = {firstIndex, indexCount, primitiveOffset|indexType, icb-lo, icb-hi} (no baseVertex). 2. canSkipDrawIndexedValidation builds the same key for a later draw with the same firstIndex/indexCount/etc but a negative baseVertex and finds the cached entry. 3. The only value-side check is cached vertexCount <= new vertexCount (an upper-bound comparison); the negative-baseVertex lower-bound requirement is never represented. 4. The draw skips the clamp shader and the GPU fetches vertices at index + baseVertex < 0 — an OOB read below the buffer.
The fix adds std::min(baseVertex, 0) as a sixth key element (DrawIndexCacheContainerKey grows from array<uint32_t,5> to [6], with a matching clampedBaseVertex in the value), so draws with different negative baseVertex (or a mix of zero vs negative) no longer collide in the cache. Independently, RenderPassEncoder gates the fast seed path with baseVertex >= 0 && before calling allIndexValuesBelow, forcing the clamp shader to run whenever baseVertex is negative because the buffer only tracks an upper bound on index values. baseVertex is threaded through canSkipDrawIndexedValidation/drawIndexedValidated and all the deferred completion-handler/scheduleWork captures.
Key code
baseVertex added to the cache key, and the fast path refuses to self-validate a negative baseVertex
// makeKey: negative baseVertex becomes part of the key (clamped so >=0 maps to 0)
return { firstIndex, indexCount, primitiveOffset | (uint32_t)(indexType << 1),
icb_lo, icb_hi, static_cast<uint32_t>(std::min(baseVertex, 0)) };
// RenderPassEncoder: clamp shader still required when baseVertex < 0
if (!apiIndexBuffer->didReadOOB() && baseVertex >= 0
&& apiIndexBuffer->allIndexValuesBelow(indexType, effectiveMinVertexCount)) {
Fix approach
Two coordinated changes. (1) The cache key gains a sixth uint32 holding std::min(baseVertex, 0): all non-negative baseVertex values collapse to 0 (they share the same trivially-zero lower bound) while each distinct negative value keys separately, so a verdict is only ever reused for a draw with the same (or no) lower-bound constraint; DrawIndexCacheContainerValue mirrors it with an int32_t clampedBaseVertex. (2) In RenderPassEncoder the opportunistic “prove it now from the tracked index upper bound” path is guarded by baseVertex >= 0, because allIndexValuesBelow only certifies an upper bound and cannot certify indices >= -baseVertex; negative-baseVertex draws fall through to the full clamp shader. baseVertex is plumbed through canSkipDrawIndexedValidation, drawIndexedValidated, makeKey and every addCompletedHandler/scheduleWork capture so the deferred validation records the same key.
Patch walkthrough
Source/WebGPU/WebGPU/BindableResource.h— DrawIndexCacheContainerKey widens from std::array<uint32_t,5> to [6]; DrawIndexCacheContainerValue gains int32_t clampedBaseVertex, populated from key[5] in its constructor.Source/WebGPU/WebGPU/Buffer.h— canSkipDrawIndexedValidation and drawIndexedValidated gain an int32_t baseVertex parameter.Source/WebGPU/WebGPU/Buffer.mm— makeKey takes baseVertex and appends std::min(baseVertex, 0) to the key (with a comment explaining why only negative values matter); the find/set call sites pass baseVertex through.Source/WebGPU/WebGPU/RenderPassEncoder.mm— The direct-path and render-bundle-replay call sites pass baseVertex into canSkipDrawIndexedValidation and the deferred drawIndexedValidated captures; the self-validation shortcut is gated with baseVertex >= 0 so negative baseVertex always runs the clamp shader.
Background
baseVertex (signed) — An offset added to every index in an indexed draw; WebGPU allows it to be negative, so the GPU may fetch vertex index+baseVertex, which is out of bounds below zero unless all indices are >= -baseVertex.
effectiveMinVertexCount — minVertexCount - baseVertex; the upper bound the clamp shader / allIndexValuesBelow proves index values stay under. It encodes only an upper bound, never the lower bound a negative baseVertex needs.
DrawIndexCacheContainerKey — The tuple identifying a cached draw-validation verdict; if it omits a field that changes the draw’s safety precondition (baseVertex sign), two unlike draws collide and one inherits the other’s verdict.
allIndexValuesBelow — A fast check using the buffer’s tracked upper bound on its index values to certify a draw without the clamp shader; valid only for non-negative baseVertex because it says nothing about a lower bound.
RC_REMOVE_SEC_ACCEL_3 disable switch — Compile-time kill switch; #ifdef RC_REMOVE_SEC_ACCEL_3 is the pre-patch behavior (5-element key, no baseVertex gate), #else is the fix.
Vulnerability window
- Setup — Script creates index/vertex buffers and a pipeline and issues an indexed draw with baseVertex = 0, which is validated and cached.
- Collide — A second drawIndexed with identical firstIndex/indexCount/indexType/ICB but a negative baseVertex hits the same (baseVertex-less) cache key.
- Trigger — canSkipDrawIndexedValidation returns the cached verdict (vertexCount check passes); the clamp shader is skipped.
- Fetch — The GPU computes index + baseVertex < 0 and reads vertex attributes before the start of the buffer — OOB read.
Proof of concept
No test in the diff. Conceptual trigger (inferred web path): with the WebGPU API, record an indexed draw (drawIndexed) with baseVertex 0 and small indices so it validates and caches; then record a drawIndexed with the same index/offset parameters but a negative baseVertex so index+baseVertex underflows the vertex buffer. Pre-patch the second draw reuses the first’s verdict and reads vertex memory below the buffer.
Exploitation
- Reach — Unprivileged WebGPU JS obtains a device and builds buffers/pipeline; drawIndexed exposes baseVertex directly.
- Trigger — Seed a verdict with baseVertex 0, then replay with negative baseVertex to inherit it and skip clamp validation.
- Primary primitive — GPU reads vertex attributes at a negative effective index, i.e. heap memory preceding the vertex buffer, surfaced into shader output.
- Secondary — Varying -baseVertex sweeps a controllable negative offset, and output is observable via rendered pixels / buffer copy readback — an adjustable OOB read window.
- Open question — Whether the pre-buffer region can be groomed to hold attacker-chosen or pointer-bearing data (for disclosure that aids a further bug) is not established by the diff (inferred).
Detection & hunting
For defenders and detection engineers:
- Cache hit with mismatched baseVertex sign —
- Skipped clamp shader on negative baseVertex —
Remaining risk in the patch
- Coupling with 54132281986c — The m_minValidatedVertexCount lower bound (companion commit) is forced to 0 for negative baseVertex; both commits must land together, otherwise one of the negative-baseVertex guards is missing and the OOB read remains reachable.
- Key clamps all non-negative baseVertex to 0 — std::min(baseVertex, 0) assumes non-negative baseVertex never adds a constraint the upper-bound proof misses; this holds because indices are unsigned, but any future draw mode where a positive baseVertex also bounds indices would break the assumption silently.
Audit directions
- Completeness of cache-key fields —
- Indirect/render-bundle replay paths —