CVE-2026-7924
Overview
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
Patch
From c7382d325da4c27d767d1d0a08007bb40f1fb940 Mon Sep 17 00:00:00 2001 From: Gregg Tavares <[email protected]> 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 <[email protected]> Auto-Submit: Gregg Tavares <[email protected]> Reviewed-by: Loko Kung <[email protected]> Commit-Queue: Gregg Tavares <[email protected]> --- 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.