Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Dawn
DescriptionOut of bounds read in Dawn
ComponentDawn
Bug ClassOOB
Tracker495489174
Fix commitc2b4b17e3faf (dawn) +120/-58
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • src/dawn/native/IndirectDrawMetadata.cpp
  • src/dawn/native/IndirectDrawValidationEncoder.cpp
  • src/dawn/native/d3d11/CommandBufferD3D11.cpp
  • src/dawn/native/d3d11/CommandBufferD3D11.h
  • src/dawn/native/d3d12/CommandBufferD3D12.cpp
From c2b4b17e3faf48841676b75621cd420d91371de2 Mon Sep 17 00:00:00 2001
From: Brandon Jones <bajones@chromium.org>
Date: Tue, 05 May 2026 17:02:01 -0700
Subject: [PATCH] Fix OOB read due to RenderBundle indirect draw validation

Fixes a potential OOB VRAM read due to overlapping validation of
indirect draws in a render bundle. Does this by keeping a side
table of the altered buffers and offsets used when validating the
indirect draws rather than overwriting the base DrawIndirectCmd
stored in the render bundle.

Bug: 495489174
Change-Id: I2189c9970fc237abbecf2726f09a087e5ec85359
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/305635
Auto-Submit: Brandon Jones <bajones@chromium.org>
Commit-Queue: Brandon Jones <bajones@chromium.org>
Reviewed-by: Loko Kung <lokokung@google.com>
---

diff --git a/src/dawn/native/IndirectDrawMetadata.cpp b/src/dawn/native/IndirectDrawMetadata.cpp
index 339062a..3f1e01b 100644
--- a/src/dawn/native/IndirectDrawMetadata.cpp
+++ b/src/dawn/native/IndirectDrawMetadata.cpp
@@ -192,10 +192,11 @@
     validatedDraw.indirectBuffer = indirectBuffer;
     validatedDraw.indirectOffset = indirectOffset;
 
-    // TODO(crbug.com/495489174): Altering these values on the original draw command does not work
-    // for render bundles. A future change will remove these in favor of relying only on the
-    // mValidatedIndirectDraws array.
-    draw.cmd->indirectBuffer = indirectBuffer;
+    // TODO(crbug.com/495489174): Currently running without validation does not populate the
+    // validated indirect draws array. Setting the indirectBuffer of the command to null is used as
+    // a signifier that the validated array should be used. This should be replaced in the future
+    // with code that explicitly sets the validated indirect draw array in all cases.
+    draw.cmd->indirectBuffer = nullptr;
     draw.cmd->indirectOffset = indirectOffset;
 }
 
@@ -268,7 +269,6 @@
             config, IndexedIndirectBufferValidationInfo(indirectBuffer));
         it = result.first;
     }
-
     IndirectDraw draw{};
     draw.validatedDrawIndex = mNextIndirectDrawIndex++;
     draw.inputBufferOffset = indirectOffset;
diff --git a/src/dawn/native/IndirectDrawValidationEncoder.cpp b/src/dawn/native/IndirectDrawValidationEncoder.cpp
index bcf70b0..10fb2e1 100644
--- a/src/dawn/native/IndirectDrawValidationEncoder.cpp
+++ b/src/dawn/native/IndirectDrawValidationEncoder.cpp
@@ -880,6 +880,10 @@
             // Update the draw command to use the validated indirect buffer.
             // The drawCountBuffer doesn't need to be updated because if it exceeds the
             // maxDrawCount it will be clamped to maxDrawCount.
+            // TODO(crbug.com/495489174): This will suffer from the same problem with render bundles
+            // as we saw with regular draw{Indexed}Indirect calls. The same fix, storing the
+            // validated buffer and offset in the ValidatedIndirectDraw array and looking it up when
+            // the native call is made in the CommandBuffer backends.
             cmd->indirectBuffer = outputParamsBuffer.GetBuffer();
             cmd->indirectOffset = outputOffset;
 
diff --git a/src/dawn/native/d3d11/CommandBufferD3D11.cpp b/src/dawn/native/d3d11/CommandBufferD3D11.cpp
index afd32c8..1243418 100644
--- a/src/dawn/native/d3d11/CommandBufferD3D11.cpp
+++ b/src/dawn/native/d3d11/CommandBufferD3D11.cpp
@@ -339,7 +339,8 @@
                 }
                 DAWN_TRY(
                     LazyClearSyncScope(GetResourceUsages().renderPasses[nextRenderPassNumber]));
