High watch CVSS 6.5 chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized Use in Dawn
DescriptionUninitialized Use in Dawn
ComponentDawn
Bug ClassUninitialized Memory
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker500087204
Fix commitc7382d325da4 (dawn) +237/-15
CWECWE-457 (Use of uninitialized)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-06 (per vendor advisory)
Fix landed2026-04-09 (public git)
Disclosed2026-05-05
Patch gap26 days — fix public in source before the advisory

Background

Lazy clearing (`LazyClearResourceOnFirstUse`)
Dawn’s optimization that defers zero-initialization of a texture until its first use, tracking per-subresource whether content has been initialized.
Subresource / `SubresourceRange`
the unit of initialization tracking (mip level × array layer); for a 3D texture a mip level spans multiple depth slices but is treated as one subresource range by the view.
`depthSlice`
the single z-slice of a 3D texture that a render pass color attachment actually renders into, even though the view’s range covers the whole mip level.
`IsSubresourceContentInitialized` / `SetIsSubresourceContentInitialized`
the query/setter Dawn uses to decide whether a lazy clear is still needed for a given range.

Root Cause Analysis

When a render pass rendered into a single depthSlice of a 3D texture, Dawn marked the entire mip level’s SubresourceRange as initialized even though only that one slice had actually been written. As the commit states, “only a single slice had been initialized,” yet the whole range was recorded as initialized, so later lazy-clear decisions for the other slices were skipped and those slices retained whatever uninitialized GPU memory they contained. A subsequent read of the texture (via copy-to-buffer or sampling in a shader, per the two regression tests) therefore returned stale, uninitialized contents rather than zeros, an uninitialized-memory use.

The fix threads a LazyClearTexture3DHelper callback into LazyClearRenderPassAttachments and, for 3D textures whose range is not yet initialized, calls clearTexture3D(texture, range) to clear the full mip level before the render pass begins. Correspondingly, the D3D11 backend’s workaround was changed to not call SetIsSubresourceContentInitialized(true, range) for 3D textures, so it no longer prematurely marks the whole level initialized and instead lets LazyClearRenderPassAttachments initialize the remaining slices.

Key insight
The single mistake was treating “rendered one depth slice” as “whole mip level initialized,” collapsing per-slice state into one range flag; the fix explicitly clears all slices of an uninitialized 3D mip level before the pass (and stops the D3D11 path from wrongly setting the initialized flag).

Attack Path

  1. Create a 3D texture The page allocates a 3D texture (via WebGPU/Dawn) with multiple depth slices and a render-attachment usage, leaving it uninitialized.
  2. Render to one slice It begins a render pass targeting a single depthSlice; pre-patch this marks the entire mip level’s range as initialized while only that slice is written.
  3. Skip lazy clears for the rest Because the range now reads as initialized, Dawn omits the lazy clear for the other, never-written slices, leaving their prior GPU-memory contents in place.
  4. Read back the uninitialized slices The page reads the other slices via CopyTextureToBuffer or by sampling them in a shader (the two regression tests), recovering uninitialized memory contents.

Impact Assessment

An attacker-controlled web page using WebGPU can read uninitialized GPU memory from the un-rendered slices of a 3D texture, an information-disclosure primitive that may leak residual data from prior allocations in the GPU/renderer process. Preconditions are only the ability to create a 3D texture and issue a render pass to one slice with lazy clearing enabled (the default LazyClearResourceOnFirstUse toggle). The vendor advisory rates it High; no in-the-wild exploitation is stated, and any use of the leaked data beyond disclosure is (inferred).

Changed Functions

FunctionChangeNotes
if
src/dawn/native/CommandBuffer.cpp
modified

Files Changed

  • src/dawn/native/CommandBuffer.cpp
  • src/dawn/native/CommandBuffer.h
  • src/dawn/native/d3d11/CommandBufferD3D11.cpp
  • src/dawn/native/d3d12/CommandBufferD3D12.cpp
  • src/dawn/native/metal/CommandBufferMTL.mm
  • src/dawn/native/opengl/CommandBufferGL.cpp
  • src/dawn/native/vulkan/CommandBufferVk.cpp
  • src/dawn/native/webgpu/CommandBufferWGPU.cpp

