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
Tracker499908918
Fix commit96214998aff4 (dawn) +77/-16
CISA KEVNot listed
CreditedAnonymous
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
src/dawn/native/vulkan/DeviceVk.cpp
modified
MultisampledRenderingWithEmptyPassWorkaroundTest
src/dawn/tests/end2end/MultisampledRenderingTests.cpp
modified
TEST_P
src/dawn/tests/end2end/MultisampledRenderingTests.cpp
modified
MultisampledRenderingWithTransientAttachmentTest
src/dawn/tests/end2end/MultisampledRenderingTests.cpp
modified

Files Changed

  • src/dawn/native/vulkan/CommandBufferVk.cpp
  • src/dawn/native/vulkan/DeviceVk.cpp
  • src/dawn/native/vulkan/DeviceVk.h
  • src/dawn/tests/end2end/MultisampledRenderingTests.cpp
From 96214998aff466823040d1ed4418b578ad0225e7 Mon Sep 17 00:00:00 2001
From: Brandon Jones <bajones@chromium.org>
Date: Wed, 22 Apr 2026 14:52:58 -0700
Subject: [PATCH] Vulkan: Cleanup EmptyPassQuerySet error handling

Cleans up the error handling when creating an empy pass query set
and adds a simple test to ensure that this branch is exercised.

Bug: 499908918
Change-Id: Ibc07005f41ee19604c5014be63dfdaea0e5ec8af
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/304177
Commit-Queue: Brandon Jones <bajones@chromium.org>
Reviewed-by: Corentin Wallez <cwallez@chromium.org>
Auto-Submit: Brandon Jones <bajones@chromium.org>
---

diff --git a/src/dawn/native/vulkan/CommandBufferVk.cpp b/src/dawn/native/vulkan/CommandBufferVk.cpp
index bd0de87..f726da9 100644
--- a/src/dawn/native/vulkan/CommandBufferVk.cpp
+++ b/src/dawn/native/vulkan/CommandBufferVk.cpp
@@ -1091,6 +1091,10 @@
             ResetUsedQuerySetsOnRenderPass(device, recordingContext->commandBuffer,
                                            usages.querySets[i], usages.queryAvailabilities[i]);
         }
+
+        if (device->IsToggleEnabled(Toggle::VulkanAddWorkToEmptyResolvePass)) {
+            DAWN_TRY(device->PrepareEmptyPassQuerySet(recordingContext));
+        }
         return {};
     };
 
@@ -1939,16 +1943,9 @@
                 // a driver bug that fails to resolve render targets in empty passes.
                 if (workCommandCount == 0 &&
                     device->IsToggleEnabled(Toggle::VulkanAddWorkToEmptyResolvePass)) {
-                    QuerySetBase* querySet = device->GetEmptyPassQuerySet();
-
-                    // Ensure that the query was allocated. In the unlikely event that it wasn't
-                    // (probably due to an OOM) empty render passes can't be safely completed using
-                    // this workaround.
-                    DAWN_INTERNAL_ERROR_IF(querySet->IsError(),
-                                           "Unable to allocate internal empty pass query set.");
-
-                    device->fn.CmdBeginQuery(commands, ToBackend(querySet)->GetHandle(), 0, 0);
-                    device->fn.CmdEndQuery(commands, ToBackend(querySet)->GetHandle(), 0);
+                    Ref<QuerySetBase> emptyQuerySet = device->UseEmptyPassQuerySet();
+                    device->fn.CmdBeginQuery(commands, ToBackend(emptyQuerySet)->GetHandle(), 0, 0);
+                    device->fn.CmdEndQuery(commands, ToBackend(emptyQuerySet)->GetHandle(), 0);
                 }
 
                 RecordEndRenderPass(recordingContext, device);