-                DAWN_TRY(ExecuteRenderPass(cmd, commandContext, &pipelineStateTracker));
+                DAWN_TRY(ExecuteRenderPass(cmd, commandContext, &pipelineStateTracker,
+                                           nextRenderPassNumber));
 
                 nextRenderPassNumber++;
                 break;
@@ -639,7 +640,11 @@
 MaybeError CommandBuffer::ExecuteRenderPass(
     BeginRenderPassCmd* renderPass,
     const ScopedSwapStateCommandRecordingContext* commandContext,
-    PipelineStateTracker* pipelineStateTracker) {
+    PipelineStateTracker* pipelineStateTracker,
+    PassIndex renderPassIndex) {
+    const IndirectDrawMetadata& metadata = GetIndirectDrawMetadata()[renderPassIndex];
+    IndirectDrawIndex indirectDrawIndex{0};
+
     // For the color attachments that the clear_color_with_draw workaround has applied, we can skip
     // the clear for them.
     for (auto i : ClearWithDrawHelper::GetAppliedColorAttachments(GetDevice(), renderPass)) {
@@ -773,7 +778,10 @@
             case Command::DrawIndirect: {
                 DrawIndirectCmd* draw = iter->NextCommand<DrawIndirectCmd>();
 
-                auto* indirectBuffer = ToGPUUsableBuffer(draw->indirectBuffer.Get());
+                IndirectDrawMetadata::ValidatedIndirectDraw validatedDraw =
+                    metadata.GetValidatedIndirectDraw(draw, indirectDrawIndex++);
+
+                auto* indirectBuffer = ToGPUUsableBuffer(validatedDraw.indirectBuffer.Get());
                 DAWN_ASSERT(indirectBuffer != nullptr);
 
                 DAWN_TRY(bindGroupTracker.Apply());
@@ -784,7 +792,7 @@
                     // Copy StartVertexLocation and StartInstanceLocation into the uniform buffer
                     // for built-in variables.
                     uint64_t offset =
-                        draw->indirectOffset +
+                        validatedDraw.indirectOffset +
                         offsetof(D3D11_DRAW_INSTANCED_INDIRECT_ARGS, StartVertexLocation);
                     DAWN_TRY(Buffer::Copy(commandContext, indirectBuffer, offset,
                                           sizeof(uint32_t) * 2,
@@ -796,7 +804,7 @@
                 DAWN_TRY_ASSIGN(d3dBuffer,
                                 indirectBuffer->GetD3D11NonConstantBuffer(commandContext));
                 commandContext->GetD3D11DeviceContext3()->DrawInstancedIndirect(
-                    d3dBuffer, draw->indirectOffset);
+                    d3dBuffer, validatedDraw.indirectOffset);
 
                 break;
             }
@@ -804,7 +812,10 @@
             case Command::DrawIndexedIndirect: {
                 DrawIndexedIndirectCmd* draw = iter->NextCommand<DrawIndexedIndirectCmd>();
 
-                auto* indirectBuffer = ToGPUUsableBuffer(draw->indirectBuffer.Get());
+                IndirectDrawMetadata::ValidatedIndirectDraw validatedDraw =
+                    metadata.GetValidatedIndirectDraw(draw, indirectDrawIndex++);
+
+                auto* indirectBuffer = ToGPUUsableBuffer(validatedDraw.indirectBuffer.Get());
                 DAWN_ASSERT(indirectBuffer != nullptr);
 
                 DAWN_TRY(bindGroupTracker.Apply());
@@ -815,7 +826,7 @@
                     // Copy StartVertexLocation and StartInstanceLocation into the uniform buffer
                     // for built-in variables.
                     uint64_t offset =
-                        draw->indirectOffset +
+                        validatedDraw.indirectOffset +
                         offsetof(D3D11_DRAW_INDEXED_INSTANCED_INDIRECT_ARGS, BaseVertexLocation);
                     DAWN_TRY(Buffer::Copy(commandContext, indirectBuffer, offset,
                                           sizeof(uint32_t) * 2,
@@ -827,7 +838,7 @@
                 DAWN_TRY_ASSIGN(d3dBuffer,
                                 indirectBuffer->GetD3D11NonConstantBuffer(commandContext));
                 commandContext->GetD3D11DeviceContext3()->DrawIndexedInstancedIndirect(
-                    d3dBuffer, draw->indirectOffset);
+                    d3dBuffer, validatedDraw.indirectOffset);
 
                 break;
             }
diff --git a/src/dawn/native/d3d11/CommandBufferD3D11.h b/src/dawn/native/d3d11/CommandBufferD3D11.h
index 5d5048c..262c2bc 100644
--- a/src/dawn/native/d3d11/CommandBufferD3D11.h
+++ b/src/dawn/native/d3d11/CommandBufferD3D11.h
@@ -56,7 +56,8 @@
                                   PipelineStateTracker* pipelineStateTracker);
     MaybeError ExecuteRenderPass(BeginRenderPassCmd* renderPass,
                                  const ScopedSwapStateCommandRecordingContext* commandContext,
-                                 PipelineStateTracker* pipelineStateTracker);
+                                 PipelineStateTracker* pipelineStateTracker,
+                                 PassIndex renderPassIndex);
     void HandleDebugCommands(const ScopedSwapStateCommandRecordingContext* commandContext,
                              CommandIterator* iter,
                              Command command);
diff --git a/src/dawn/native/d3d12/CommandBufferD3D12.cpp b/src/dawn/native/d3d12/CommandBufferD3D12.cpp
index d496c22..4d8d4a4 100644
--- a/src/dawn/native/d3d12/CommandBufferD3D12.cpp
+++ b/src/dawn/native/d3d12/CommandBufferD3D12.cpp
@@ -1026,7 +1026,7 @@
 
                 DAWN_TRY(RecordRenderPass(commandContext,
                                           descriptorHeapState.GetGraphicsBindingTracker(),
-                                          beginRenderPassCmd, passHasUAV));
+                                          beginRenderPassCmd, nextRenderPassNumber, passHasUAV));
 
                 nextRenderPassNumber++;
                 break;