Audit Directions

  • Coarse initialization tracking
    Look for places that set a whole SubresourceRange as initialized after writing only part of it (single depth slice, single array layer, partial rect); the tracked granularity must match what was actually written.
  • 3D vs 2D/array divergence
    Audit backend workarounds that treat a texture view’s range as the write extent; 3D views cover all depth slices while the write may hit one, so initialized-state updates need a dimension-specific branch.
  • Lazy-init read paths
    Fuzz create-render-one-slice-then-readback sequences (copy and shader-sample) across all backends to catch subresources that read back non-zero before any write, indicating a skipped lazy clear.
From c7382d325da4c27d767d1d0a08007bb40f1fb940 Mon Sep 17 00:00:00 2001
From: Gregg Tavares <gman@chromium.org>
Date: Thu, 09 Apr 2026 12:22:20 -0700
Subject: [PATCH] Clear 3D textures when rendering to a slice.

Dawn lazily clears resources. If you rendered to
a single slice of a 3D texture, Dawn would mark the entire
mip level as initialized when really, only a single slice
had been initialized.

The quick fix is to clear the entire mip level before
rendering. A future optimization would be to track which
slices are uninitialized and only initialize them just before
they are actually read from.

Bug: 500087204
Fixes: 500087204
Change-Id: I460e1f41db1cc36423b78f5f652573559bca415b
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/301457
Reviewed-by: Brandon Jones <bajones@chromium.org>
Auto-Submit: Gregg Tavares <gman@chromium.org>
Reviewed-by: Loko Kung <lokokung@google.com>
Commit-Queue: Gregg Tavares <gman@chromium.org>
---

diff --git a/src/dawn/native/CommandBuffer.cpp b/src/dawn/native/CommandBuffer.cpp
index edc1d02..b7b346d 100644
--- a/src/dawn/native/CommandBuffer.cpp
+++ b/src/dawn/native/CommandBuffer.cpp
@@ -161,9 +161,11 @@
     DAWN_UNREACHABLE();
 }
 
