Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebGPU
DescriptionUse after free in WebGPU
ComponentWebGPU
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
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker491164019
Fix commit8974f6f5ba39 (dawn) +33/-16
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-10 (per vendor advisory)
Fix landed2026-03-14 (public git)
Disclosed2026-03-23
Patch gap9 days — fix public in source before the advisory

Background

Dawn wire
Chrome’s WebGPU client/server serialization layer; the client half runs in the sandboxed renderer process and proxies WGPU* calls to the GPU-process server.
`MapAsyncEvent`
the async-callback event backing wgpuBufferMapAsync; it holds the completion state (status + message) consumed by the user’s callback in CompleteImpl.
`EventManager::SetFutureReady`
marks a future’s event as ready to fire its callback, driven either by a server reply or by a client-side abort.
`MutexProtected<T>`
a Dawn wrapper that gates access to T behind a mutex, exposed via Use([&](auto res){...}).

Root Cause Analysis

The MapAsyncEvent stored its completion state as plain, unsynchronized members mStatus (WGPUMapAsyncStatus) and mMessage (std::string) in src/dawn/wire/client/Buffer.cpp. Two distinct threads can write these members: the server-reply path (delivering the mapping result) and the client abort path invoked from Buffer::Unmap / Buffer::Destroy, which forces the map to fail. Because MapAsync uniquely allows the client to abort a still-outstanding request, the abort and the reply can execute concurrently with no lock, producing a data race on mStatus and, critically, on the heap-backed std::string mMessage. One thread can be reading or freeing the std::string’s buffer while the other reassigns or destroys it, yielding a use-after-free of the response state that CompleteImpl feeds to the callback.

The fix consolidates the fields into a struct Response { WGPUMapAsyncStatus status; std::string message; } guarded by MutexProtected<Response> mResponse, so all writers mutate it under the mutex and CompleteImpl moves a private copy out under the lock before invoking the callback.

Key insight
Only MapAsync exposes a client-initiated abort (Unmap/Destroy) that can race the asynchronous server reply, so the unsynchronized std::string mMessage becomes a shared, concurrently-mutated heap object rather than a single-writer field.

Attack Path

  1. Start async map The renderer calls wgpuBufferMapAsync, creating a MapAsyncEvent with an outstanding future awaiting the server reply.
  2. Race the abort On one thread the page calls Buffer::Unmap or Buffer::Destroy to abort the map while, on another, the server reply arrives to complete it.
  3. Concurrent write of response Both paths write mStatus/mMessage at once; the std::string’s internal buffer is reassigned/freed by one thread while the other reads or frees it.
  4. Use-after-free at completion CompleteImpl reads the corrupted Response and passes a dangling message view (via ToOutputStringView) into the user callback.
  5. Exploitation A crafted heap layout turns the freed-string read into an attacker-influenced read/write primitive within the renderer.

Impact Assessment

This is a renderer-process use-after-free in the WebGPU wire client, reachable from ordinary JavaScript that drives mapAsync alongside unmap/destroy. The data race on the std::string response state can corrupt heap memory and feed a dangling pointer to the callback, enabling memory disclosure or control-flow corruption inside the sandboxed renderer.

Changed Functions

FunctionChangeNotes
if
src/dawn/wire/client/Buffer.cpp
modified
switch
src/dawn/wire/client/Buffer.cpp
modified

Files Changed

  • src/dawn/wire/client/Buffer.cpp
  • src/dawn/wire/client/EventManager.h

Audit Directions

  • Async event state shared with client-side abort
    Audit other wire-client events whose completion fields can be written both by a server reply and a client-initiated Destroy/Unmap/cancel path; require MutexProtected or single-owner handoff.
  • Unsynchronized heap-owning members
    Flag plain std::string/std::vector/pointer members on wire objects touched by more than one thread, where concurrent reassignment can free a buffer another thread still reads.
  • `SetFutureReady` callers
    Trace all callers of EventManager::SetFutureReady to confirm the underlying event’s payload is captured under a lock (moved out) before the callback consumes it.
From 8974f6f5ba398442026895b7ef3be6fa07b2c22b Mon Sep 17 00:00:00 2001
From: Lokbondo Kung <[email protected]>
Date: Fri, 13 Mar 2026 21:26:29 -0700
Subject: [PATCH] [dawn][wire][client] Prevent race condition in EventManager::SetFutureReady

The race only really happens for MapAsync because we can race between the
client aborting the map via Unmap or Destroy, and the server replying. As
a result, we can just put a lock around that particular area.

Bug: 491164019
Change-Id: Idb74cd07ed5ac94ab89522ffbea1f1b404bfc648
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/296655
Reviewed-by: Kai Ninomiya <[email protected]>
Reviewed-by: Corentin Wallez <[email protected]>
Commit-Queue: Kai Ninomiya <[email protected]>
Auto-Submit: Loko Kung <[email protected]>
---

