CVE-2026-14424
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/dawn/native/Buffer.cpp |
modified | |
ifsrc/dawn/tests/DawnTest.cpp |
modified |
Files Changed
src/dawn/native/Buffer.cppsrc/dawn/native/Buffer.hsrc/dawn/tests/DawnTest.cpp
Patch
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();
Regression Test / PoC
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();
Original Bug Report
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.cppthird_party/dawn/src/dawn/dawn_wire.jsonthird_party/dawn/src/dawn/native/metal/BufferMTL.mmthird_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
- When the Dawn wire server is instantiated on macOS, it is typically configured with
useSpontaneousCallbacks = truebecausefeatures::kWebGPUSpontaneousWireServeris enabled by default (seegpu/command_buffer/service/webgpu_decoder_impl.ccline 137). - Spontaneous mapping callbacks (like
OnBufferMapAsyncCallback) are invoked via theForwardToServerHelper::Callbackhelper which acquires the server-side mutex viaauto serverGuard = server.get()->GetGuard();(seethird_party/dawn/src/dawn/wire/server/Server.hline 103). This serializes spontaneous callbacks against other wire server commands. - However,
DeviceDestroydoes not acquire the wire server-side mutex. When a compromised renderer sends aDeviceDestroycommand, the GPU main thread directly executes nativeDeviceBase::APIDestroy()(inthird_party/dawn/src/dawn/native/Device.cppline 642). - During
DeviceBase::Destroy(), the GPU main thread blocks inmQueue->WaitForIdleForDestruction()(line 595), waiting on themCompletedSerialcondition variable for outstanding commands to finish. - Once the command buffer finishes executing, the background Metal driver thread fires the completion block registered via
addCompletedHandler:(inthird_party/dawn/src/dawn/native/metal/QueueMTL.mmline 236), which callsUpdateCompletedSerialToon the background thread. - On the background thread,
UpdateCompletedSerialTodoes two things concurrently/sequentially:- It marks the
MapAsyncEventas ready, which triggers spontaneous execution ofOnBufferMapAsyncCallbackon the background thread. The callback enters the server critical section (GetGuard()) and begins copying data frommMtlBuffercontents. - It updates
mCompletedSerialto the completed serial, which immediately wakes up the main thread waiting inWaitForIdleForDestruction().
- It marks the
- Since the GPU main thread’s destruction sequence does not hold the wire server mutex (
ServerBase::mMutex), the main thread resumes and proceeds immediately toDestroyObjects()(line 577), iterating toBuffer::DestroyImpl()and settingmMtlBuffer = nullptr(line 237). - The background thread, still executing
OnBufferMapAsyncCallback, is left reading from the deallocated/clearedid<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.
- From a compromised renderer, create a large mappable buffer (e.g., 128 MB) with
MapReadandCopyDstusage. - Submit a queue operation touching the buffer, then issue
BufferMapAsync(READ, 0, size)and flush. - Wait until the Metal completion handler is about to execute.
- Send a
DeviceDestroycommand and flush. - The GPU main thread executes
DeviceDestroyand frees theMTLBufferbacking 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.