Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in Dawn
DescriptionOut of bounds write in Dawn
ComponentDawn
Bug ClassOOB
Tracker501499832
Fix commit72871431b55a (dawn) +58/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
TEST_P
src/dawn/tests/end2end/RenderPassTests.cpp
modified

Files Changed

  • src/dawn/native/metal/UtilsMetal.mm
  • src/dawn/tests/end2end/RenderPassTests.cpp
From 72871431b55aa888c0cf5e0f8942421da125994a Mon Sep 17 00:00:00 2001
From: Kai Ninomiya <[email protected]>
Date: Wed, 22 Apr 2026 15:13:34 -0700
Subject: [PATCH] [dawn][metal] Fix Metal r8/rg8 workaround for 3d slice rendering

When depthSlice was added this workaround didn't get updated.

Fixes: 501499832
Change-Id: I7810188ff55e75f2801ea211299aebd105872837
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/304296
Reviewed-by: Loko Kung <[email protected]>
Commit-Queue: Kai Ninomiya <[email protected]>
---

diff --git a/src/dawn/native/metal/UtilsMetal.mm b/src/dawn/native/metal/UtilsMetal.mm
index 5eb75b8..4fb05ec 100644
--- a/src/dawn/native/metal/UtilsMetal.mm
+++ b/src/dawn/native/metal/UtilsMetal.mm
@@ -84,6 +84,7 @@
     id<MTLTexture> texture = nil;
     NSUInteger level;
     NSUInteger slice;
+    NSUInteger depthPlane;
 
     NSPRef<id<MTLTexture>> temporary;
 
@@ -97,7 +98,7 @@
                     toTexture:texture
              destinationSlice:slice
              destinationLevel:level
-            destinationOrigin:MTLOriginMake(0, 0, 0)];
+            destinationOrigin:MTLOriginMake(0, 0, depthPlane)];
     }
 
     void CopyFromAttachmentToTemporary(CommandRecordingContext* commandContext) {
@@ -105,7 +106,7 @@
               copyFromTexture:texture
                   sourceSlice:slice
                   sourceLevel:level
-                 sourceOrigin:MTLOriginMake(0, 0, 0)
+                 sourceOrigin:MTLOriginMake(0, 0, depthPlane)
                    sourceSize:MTLSizeMake([temporary.Get() width], [temporary.Get() height], 1)
                     toTexture:temporary.Get()
              destinationSlice:0
@@ -118,12 +119,14 @@
 ResultOrError<SavedMetalAttachment> SaveAttachmentCreateTemporary(Device* device,
                                                                   id<MTLTexture> attachmentTexture,
                                                                   NSUInteger attachmentLevel,
-                                                                  NSUInteger attachmentSlice) {
+                                                                  NSUInteger attachmentSlice,
+                                                                  NSUInteger attachmentDepthPlane) {
     // Save the attachment.
     SavedMetalAttachment result;
     result.texture = attachmentTexture;
     result.level = attachmentLevel;
     result.slice = attachmentSlice;
+    result.depthPlane = attachmentDepthPlane;
 
     // Create the temporary texture.
     NSRef<MTLTextureDescriptor> mtlDescRef = AcquireNSRef([MTLTextureDescriptor new]);
@@ -155,13 +158,15 @@
     Device* device,
     MTLRenderPassAttachmentDescriptor* attachment) {
     SavedMetalAttachment result;
-    DAWN_TRY_ASSIGN(result, SaveAttachmentCreateTemporary(device, attachment.texture,
-                                                          attachment.level, attachment.slice));
+    DAWN_TRY_ASSIGN(result,
+                    SaveAttachmentCreateTemporary(device, attachment.texture, attachment.level,
+                                                  attachment.slice, attachment.depthPlane));
 
     // Replace the attachment with the temporary
     attachment.texture = result.temporary.Get();
     attachment.level = 0;
     attachment.slice = 0;
+    attachment.depthPlane = 0;
 
     return result;
 }
diff --git a/src/dawn/tests/end2end/RenderPassTests.cpp b/src/dawn/tests/end2end/RenderPassTests.cpp
index f5a70bd..1cf0a85 100644
--- a/src/dawn/tests/end2end/RenderPassTests.cpp
+++ b/src/dawn/tests/end2end/RenderPassTests.cpp
@@ -234,6 +234,53 @@
     EXPECT_BUFFER_U8_EQ(255, buf, 0);
 }
 
