Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Dawn
DescriptionUse after free in Dawn
ComponentDawn
Bug ClassUAF
Tracker517692772
Fix commit23cf554e645f (dawn) +26/-21
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
src/dawn/native/Buffer.cpp
modified
if
src/dawn/tests/DawnTest.cpp
modified

Files Changed

  • src/dawn/native/Buffer.cpp
  • src/dawn/native/Buffer.h
  • src/dawn/tests/DawnTest.cpp
From 23cf554e645f61acabcd10aac24bfe6d6b0eeeec Mon Sep 17 00:00:00 2001
From: Lokbondo Kung <lokokung@google.com>
Date: Fri, 05 Jun 2026 23:02:56 -0700
Subject: [PATCH] Reland "[native] Guarantee thread safety during the map async callback."

- This is a reland of commit fd625322444f0c207ac196e258d218a9237ce72e
- Moves locking in map callback in tests. The lock should be held for
  the entire duration of the expectation handling, not only during the
  callback because the callback captures a reference to the `slot`
  which could be invalidated if the expectation states were modified.
  On top of that, it was causing a lock inversion issue under TSAN
  noted in the bug. The original lock was also incorrect because we
  would take that lock when we access all the buffers to Unmap or
  Destroy the expectation buffers, but also try to take the lock again
  in the callback. That means it could have been possible to deadlock
  if we were using AllowSpontaneous instead of AllowProcessEvents,
  i.e. imagine taking the lock, calling Unmap which spontaneously
  calls the callback which tries to take the lock again in the same
  thread.

Bug: 517692772
Original change's description:
> [native] Guarantee thread safety during the map async callback.
>
> - This change makes the pending map mutex into a recursive one so
>   that it can be held throughout the duration of the map async
>   callback. This ensures that during the callback, there cannot be
>   a race with Unmap or Destroy.
> - This addresses the security bug below by because on the server,
>   the call to GetMappedRange always happens in the scope of the map
>   async callback. This means that even if an Unmap or Destroy
>   races, either the Unmap/Destroy will happen first, thereby
>   resulting in a failed map async callback, or the callback will
>   happen first before the Unmap goes through.
> - Note that we needed to use a recursive mutex instead of a normal
>   one because otherwise, a map async callback that calls Unmap
>   would deadlock.
>
> Bug: 517692772
> Change-Id: I25068c3722a138ec3014a5b942457e5d484462ec
> Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/313216
> Reviewed-by: Kai Ninomiya <kainino@chromium.org>
> Reviewed-by: Kyle Charbonneau <kylechar@google.com>
> Commit-Queue: Loko Kung <lokokung@google.com>

Bug: 517692772
Change-Id: Ic07bacc8e99cf8305038c1489458218a3f138e6c
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/313915
Auto-Submit: Loko Kung <lokokung@google.com>
Reviewed-by: Kai Ninomiya <kainino@chromium.org>
Commit-Queue: Kai Ninomiya <kainino@chromium.org>
---

diff --git a/src/dawn/native/Buffer.cpp b/src/dawn/native/Buffer.cpp
index 866fe86..93d475a 100644
--- a/src/dawn/native/Buffer.cpp
+++ b/src/dawn/native/Buffer.cpp
@@ -303,11 +303,12 @@
         //    but otherwise this finishes on the same path as #2.
         // 4. Event was created for an error and `mBuffer` was always null. This uses
         //    `mErrorMessage`` and `mStatus` as set in the constructor when running the callback.
