High CVSS 4.3 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in Dawn
DescriptionOut of bounds write in Dawn
ComponentDawn
Sub-componentDawn
Bug ClassOOB
Tracker501499832
Fix commit72871431b55a (dawn) +58/-6
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-10 (per vendor advisory)
Fix landed2026-04-22 (public git)
Disclosed2026-05-27
Patch gap35 days — fix public in source before the advisory

Background

Dawn
Chrome’s WebGPU implementation. Its Metal backend (src/dawn/native/metal/) translates WebGPU render passes into MTLRenderPassDescriptors for Apple’s Metal driver, which runs in the GPU process.
`depthSlice` / `depthPlane`
WebGPU lets a 3D texture be a color attachment and select one Z-slice via depthSlice. CreateMTLRenderPassDescriptor maps depthSlice onto the Metal attachment’s depthPlane property, telling the driver which Z-plane to render into.
`MetalRenderR8RG8UnormSmallMipToTempTexture` workaround
a default-on toggle for Intel GPUs on macOS. For small mip levels (>= 2) of R8Unorm/RG8Unorm textures, Dawn renders into a freshly allocated temporary 2D texture (depth 1) and copies the result back, working around a driver bug.
`PatchAttachmentWithTemporary` / `SavedMetalAttachment`
the workaround helper that swaps the real attachment for the temporary and records the original attachment’s fields so data can be copied back afterward.

Root Cause Analysis

The invariant is that when the workaround substitutes a 1-layer 2D temporary texture for the real attachment, every attachment descriptor field that selects a sub-resource must be reset to match the temporary’s geometry. PatchAttachmentWithTemporary (src/dawn/native/metal/UtilsMetal.mm) reset attachment.texture, attachment.level = 0, and attachment.slice = 0, but failed to reset attachment.depthPlane. If the application rendered to a Z-slice of a 3D R8Unorm/RG8Unorm texture, the mapped depthPlane (e.g. 3) survived the swap, so the Metal driver received a 1-depth 2D temporary texture together with a non-zero depthPlane. With Metal validation off (release builds), the driver computes the write offset as roughly base_address + depthPlane * slice_stride against a 1-layer allocation, producing an out-of-bounds GPU-memory write during the render pass (clears/fragment outputs land outside the temporary’s backing store). The workaround’s copy-back path compounded this by hardcoding MTLOriginMake(0,0,0) and slice 0, ignoring the original Z entirely.

The patch threads the original depthPlane through the workaround. SavedMetalAttachment gains a NSUInteger depthPlane member; SaveAttachmentCreateTemporary takes a new attachmentDepthPlane argument and stores it; PatchAttachmentWithTemporary passes attachment.depthPlane into it and then, critically, adds attachment.depthPlane = 0; alongside the existing level/slice resets — so the driver now sees a consistent 2D-texture-with-plane-0 descriptor and cannot compute an out-of-bounds offset. The copy helpers are corrected to use the saved plane: CopyFromTemporaryToAttachment now copies to destinationOrigin:MTLOriginMake(0, 0, depthPlane) and CopyFromAttachmentToTemporary reads from sourceOrigin:MTLOriginMake(0, 0, depthPlane), making the workaround functionally correct for 3D slices. The regression test RenderPassTest.ClearR8UnormDepthSlice clears slice 1 of a 3D R8Unorm texture at the small-mip level under the workaround toggle and verifies slice 0 stays zero, slice 1 becomes 255 — proving both safety and correctness. The advisory’s “Out-of-bounds write” (CWE-787) matches the diff.

Key insight
The Intel small-mip temporary-texture workaround reset the attachment’s texture, level, and slice but not its depthPlane — so a 3D-texture depthSlice leaked through as a stale Z-plane index applied to a 1-layer 2D temporary, letting the Metal driver write out of bounds.

Attack Path

  1. Attacker page using WebGPU, create a 3D texture, format r8unorm (or rg8unorm), usage RENDER_ATTACHMENT, mipLevelCount >= 3.
  2. Select a slice at a small mip create a GPUTextureView with baseMipLevel: 2 (or greater), dimension 3d.
  3. Begin a render pass with this view as a color attachment and a non-zero depthSlice (e.g. 3); this passes Dawn’s frontend validation because the slice is in-bounds for the full 3D texture.
  4. Submit the Metal backend maps depthSlice to depthPlane: 3; the small-mip Intel workaround fires and swaps in a 1-depth 2D temporary while (pre-patch) leaving depthPlane: 3.
  5. Primitive the Metal driver renders the clear/fragment output at plane 3 of a 1-layer allocation, an out-of-bounds write into GPU memory beyond the temporary texture’s backing store.

Impact Assessment

Realized in the GPU process (Metal driver), reachable from any WebGPU-capable origin with no special privilege (user loads the page). The diff substantiates the stale-depthPlane logic error and its correction; the OOB write itself occurs inside the closed-source Metal driver’s offset computation and is (inferred) from the descriptor mismatch rather than shown by Dawn code. CVSS (I:L, C:N/A:N) and the report frame it as a limited, bounded integrity primitive (clear-color / shader-output bytes to an attacker-influenced Z-offset) rather than a fully controlled write. Gating is significant: it requires the Intel-macOS default toggle to be active (metal_render_r8_rg8_unorm_small_mip_to_temp_texture), an R8/RG8 3D texture, and a small mip level — the workaround path.

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

Audit Directions

  • Other descriptor fields in attachment swaps
    any workaround that mutates an MTLRenderPassAttachmentDescriptor in place (resolve attachments, depth/stencil, MSAA) should reset every sub-resource selector (level, slice, depthPlane) to match the substitute; audit for other partial resets.
  • 3D-texture / depthSlice coverage
    depthSlice was added after this workaround existed; review other Metal (and D3D12/Vulkan) backend paths written before depthSlice for implicit 2D-only assumptions when handed a 3D attachment.
  • Frontend-vs-backend validation gap
    the malicious depthSlice passes Dawn frontend validation against the full 3D texture; audit whether backend substitutions that shrink the effective target geometry should re-validate slice/plane bounds.
  • Copy-back correctness regressions
    the hardcoded origin (0,0,0) / slice 0 shows the workaround silently corrupted 3D data even without the security angle; search for other hardcoded origins/slices in Metal copy helpers.
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