diff --git a/src/dawn/wire/client/Buffer.cpp b/src/dawn/wire/client/Buffer.cpp
index 84abc8a..f6be16b 100644
--- a/src/dawn/wire/client/Buffer.cpp
+++ b/src/dawn/wire/client/Buffer.cpp
@@ -88,8 +88,10 @@
                          uint64_t readDataUpdateInfoLength = 0,
                          const uint8_t* readDataUpdateInfo = nullptr) {
         if (status != WGPUMapAsyncStatus_Success) {
-            mStatus = status;
-            mMessage = ToString(message);
+            mResponse.Use([&](auto response) {
+                response->status = status;
+                response->message = ToString(message);
+            });
             return WireResult::Success;
         }
 
@@ -100,12 +102,14 @@
         }
 
         auto FailRequest = [this](const char* message) -> WireResult {
-            mStatus = static_cast<WGPUMapAsyncStatus>(0);
-            mMessage = message;
+            mResponse.Use([&](auto response) {
+                response->status = static_cast<WGPUMapAsyncStatus>(0);
+                response->message = message;
+            });
             return WireResult::FatalError;
         };
 
-        mStatus = status;
+        mResponse->status = status;
         const auto& pending = mBuffer->mPendingMapRequest.value();
         if (!pending.type) {
             return FailRequest("Invalid map call without a specified mapping type.");
@@ -140,31 +144,36 @@
 
   private:
     void CompleteImpl(FutureID futureID, EventCompletionType completionType) override {
+        // Move the response while holding the lock so that we avoid racing against the callback
+        // firing and the server replying with a response.
+        Response response = {};
+        mResponse.Use([&](auto res) { response = std::move(*res); });
+
         if (completionType == EventCompletionType::Shutdown) {
-            mStatus = WGPUMapAsyncStatus_CallbackCancelled;
-            mMessage = "A valid external Instance reference no longer exists.";
+            response.status = WGPUMapAsyncStatus_CallbackCancelled;
+            response.message = "A valid external Instance reference no longer exists.";
         }
 
-        auto Callback = [this]() {
+        auto Callback = [&]() {
             if (mCallback) {
-                mCallback(mStatus, ToOutputStringView(mMessage), mUserdata1.ExtractAsDangling(),
-                          mUserdata2.ExtractAsDangling());
+                mCallback(response.status, ToOutputStringView(response.message),
+                          mUserdata1.ExtractAsDangling(), mUserdata2.ExtractAsDangling());
             }
         };
 
         // The request has been cancelled before completion, return that result.
         if (!IsPendingRequest(futureID)) {
-            DAWN_ASSERT(mStatus != WGPUMapAsyncStatus_Success);
+            DAWN_ASSERT(response.status != WGPUMapAsyncStatus_Success);
             return Callback();
         }
 
         // Device destruction/loss implicitly makes the map requests aborted.
         if (!mBuffer->mDevice->IsAlive()) {
-            mStatus = WGPUMapAsyncStatus_Aborted;
-            mMessage = "The Device was lost before mapping was resolved.";
+            response.status = WGPUMapAsyncStatus_Aborted;
+            response.message = "The Device was lost before mapping was resolved.";
         }
 
-        if (mStatus == WGPUMapAsyncStatus_Success) {
+        if (response.status == WGPUMapAsyncStatus_Success) {
             DAWN_ASSERT(mBuffer->mPendingMapRequest && mBuffer->mPendingMapRequest->type);
             switch (*mBuffer->mPendingMapRequest->type) {
                 case MapRequestType::Read:
@@ -183,8 +192,14 @@
     raw_ptr<void> mUserdata1;
     raw_ptr<void> mUserdata2;
 
-    WGPUMapAsyncStatus mStatus;
-    std::string mMessage;
+    // The response for the map async callback needs to be protected with a lock since the response
+    // can be updated from the server (via a response) or from the client (via an unmap/destroy
+    // call).
+    struct Response {
+        WGPUMapAsyncStatus status;
+        std::string message;
+    };
+    MutexProtected<Response> mResponse;
 
     // Strong reference to the buffer so that when we call the callback we can pass the buffer.
     Ref<Buffer> mBuffer;
diff --git a/src/dawn/wire/client/EventManager.h b/src/dawn/wire/client/EventManager.h
index 16e442c..4d27f2b 100644
--- a/src/dawn/wire/client/EventManager.h
+++ b/src/dawn/wire/client/EventManager.h
@@ -76,6 +76,8 @@
     virtual EventType GetType() = 0;
 
     WGPUCallbackMode GetCallbackMode() const;
+
+    // Returns true iff the event is not |Pending|.
     bool IsReady() const;
 
     void SetReady();
Loading diff…

Original Bug Report

reported by [email protected]

Heap-use-after-free in blink::AsciiStringAttributes blink::CharacterAttributes<unsigned char>

Detailed Report: https://clusterfuzz.com/testcase?key=4605331497746432

Fuzzer: btiszka_webgpudomato Job Type: linux_asan_chrome_webgpu Platform Id: linux

Crash Type: Heap-use-after-free READ 16 Crash Address: 0x769ba8aa9980 Crash State: blink::AsciiStringAttributes blink::CharacterAttributes<unsigned char> blink::String::FromUTF8 blink::GPUBuffer::OnMapAsyncCallback

Sanitizer: address (ASAN)

Recommended Security Severity: Critical

Regressed: https://clusterfuzz.com/revisions?job=linux_asan_chrome_webgpu&range=1561910:1561922

Reproducer Testcase: https://clusterfuzz.com/download?testcase_id=4605331497746432

Issue filed automatically.

To reproduce this, please build the target in this report and run it against the reproducer testcase. Please use the GN arguments provided at bottom of this report when building the binary.

If you have trouble reproducing, please also export the environment variables listed under “[Environment]” in the crash stacktrace.

If you have any feedback on reproducing test cases, let us know at https://forms.gle/Yh3qCYFveHj6E5jz5 so we can improve.

View on issue tracker