Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Dawn
DescriptionUse after free in Dawn
ComponentDawn
Bug ClassUAF
Tracker517303276
Fix commita2cff62294d3 (dawn) +34/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-08

Changed Functions

FunctionChangeNotes
TEST
src/dawn/tests/unittests/MutexProtectedTests.cpp
modified

Files Changed

  • src/dawn/common/MutexProtected.h
  • src/dawn/tests/unittests/MutexProtectedTests.cpp
From a2cff62294d3d3bfc9aaaab047dff2ef0f06c9e9 Mon Sep 17 00:00:00 2001
From: Corentin Wallez <cwallez@chromium.org>
Date: Thu, 28 May 2026 14:39:02 -0700
Subject: [PATCH] [dawn][common] Signal the CondVar before unlocking the mutex.

MutexCondVarProtected's guard unlocks the mutex before signaling the
condition variable. This means that usage of the condition variable
itself is not always protected by the mutex, in particular an other
thread could free the memory between mutex.unlock() and cv.signal().

Reorder the members of CondVarGuard to first notify the condition
variable and then unlock the mutex.

Adds a test that tries to catch the incorrect behavior when running
under TSan. Without the fix it reports a warning.

Fixed: 517303276
Change-Id: I373bdc4224d90b999199c58c5de0be1ba5f04c47
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/311897
Reviewed-by: Loko Kung <lokokung@google.com>
Commit-Queue: Corentin Wallez <cwallez@chromium.org>
---

diff --git a/src/dawn/common/MutexProtected.h b/src/dawn/common/MutexProtected.h
index d47a245..6f09151 100644
--- a/src/dawn/common/MutexProtected.h
+++ b/src/dawn/common/MutexProtected.h
@@ -224,7 +224,7 @@
 
   protected:
     CondVarGuard(T* obj, Traits::MutexType& mutex, std::condition_variable* cv)
-        : mNotifyScope(cv), mGuard(obj, mutex) {}
+        : mGuard(obj, mutex), mNotifyScope(cv) {}
 
     auto* Get() const { return mGuard.Get(); }
 
@@ -251,10 +251,11 @@
         }
     };
 
-    NotifyScope<NotifyT> mNotifyScope;
-    // Note that this class needs to hold a Guard member instead of extending it because we want the
-    // lock to be released before we notify.
+    // Note that the Guard must be before the NotifyScope so that the C++ member destruction order
+    // signals the condition variable before unlocking the mutex. This keeps all uses of the
+    // condition variable guarded by the mutex.
     Guard<T, Traits> mGuard;
+    NotifyScope<NotifyT> mNotifyScope;
 };
 
 }  // namespace detail
diff --git a/src/dawn/tests/unittests/MutexProtectedTests.cpp b/src/dawn/tests/unittests/MutexProtectedTests.cpp
index 5a9fafc..9c08ba4 100644
--- a/src/dawn/tests/unittests/MutexProtectedTests.cpp
+++ b/src/dawn/tests/unittests/MutexProtectedTests.cpp
@@ -25,6 +25,7 @@
 // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
+#include <memory>
 #include <string>
 #include <thread>
 #include <type_traits>
@@ -32,6 +33,7 @@
 #include <vector>
 
 #include "dawn/common/MutexProtected.h"
+#include "dawn/common/Range.h"
 #include "dawn/common/Ref.h"
 #include "dawn/common/RefCounted.h"
 #include "dawn/common/Time.h"
@@ -262,6 +264,33 @@
     thread2.join();
 }
 