-void LazyClearRenderPassAttachments(DeviceBase* device, BeginRenderPassCmd* renderPass) {
+MaybeError LazyClearRenderPassAttachments(DeviceBase* device,
+                                          BeginRenderPassCmd* renderPass,
+                                          LazyClearTexture3DHelper clearTexture3D) {
     if (!device->IsToggleEnabled(Toggle::LazyClearResourceOnFirstUse)) {
-        return;
+        return {};
     }
 
     for (auto i : renderPass->attachmentState->GetColorAttachmentsMask()) {
@@ -174,14 +176,24 @@
         DAWN_ASSERT(view->GetLayerCount() == 1);
         DAWN_ASSERT(view->GetLevelCount() == 1);
         SubresourceRange range = view->GetSubresourceRange();
+        TextureBase* texture = view->GetTexture();
 
         // If the loadOp is Load, but the subresource is not initialized, use Clear instead.
         if (attachmentInfo.loadOp == wgpu::LoadOp::Load &&
-            !view->GetTexture()->IsSubresourceContentInitialized(range)) {
+            !texture->IsSubresourceContentInitialized(range)) {
             attachmentInfo.loadOp = wgpu::LoadOp::Clear;
             attachmentInfo.clearColor = {0.f, 0.f, 0.f, 0.f};
         }
 
+        // For 3D textures, rendering to a single depthSlice marks the entire mip level as
+        // initialized. If it wasn't already initialized, we must clear the other slices
+        // before the render pass starts.
+        // TODO(500975625): Optimize this.
+        if (texture->GetDimension() == wgpu::TextureDimension::e3D &&
+            !texture->IsSubresourceContentInitialized(range)) {
+            DAWN_TRY(clearTexture3D(texture, range));
+        }
+
         if (hasResolveTarget) {
             // We need to set the resolve target to initialized so that it does not get
             // cleared later in the pipeline. The texture will be resolved from the
@@ -276,6 +288,7 @@
             }
         }
     }
+    return {};
 }
 
 bool IsFullBufferOverwrittenInTextureToBufferCopy(const CopyTextureToBufferCmd* copy) {
diff --git a/src/dawn/native/CommandBuffer.h b/src/dawn/native/CommandBuffer.h
index 45dba72..907115e 100644
--- a/src/dawn/native/CommandBuffer.h
+++ b/src/dawn/native/CommandBuffer.h
@@ -92,7 +92,10 @@
 SubresourceRange GetSubresourcesAffectedByCopy(const TextureCopy& copy,
                                                const TexelExtent3D& copySize);
 
-void LazyClearRenderPassAttachments(DeviceBase* device, BeginRenderPassCmd* renderPass);
+using LazyClearTexture3DHelper = std::function<MaybeError(TextureBase*, const SubresourceRange&)>;
+MaybeError LazyClearRenderPassAttachments(DeviceBase* device,
+                                          BeginRenderPassCmd* renderPass,
+                                          LazyClearTexture3DHelper clearTexture);
 
 bool IsFullBufferOverwrittenInTextureToBufferCopy(const CopyTextureToBufferCmd* copy);
 bool IsFullBufferOverwrittenInTextureToBufferCopy(const TextureCopy& source,
diff --git a/src/dawn/native/d3d11/CommandBufferD3D11.cpp b/src/dawn/native/d3d11/CommandBufferD3D11.cpp
index 2439b73..ee935cd 100644
--- a/src/dawn/native/d3d11/CommandBufferD3D11.cpp
+++ b/src/dawn/native/d3d11/CommandBufferD3D11.cpp
@@ -641,10 +641,19 @@
         // Skip the clear as it will be handled by the workaround.
         colorAttachment.loadOp = wgpu::LoadOp::Load;
         // Mark the resource as initialized to avoid the lazy clear.
-        SubresourceRange range = colorAttachment.view->GetSubresourceRange();
-        colorAttachment.view->GetTexture()->SetIsSubresourceContentInitialized(true, range);
+        // For 3D textures, the view range covers the entire mip level (all depth slices), but
+        // the workaround only clears a single slice. So we must not mark it as initialized
+        // here, and let LazyClearRenderPassAttachments handle the initialization of the
+        // other slices.
+        if (colorAttachment.view->GetTexture()->GetDimension() != wgpu::TextureDimension::e3D) {
+            SubresourceRange range = colorAttachment.view->GetSubresourceRange();
+            colorAttachment.view->GetTexture()->SetIsSubresourceContentInitialized(true, range);
+        }
     }
-    LazyClearRenderPassAttachments(GetDevice(), renderPass);
+    DAWN_TRY(LazyClearRenderPassAttachments(
+        GetDevice(), renderPass, [&](TextureBase* texture, const SubresourceRange& range) {
+            return ToBackend(texture)->EnsureSubresourceContentInitialized(commandContext, range);
+        }));
 
     auto* d3d11DeviceContext = commandContext->GetD3D11DeviceContext3();
     // Hold ID3D11RenderTargetView ComPtr to make attachments alive.
diff --git a/src/dawn/native/d3d12/CommandBufferD3D12.cpp b/src/dawn/native/d3d12/CommandBufferD3D12.cpp
index 0126d9d..f3e1cdd 100644
--- a/src/dawn/native/d3d12/CommandBufferD3D12.cpp
+++ b/src/dawn/native/d3d12/CommandBufferD3D12.cpp
@@ -1015,7 +1015,13 @@
                     commandContext, GetResourceUsages().renderPasses[nextRenderPassNumber],
                     &passHasUAV));
 
-                LazyClearRenderPassAttachments(device, beginRenderPassCmd);
+                DAWN_TRY(LazyClearRenderPassAttachments(
+                    device, beginRenderPassCmd,
+                    [&](TextureBase* texture, const SubresourceRange& range) {
+                        return ToBackend(texture)->EnsureSubresourceContentInitialized(
+                            commandContext, range);
+                    }));
+
                 DAWN_TRY(RecordRenderPass(commandContext,
                                           descriptorHeapState.GetGraphicsBindingTracker(),
                                           beginRenderPassCmd, passHasUAV));
