Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Dawn
DescriptionOut of bounds read in Dawn
ComponentDawn
Bug ClassOOB
Tracker500090141
Fix commit175606a9f9d8 (dawn) +26/-8
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
src/dawn/native/Commands.cpp
modified
if
src/dawn/native/d3d11/CommandBufferD3D11.cpp
modified
if
src/dawn/native/d3d12/CommandBufferD3D12.cpp
modified
if
src/dawn/native/metal/CommandBufferMTL.mm
modified
if
src/dawn/native/opengl/CommandBufferGL.cpp
modified
if
src/dawn/native/vulkan/CommandBufferVk.cpp
modified
TEST_P
src/dawn/tests/end2end/CommandEncoderTests.cpp
modified

Files Changed

  • src/dawn/native/Commands.cpp
  • 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/tests/end2end/CommandEncoderTests.cpp
From 175606a9f9d8e0a1ffd1c08e05d7a02fdc9d9fbe Mon Sep 17 00:00:00 2001
From: Corentin Wallez <[email protected]>
Date: Thu, 23 Apr 2026 11:15:32 -0700
Subject: [PATCH] [dawn] Fix backends not consuming data of empty WriteBuffer commands

Fixed: 500090141
Change-Id: I3ceb070c59cc9c865c1d5bcc0451c80d25b50d4a
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/304675
Commit-Queue: Corentin Wallez <[email protected]>
Auto-Submit: Corentin Wallez <[email protected]>
Reviewed-by: Loko Kung <[email protected]>
---

diff --git a/src/dawn/native/Commands.cpp b/src/dawn/native/Commands.cpp
index 05d9afa..f131c97 100644
--- a/src/dawn/native/Commands.cpp
+++ b/src/dawn/native/Commands.cpp
@@ -422,9 +422,7 @@
 
         case Command::WriteBuffer: {
             auto cmd = commands->NextCommand<WriteBufferCmd>();
-            if (cmd->size > 0) {
-                commands->NextData<uint8_t>(cmd->size);
-            }
+            commands->NextData<uint8_t>(cmd->size);
             break;
         }
 