+// Regression test for https://crbug.com/517303276 where the condition variable is signaled after
+// the mutex is unlocked, which could lead to issues because work on other threads could happen in
+// between the two (including destruction of the cond var itself).
+TEST(MutexCondVarProtectedTest, NotifyIsInLock) {
+    std::vector<std::unique_ptr<MutexCondVarProtected<bool>>> condVars;
+    for (size_t _ : Range(100)) {
+        condVars.push_back(std::make_unique<MutexCondVarProtected<bool>>(false));
+    }
+
+    std::thread doDestruction([&] {
+        for (size_t i : Range(condVars.size())) {
+            condVars[i]->Use(
+                [&](auto c) { c.Wait([](bool readyToDestroy) { return readyToDestroy; }); });
+            condVars[i] = nullptr;
+        }
+    });
+
+    std::thread markForDestruction([&] {
+        for (size_t i : Range(condVars.size())) {
+            condVars[i]->Use([&](auto c) { *c = true; });
+        }
+    });
+
+    markForDestruction.join();
+    doDestruction.join();
+}
+
 // Test that if we specifically ask for only one thread to be notified, then only one thread should
 // wake up from waiting.
 TEST(MutexCondVarProtectedTest, NotifyTypes) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/dawn/tests/unittests/MutexProtectedTests.cpp b/src/dawn/tests/unittests/MutexProtectedTests.cpp
index 5a9fafc..9c08ba4 100644
--- a/src/dawn/tests/unittests/MutexProtectedTests.cpp
+++ b/src/dawn/tests/unittests/MutexProtectedTests.cpp
@@ -25,6 +25,7 @@
 // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
+#include <memory>
 #include <string>
 #include <thread>
 #include <type_traits>
@@ -32,6 +33,7 @@
 #include <vector>
 
 #include "dawn/common/MutexProtected.h"
+#include "dawn/common/Range.h"
 #include "dawn/common/Ref.h"
 #include "dawn/common/RefCounted.h"
 #include "dawn/common/Time.h"
@@ -262,6 +264,33 @@
     thread2.join();
 }
 