diff --git a/src/dawn/native/metal/CommandBufferMTL.mm b/src/dawn/native/metal/CommandBufferMTL.mm
index 810a8ac..56fe9bc 100644
--- a/src/dawn/native/metal/CommandBufferMTL.mm
+++ b/src/dawn/native/metal/CommandBufferMTL.mm
@@ -1161,7 +1161,12 @@
                 }
 
                 Device* device = ToBackend(GetDevice());
-                LazyClearRenderPassAttachments(device, cmd);
+                DAWN_TRY(LazyClearRenderPassAttachments(
+                    device, cmd, [&](TextureBase* texture, const SubresourceRange& range) {
+                        return ToBackend(texture)->EnsureSubresourceContentInitialized(
+                            commandContext, range);
+                    }));
+
                 if (cmd->attachmentState->HasDepthStencilAttachment() &&
                     ToBackend(cmd->depthStencilAttachment.view->GetTexture())
                         ->ShouldKeepInitialized()) {
diff --git a/src/dawn/native/opengl/CommandBufferGL.cpp b/src/dawn/native/opengl/CommandBufferGL.cpp
index 0624509..06d2d6b 100644
--- a/src/dawn/native/opengl/CommandBufferGL.cpp
+++ b/src/dawn/native/opengl/CommandBufferGL.cpp
@@ -813,7 +813,10 @@
                 }
                 DAWN_TRY(
                     LazyClearSyncScope(GetResourceUsages().renderPasses[nextRenderPassNumber]));
-                LazyClearRenderPassAttachments(GetDevice(), cmd);
+                DAWN_TRY(LazyClearRenderPassAttachments(
+                    GetDevice(), cmd, [&](TextureBase* texture, const SubresourceRange& range) {
+                        return ToBackend(texture)->EnsureSubresourceContentInitialized(gl, range);
+                    }));
                 DAWN_TRY(ExecuteRenderPass(cmd, gl));
 
                 nextRenderPassNumber++;
diff --git a/src/dawn/native/vulkan/CommandBufferVk.cpp b/src/dawn/native/vulkan/CommandBufferVk.cpp
index dffad57..717cf48 100644
--- a/src/dawn/native/vulkan/CommandBufferVk.cpp
+++ b/src/dawn/native/vulkan/CommandBufferVk.cpp
@@ -1312,7 +1312,11 @@
                     device, recordingContext,
                     GetResourceUsages().renderPasses[nextRenderPassNumber]));
 
-                LazyClearRenderPassAttachments(device, cmd);
+                DAWN_TRY(LazyClearRenderPassAttachments(
+                    device, cmd, [&](TextureBase* texture, const SubresourceRange& range) {
+                        return ToBackend(texture)->EnsureSubresourceContentInitialized(
+                            recordingContext, range);
+                    }));
                 DAWN_TRY(RecordRenderPass(recordingContext, cmd));
 
                 recordingContext->hasRecordedRenderPass = true;
diff --git a/src/dawn/native/webgpu/CommandBufferWGPU.cpp b/src/dawn/native/webgpu/CommandBufferWGPU.cpp
index 587bc0b..f852c74 100644
--- a/src/dawn/native/webgpu/CommandBufferWGPU.cpp
+++ b/src/dawn/native/webgpu/CommandBufferWGPU.cpp
@@ -999,7 +999,7 @@
     return {};
 }
 
