CVE-2026-7924
Overview
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.
Attack Path
- 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.
- 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. - 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.
- Read back the uninitialized slices
The page reads the other slices via
CopyTextureToBufferor by sampling them in a shader (the two regression tests), recovering uninitialized memory contents.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifsrc/dawn/native/CommandBuffer.cpp |
modified |
Files Changed
src/dawn/native/CommandBuffer.cppsrc/dawn/native/CommandBuffer.hsrc/dawn/native/d3d11/CommandBufferD3D11.cppsrc/dawn/native/d3d12/CommandBufferD3D12.cppsrc/dawn/native/metal/CommandBufferMTL.mmsrc/dawn/native/opengl/CommandBufferGL.cppsrc/dawn/native/vulkan/CommandBufferVk.cppsrc/dawn/native/webgpu/CommandBufferWGPU.cpp
Audit Directions
- Coarse initialization trackingLook for places that set a whole
SubresourceRangeas 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 divergenceAudit 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 pathsFuzz 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.
Patch
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() {
Regression Test / PoC
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
Original Bug Report
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.cppthird_party/dawn/src/dawn/native/Texture.cppthird_party/dawn/src/dawn/native/Subresource.hthird_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
- 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 becauseTextureBase::GetArrayLayers()explicitly returns1for 3D textures (third_party/dawn/src/dawn/native/Texture.cpp:1247). - Begin Render Pass on Slice 0: The attacker begins a render pass, targeting
depthSlice: 0withloadOp: 'clear'andstoreOp: 'store'. - State Corruption: During command submission,
LazyClearRenderPassAttachments(third_party/dawn/src/dawn/native/CommandBuffer.cpp:164) is called. At line 198, becausestoreOpisStore, it callsSetIsSubresourceContentInitialized(true, range). This function completely ignoresattachmentInfo.depthSliceand marks the single tracking bit for the mip level astrue. - 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. - Copy Uninitialized Slice: The attacker executes a
copyTextureToBuffercommand, copying fromdepthSlice: 1. Dawn calculates the affected subresources, which resolves to the single bit representing the whole mip level. - Bypass Lazy Clear: Dawn calls
EnsureSubresourceContentInitialized. Because the tracking bit was erroneously set totruein step 3, Dawn believes the slice is already initialized and skips zeroing the memory. - Information Leak: The hardware copies the raw, uninitialized memory from
depthSlice: 1into 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,
LazyClearRenderPassAttachmentsshould 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.