5c003980c0fea863cb9bf6de345b1e0589b61874 WebGPU executeBundles batched indirect-draw reuse skips buffer revalidation registration, leading to an out-of-bounds GPU memory read in the GPU Process
Triage note: Bracketed by RC_REMOVE_SEC_ACCEL_3. Adds ReusedIndirectIndexedDraw tracking plus takeSlowBundleIndirectDrawValidationPath()/verifyClampedIndirectIndexBufferData() that re-scan the index buffer against the baked clamp and clearIndexBufferForCommandBuffer() when indices now exceed vertexCountBound; RenderPassEncoder.mm records the reused draw instead of silently continuing. Fixes a stale-index-buffer OOB in WebGPU indexed indirect draws.
The bug at a glance
WebGPU indexed-indirect draw in the GPU process. A render bundle bakes an indexed-indirect draw into an ICB slot together with an already-clamped index count and a vertexCountBound; on a later submission the same baked slot is reused when generation counters match, but a script can rewrite the index buffer’s contents after the slot was baked so the baked clamp no longer bounds the indices the GPU will actually fetch. The GPU then reads vertices using out-of-bounds indices, i.e. an out-of-bounds read of GPU-process vertex memory whose bytes can surface in the rendered output. High is defensible as a web-reachable GPU-process OOB read; this is a read primitive only (no write is shown), so RCE escalation is (inferred).
ICB slot reuse is keyed purely on generation equality, yet the index buffer’s contents (and its m_indexContentsGeneration) can diverge from the clamp that was baked into the slot, so the baked vertexCountBound stops bounding the live indices.
Root cause
The invariant is that the indices a baked indexed-indirect draw dispatches must always be <= the vertexCountBound that the clamp shader (vsUshortIndexClamp()/vsUintIndexClamp()) validated when the ICB slot was baked. Pre-patch, when a render bundle’s baked slot was eligible for reuse the encoder recorded/replayed it rather than re-encoding and re-clamping, and nothing re-checked the index buffer at submission.
Pre-patch sequence: 1. A render bundle encodes an indexed indirect draw; the clamp dispatch bakes a clamped index count and a vertexCountBound into the ICB slot. 2. On a subsequent command-buffer build the encoder decides the slot can be reused because the tracked generations are equal. 3. Script rewrites the index buffer contents (bumping m_indexContentsGeneration via writeBuffer/mapping) so the fetched index values are now larger than what the clamp saw. 4. At commit the stale baked slot drives the GPU to fetch indices that exceed vertexCountBound -> OOB vertex fetch. The retained types matter: ReusedIndirectIndexedDraw holds a RefPtr indirectBuffer plus indirectGeneration/indexGeneration/minVertexCount/indexType so the draw can be revalidated at commit; CommandEncoder::m_reusedIndirectIndexedDraws maps a buffer identifier to the Vector of reused draws.
The fix adds a commit-time reconciliation. In CommandEncoder.mm’s commit path, for each reused draw whose draw.indexGeneration != apiBuffer->indexContentsGeneration() it calls Buffer::takeSlowBundleIndirectDrawValidationPath(), which synchronizes the index (and, if the indirect buffer is still live and its contentsGeneration matches draw.indirectGeneration, reads the baked baseVertex from the indirect arguments) and runs verifyClampedIndirectIndexBufferData over the whole index buffer. That scan mirrors the GPU clamp: each index is offset by primitiveRestart and saturating-added to baseVertex, then compared against vertexCountBound; scanning the entire buffer rather than [indexStart, indexStart+indexCount) keeps it a superset of what the shader could read. If verification fails (or the indirect args are no longer trustworthy) clearIndexBufferForCommandBuffer() zeroes the buffer for this command buffer and queues a post-commit restore of the original bytes.
Key code
Commit-time generation check + the per-index bound comparison that was missing pre-patch
for (auto& draw : reusedIndirectIndexedDraws) {
if (draw.indexGeneration == apiBuffer->indexContentsGeneration())
continue;
apiBuffer->takeSlowBundleIndirectDrawValidationPath(commandBuffer, draw);
}
// ... inside verifyClampedIndirectIndexBufferData<T>:
auto vertexCountBound = static_cast<uint64_t>(vertexCount) + primitiveRestart;
for (auto indexValue : indexData) {
uint32_t vertexIndex = primitiveRestart + static_cast<uint32_t>(indexValue);
auto shiftedVertexIndex = std::min<uint64_t>(static_cast<uint64_t>(vertexIndex) + static_cast<uint32_t>(baseVertex), UINT32_MAX);
if (shiftedVertexIndex >= vertexCountBound)
return false;
}
Fix approach
The patch retains each reused indexed-indirect draw (ReusedIndirectIndexedDraw, deduped via its defaulted operator==) in CommandEncoder::m_reusedIndirectIndexedDraws until commit instead of dropping it. At commit it compares the draw’s captured indexGeneration against the buffer’s current indexContentsGeneration; only on a mismatch does it take the slow path, re-synchronize the resources, and re-scan the whole index buffer against the baked clamp. If any index now exceeds vertexCountBound (or the indirect arguments were themselves rewritten, detected by indirectBuffer->contentsGeneration() != draw.indirectGeneration), it clears the index buffer for that command buffer and schedules a post-commit writeBuffer restore of priorData. m_clearedForCommandBuffer dedups so several failed validations sharing one command buffer do not each snapshot/restore. When generations still match, the hot path collapses to a single continue (zero extra GPU work).
Patch walkthrough
Source/WebGPU/WebGPU/BindableResource.h— Adds the ReusedIndirectIndexedDraw struct (RefPtr indirectBuffer, indirect/index offsets, indirect/index generations, minVertexCount, indexType, primitiveType) with a defaulted operator== so reused draws can be stored and deduped.Source/WebGPU/WebGPU/Buffer.h— Declares skippedBundleIndirectDrawValidation(), takeSlowBundleIndirectDrawValidationPath(), the new private clearIndexBufferForCommandBuffer(), and the m_clearedForCommandBuffer identity field used to dedupe clears/restores per command buffer.Source/WebGPU/WebGPU/Buffer.mm— Factors the clear+restore logic out of takeSlowIndexValidationPath into clearIndexBufferForCommandBuffer, and adds verifyClampedIndirectIndexBufferData plus takeSlowBundleIndirectDrawValidationPath, which re-scans the index buffer against the baked clamp (using baseVertex from the indirect args only if that buffer is still live and its generation matches) and clears on failure.Source/WebGPU/WebGPU/CommandEncoder.h— Adds skippedBundleIndirectDrawValidation() and the m_reusedIndirectIndexedDraws HashMap from buffer identifier to Vector.Source/WebGPU/WebGPU/CommandEncoder.mm— On commit, iterates m_reusedIndirectIndexedDraws, looks up the live API buffer, and for any draw whose indexGeneration no longer matches indexContentsGeneration invokes the slow revalidation path; also records reused draws (deduped) in skippedBundleIndirectDrawValidation.
Background
WebGPU indirect draw — A draw whose vertex/index/instance counts are read by the GPU from a buffer (MTLDrawIndexedPrimitivesIndirectArguments) at execution time rather than from CPU-supplied immediates, so the CPU cannot bound them directly.
Indirect Command Buffer (ICB) / baked slot — A render bundle pre-encodes (bakes) commands into a reusable Metal ICB slot; WebKit reuses the baked slot across submissions when it believes inputs are unchanged, avoiding re-encoding.
Index clamp shaders — vsUshortIndexClamp()/vsUintIndexClamp() run before a draw to clamp the index count and establish a vertexCountBound, so indices that would read past the vertex buffer are prevented on hardware without CPU readback.
Generation counters — m_contentsGeneration / m_indexContentsGeneration are bumped whenever a buffer’s contents change; WebKit compares them to decide whether cached validation or a baked slot is still valid.
primitiveRestart / baseVertex — Index-decode parameters: primitiveRestart offsets strip topologies by 1 and baseVertex is added to every fetched index, so a correct bound check must fold both in before comparing to the vertex count.
Vulnerability window
- Setup — Script creates an index buffer, an indirect-arguments buffer, and a render bundle containing an indexed indirect draw.
- Bake — The bundle is encoded; the clamp dispatch bakes a clamped index count and vertexCountBound into the ICB slot for the current index/indirect generations.
- Mutate — Script rewrites the index buffer with larger index values (bumping m_indexContentsGeneration) after the slot was baked.
- Reuse — A new command buffer reuses the baked slot because the encoder’s generation bookkeeping marks it eligible, recording a ReusedIndirectIndexedDraw.
- Trigger — Pre-patch: the stale baked clamp lets the GPU fetch out-of-bounds vertex indices; post-patch the commit-time re-scan clears the buffer first.
Proof of concept
No regression test ships in the diff. Conceptual trigger (web path inferred): via the WebGPU JS API create an index buffer and an indirect-args buffer; build a GPURenderBundle that issues drawIndexedIndirect; execute the bundle once so its ICB slot is baked with a small vertexCountBound; then writeBuffer()/map the index buffer with indices far larger than the vertex count while keeping the slot reuse-eligible; re-submit a command buffer that replays the bundle. Pre-patch the GPU fetches vertices out of bounds; observable as corrupted rendering or, with a crafted vertex layout, leaked adjacent GPU memory read back through the canvas.
Exploitation
- Reach — WebGPU is exposed to web content; the vulnerable code runs in the GPU process that services renderer WebGPU calls.
- Trigger — Bake a bundle slot, then mutate the index buffer so live indices exceed the baked vertexCountBound while the slot stays reuse-eligible.
- Primitive — GPU reads vertex attributes using out-of-bounds indices -> OOB read of memory adjacent to the vertex buffer within the GPU process.
- Secondary — Read bytes can be steered into rendered pixels (color/position) and recovered via canvas readback, turning the OOB read into a cross-resource information leak.
- Escalation (inferred) — Open questions gating anything beyond info-leak/crash: how much the OOB window can be widened by vertex-stride choice, and whether any corresponding write path exists (none is shown here).
Detection & hunting
For defenders and detection engineers:
- Severe-performance WTFLogAlways warning —
- Index generation mismatch at commit —
- Index buffer clears/restores —
Remaining risk in the patch
- Trust in indirect-args generation — takeSlowBundleIndirectDrawValidationPath uses the baked baseVertex from the indirect arguments only when indirectBuffer is live and contentsGeneration() == draw.indirectGeneration; otherwise it clears unconditionally. The correctness of ‘unconditional clear’ depends on clearIndexBufferForCommandBuffer being reached on every such path.
- Clamp-shader equivalence unverified — verifyClampedIndirectIndexBufferData claims to mirror vsUshortIndexClamp()/vsUintIndexClamp(), but those shader bodies are not in the diff, so the CPU re-scan being a true superset of the GPU clamp is asserted, not shown (inferred).
- Whole-buffer scan cost/truncation — The scan walks the entire index buffer via span(buffer, offset); the indexData.empty() early-return returns false (treated as unverified -> clear), but behavior for a buffer whose length is not a multiple of sizeof(T) is not shown.
Audit directions
- Other baked-slot reuse paths — Audit every place an ICB/render-bundle slot is reused on generation equality (non-indexed drawIndirect, drawIndexed caches) for the same bake-then-mutate divergence.
- Generation-equality cache keys — Review DrawIndexCacheContainer and m_skippedDrawIndexedValidationKeys for cases where equal generations are trusted without a contents re-scan.
- baseVertex / primitiveRestart folding — Check all index-bound validators compute vertexIndex with primitiveRestart and saturating baseVertex exactly as the shader does, to avoid off-by-one bound gaps.