-WGPUCommandBuffer CommandBuffer::Encode() {
+ResultOrError<WGPUCommandBuffer> CommandBuffer::Encode() {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/dawn/tests/end2end/TextureZeroInitTests.cpp b/src/dawn/tests/end2end/TextureZeroInitTests.cpp
index 425ff5b..301a663 100644
--- a/src/dawn/tests/end2end/TextureZeroInitTests.cpp
+++ b/src/dawn/tests/end2end/TextureZeroInitTests.cpp
@@ -33,6 +33,7 @@
 #include "dawn/utils/ComboRenderPipelineDescriptor.h"
 #include "dawn/utils/TestUtils.h"
 #include "dawn/utils/WGPUHelpers.h"
+#include "webgpu/webgpu_cpp.h"
 
 namespace dawn {
 namespace {
@@ -1162,6 +1163,176 @@
         wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::RenderAttachment);
 }
 
+// This is a regression test for a bug where rendering to a single slice of a 3D texture
+// would mark the entire mip level as initialized, skipping lazy clears for other slices.
+// This test renders to a single slice of a 3d texture and then reads it back via
+// CopyTextureToBuffer.
+TEST_P(TextureZeroInitTest, RenderPass3DTextureDepthSliceClearTestViaCopy) {
+    constexpr uint32_t kNumSlices = 3;
+    for (uint32_t slice = 0; slice < kNumSlices; ++slice) {
+        wgpu::TextureDescriptor desc;
+        desc.dimension = wgpu::TextureDimension::e3D;
+        desc.size = {kSize, kSize, kNumSlices};
+        desc.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc;
+        desc.format = kColorFormat;
+
+        wgpu::Texture texture = device.CreateTexture(&desc);
+
+        // Create a view of the 3D texture.
+        wgpu::TextureViewDescriptor viewDesc;
+        viewDesc.dimension = wgpu::TextureViewDimension::e3D;
+        wgpu::TextureView view = texture.CreateView(&viewDesc);
+
+        // Render to slice at index |slice|
+        {
+            utils::ComboRenderPassDescriptor renderPassDesc({view});
+            renderPassDesc.cColorAttachments[0].depthSlice = slice;
+            renderPassDesc.cColorAttachments[0].loadOp = wgpu::LoadOp::Clear;
+            renderPassDesc.cColorAttachments[0].clearValue = {0.502, 0.502, 0.502, 0.502};
+
+            wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+            wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderPassDesc);
+            pass.End();
+            wgpu::CommandBuffer commands = encoder.Finish();
+            queue.Submit(1, &commands);
+        }
+
+        std::vector<utils::RGBA8> expectedZeros(kSize * kSize, utils::RGBA8::kZero);
+        std::vector<utils::RGBA8> expectedCleared(kSize * kSize, {128, 128, 128, 128});
+
+        std::vector<const std::vector<utils::RGBA8>*> expectedSlices(kNumSlices, &expectedZeros);
+        expectedSlices[slice] = &expectedCleared;
+
+        for (uint32_t i = 0; i < kNumSlices; ++i) {
+            EXPECT_TEXTURE_EQ(expectedSlices[i]->data(), texture, {0, 0, i}, {kSize, kSize})
+                << "Slice " << i << " did not match expected values.";
+        }
+    }
+}
+
+// This is a regression test for a bug where rendering to a single slice of a 3D texture
+// would mark the entire mip level as initialized, skipping lazy clears for other slices.
+// This test renders to a single slice of a 3d texture and then reads it back by rendering the 3D
+// texture to a 2D array render target and sampling it in a shader.
+TEST_P(TextureZeroInitTest, RenderPass3DTextureDepthSliceClearTestViaUsage) {
+    constexpr uint32_t kNumSlices = 3;
+    for (uint32_t slice = 0; slice < kNumSlices; ++slice) {
+        wgpu::TextureDescriptor desc;
+        desc.dimension = wgpu::TextureDimension::e3D;
+        desc.size = {kSize, kSize, kNumSlices};
+        desc.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::TextureBinding;
+        desc.format = kColorFormat;
+
+        wgpu::Texture texture = device.CreateTexture(&desc);
+
+        // Create a view of the 3D texture.
+        wgpu::TextureViewDescriptor viewDesc;
+        viewDesc.dimension = wgpu::TextureViewDimension::e3D;
+        wgpu::TextureView view = texture.CreateView(&viewDesc);
+
+        wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+
+        // Render to slice at index |slice|
+        {
+            utils::ComboRenderPassDescriptor renderPassDesc({view});
+            renderPassDesc.cColorAttachments[0].depthSlice = slice;
+            renderPassDesc.cColorAttachments[0].loadOp = wgpu::LoadOp::Clear;
+            renderPassDesc.cColorAttachments[0].clearValue = {0.502, 0.502, 0.502, 0.502};
+
+            wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderPassDesc);
+            pass.End();
+        }
+
+        wgpu::TextureDescriptor rtDesc;
+        rtDesc.dimension = wgpu::TextureDimension::e2D;
+        rtDesc.size = {kSize, kSize, 3};
+        rtDesc.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc;
+        rtDesc.format = kColorFormat;
+        wgpu::Texture renderTarget = device.CreateTexture(&rtDesc);
+
+        // Render the 3D slices to a 2D-array texture
+        {
+            // Make a single full clips space triangle vertex shader and a fragment shader that will
+            // use the current fragment position to sample the 3d texture.
+            wgpu::ShaderModule mod = utils::CreateShaderModule(device, R"(
+                @vertex fn vs(@builtin(vertex_index) VertexIndex : u32) -> @builtin(position) vec4<f32> {
+                    var pos = array(
+                        vec2<f32>(-1.0, -1.0),
+                        vec2<f32>(3.0, -1.0),
+                        vec2<f32>(-1.0, 3.0));
+                    return vec4f(pos[VertexIndex], 0.0, 1.0);
+                }
+
+                @group(0) @binding(0) var t : texture_3d<f32>;
+
+                struct FragmentOutput {
+                    @location(0) color0 : vec4f,
+                    @location(1) color1 : vec4f,
+                    @location(2) color2 : vec4f,
+                };
+
+                @fragment fn fs(@builtin(position) position : vec4f) -> FragmentOutput {
+                    let xy = vec2u(position.xy);
+                    return FragmentOutput(
+                        textureLoad(t, vec3u(xy, 0), 0),
+                        textureLoad(t, vec3u(xy, 1), 0),
+                        textureLoad(t, vec3u(xy, 2), 0),
+                    );
+                }
+            )");
+
+            utils::ComboRenderPipelineDescriptor renderPipelineDescriptor;
+            renderPipelineDescriptor.cTargets[0].format = kColorFormat;
+            renderPipelineDescriptor.cTargets[1].format = kColorFormat;
+            renderPipelineDescriptor.cTargets[2].format = kColorFormat;
+            renderPipelineDescriptor.vertex.module = mod;
+            renderPipelineDescriptor.cFragment.module = mod;
+            renderPipelineDescriptor.cFragment.targetCount = kNumSlices;
+            wgpu::RenderPipeline renderPipeline =
+                device.CreateRenderPipeline(&renderPipelineDescriptor);
+
+            wgpu::BindGroup bindGroup = utils::MakeBindGroup(
+                device, renderPipeline.GetBindGroupLayout(0), {{0, texture.CreateView()}});
+
+            std::vector<wgpu::TextureView> renderTargets;
+            for (uint32_t i = 0; i < kNumSlices; ++i) {
+                wgpu::TextureViewDescriptor viewDesc{
+                    .dimension = wgpu::TextureViewDimension::e2DArray,
+                    .baseArrayLayer = i,
+                    .arrayLayerCount = 1,
+                };
+                renderTargets.push_back(renderTarget.CreateView(&viewDesc));
+            }
+            utils::ComboRenderPassDescriptor renderPassDesc(renderTargets);
+            for (uint32_t i = 0; i < kNumSlices; ++i) {
+                // Clear to something completely unexpected.
+                renderPassDesc.cColorAttachments[i].clearValue = {0.25, 0.25, 0.25, 0.25};
+                renderPassDesc.cColorAttachments[i].loadOp = wgpu::LoadOp::Clear;
+            }
+
+            wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderPassDesc);
+            pass.SetPipeline(renderPipeline);
+            pass.SetBindGroup(0, bindGroup);
+            pass.Draw(3);
+            pass.End();
+        }
+
+        wgpu::CommandBuffer commands = encoder.Finish();
+        queue.Submit(1, &commands);
+
+        std::vector<utils::RGBA8> expectedZeros(kSize * kSize, utils::RGBA8::kZero);
+        std::vector<utils::RGBA8> expectedCleared(kSize * kSize, {128, 128, 128, 128});
+
+        std::vector<const std::vector<utils::RGBA8>*> expectedSlices(kNumSlices, &expectedZeros);
+        expectedSlices[slice] = &expectedCleared;
+
+        for (uint32_t i = 0; i < kNumSlices; ++i) {
+            EXPECT_TEXTURE_EQ(expectedSlices[i]->data(), renderTarget, {0, 0, i}, {kSize, kSize})
+                << "Slice " << i << " did not match expected values.";
+        }
+    }
+}
+
 // This is a regression test for a bug where a texture wouldn't get clear for a pass if at least
 // one of its subresources was used as an attachment. It tests that if a texture is used as both
 // sampled and attachment (with LoadOp::Clear so the lazy clear can be skipped) then the sampled