diff --git a/src/dawn/native/vulkan/DeviceVk.cpp b/src/dawn/native/vulkan/DeviceVk.cpp
index 29b60ed..1fe3dbc 100644
--- a/src/dawn/native/vulkan/DeviceVk.cpp
+++ b/src/dawn/native/vulkan/DeviceVk.cpp
@@ -1222,17 +1222,36 @@
     return true;
 }
 
-// Gets or creates an occlusion Query object for use with Toggle::VulkanAddWorkToEmptyResolvePass.
-QuerySetBase* Device::GetEmptyPassQuerySet() {
+// Prepares the Empty Pass QuerySet, creating and resetting it if needed.
+MaybeError Device::PrepareEmptyPassQuerySet(CommandRecordingContext* recordingContext) {
     DAWN_ASSERT(IsToggleEnabled(Toggle::VulkanAddWorkToEmptyResolvePass));
-
     if (!mEmptyPassQuerySet) {
         QuerySetDescriptor descriptor;
         descriptor.type = wgpu::QueryType::Occlusion;
         descriptor.count = 1;
-        mEmptyPassQuerySet = APICreateQuerySet(&descriptor);
+        DAWN_TRY_ASSIGN(mEmptyPassQuerySet, CreateQuerySet(&descriptor));
+        mEmptyPassQuerySetNeedsReset = true;
     }
-    return mEmptyPassQuerySet.Get();
+
+    if (mEmptyPassQuerySetNeedsReset) {
+        fn.CmdResetQueryPool(recordingContext->commandBuffer,
+                             ToBackend(mEmptyPassQuerySet)->GetHandle(), 0, 1);
+        mEmptyPassQuerySetNeedsReset = false;
+    }
+
+    return {};
+}
+
+// Gets the occlusion Query object for use with Toggle::VulkanAddWorkToEmptyResolvePass.
+// Must call PrepareEmptyPassQuerySet outside of recording a render pass prior to calling this every
+// time.
+Ref<QuerySetBase> Device::UseEmptyPassQuerySet() {
+    DAWN_ASSERT(IsToggleEnabled(Toggle::VulkanAddWorkToEmptyResolvePass));
+    DAWN_ASSERT(mEmptyPassQuerySet);
+    DAWN_ASSERT(!mEmptyPassQuerySetNeedsReset);
+
+    mEmptyPassQuerySetNeedsReset = true;
+    return mEmptyPassQuerySet;
 }
 
 }  // namespace dawn::native::vulkan
diff --git a/src/dawn/native/vulkan/DeviceVk.h b/src/dawn/native/vulkan/DeviceVk.h
index bc6c8d3..5c74441 100644
--- a/src/dawn/native/vulkan/DeviceVk.h
+++ b/src/dawn/native/vulkan/DeviceVk.h
@@ -151,7 +151,8 @@
                                                       size_t bufferSize) const override;
     bool NeedsStaticSamplerForExternalTexture() const override;
 
-    QuerySetBase* GetEmptyPassQuerySet();
+    MaybeError PrepareEmptyPassQuerySet(CommandRecordingContext* recordingContext);
+    Ref<QuerySetBase> UseEmptyPassQuerySet();
 
     VulkanRenderPassType GetRenderPassType() { return mRenderPassType; }
 
@@ -242,6 +243,7 @@
     Ref<PipelineCache> mMonolithicPipelineCache;
 
     Ref<QuerySetBase> mEmptyPassQuerySet;
+    bool mEmptyPassQuerySetNeedsReset = true;
     std::atomic<uint64_t> mNextTextureViewId = 1;
 
     VulkanRenderPassType mRenderPassType = VulkanRenderPassType::CreateRenderPass;
diff --git a/src/dawn/tests/end2end/MultisampledRenderingTests.cpp b/src/dawn/tests/end2end/MultisampledRenderingTests.cpp
index 32a52a8..19dcd1b 100644
--- a/src/dawn/tests/end2end/MultisampledRenderingTests.cpp
+++ b/src/dawn/tests/end2end/MultisampledRenderingTests.cpp
@@ -1382,6 +1382,46 @@
     VerifyResolveTarget(kGreen, mResolveTexture, 0, 0, kMSAACoverage, kGreenX, kGreenY);
 }
 