+        RecursiveMutex::AutoLock lock;
         Ref<BufferBase> buffer = mBuffer.Promote();
         if (buffer) {
             // Locking the mutex provides synchronization so that either path #1 or #2 is taken if
             // Complete() and Unmap() race on different threads.
-            Mutex::AutoLock lock(&buffer->mPendingMapMutex);
+            lock = RecursiveMutex::AutoLock(&buffer->mPendingMapMutex);
             if (mStatus == WGPUMapAsyncStatus_Success) {
                 // Complete() happened before Unmap().
                 DAWN_CHECK(buffer->mPendingMapEvent);
@@ -904,7 +905,7 @@
     if (state == BufferState::PendingMap) {
         Ref<MapAsyncEvent> event;
         {
-            Mutex::AutoLock lock(&mPendingMapMutex);
+            RecursiveMutex::AutoLock lock(&mPendingMapMutex);
             // `mPendingMapEvent` is always reset while holding the mutex. If Complete() ran and
             // already reset the event then map is about to complete. If not, reset here and do an
             // early unmap.
@@ -935,6 +936,14 @@
         mState.wait(BufferState::PendingMap, std::memory_order::acquire);
     }
 
+    // Wait for any active MapAsyncEvent callback to finish. Just acquiring and releasing the lock
+    // is sufficient because MapAsyncEvent::Complete transitions the state and holds the lock
+    // throughout the state transition and the callback exactly once, therefore if we can acquire
+    // the lock, the callback must have completed.
+    {
+        RecursiveMutex::AutoLock lock(&mPendingMapMutex);
+    }
+
     DAWN_TRY(Unmap(forDestroy));
     return {};
 }
diff --git a/src/dawn/native/Buffer.h b/src/dawn/native/Buffer.h
index 2178183..2aa81ae 100644
--- a/src/dawn/native/Buffer.h
+++ b/src/dawn/native/Buffer.h
@@ -265,7 +265,7 @@
     // until after `mPendingMapEvent` is reset and potential race is averted.
     // Note: MutexProtected isn't used here due to Use() providing MapAsyncEvent* instead of
     // Ref<MapAsyncEvent> which doesn't allow resetting the Ref.
-    Mutex mPendingMapMutex;
+    RecursiveMutex mPendingMapMutex;
     Ref<MapAsyncEvent> mPendingMapEvent;
 
     // Track texel buffer views created from this buffer so they can be destroyed when the buffer is
diff --git a/src/dawn/tests/DawnTest.cpp b/src/dawn/tests/DawnTest.cpp
index 990f126..f142b02 100644
--- a/src/dawn/tests/DawnTest.cpp
+++ b/src/dawn/tests/DawnTest.cpp
@@ -2242,21 +2242,19 @@
     for (size_t slotIndex = 0; slotIndex < readbacks.size(); ++slotIndex) {
         auto& slot = readbacks[slotIndex];
 
-        slot.buffer.MapAsync(
-            wgpu::MapMode::Read, 0, wgpu::kWholeMapSize, wgpu::CallbackMode::AllowProcessEvents,
-            [this, &slot, &pendingMaps](wgpu::MapAsyncStatus status, wgpu::StringView) {
-                DAWN_ASSERT(status == wgpu::MapAsyncStatus::Success);
-                Mutex::AutoLock lg(&mMutex);
+        slot.buffer.MapAsync(wgpu::MapMode::Read, 0, wgpu::kWholeMapSize,
+                             wgpu::CallbackMode::AllowProcessEvents,
+                             [&slot, &pendingMaps](wgpu::MapAsyncStatus status, wgpu::StringView) {
+                                 DAWN_ASSERT(status == wgpu::MapAsyncStatus::Success);
+                                 if (status == wgpu::MapAsyncStatus::Success) {
+                                     slot.mappedData = slot.buffer.GetConstMappedRange();
+                                     DAWN_ASSERT(slot.mappedData != nullptr);
+                                 } else {
+                                     slot.mappedData = nullptr;
+                                 }
 
-                if (status == wgpu::MapAsyncStatus::Success) {
-                    slot.mappedData = slot.buffer.GetConstMappedRange();
-                    DAWN_ASSERT(slot.mappedData != nullptr);
-                } else {
-                    slot.mappedData = nullptr;
-                }
-
-                pendingMaps.fetch_sub(1, std::memory_order_release);
-            });
+                                 pendingMaps.fetch_sub(1, std::memory_order_release);
+                             });
     }
 
     // Busy wait until all map operations are done.
@@ -2414,11 +2412,9 @@
 void DawnTestBase::ResolveDeferredExpectationsNow() {
     FlushWire();
 
-    MapSlotsSynchronously(mReadbackSlots);
-
-    CheckReplayedReadbackBuffers(mReadbackSlots);
-
     Mutex::AutoLock lg(&mMutex);
+    MapSlotsSynchronously(mReadbackSlots);
+    CheckReplayedReadbackBuffers(mReadbackSlots);
     ResolveExpectations();
 
     mDeferredExpectations.clear();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/dawn/tests/DawnTest.cpp b/src/dawn/tests/DawnTest.cpp
index 990f126..f142b02 100644
--- a/src/dawn/tests/DawnTest.cpp
+++ b/src/dawn/tests/DawnTest.cpp
@@ -2242,21 +2242,19 @@
     for (size_t slotIndex = 0; slotIndex < readbacks.size(); ++slotIndex) {
         auto& slot = readbacks[slotIndex];
 
-        slot.buffer.MapAsync(
-            wgpu::MapMode::Read, 0, wgpu::kWholeMapSize, wgpu::CallbackMode::AllowProcessEvents,
-            [this, &slot, &pendingMaps](wgpu::MapAsyncStatus status, wgpu::StringView) {
-                DAWN_ASSERT(status == wgpu::MapAsyncStatus::Success);
-                Mutex::AutoLock lg(&mMutex);
+        slot.buffer.MapAsync(wgpu::MapMode::Read, 0, wgpu::kWholeMapSize,
+                             wgpu::CallbackMode::AllowProcessEvents,
+                             [&slot, &pendingMaps](wgpu::MapAsyncStatus status, wgpu::StringView) {
+                                 DAWN_ASSERT(status == wgpu::MapAsyncStatus::Success);
+                                 if (status == wgpu::MapAsyncStatus::Success) {
+                                     slot.mappedData = slot.buffer.GetConstMappedRange();
+                                     DAWN_ASSERT(slot.mappedData != nullptr);
+                                 } else {
+                                     slot.mappedData = nullptr;
+                                 }
 
-                if (status == wgpu::MapAsyncStatus::Success) {
-                    slot.mappedData = slot.buffer.GetConstMappedRange();
-                    DAWN_ASSERT(slot.mappedData != nullptr);
-                } else {
-                    slot.mappedData = nullptr;
-                }
-
-                pendingMaps.fetch_sub(1, std::memory_order_release);
-            });
+                                 pendingMaps.fetch_sub(1, std::memory_order_release);
+                             });
     }
 
     // Busy wait until all map operations are done.
@@ -2414,11 +2412,9 @@
 void DawnTestBase::ResolveDeferredExpectationsNow() {
     FlushWire();
 
-    MapSlotsSynchronously(mReadbackSlots);
-
-    CheckReplayedReadbackBuffers(mReadbackSlots);
-
     Mutex::AutoLock lg(&mMutex);
+    MapSlotsSynchronously(mReadbackSlots);
+    CheckReplayedReadbackBuffers(mReadbackSlots);
     ResolveExpectations();
 
     mDeferredExpectations.clear();
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential UAF on Metal backend via spontaneous OnBufferMapAsyncCallback racing DeviceDestroy

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential race condition in the Dawn wire server allows a compromised renderer to bypass mapState synchronization during DeviceDestroy. When spontaneous callbacks are enabled, a background driver thread can execute OnBufferMapAsyncCallback and read from the driver-mapped pointer of an MTLBuffer while the main thread concurrently destroys the buffer and clears its backing allocation.

Affected files:

  • third_party/dawn/src/dawn/wire/server/ServerBuffer.cpp
  • third_party/dawn/src/dawn/dawn_wire.json
  • third_party/dawn/src/dawn/native/metal/BufferMTL.mm
  • third_party/dawn/src/dawn/native/Device.cpp

Estimated timestamp from git blame: 2025-09-03

Description

A potential Use-After-Free (UAF) read vulnerability exists in the WebGPU/Dawn graphics component of Chromium on the macOS/Metal platform. The issue arises from a synchronization mismatch between Dawn’s wire server-side mutex (ServerBase::mMutex) and native Dawn device/buffer destruction sequence.

Root Cause Analysis

  1. When the Dawn wire server is instantiated on macOS, it is typically configured with useSpontaneousCallbacks = true because features::kWebGPUSpontaneousWireServer is enabled by default (see gpu/command_buffer/service/webgpu_decoder_impl.cc line 137).
  2. Spontaneous mapping callbacks (like OnBufferMapAsyncCallback) are invoked via the ForwardToServerHelper::Callback helper which acquires the server-side mutex via auto serverGuard = server.get()->GetGuard(); (see third_party/dawn/src/dawn/wire/server/Server.h line 103). This serializes spontaneous callbacks against other wire server commands.
  3. However, DeviceDestroy does not acquire the wire server-side mutex. When a compromised renderer sends a DeviceDestroy command, the GPU main thread directly executes native DeviceBase::APIDestroy() (in third_party/dawn/src/dawn/native/Device.cpp line 642).
  4. During DeviceBase::Destroy(), the GPU main thread blocks in mQueue->WaitForIdleForDestruction() (line 595), waiting on the mCompletedSerial condition variable for outstanding commands to finish.
  5. Once the command buffer finishes executing, the background Metal driver thread fires the completion block registered via addCompletedHandler: (in third_party/dawn/src/dawn/native/metal/QueueMTL.mm line 236), which calls UpdateCompletedSerialTo on the background thread.
  6. On the background thread, UpdateCompletedSerialTo does two things concurrently/sequentially:
    • It marks the MapAsyncEvent as ready, which triggers spontaneous execution of OnBufferMapAsyncCallback on the background thread. The callback enters the server critical section (GetGuard()) and begins copying data from mMtlBuffer contents.
    • It updates mCompletedSerial to the completed serial, which immediately wakes up the main thread waiting in WaitForIdleForDestruction().
  7. Since the GPU main thread’s destruction sequence does not hold the wire server mutex (ServerBase::mMutex), the main thread resumes and proceeds immediately to DestroyObjects() (line 577), iterating to Buffer::DestroyImpl() and setting mMtlBuffer = nullptr (line 237).
  8. The background thread, still executing OnBufferMapAsyncCallback, is left reading from the deallocated/cleared id<MTLBuffer> resource, resulting in a use-after-free read of GPU driver-allocated memory.

Potential Attack Scenario / Steps to Reproduce

Note: These steps are theoretical as our environment does not support executing or validating live graphics exploits.

  1. From a compromised renderer, create a large mappable buffer (e.g., 128 MB) with MapRead and CopyDst usage.
  2. Submit a queue operation touching the buffer, then issue BufferMapAsync(READ, 0, size) and flush.
  3. Wait until the Metal completion handler is about to execute.
  4. Send a DeviceDestroy command and flush.
  5. The GPU main thread executes DeviceDestroy and frees the MTLBuffer backing on the main thread while the background thread is copying memory from the mapping pointer, leaking reclaimed driver-allocated bytes into the shared memory backing visible to the renderer.

Suggested Fix

To remediate this issue, ensure that DeviceDestroy is listed under server_custom_pre_handler_commands in third_party/dawn/src/dawn/dawn_wire.json (line 357). This will generate a custom pre-handler for DeviceDestroy that acquires the appropriate locks and cleans up pending client/server mapping handles before initiating backend device destruction.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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