+// Regression test for https://crbug.com/517303276 where the condition variable is signaled after
+// the mutex is unlocked, which could lead to issues because work on other threads could happen in
+// between the two (including destruction of the cond var itself).
+TEST(MutexCondVarProtectedTest, NotifyIsInLock) {
+    std::vector<std::unique_ptr<MutexCondVarProtected<bool>>> condVars;
+    for (size_t _ : Range(100)) {
+        condVars.push_back(std::make_unique<MutexCondVarProtected<bool>>(false));
+    }
+
+    std::thread doDestruction([&] {
+        for (size_t i : Range(condVars.size())) {
+            condVars[i]->Use(
+                [&](auto c) { c.Wait([](bool readyToDestroy) { return readyToDestroy; }); });
+            condVars[i] = nullptr;
+        }
+    });
+
+    std::thread markForDestruction([&] {
+        for (size_t i : Range(condVars.size())) {
+            condVars[i]->Use([&](auto c) { *c = true; });
+        }
+    });
+
+    markForDestruction.join();
+    doDestruction.join();
+}
+
 // Test that if we specifically ask for only one thread to be notified, then only one thread should
 // wake up from waiting.
 TEST(MutexCondVarProtectedTest, NotifyTypes) {
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Use-After-Free in Dawn Metal backend via notify-after-unlock race

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 Use-After-Free (UAF) vulnerability exists in the Dawn Metal backend due to a race condition between a background command completion handler and Queue destruction. The background handler captures a raw Queue pointer, and the synchronization primitive used (CondVarGuard) releases its lock before notifying the condition variable, allowing the Queue to be destroyed prematurely.

Affected files:

  • third_party/dawn/src/dawn/native/metal/QueueMTL.mm
  • third_party/dawn/src/dawn/native/ExecutionQueue.cpp
  • third_party/dawn/src/dawn/common/MutexProtected.h

Estimated timestamp from git blame: 2026-03-02

Summary

A potential race condition leading to a Use-After-Free (UAF) vulnerability exists in the Dawn Metal backend. The issue occurs when an asynchronous Metal command buffer completion handler executes on a background thread while the main thread is destroying the associated Queue object. The synchronization mechanism used (CondVarGuard in MutexProtected.h) releases its internal lock before notifying the condition variable, allowing the destruction process to proceed prematurely and free the Queue (and its internal synchronization primitives) before the background thread finishes executing its destructor sequence.

Technical Details

In third_party/dawn/src/dawn/native/metal/QueueMTL.mm, the SubmitPendingCommandBuffer method registers a completion handler using Metal’s addCompletedHandler:

[*pendingCommands addCompletedHandler:^(id<MTLCommandBuffer>) {
    ...
    this->UpdateCompletedSerialTo(QueuePriority::Lowest, pendingSerial);
}];

This block captures the this pointer of the Queue object as a raw pointer and runs on a background thread managed by the Metal framework.

The background thread eventually calls ExecutionQueueBase::UpdateCompletedSerialToInternal in third_party/dawn/src/dawn/native/ExecutionQueue.cpp to update the queue’s completed serial:

mCompletedSerial.Use([&](auto completedSerial) {
    *completedSerial = std::max(*completedSerial, static_cast<uint64_t>(newCompletedSerial));
});

The serial update uses mCompletedSerial.Use(), which returns a CondVarGuard from third_party/dawn/src/dawn/common/MutexProtected.h.

CondVarGuard is designed to release its lock before notifying waiting threads. This is achieved by declaring mGuard after mNotifyScope in the class definition, ensuring the lock (mGuard) is destroyed first in the destructor sequence:

template <typename T, typename Traits, NotifyType NotifyT = NotifyType::All>
class CondVarGuard : public NonMovable, StackAllocated {
...
  private:
    NotifyScope<NotifyT> mNotifyScope;
    Guard<T, Traits> mGuard;
};

This creates an unlock-then-notify gap. The potential vulnerability manifests via the following race sequence:

  1. The background thread updates the serial value inside UpdateCompletedSerialToInternal and begins executing the destructor of CondVarGuard.
  2. First, mGuard is destroyed, releasing the mutex on mCompletedSerial.
  3. Simultaneously, the main thread is waiting for the queue to idle during destruction in Queue::WaitForIdleForDestructionImpl(), which blocks on mCompletedSerial via WaitForQueueSerialImpl().
  4. Because the background thread has released the lock and updated the serial, the main thread’s wait predicate becomes true. The main thread can acquire the lock, observe the predicate as satisfied, and return from the wait immediately without waiting for the condition variable notification.
  5. The main thread proceeds to complete the destruction of the Queue object, freeing mCompletedSerial (and its internal std::condition_variable and std::mutex objects).
  6. The background thread, still executing the destructor of CondVarGuard, resumes and attempts to run ~NotifyScope(), which calls this->cv->notify_all(). However, cv is a bare std::condition_variable* pointing to the now-freed member inside the destroyed Queue object, causing a Use-After-Free (UAF).

Note: These are potential steps and findings as our tooling agent does not currently have the ability to execute code and run a live proof of concept.

Impact and Scope

If exploitable, an attacker with the ability to execute WebGPU code (e.g., from a compromised or malicious renderer process) could trigger this race by rapidly submitting command buffers and immediately destroying the device and queue. This would lead to memory corruption or a crash in the GPU process. Since Metal is the default backend on macOS and iOS, this represents a high-impact potential vulnerability.

MiraclePtr does not mitigate this because the stack-allocated NotifyScope holds a bare std::condition_variable* rather than raw_ptr<>, and the Objective-C block captures this as a raw pointer.

Suggested Fix

The vulnerability can be resolved by ensuring that the condition variable is notified before the mutex lock is released in CondVarGuard. This guarantees that the background thread has finished accessing the condition variable before the waiting thread can acquire the lock and destroy the parent Queue object.

This can be done by changing the declaration order of the member variables in CondVarGuard in third_party/dawn/src/dawn/common/MutexProtected.h:

  private:
    Guard<T, Traits> mGuard;
    NotifyScope<NotifyT> mNotifyScope;

By declaring mGuard first, mNotifyScope will be destroyed first, calling notify_all() while the lock is still held.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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