+class MultisampledRenderingWithEmptyPassWorkaroundTest : public MultisampledRenderingTest {};
+
+TEST_P(MultisampledRenderingWithEmptyPassWorkaroundTest, ResolveOneMultisampledTextureTwice) {
+    constexpr bool kTestDepth = false;
+    wgpu::CommandEncoder commandEncoder = device.CreateCommandEncoder();
+
+    wgpu::Texture resolveTexture2 = CreateTextureForRenderAttachment(kColorFormat, 1);
+
+    // In first render pass clear the color view to green triangle resolve.
+    {
+        utils::ComboRenderPassDescriptor renderPass = CreateComboRenderPassDescriptorForTest(
+            {mMultisampledColorView}, {mResolveView}, wgpu::LoadOp::Clear, wgpu::LoadOp::Clear,
+            kTestDepth);
+        renderPass.cColorAttachments[0].clearValue = kGreen;
+
+        wgpu::RenderPassEncoder renderPassEncoder = commandEncoder.BeginRenderPass(&renderPass);
+        // No work done during the pass
+        renderPassEncoder.End();
+    }
+
+    // In second render pass load the previously cleared color view and resolve.
+    {
+        wgpu::TextureView resolveView2 = resolveTexture2.CreateView();
+        utils::ComboRenderPassDescriptor renderPass = CreateComboRenderPassDescriptorForTest(
+            {mMultisampledColorView}, {resolveView2}, wgpu::LoadOp::Load, wgpu::LoadOp::Load,
+            kTestDepth);
+
+        wgpu::RenderPassEncoder renderPassEncoder = commandEncoder.BeginRenderPass(&renderPass);
+        // No work done during the pass
+        renderPassEncoder.End();
+    }
+
+    wgpu::CommandBuffer commandBuffer = commandEncoder.Finish();
+    queue.Submit(1, &commandBuffer);
+
+    constexpr float kMSAACoverage = 1.0f;
+    VerifyResolveTarget(kGreen, mResolveTexture, 0, 0, kMSAACoverage);
+    VerifyResolveTarget(kGreen, resolveTexture2, 0, 0, kMSAACoverage);
+}
+
 class MultisampledRenderingWithTransientAttachmentTest : public MultisampledRenderingTest {};
 
 // Test using one multisampled color transient attachment with resolve target can render correctly.
@@ -2888,6 +2928,9 @@
                       MetalBackend({"always_resolve_into_zero_level_and_layer",
                                     "emulate_store_and_msaa_resolve"}));
 
+DAWN_INSTANTIATE_TEST(MultisampledRenderingWithEmptyPassWorkaroundTest,
+                      VulkanBackend({"vulkan_add_work_to_empty_resolve_pass"}));
+
 DAWN_INSTANTIATE_TEST(MultisampledRenderingWithTransientAttachmentTest,
                       D3D11Backend(),
                       D3D12Backend(),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/dawn/tests/end2end/MultisampledRenderingTests.cpp b/src/dawn/tests/end2end/MultisampledRenderingTests.cpp
index 32a52a8..19dcd1b 100644
--- a/src/dawn/tests/end2end/MultisampledRenderingTests.cpp
+++ b/src/dawn/tests/end2end/MultisampledRenderingTests.cpp
@@ -1382,6 +1382,46 @@
     VerifyResolveTarget(kGreen, mResolveTexture, 0, 0, kMSAACoverage, kGreenX, kGreenY);
 }
 