@@ -1734,10 +1734,14 @@
 MaybeError CommandBuffer::RecordRenderPass(CommandRecordingContext* commandContext,
                                            BindGroupStateTracker<RenderPipeline>* bindingTracker,
                                            BeginRenderPassCmd* renderPass,
+                                           PassIndex renderPassIndex,
                                            const bool passHasUAV) {
     Device* device = ToBackend(GetDevice());
     const bool useRenderPass = device->IsToggleEnabled(Toggle::UseD3D12RenderPass);
 
+    const IndirectDrawMetadata& metadata = GetIndirectDrawMetadata()[renderPassIndex];
+    IndirectDrawIndex indirectDrawIndex{0};
+
     // renderPassBuilder must be scoped to RecordRenderPass because any underlying
     // D3D12_RENDER_PASS_ENDING_ACCESS_RESOLVE_SUBRESOURCE_PARAMETERS structs must remain
     // valid until after EndRenderPass() has been called.
@@ -1823,11 +1827,16 @@
                 vertexBufferTracker.Apply(commandList, lastPipeline);
                 immediates.Apply(commandContext);
 
-                Buffer* buffer = ToBackend(draw->indirectBuffer.Get());
+                IndirectDrawMetadata::ValidatedIndirectDraw validatedDraw =
+                    metadata.GetValidatedIndirectDraw(draw, indirectDrawIndex++);
+
+                Buffer* indirectBuffer = ToBackend(validatedDraw.indirectBuffer.Get());
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/dawn/tests/end2end/RenderBundleTests.cpp b/src/dawn/tests/end2end/RenderBundleTests.cpp
index a954888..e834d28 100644
--- a/src/dawn/tests/end2end/RenderBundleTests.cpp
+++ b/src/dawn/tests/end2end/RenderBundleTests.cpp
@@ -341,9 +341,6 @@
 // a specific scenario where the a render bundle with the indirect draw was executed multiple times
 // in a single encoder. Test based on a POC produced for that issue.
 TEST_P(RenderBundleIndirectValidationTest, RepeatedIndirectDrawValidation) {
-    // Test currently fails on all backends
-    DAWN_TEST_UNSUPPORTED_IF(true);
-
     const uint32_t OOB_COUNT = 100000;
 
     // Render Pass
Loading diff…

Original Bug Report

reported by rj...@google.com

OOB VRAM access via RenderBundle indirect draw command mutation

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: Dawn’s indirect draw validation pipeline incorrectly mutates persistent command objects owned by RenderBundles. If a bundle is executed in multiple render passes, later passes overwrite the validation buffer pointers used by earlier passes, leading to the GPU reading unvalidated or uninitialized indirect draw parameters. This bypasses WebGPU’s security clamping and can lead to out-of-bounds VRAM access.

Affected files:

  • third_party/dawn/src/dawn/native/IndirectDrawValidationEncoder.cpp
  • third_party/dawn/src/dawn/native/IndirectDrawMetadata.h
  • third_party/dawn/src/dawn/native/ScratchBuffer.cpp
  • third_party/dawn/src/dawn/native/RenderBundle.cpp

Estimated timestamp from git blame: 2024-08-16

Summary

A logic flaw exists in Dawn’s indirect draw validation pipeline where validation-time code mutates persistent command objects owned by RenderBundles. When an indirect draw is processed, Dawn rewrites the command’s indirectBuffer and indirectOffset to point into a device-level scratch buffer that holds compute-shader-validated (clamped) parameters. However, for RenderBundles, these mutations persist across render passes. This results in state corruption that allows bypassing WebGPU’s security clamping.

Technical Details

1. Persistent Mutation of RenderBundle Commands

In IndirectDrawValidationEncoder.cpp, the validation logic rewrites the indirect draw command directly in the command stream:

// IndirectDrawValidationEncoder.cpp
draw.cmd->indirectBuffer = outputParamsBuffer.GetBuffer();
draw.cmd->indirectOffset = outputParamsOffset;

The draw.cmd pointer is a raw_ptr that points into the command stream baked into a RenderBundle (RenderBundleBase::mCommands). Unlike a RenderPassEncoder where the command stream is transient, a RenderBundle’s command stream persists until the bundle is destroyed.

2. Cross-Draw Parameter Confusion and Uninitialized VRAM Access

When a CommandEncoder encodes multiple render passes that use the same RenderBundle, the following occurs:

  1. Pass 1: Validation code mutates the bundle’s DrawIndirectCmd to point to the current scratch buffer at Offset A. Pass 1’s compute shader is recorded to write clamped parameters to this location.
  2. Pass 2: Validation code runs again. If Pass 2 requires more validation space, ScratchBuffer::EnsureCapacity reallocates the scratch buffer. It explicitly calls mBuffer->SetInitialized(true), suppressing Dawn’s lazy-clearing mechanism. The new scratch buffer contains uninitialized VRAM.
  3. Pass 2 Mutation: Validation code mutates the same DrawIndirectCmd in the bundle to point to the new scratch buffer at Offset B.

When the CommandBuffer is submitted to the GPU:

  • Pass 1’s compute shader writes clamped parameters to the old scratch buffer.
  • Pass 1’s render pass executes the bundle. However, the bundle’s DrawIndirectCmd now points to the new scratch buffer at Offset B.
  • The GPU reads uninitialized, unvalidated VRAM from the new scratch buffer and uses it as the vertexCount, instanceCount, etc.

Impact

An attacker can trigger out-of-bounds GPU memory access from the sandboxed GPU process. By bypassing WebGPU’s indirect draw security clamping, the attacker can cause the GPU to read or write arbitrary VRAM, potentially leaking cross-origin data or triggering driver-level vulnerabilities.

Suggested Steps to Trigger

(Note: These are potential steps as our setup cannot run code)

  1. Create a RenderBundle containing an indirectDraw command.
  2. Create a CommandEncoder.
  3. Begin a RenderPassEncoder (Pass 1). Call executeBundles() with the created bundle. End Pass 1.
  4. Begin a second RenderPassEncoder (Pass 2). Encode additional indirect draws to force the scratch buffer to grow and reallocate. Call executeBundles() with the same bundle. End Pass 2.
  5. Call finish() on the CommandEncoder and submit() the resulting command buffer to the queue.
  6. Pass 1 will execute using the uninitialized scratch buffer allocated during Pass 2’s validation, using unvalidated parameters for its draw call.

Suggested Fix

Do not mutate DrawIndirectCmd or MultiDrawIndirectCmd objects within a RenderBundle’s persistent command stream. Instead, maintain a side-table in the per-pass state that maps bundle commands to their validated scratch buffer and offset, and have the backend apply these validated parameters dynamically during bundle execution. Alternatively, RenderBundles may need their own dedicated, immutable validation buffers that are updated by the compute shader prior to execution.

Evaluated with Chrome root at commit: 9760e6c70cd33a320713361f17c6dcca85648c0f


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker