CVE-2026-4678
Overview
Background
- Dawn wire
- Chrome’s WebGPU client/server serialization layer; the
clienthalf runs in the sandboxed renderer process and proxiesWGPU*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 inCompleteImpl. - `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
Tbehind a mutex, exposed viaUse([&](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.
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
- Start async map
The renderer calls
wgpuBufferMapAsync, creating aMapAsyncEventwith an outstanding future awaiting the server reply. - Race the abort
On one thread the page calls
Buffer::UnmaporBuffer::Destroyto abort the map while, on another, the server reply arrives to complete it. - Concurrent write of response
Both paths write
mStatus/mMessageat once; thestd::string’s internal buffer is reassigned/freed by one thread while the other reads or frees it. - Use-after-free at completion
CompleteImplreads the corruptedResponseand passes a danglingmessageview (viaToOutputStringView) into the user callback. - Exploitation A crafted heap layout turns the freed-string read into an attacker-influenced read/write primitive within the renderer.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifsrc/dawn/wire/client/Buffer.cpp |
modified | |
switchsrc/dawn/wire/client/Buffer.cpp |
modified |
Files Changed
src/dawn/wire/client/Buffer.cppsrc/dawn/wire/client/EventManager.h
Audit Directions
- Async event state shared with client-side abortAudit other wire-client events whose completion fields can be written both by a server reply and a client-initiated
Destroy/Unmap/cancel path; requireMutexProtectedor single-owner handoff. - Unsynchronized heap-owning membersFlag 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` callersTrace all callers of
EventManager::SetFutureReadyto confirm the underlying event’s payload is captured under a lock (moved out) before the callback consumes it.
Patch
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();
Original Bug Report
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.