+class MultisampledRenderingWithEmptyPassWorkaroundTest : public MultisampledRenderingTest {};
+
+TEST_P(MultisampledRenderingWithEmptyPassWorkaroundTest, ResolveOneMultisampledTextureTwice) {
+    constexpr bool kTestDepth = false;
+    wgpu::CommandEncoder commandEncoder = device.CreateCommandEncoder();
+
+    wgpu::Texture resolveTexture2 = CreateTextureForRenderAttachment(kColorFormat, 1);
+
+    // In first render pass clear the color view to green triangle resolve.
+    {
+        utils::ComboRenderPassDescriptor renderPass = CreateComboRenderPassDescriptorForTest(
+            {mMultisampledColorView}, {mResolveView}, wgpu::LoadOp::Clear, wgpu::LoadOp::Clear,
+            kTestDepth);
+        renderPass.cColorAttachments[0].clearValue = kGreen;
+
+        wgpu::RenderPassEncoder renderPassEncoder = commandEncoder.BeginRenderPass(&renderPass);
+        // No work done during the pass
+        renderPassEncoder.End();
+    }
+
+    // In second render pass load the previously cleared color view and resolve.
+    {
+        wgpu::TextureView resolveView2 = resolveTexture2.CreateView();
+        utils::ComboRenderPassDescriptor renderPass = CreateComboRenderPassDescriptorForTest(
+            {mMultisampledColorView}, {resolveView2}, wgpu::LoadOp::Load, wgpu::LoadOp::Load,
+            kTestDepth);
+
+        wgpu::RenderPassEncoder renderPassEncoder = commandEncoder.BeginRenderPass(&renderPass);
+        // No work done during the pass
+        renderPassEncoder.End();
+    }
+
+    wgpu::CommandBuffer commandBuffer = commandEncoder.Finish();
+    queue.Submit(1, &commandBuffer);
+
+    constexpr float kMSAACoverage = 1.0f;
+    VerifyResolveTarget(kGreen, mResolveTexture, 0, 0, kMSAACoverage);
+    VerifyResolveTarget(kGreen, resolveTexture2, 0, 0, kMSAACoverage);
+}
+
 class MultisampledRenderingWithTransientAttachmentTest : public MultisampledRenderingTest {};
 
 // Test using one multisampled color transient attachment with resolve target can render correctly.
@@ -2888,6 +2928,9 @@
                       MetalBackend({"always_resolve_into_zero_level_and_layer",
                                     "emulate_store_and_msaa_resolve"}));
 
