Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Dawn
DescriptionUse after free in Dawn
ComponentDawn
Sub-componentDawn
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Impact says 'compromised renderer/GPU' yet factors claim web/default. Backend-specific (default WaitForQueueSerial idle-wait only). Diff doesn't show Chrome WebGPU hitting this cross-thread race. Wrong that destroyer waits until updater finishes: the waitingForIdle path skips the mCallingCallbacks write because the destroyer is woken immediately.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker497969820
Fix commit7c11e1188705 (dawn) +29/-17
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-31 (per vendor advisory)
Fix landed2026-04-08 (public git)
Disclosed2026-04-15
Patch gap7 days — fix public in source before the advisory

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 mCompletedSerial records 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, and mCallingCallbacks, 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.

Key insight
The single mistake was publishing the completion serial before consulting queue state, which let a concurrently destroying thread free the queue out from under the still-running function; the fix defers the serial update until after the state check so the wait is never satisfied while the queue is still in use.

Attack Path

  1. Concurrent submit and destroy One thread updates completed serials as GPU work finishes while another thread begins tearing down the device and enters WaitForIdleForDestruction.
  2. Set the waiting-for-idle state The destroying thread sets mWaitingForIdle and blocks in a WaitForQueueSerial-backed wait that only checks the completed-serial value.
  3. Premature serial publish The updating thread enters UpdateCompletedSerialToInternal and writes the new value to mCompletedSerial before reading mState, satisfying the destroyer’s wait condition.
  4. Destroyer frees the queue The awakened destroying thread completes WaitForIdleForDestruction and frees the Queue and its owning device.
  5. Use-after-free The original thread continues in UpdateCompletedSerialToInternal, dereferencing now-freed mState/members, yielding memory corruption an attacker can shape from crafted WebGPU workloads.

Impact Assessment

An attacker who can drive WebGPU work and device teardown from a compromised or malicious renderer/GPU-process context gains a use-after-free on freed 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

FunctionChangeNotes
if
src/dawn/native/ExecutionQueue.cpp
modified
for
src/dawn/native/ExecutionQueue.cpp
modified

Files Changed

  • src/dawn/native/ExecutionQueue.cpp
  • src/dawn/native/ExecutionQueue.h

Audit Directions

  • Publish-before-check ordering
    Look 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 counters
    Audit 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 state
    Review 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.
From 7c11e118870577ccf42a84108fed8890c71c69e7 Mon Sep 17 00:00:00 2001
From: Lokbondo Kung <[email protected]>
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 <[email protected]>
Commit-Queue: Loko Kung <[email protected]>
---

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.
Loading diff…

Original Bug Report

reported by [email protected]

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.cpp
  • third_party/dawn/src/dawn/native/metal/QueueMTL.mm
  • third_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:

  1. mCompletedSerial.Use(...): Updates the completed serial and signals a condition variable (notify_all()).
  2. 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.)

  1. Setup: From a compromised renderer process, create a WebGPU Device and Queue.
  2. Submit Work: Submit a WebGPU command buffer that takes a predictable amount of time to execute.
  3. Trigger Teardown: Immediately drop all renderer-side references to the WebGPU device, prompting the GPU process to call DeviceBase::WillDropLastExternalRef() and subsequently WaitForIdleForDestruction().
  4. Heap Spray: Rapidly spray the GPU process heap via other WebGPU allocations (e.g., buffer creations) sized perfectly to reclaim the Queue object’s memory once it is freed.
  5. 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 fake mState object with a forged mWaitingProcessors vector.
  6. Hijack Control Flow: The background thread resumes, accesses the sprayed mState memory, and iterates over the fake mWaitingProcessors. It executes processor->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.

View on issue tracker