Loading diff…

Original Bug Report

reported by vm...@google.com

Info Leak: Dawn LazyClearRenderPassAttachments ignores 3D texture depthSlice

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: Dawn tracks the initialization state of 3D textures at the mip-level granularity, using a single bit for all depth slices. When a render pass targets a single depth slice of a 3D texture with a ‘store’ operation, Dawn erroneously marks the entire mip level as initialized. This bypasses lazy-clearing for the other slices, potentially allowing an attacker to read uninitialized GPU memory.

Affected files:

  • third_party/dawn/src/dawn/native/CommandBuffer.cpp
  • third_party/dawn/src/dawn/native/Texture.cpp
  • third_party/dawn/src/dawn/native/Subresource.h
  • third_party/dawn/src/dawn/native/CommandEncoder.cpp

Estimated timestamp from git blame: 2023-09-13

Overview

Dawn’s internal tracking for subresource initialization (mIsSubresourceContentInitializedAtIndex) lacks the granularity to track individual depth slices of 3D textures. When a WebGPU render pass targets a specific depth slice of a 3D texture and stores the result, Dawn incorrectly records the entire mip level (all depth slices) as initialized. Subsequent operations, such as copying from other slices of that 3D texture, will bypass mandatory zero-initialization checks, leading to a potential disclosure of uninitialized GPU memory.