+DAWN_INSTANTIATE_TEST(MultisampledRenderingWithEmptyPassWorkaroundTest,
+                      VulkanBackend({"vulkan_add_work_to_empty_resolve_pass"}));
+
 DAWN_INSTANTIATE_TEST(MultisampledRenderingWithTransientAttachmentTest,
                       D3D11Backend(),
                       D3D12Backend(),
Loading diff…

Original Bug Report

reported by ki...@gmail.com

heap-buffer-overflow in Dawn Device::GetEmptyPassQuerySet

VULNERABILITY DETAILS

A heap OOB access vulnerability exists in the WebGPU Dawn Vulkan backend when handling a GPU device OOM condition during an empty render pass.

The issue stems from Device::GetEmptyPassQuerySet() which dynamically creates an occlusion QuerySet to inject dummy work into empty render passes [0].

QuerySetBase* Device::GetEmptyPassQuerySet() {
    DAWN_ASSERT(IsToggleEnabled(Toggle::VulkanAddWorkToEmptyResolvePass));

    if (!mEmptyPassQuerySet) {
        QuerySetDescriptor descriptor;
        descriptor.type = wgpu::QueryType::Occlusion;
        descriptor.count = 1;
        mEmptyPassQuerySet = APICreateQuerySet(&descriptor); // [0]
    }
    return mEmptyPassQuerySet.Get();
}

When the underlying Vulkan driver fails to create the query pool and returns an error such as VK_ERROR_OUT_OF_DEVICE_MEMORY or VK_ERROR_OUT_OF_HOST_MEMORY, Dawn catches this via its CheckVkOOMThenSuccess fault-tolerance mechanism and gracefully downgrades the allocation by silently emitting an ErrorQuerySet object instead of a full vulkan::QuerySet.

ErrorQuerySet only occupies the memory footprint of its base class QuerySetBase and completely lacks backend-specific variables, including the VkQueryPool mHandle.

However, the subsequent caller in CommandBuffer::RecordRenderPass does not verify if the returned QuerySetBase is actually an ErrorQuerySet (e.g., via querySet->IsError()). It immediately up-casts the memory instance directly to the backend representation via ToBackend and begins the query loop using the GetHandle() method [1] [2].

if (workCommandCount == 0 &&
    device->IsToggleEnabled(Toggle::VulkanAddWorkToEmptyResolvePass)) {
    QuerySetBase* querySet = device->GetEmptyPassQuerySet();
    device->fn.CmdBeginQuery(commands, ToBackend(querySet)->GetHandle(), 0, 0); // [1]
    device->fn.CmdEndQuery(commands, ToBackend(querySet)->GetHandle(), 0); // [2]
}

This blind up-casting to vulkan::QuerySet results in a Type Confusion. When GetHandle() attempts to load mHandle [3], it reads past the truncated allocation size of the ErrorQuerySet object, triggering a Heap OOB access memory corruption.

VkQueryPool QuerySet::GetHandle() const {
    return mHandle; // [3]
}

[0] https://source.chromium.org/chromium/chromium/src/+/main:third_party/dawn/src/dawn/native/vulkan/DeviceVk.cpp;drc=cde13c6d417485b7cf3edadf6edc2605faf13e22;l=1233

[1] https://source.chromium.org/chromium/chromium/src/+/main:third_party/dawn/src/dawn/native/vulkan/CommandBufferVk.cpp;drc=cde13c6d417485b7cf3edadf6edc2605faf13e22;l=1929

[2] https://source.chromium.org/chromium/chromium/src/+/main:third_party/dawn/src/dawn/native/vulkan/CommandBufferVk.cpp;drc=cde13c6d417485b7cf3edadf6edc2605faf13e22;l=1930

[3] https://source.chromium.org/chromium/chromium/src/+/main:third_party/dawn/src/dawn/native/vulkan/QuerySetVk.cpp;drc=cde13c6d417485b7cf3edadf6edc2605faf13e22;l=80

BISECTION

Introduced by Dawn upstream commit [0] which added the VulkanAddWorkToEmptyResolvePass toggle and its workaround mechanism involving GetEmptyPassQuerySet().

This regression was rolled into Chromium in commit [1].

[0] https://dawn.googlesource.com/dawn/+/a76b32ec053451a479ba6989ba1d020a302580a4 (Add VulkanAddWorkToEmptyResolvePass Toggle)

[1] https://chromium.googlesource.com/chromium/src/+/7bc869b0e849bed0329997b711b9be73ffede928 (Roll Dawn from 1bdb72cd216e to d074fa41df60 (17 revisions))

VERSION

Chrome Version: HEAD

Operating System: Linux

REPRODUCTION CASE

To reliably simulate a GPU device OOM and prove the underlying vulnerability in Dawn’s error handling, inject_oom.patch is provided. It minimally modifies SwiftShader to force vkCreateQueryPool to return VK_ERROR_OUT_OF_HOST_MEMORY. The vulnerability itself resides within Dawn and is independent of the Vulkan backend driver.

  1. Apply inject_oom.patch to third_party/swiftshader, then build Chromium with ASan.
  2. Host the poc.html on an HTTP server.
  3. Run Chrome against the PoC.
$ python3 -m http.server
$ ./out/asan/chrome --enable-unsafe-webgpu --enable-dawn-features=vulkan_add_work_to_empty_resolve_pass "http://localhost:8000/poc.html"

CRASH INFORMATION

Type of crash: GPU process

Crash log: see the attached asan.txt ASan trace.

CREDIT INFORMATION

Reporter credit: Anonymous

View on issue tracker