+// Test that clearing one slice of an R8Unorm texture works. Regression test for crbug.com/501499832
+// with toggle metal_render_r8_rg8_unorm_small_mip_to_temp_texture and --enable-backend-validation.
+TEST_P(RenderPassTest, ClearR8UnormDepthSlice) {
+    const uint32_t kLastMipLevel = 2;
+
+    // Create the texture and buffer used for readback.
+    wgpu::TextureDescriptor texDesc;
+    texDesc.dimension = wgpu::TextureDimension::e3D;
+    texDesc.format = wgpu::TextureFormat::R8Unorm;
+    texDesc.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc;
+    texDesc.size = {32, 32, 8};
+    texDesc.mipLevelCount = kLastMipLevel + 1;
+    wgpu::Texture tex = device.CreateTexture(&texDesc);
+
+    wgpu::BufferDescriptor bufDesc;
+    bufDesc.size = 8;
+    bufDesc.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::CopySrc;
+    wgpu::Buffer buf = device.CreateBuffer(&bufDesc);
+
+    wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+
+    // Clear the texture with a render pass.
+    {
+        wgpu::TextureViewDescriptor viewDesc;
+        viewDesc.baseMipLevel = kLastMipLevel;
+
+        utils::ComboRenderPassDescriptor renderPass({tex.CreateView(&viewDesc)});
+        renderPass.cColorAttachments[0].clearValue = {1.0f, 0.0f, 0.0f, 1.0f};
+        renderPass.cColorAttachments[0].loadOp = wgpu::LoadOp::Clear;
+        renderPass.cColorAttachments[0].storeOp = wgpu::StoreOp::Store;
+        renderPass.cColorAttachments[0].depthSlice = 1;
+        wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderPass);
+        pass.End();
+    }
+
+    wgpu::CommandBuffer commands = encoder.Finish();
+    queue.Submit(1, &commands);
+
+    static uint8_t expected[]{
+        // Slice 0 still initialized to 0.0
+        0,
+        // Slice 1 cleared to 1.0
+        255,
+    };
+    EXPECT_TEXTURE_EQ(expected, tex, {0, 0, 0}, {1, 1, 2}, kLastMipLevel);
+}
+
 // Test that clearing a depth16unorm texture with multiple subresources works. This is a regression
 // test for dawn:1389 where Intel Metal devices fail to do that correctly, requiring a workaround.
 TEST_P(RenderPassTest, ClearMultisubresourceAfterWriteDepth16Unorm) {
@@ -371,7 +418,7 @@
     D3D12Backend({}, {"use_d3d12_render_pass"}),
     MetalBackend(),
 
-    // for dawn:1071 regression
+    // for crbug.com/40096166 and crbug.com/501499832 regressions
     MetalBackend({"metal_render_r8_rg8_unorm_small_mip_to_temp_texture"}),
 
     // for dawn:1389 regression
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/dawn/tests/end2end/RenderPassTests.cpp b/src/dawn/tests/end2end/RenderPassTests.cpp
index f5a70bd..1cf0a85 100644
--- a/src/dawn/tests/end2end/RenderPassTests.cpp
+++ b/src/dawn/tests/end2end/RenderPassTests.cpp
@@ -234,6 +234,53 @@
     EXPECT_BUFFER_U8_EQ(255, buf, 0);
 }
 