Potential Steps to Reproduce

  1. Create a 3D Texture: An attacker creates a 3D texture (e.g., size: [256, 256, 16]). Dawn’s internal tracking mechanism allocates only a single initialization bit for the entire base mip level because TextureBase::GetArrayLayers() explicitly returns 1 for 3D textures (third_party/dawn/src/dawn/native/Texture.cpp:1247).
  2. Begin Render Pass on Slice 0: The attacker begins a render pass, targeting depthSlice: 0 with loadOp: 'clear' and storeOp: 'store'.
  3. State Corruption: During command submission, LazyClearRenderPassAttachments (third_party/dawn/src/dawn/native/CommandBuffer.cpp:164) is called. At line 198, because storeOp is Store, it calls SetIsSubresourceContentInitialized(true, range). This function completely ignores attachmentInfo.depthSlice and marks the single tracking bit for the mip level as true.
  4. Hardware Execution: The hardware backend (e.g., Vulkan) executes the render pass using a temporary 2D view restricted strictly to depthSlice: 0. Slices 1-15 remain uninitialized.
  5. Copy Uninitialized Slice: The attacker executes a copyTextureToBuffer command, copying from depthSlice: 1. Dawn calculates the affected subresources, which resolves to the single bit representing the whole mip level.
  6. Bypass Lazy Clear: Dawn calls EnsureSubresourceContentInitialized. Because the tracking bit was erroneously set to true in step 3, Dawn believes the slice is already initialized and skips zeroing the memory.
  7. Information Leak: The hardware copies the raw, uninitialized memory from depthSlice: 1 into the buffer. The attacker maps the buffer and reads sensitive cross-origin or cross-process GPU memory.

Suggested Fix

Dawn needs to handle the initialization state of 3D textures correctly.

  • Option A (Finer Granularity): Update the tracking mechanism to track 3D textures per depth slice, allocating a bit per slice rather than per mip level.
  • Option B (Conservative Tracking): If tracking per slice is too expensive, LazyClearRenderPassAttachments should not mark the entire mip level as initialized unless the entire mip level was rendered to (which is not possible in a single color attachment view for a 3D texture). If a single slice is rendered, the texture should either remain marked as uninitialized (triggering lazy clears later), or Dawn must explicitly clear the remaining slices at creation or during the render pass.

Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad


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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker