CVE-2026-6310
Overview
Background
- `ExecutionQueueBase`
- the Dawn native base class that tracks command submission and completion serials for a device’s GPU queue and drives callback/task processing as work completes.
- `ExecutionSerial`
- a monotonically increasing 64-bit counter identifying submitted GPU work, where
mCompletedSerialrecords the highest serial the GPU has finished executing. - `WaitForIdleForDestruction`
- the teardown path a thread destroying the
Queue/device blocks in, waiting for the completed serial to reach the last-submitted value before it frees queue-owned state. - `mState`
- a lock-guarded structure holding
mWaitingForIdle,mWaitingTasks,mWaitingProcessors, andmCallingCallbacks, coordinating which thread is allowed to run completion callbacks.
Root Cause Analysis
ExecutionQueueBase::UpdateCompletedSerialToInternal updated mCompletedSerial up front (before inspecting mState) so that as many callbacks as possible could be processed. On some backends, WaitForIdleForDestructionImpl is implemented via WaitForQueueSerial, which blocks purely on the completed-serial value; publishing the new serial therefore immediately satisfied a destroying thread’s wait condition. That destroying thread could then wake, run through WaitForIdleForDestruction, and free the Queue (and its owning device) while the original thread was still mid-execution inside UpdateCompletedSerialToInternal, dereferencing mState and other freed members — a use-after-free.
The fix reorders the operation: it first checks mState and captures a waitingForIdle flag, and only afterward advances mCompletedSerial, so the destroying thread is never released until the updating thread has finished touching queue state. It additionally guards the trailing mCallingCallbacks = false write behind !waitingForIdle, so the function does not write into state that the idle-wait path now owns.
Attack Path
- Concurrent submit and destroy
One thread updates completed serials as GPU work finishes while another thread begins tearing down the device and enters
WaitForIdleForDestruction. - Set the waiting-for-idle state
The destroying thread sets
mWaitingForIdleand blocks in aWaitForQueueSerial-backed wait that only checks the completed-serial value. - Premature serial publish
The updating thread enters
UpdateCompletedSerialToInternaland writes the new value tomCompletedSerialbefore readingmState, satisfying the destroyer’s wait condition. - Destroyer frees the queue
The awakened destroying thread completes
WaitForIdleForDestructionand frees theQueueand its owning device. - Use-after-free
The original thread continues in
UpdateCompletedSerialToInternal, dereferencing now-freedmState/members, yielding memory corruption an attacker can shape from crafted WebGPU workloads.
Impact Assessment
Queue/device memory, which can be groomed toward controlled writes and code execution. Exploitation requires winning a race between a serial-updating thread and a queue-destroying thread, so it is timing-dependent but reachable from ordinary WebGPU usage patterns.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/dawn/native/ExecutionQueue.cpp |
modified | |
forsrc/dawn/native/ExecutionQueue.cpp |
modified |
Files Changed
src/dawn/native/ExecutionQueue.cppsrc/dawn/native/ExecutionQueue.h
Audit Directions
- Publish-before-check orderingLook for functions that make a value visible to another thread (updating a serial, flag, or refcount) before validating or capturing the state that other threads use as a wakeup condition.
- Wait conditions on shared countersAudit any
WaitForQueueSerial/idle-wait paths where the wait predicate is a single monotonic value, since satisfying it can release a thread that immediately frees the object still in use. - Teardown races on lock-guarded stateReview paths where one thread’s destruction wait and another thread’s ongoing use of
mState-style guarded members overlap, ensuring the owner finishes all member access before the waiter can be signaled.
Patch
From 7c11e118870577ccf42a84108fed8890c71c69e7 Mon Sep 17 00:00:00 2001
From: Lokbondo Kung <lokokung@google.com>
Date: Tue, 07 Apr 2026 19:22:22 -0700
Subject: [PATCH] [dawn][native] Check for waiting for idle before updating serials.
- In the ExecutionQueue, we need to make sure to check whether a thread
is waiting for idle prior to updating the completed serial. Otherwise,
as the bug below points out, it's possible for the thread that's
waiting for idle (which just waits for the completed serial to reach
a certain value), to complete and destroy the Queue before the rest of
the UpdateSerial call completes.
Bug: 497969820
Change-Id: I7b9dba50f4ccb1aa8dfced122801e17db3ee4e0e
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/300595
Reviewed-by: Kai Ninomiya <kainino@chromium.org>
Commit-Queue: Loko Kung <lokokung@google.com>
---
diff --git a/src/dawn/native/ExecutionQueue.cpp b/src/dawn/native/ExecutionQueue.cpp
index 8be4e70..2bb5a6a 100644
--- a/src/dawn/native/ExecutionQueue.cpp
+++ b/src/dawn/native/ExecutionQueue.cpp
@@ -253,20 +253,21 @@
}
void ExecutionQueueBase::UpdateCompletedSerialToInternal(QueuePriority priority,
- ExecutionSerial completedSerial,
- bool forceTasks) {
+ ExecutionSerial newCompletedSerial,
+ bool forceTasksForDestroy) {
QueuePriorityArray<std::vector<Ref<SerialProcessor>>>* processors = nullptr;
std::vector<Task> tasks;
- // We update the completed serial as soon as possible before waiting for callback rights so
- // that we almost always process as many callbacks as possible.
- ExecutionSerial serial = mCompletedSerial.Use([&](auto old) {
- *old = std::max(*old, static_cast<uint64_t>(completedSerial));
- return ExecutionSerial(*old);
- });
-
- mState.Use<NotifyType::None>([&](auto state) {
- if (state->mWaitingForIdle && !forceTasks) {
+ // Note that we need to determine whether we are waiting for idle before updating the completed
+ // serial because some backends WaitForIdleForDestructionImpl may be implemented via a call to
+ // WaitForQueueSerial which (by default without overrides), waits on the completed serial value.
+ // If we updated the serial value before checking the other pieces of state, a thread destroying
+ // the Queue calling WaitForIdleForDestruction, could end up being woken up and destroying the
+ // Queue device before the rest of this function completes. By checking the state first before
+ // updating the serial, however, we avoid waking up the thread that's waiting for idle until we
+ // have completed using the queue.
+ bool waitingForIdle = mState.Use<NotifyType::None>([&](auto state) {
+ if (state->mWaitingForIdle && !forceTasksForDestroy) {
// If we are waiting for idle, then the callbacks will be fired there. It is currently
// necessary to avoid calling the callbacks in this function and doing it in the
// |WaitForIdleForDestruction| call because |WaitForIdleForDestruction| is called while
@@ -274,8 +275,11 @@
// device lock. As a result, if the main thread is waiting for idle, and another thread
// is trying to update the completed serial and call callbacks, it could deadlock. Once
// we update |WaitForIdleForDestruction| to release the device lock on the wait, we may
- // be able to simplify the code here.
- return;
+ // be able to simplify the code here. Note that skipping this when
+ // |forceTasksForDestroy| is currently ok because that branch is only called when we are
+ // also holding the device lock, either via a Destroy or via an error that is being
+ // handled.
+ return true;
}
// Wait until we can exclusively call callbacks.
@@ -284,16 +288,22 @@
// Call all callbacks that for the given priority and anything of higher priority as well.
processors = &state->mWaitingProcessors;
for (QueuePriority p = QueuePriority::Highest; p >= priority; p -= 1) {
- PopWaitingTasksInto(serial, state->mWaitingTasks[p], tasks);
+ PopWaitingTasksInto(newCompletedSerial, state->mWaitingTasks[p], tasks);
}
state->mCallingCallbacks = true;
+ return false;
+ });
+
+ // Update the serial now that we know whether we are waiting for idle.
+ mCompletedSerial.Use([&](auto completedSerial) {
+ *completedSerial = std::max(*completedSerial, static_cast<uint64_t>(newCompletedSerial));
});
// Always call the processors before processing individual tasks.
if (processors) {
for (QueuePriority p = QueuePriority::Highest; p >= priority; p -= 1) {
for (auto& processor : (*processors)[p]) {
- processor->UpdateCompletedSerialTo(serial);
+ processor->UpdateCompletedSerialTo(newCompletedSerial);
}
}
}
@@ -304,7 +314,9 @@
task();
}
- mState->mCallingCallbacks = false;
+ if (!waitingForIdle) {
+ mState->mCallingCallbacks = false;
+ }
}
MaybeError ExecutionQueueBase::EnsureCommandsFlushed(ExecutionSerial serial) {
diff --git a/src/dawn/native/ExecutionQueue.h b/src/dawn/native/ExecutionQueue.h
index 9140f9e..c0bfd75 100644
--- a/src/dawn/native/ExecutionQueue.h
+++ b/src/dawn/native/ExecutionQueue.h
@@ -183,7 +183,7 @@
void UpdateCompletedSerialToInternal(QueuePriority priority,
ExecutionSerial completedSerial,
- bool forceTasks = false);
+ bool forceTasksForDestroy = false);
// |mCompletedSerial| tracks the last completed command serial that the fence has returned.
// |mLastSubmittedSerial| tracks the last submitted command serial.
Original Bug Report
Potential UAF in Dawn Metal completion handler via race condition during Queue destruction
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A race condition in Dawn’s Metal backend can lead to a potential Use-After-Free (UAF) vulnerability when a Queue is destroyed. An Objective-C completion handler captures a raw this pointer to the Queue object, which can be freed by the main thread before the background handler finishes executing. A compromised renderer could potentially exploit this to achieve arbitrary code execution in the highly privileged GPU process.
Affected files:
third_party/dawn/src/dawn/native/ExecutionQueue.cppthird_party/dawn/src/dawn/native/metal/QueueMTL.mmthird_party/dawn/src/dawn/native/ExecutionQueue.h
Estimated timestamp from git blame: 2026-03-02
Vulnerability Details
A potential Use-After-Free (UAF) vulnerability exists in the Dawn GPU process when using the Metal backend on macOS.
In third_party/dawn/src/dawn/native/metal/QueueMTL.mm, the SubmitPendingCommandBuffer function registers an MTLCommandBuffer completion handler. This Objective-C block inherently captures the C++ this pointer as a raw pointer, bypassing Dawn’s Ref<T> reference counting and MiraclePtr (raw_ptr<T>) protections:
// QueueMTL.mm
[*pendingCommands addCompletedHandler:^(id<MTLCommandBuffer>) {
// `this` is captured as a raw pointer
this->UpdateCompletedSerialTo(QueuePriority::Lowest, pendingSerial);
}];
When the GPU command finishes, this block executes on a background Metal driver thread, calling ExecutionQueueBase::UpdateCompletedSerialToInternal in ExecutionQueue.cpp. This function relies on two distinct mutex-protected scopes:
mCompletedSerial.Use(...): Updates the completed serial and signals a condition variable (notify_all()).mState.Use(...): Accesses internal queue state and fires waiting callbacks/processors.
A race condition occurs because signaling the condition variable at the end of the first scope can wake up the main thread if it is currently waiting in WaitForIdleForDestruction (invoked during device teardown).
If the main thread wakes up, it can complete the destruction of the Device and Queue. It sets mQueue = nullptr, dropping the last reference to the queue, causing it to be deleted. If the background thread is preempted by the OS exactly between the two lock scopes, it will resume execution with a dangling this pointer when it attempts to enter the mState.Use(...) scope.
Suggested Steps to Trigger
(Note: These are potential steps as our tooling agent does not yet have the ability to run or verify arbitrary exploit code.)
- Setup: From a compromised renderer process, create a WebGPU
DeviceandQueue. - Submit Work: Submit a WebGPU command buffer that takes a predictable amount of time to execute.
- Trigger Teardown: Immediately drop all renderer-side references to the WebGPU device, prompting the GPU process to call
DeviceBase::WillDropLastExternalRef()and subsequentlyWaitForIdleForDestruction(). - Heap Spray: Rapidly spray the GPU process heap via other WebGPU allocations (e.g., buffer creations) sized perfectly to reclaim the
Queueobject’s memory once it is freed. - Win the Race: The background thread signals the condition variable and the OS preempts it. The main thread wakes up, frees the
Queue, and the attacker’s heap spray reclaims the memory, setting up a fakemStateobject with a forgedmWaitingProcessorsvector. - Hijack Control Flow: The background thread resumes, accesses the sprayed
mStatememory, and iterates over the fakemWaitingProcessors. It executesprocessor->UpdateCompletedSerialTo(serial);, which is a virtual function call. This dereferences the attacker-controlled vtable, leading to arbitrary Remote Code Execution (RCE) in the GPU process.
Suggested Fix
To fix this issue, the Objective-C completion handler must keep the Queue object alive for the duration of its execution, or safely check if it has been destroyed.
The most straightforward fix is to capture a strongly-referenced Ref<Queue> inside the Objective-C block instead of relying on the implicit raw this pointer:
// In QueueMTL.mm
Ref<Queue> selfRef = this;
[*pendingCommands addCompletedHandler:^(id<MTLCommandBuffer>) {
selfRef->UpdateCompletedSerialTo(QueuePriority::Lowest, pendingSerial);
}];
This ensures the Queue’s reference count remains elevated until the completion handler fully finishes executing, preventing the main thread from deleting the object prematurely.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.