+// Test that clearing one slice of an R8Unorm texture works. Regression test for crbug.com/501499832
+// with toggle metal_render_r8_rg8_unorm_small_mip_to_temp_texture and --enable-backend-validation.
+TEST_P(RenderPassTest, ClearR8UnormDepthSlice) {
+    const uint32_t kLastMipLevel = 2;
+
+    // Create the texture and buffer used for readback.
+    wgpu::TextureDescriptor texDesc;
+    texDesc.dimension = wgpu::TextureDimension::e3D;
+    texDesc.format = wgpu::TextureFormat::R8Unorm;
+    texDesc.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc;
+    texDesc.size = {32, 32, 8};
+    texDesc.mipLevelCount = kLastMipLevel + 1;
+    wgpu::Texture tex = device.CreateTexture(&texDesc);
+
+    wgpu::BufferDescriptor bufDesc;
+    bufDesc.size = 8;
+    bufDesc.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::CopySrc;
+    wgpu::Buffer buf = device.CreateBuffer(&bufDesc);
+
+    wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+
+    // Clear the texture with a render pass.
+    {
+        wgpu::TextureViewDescriptor viewDesc;
+        viewDesc.baseMipLevel = kLastMipLevel;
+
+        utils::ComboRenderPassDescriptor renderPass({tex.CreateView(&viewDesc)});
+        renderPass.cColorAttachments[0].clearValue = {1.0f, 0.0f, 0.0f, 1.0f};
+        renderPass.cColorAttachments[0].loadOp = wgpu::LoadOp::Clear;
+        renderPass.cColorAttachments[0].storeOp = wgpu::StoreOp::Store;
+        renderPass.cColorAttachments[0].depthSlice = 1;
+        wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderPass);
+        pass.End();
+    }
+
+    wgpu::CommandBuffer commands = encoder.Finish();
+    queue.Submit(1, &commands);
+
+    static uint8_t expected[]{
+        // Slice 0 still initialized to 0.0
+        0,
+        // Slice 1 cleared to 1.0
+        255,
+    };
+    EXPECT_TEXTURE_EQ(expected, tex, {0, 0, 0}, {1, 1, 2}, kLastMipLevel);
+}
+
 // Test that clearing a depth16unorm texture with multiple subresources works. This is a regression
 // test for dawn:1389 where Intel Metal devices fail to do that correctly, requiring a workaround.
 TEST_P(RenderPassTest, ClearMultisubresourceAfterWriteDepth16Unorm) {
@@ -371,7 +418,7 @@
     D3D12Backend({}, {"use_d3d12_render_pass"}),
     MetalBackend(),
 
-    // for dawn:1071 regression
+    // for crbug.com/40096166 and crbug.com/501499832 regressions
     MetalBackend({"metal_render_r8_rg8_unorm_small_mip_to_temp_texture"}),
 
     // for dawn:1389 regression
Loading diff…

Original Bug Report

reported by [email protected]

Potential OOB GPU write in Dawn Metal backend via unreset depthPlane in texture workaround

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 Chrome Security team.

Overview: A logic error exists in Dawn’s Metal backend where the depthPlane property is not reset when applying the MetalRenderR8RG8UnormSmallMipToTempTexture workaround. If a 3D texture is used as a color attachment, this omission passes an attacker-controlled, non-zero depth slice to the Metal driver alongside a 1-depth temporary 2D texture. This mismatch causes the GPU driver to calculate an out-of-bounds memory offset during the render pass, potentially leading to arbitrary writes in GPU memory.

Affected files:

  • third_party/dawn/src/dawn/native/metal/UtilsMetal.mm
  • third_party/dawn/src/dawn/native/metal/TextureMTL.mm
  • third_party/dawn/src/dawn/native/metal/CommandBufferMTL.mm

Estimated timestamp from git blame: 2022-05-01

Summary

A vulnerability exists in Dawn’s Metal backend within the PatchAttachmentWithTemporary function. When the Toggle::MetalRenderR8RG8UnormSmallMipToTempTexture workaround is triggered (default-enabled for Intel GPUs on macOS), Dawn swaps the render attachment with a temporary 2D texture. However, it fails to reset the depthPlane property of the Metal render pass descriptor. When an attacker provides a 3D texture view and specifies a depthSlice, the stale depthPlane value is used by the Metal driver against the temporary 1-depth 2D texture, resulting in a potential out-of-bounds (OOB) write on the GPU.

Technical Details

Dawn permits 3D textures to be created with the RENDER_ATTACHMENT usage flag and supports rendering to specific slices via the depthSlice property in the render pass descriptor. During command translation, CreateMTLRenderPassDescriptor (CommandBufferMTL.mm:214) maps this depthSlice directly to the depthPlane property of the MTLRenderPassColorAttachmentDescriptor.

When EncodeMetalRenderPass (UtilsMetal.mm:845) detects a small mip level (>= 2) of an R8Unorm or RG8Unorm texture on Intel hardware, it triggers a workaround to patch the attachment using PatchAttachmentWithTemporary.

This function (UtilsMetal.mm:154) allocates a temporary MTLTextureType2D texture with a hardcoded depth of 1. It then updates the attachment descriptor in-place:

    // Replace the attachment with the temporary
    attachment.texture = result.temporary.Get();
    attachment.level = 0;
    attachment.slice = 0;
    // BUG: attachment.depthPlane is not reset.

Because depthPlane is not reset to 0, the Metal driver receives a descriptor for a 2D texture with a potentially large depthPlane index. Without Metal validation layers enabled in release builds, the driver calculates the memory offset using base_address + (depthPlane * slice_stride). Since the temporary allocation is only 1 layer deep, the GPU will perform the render pass out-of-bounds of the temporary texture’s backing memory.

Further evidence that 3D textures were completely overlooked in this workaround can be found in SavedMetalAttachment. When copying the data back to the original texture (CopyFromTemporaryToAttachment), it hardcodes destinationOrigin:MTLOriginMake(0, 0, 0) and destinationSlice:0, ignoring the original depthPlane entirely.

Potential Exploit Steps

  1. Using WebGPU, create a 3D texture with format r8unorm (or rg8unorm), usage RENDER_ATTACHMENT, and mipLevelCount >= 3.
  2. Create a GPUTextureView targeting this 3D texture with baseMipLevel: 2 (or greater) and dimension: "3d".
  3. Begin a render pass, using this view as a color attachment and specifying an out-of-bounds depthSlice multiplier (e.g., depthSlice: 3). This passes Dawn’s frontend validation because it is within the bounds of the original 3D texture.
  4. Submit the commands. The Metal backend translates the depthSlice to depthPlane: 3.
  5. The MetalRenderR8RG8UnormSmallMipToTempTexture workaround activates, swapping the 3D texture for a newly allocated 1-depth 2D texture, but leaving depthPlane: 3.
  6. The Metal driver executes the render pass. Because depthPlane is 3 on a 1-depth allocation, fragment shader outputs (or clear colors) are written to an out-of-bounds memory address on the GPU.

Note: These are suggested steps based on static code analysis; our tooling agent does not yet have the ability to run code to provide a working proof of concept.

Suggested Fix

  1. In PatchAttachmentWithTemporary (third_party/dawn/src/dawn/native/metal/UtilsMetal.mm), explicitly reset the depthPlane property:
    attachment.texture = result.temporary.Get();
    attachment.level = 0;
    attachment.slice = 0;
    attachment.depthPlane = 0;
  1. To ensure correct functional behavior for 3D textures, SavedMetalAttachment should be updated to store the original depthPlane value. CopyFromTemporaryToAttachment and CopyFromAttachmentToTemporary should then use this saved depthPlane value for the Z-coordinate of the origin or the destination slice, ensuring the workaround successfully copies data to and from the correct Z-slice of the original 3D texture.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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