diff --git a/src/dawn/native/d3d11/CommandBufferD3D11.cpp b/src/dawn/native/d3d11/CommandBufferD3D11.cpp
index ee935cd..c74bb8e 100644
--- a/src/dawn/native/d3d11/CommandBufferD3D11.cpp
+++ b/src/dawn/native/d3d11/CommandBufferD3D11.cpp
@@ -502,6 +502,8 @@
 
             case Command::WriteBuffer: {
                 WriteBufferCmd* cmd = mCommands.NextCommand<WriteBufferCmd>();
+                uint8_t* data = mCommands.NextData<uint8_t>(cmd->size);
+
                 if (cmd->size == 0) {
                     // Skip no-op writes.
                     continue;
@@ -509,7 +511,6 @@
 
                 Buffer* dstBuffer = ToBackend(cmd->buffer.Get());
                 DAWN_TRY(dstBuffer->TrackUsage(commandContext, pendingSerial));
-                uint8_t* data = mCommands.NextData<uint8_t>(cmd->size);
                 DAWN_TRY(dstBuffer->Write(commandContext, cmd->offset, data, cmd->size));
 
                 break;
diff --git a/src/dawn/native/d3d12/CommandBufferD3D12.cpp b/src/dawn/native/d3d12/CommandBufferD3D12.cpp
index 2d6c6b4..28f9822 100644
--- a/src/dawn/native/d3d12/CommandBufferD3D12.cpp
+++ b/src/dawn/native/d3d12/CommandBufferD3D12.cpp
@@ -1389,12 +1389,13 @@
                 WriteBufferCmd* write = mCommands.NextCommand<WriteBufferCmd>();
                 const uint64_t offset = write->offset;
                 const uint64_t size = write->size;
+                uint8_t* data = mCommands.NextData<uint8_t>(size);
+
                 if (size == 0) {
                     continue;
                 }
 
                 Buffer* dstBuffer = ToBackend(write->buffer.Get());
-                uint8_t* data = mCommands.NextData<uint8_t>(size);
 
                 DAWN_TRY(device->GetDynamicUploader()->WithUploadReservation(
                     size, kCopyBufferToBufferOffsetAlignment,
diff --git a/src/dawn/native/metal/CommandBufferMTL.mm b/src/dawn/native/metal/CommandBufferMTL.mm
index a8db888..ed8b4a4 100644
--- a/src/dawn/native/metal/CommandBufferMTL.mm
+++ b/src/dawn/native/metal/CommandBufferMTL.mm
@@ -1513,12 +1513,13 @@
                 WriteBufferCmd* write = mCommands.NextCommand<WriteBufferCmd>();
                 const uint64_t offset = write->offset;
                 const uint64_t size = write->size;
+                uint8_t* data = mCommands.NextData<uint8_t>(size);
+
                 if (size == 0) {
                     continue;
                 }
 
                 Buffer* dstBuffer = ToBackend(write->buffer.Get());
-                uint8_t* data = mCommands.NextData<uint8_t>(size);
                 Device* device = ToBackend(GetDevice());
 
                 DAWN_TRY(device->GetDynamicUploader()->WithUploadReservation(
diff --git a/src/dawn/native/opengl/CommandBufferGL.cpp b/src/dawn/native/opengl/CommandBufferGL.cpp
index 06d2d6b..6d14648 100644
--- a/src/dawn/native/opengl/CommandBufferGL.cpp
+++ b/src/dawn/native/opengl/CommandBufferGL.cpp
@@ -1139,12 +1139,13 @@
                 WriteBufferCmd* write = mCommands.NextCommand<WriteBufferCmd>();
                 uint64_t offset = write->offset;
                 uint64_t size = write->size;
+                uint8_t* data = mCommands.NextData<uint8_t>(size);
+
                 if (size == 0) {
                     continue;
                 }
 
                 Buffer* dstBuffer = ToBackend(write->buffer.Get());
-                uint8_t* data = mCommands.NextData<uint8_t>(size);
                 DAWN_TRY(dstBuffer->EnsureDataInitializedAsDestination(offset, size));
 
                 DAWN_GL_TRY(gl, BindBuffer(GL_ARRAY_BUFFER, dstBuffer->GetHandle()));
diff --git a/src/dawn/native/vulkan/CommandBufferVk.cpp b/src/dawn/native/vulkan/CommandBufferVk.cpp
index f726da9..60438f4 100644
--- a/src/dawn/native/vulkan/CommandBufferVk.cpp
+++ b/src/dawn/native/vulkan/CommandBufferVk.cpp
@@ -1455,12 +1455,13 @@
                 WriteBufferCmd* write = mCommands.NextCommand<WriteBufferCmd>();
                 const uint64_t offset = write->offset;
                 const uint64_t size = write->size;
+                uint8_t* data = mCommands.NextData<uint8_t>(size);
+
                 if (size == 0) {
                     continue;
                 }
 
                 Buffer* dstBuffer = ToBackend(write->buffer.Get());
-                uint8_t* data = mCommands.NextData<uint8_t>(size);
 
                 DAWN_TRY(device->GetDynamicUploader()->WithUploadReservation(
                     size, kCopyBufferToBufferOffsetAlignment,
diff --git a/src/dawn/tests/end2end/CommandEncoderTests.cpp b/src/dawn/tests/end2end/CommandEncoderTests.cpp
index 94be114..258f5ae 100644
--- a/src/dawn/tests/end2end/CommandEncoderTests.cpp
+++ b/src/dawn/tests/end2end/CommandEncoderTests.cpp
@@ -61,6 +61,21 @@
     EXPECT_BUFFER_U32_EQ(0, bufferC, 3 * sizeof(uint32_t));
 }
 
+// Tests an empty WriteBuffer commands.
+TEST_P(CommandEncoderTests, EmptyWriteBuffer) {
+    wgpu::Buffer buffer =
+        utils::CreateBufferFromData(device, wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::CopySrc,
+                                    {
+                                        42,
+                                    });
+
+    wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+    encoder.WriteBuffer(buffer, 0, nullptr, 0);
+    wgpu::CommandBuffer commands = encoder.Finish();
+    queue.Submit(1, &commands);
+
+    EXPECT_BUFFER_U32_EQ(42, buffer, 0);
+}
 DAWN_INSTANTIATE_TEST(CommandEncoderTests,
                       D3D11Backend(),
                       D3D12Backend(),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/dawn/tests/end2end/CommandEncoderTests.cpp b/src/dawn/tests/end2end/CommandEncoderTests.cpp
index 94be114..258f5ae 100644
--- a/src/dawn/tests/end2end/CommandEncoderTests.cpp
+++ b/src/dawn/tests/end2end/CommandEncoderTests.cpp
@@ -61,6 +61,21 @@
     EXPECT_BUFFER_U32_EQ(0, bufferC, 3 * sizeof(uint32_t));
 }
 
+// Tests an empty WriteBuffer commands.
+TEST_P(CommandEncoderTests, EmptyWriteBuffer) {
+    wgpu::Buffer buffer =
+        utils::CreateBufferFromData(device, wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::CopySrc,
+                                    {
+                                        42,
+                                    });
+
+    wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+    encoder.WriteBuffer(buffer, 0, nullptr, 0);
+    wgpu::CommandBuffer commands = encoder.Finish();
+    queue.Submit(1, &commands);
+
+    EXPECT_BUFFER_U32_EQ(42, buffer, 0);
+}
 DAWN_INSTANTIATE_TEST(CommandEncoderTests,
                       D3D11Backend(),
                       D3D12Backend(),
Loading diff…

Original Bug Report

reported by [email protected]

GPU Process RCE via Dawn CommandIterator Desync in WriteBuffer(size=0)

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: A logic error in Dawn’s command stream handling allows a compromised renderer to cause a command iterator desync in the GPU process. By sending a WriteBuffer command with a size of zero, an unconsumed marker is left in the stream, leading to undefined behavior and potential Remote Code Execution (RCE) via type confusion in the D3D12, Metal, and OpenGL backends.

Affected files:

  • third_party/dawn/src/dawn/native/d3d12/CommandBufferD3D12.cpp
  • third_party/dawn/src/dawn/native/metal/CommandBufferMTL.mm
  • third_party/dawn/src/dawn/native/opengl/CommandBufferGL.cpp
  • third_party/dawn/src/dawn/native/CommandEncoder.cpp
  • third_party/dawn/src/dawn/native/CommandAllocator.h

Estimated timestamp from git blame: 2025-12-30

Summary

A vulnerability in Dawn’s command stream handling allows a compromised renderer to cause a command iterator desync in the GPU process. When CommandEncoder.WriteBuffer is called with a size of 0, an internal kAdditionalData marker is written to the command stream but is not properly consumed by the D3D12, Metal, and OpenGL backends during execution. This causes the next iteration of the command loop to interpret the marker as a command ID, triggering a DAWN_UNREACHABLE() fallback. Due to compiler optimizations on undefined behavior, this can deterministically redirect execution to a valid command handler, leading to type confusion, arbitrary pointer dereference, and potential Remote Code Execution (RCE).

Technical Details

When a renderer sends a CommandEncoderWriteBuffer command with size == 0 over the WebGPU wire protocol, the following sequence occurs:

  1. Encoding: In CommandEncoder::APIWriteBuffer (CommandEncoder.cpp), the encoder calls allocator->AllocateData<uint8_t>(0). This helper unconditionally writes a 4-byte marker (detail::kAdditionalData / 0xFFFFFFFE) into the native command stream, even when the size is 0.
  2. Validation: ValidateWriteBuffer allows a size of 0 as it is a valid WebGPU operation.
  3. Backend Execution Flaw: During command recording in the D3D12 (CommandBufferD3D12.cpp), Metal (CommandBufferMTL.mm), and OpenGL (CommandBufferGL.cpp) backends, the loop encounters the WriteBuffer command. The code checks if (size == 0) and executes continue;. This critically skips the call to mCommands.NextData<uint8_t>(size), failing to consume the kAdditionalData marker from the stream.
  4. Desynchronization: On the next iteration of the while (mCommands.NextCommandId(&type)) loop, the iterator reads the unconsumed 0xFFFFFFFE marker as the next command ID. In the C++ Command enum, this value is interpreted as -2.
  5. Undefined Behavior: The switch (type) statement does not have a case for -2 and falls to the default case, which calls DAWN_UNREACHABLE(). In release builds, this macro expands to __builtin_unreachable() or __assume(false).
  6. Exploitation via Optimization: Because __builtin_unreachable() tells the compiler the path is impossible, the compiler optimizes the sparse switch statement (e.g., jump tables) without bounds checks. Executing -2 through this optimized switch deterministically forces execution into a valid, attacker-selected command case (e.g., Command::BeginRenderPass).
  7. Type Confusion & RCE: The incorrectly reached handler calls NextCommand<BeginRenderPassCmd>(). Because BeginRenderPassCmd is 8-byte aligned, the internal AlignPtr skips the 4-byte ID of the actual next command in the stream. The iterator lands perfectly on the attacker-controlled data payload of the next command (e.g., SetImmediates data). This raw data is interpreted as the BeginRenderPassCmd struct, which contains unprotected raw pointers inside Ref<T> members. Dereferencing these forged pointers during execution leads to vtable hijacking and immediate RCE in the GPU process.

Note: The Vulkan backend is partially protected because its default case uses break instead of DAWN_UNREACHABLE(), and D3D11 is protected because it explicitly returns an error.

Potential Reproduction Steps

Please note these are suggested steps, as we do not have a working Proof of Concept.

  1. From a compromised renderer, serialize a CommandEncoderWriteBuffer wire command with size=0.
  2. Immediately follow this command in the wire stream with a command that accepts arbitrary data (e.g., RenderPassEncoderSetImmediates).
  3. Structure the arbitrary data payload to perfectly mimic a Dawn command struct (e.g., BeginRenderPassCmd), containing a forged Ref<AttachmentState> pointer targeting a fake vtable in renderer-sprayed memory.
  4. Submit the command buffer. The GPU process will hit DAWN_UNREACHABLE(), desync, parse the fake struct, dereference the forged pointer, and execute the ROP chain.

Suggested Fix

Remove the early continue in the backend execution loops for WriteBuffer. The marker must be consumed regardless of the data size.

Change the backend implementations (D3D12, Metal, OpenGL) from:

if (size == 0) {
    continue;
}
Buffer* dstBuffer = ToBackend(write->buffer.Get());
uint8_t* data = mCommands.NextData<uint8_t>(size);

To:

uint8_t* data = mCommands.NextData<uint8_t>(size);
if (size == 0) {
    continue;
}
Buffer* dstBuffer = ToBackend(write->buffer.Get());

Additionally, the helper SkipCommand in Commands.cpp:423 shares this bug and should be similarly fixed.

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