CVE-2026-106375
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/dawn/native/Device.cpp |
modified | |
switchsrc/dawn/native/Device.cpp |
modified |
Files Changed
src/dawn/native/Device.cppsrc/dawn/native/Device.hsrc/dawn/native/ExecutionQueue.cpp
Patch
From 85124738f57301ae5935a98d42b26f59807f1e02 Mon Sep 17 00:00:00 2001
From: Lokbondo Kung <lokokung@google.com>
Date: Mon, 31 Aug 2026 18:30:45 -0700
Subject: [PATCH] [native] Add additional paths to trigger device cleanup.
- In https://dawn-review.git.corp.google.com/c/dawn/+/302578 we
deferred device disconnection to happen only when either the
last external reference is dropped, or if an explicit Destroy
was called. This change adds additional places where we can
cause the cleanup to happen inline, specifically in Tick,
WaitAny, and ProcessEvents. The motivation behind this change
is two-folds:
1) To handle cases like the one found in crbug.com/536507840
where WaitAny and ProcessEvents could be exploited to
submit incomplete or error-ed pending commands.
2) Waiting until an explicit Destroy or last external ref
drop can sometimes take a while, so providing another
way that an embedder can clean up resources is beneficial
to keep resources low.
Bug: 536507840
Change-Id: I7b5d8136f25d16c9c0a4f94279a75ed5b8f555c7
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/337315
Commit-Queue: Loko Kung <lokokung@google.com>
Reviewed-by: Kai Ninomiya <kainino@chromium.org>
---
diff --git a/src/dawn/native/Device.cpp b/src/dawn/native/Device.cpp
index 79df945..c812391 100644
--- a/src/dawn/native/Device.cpp
+++ b/src/dawn/native/Device.cpp
@@ -552,7 +552,7 @@
}
// Move away from the Alive state now so that the application cannot use this device anymore.
- auto state = mState.exchange(State::BeingDisconnected);
+ SetDisconnectingIfAlive();
// This function may be called re-entrantly inside APITick(). Tick triggers callbacks
// inside which the application may destroy the device. Thus, we should be careful not
@@ -564,7 +564,7 @@
// from Tick() whether or not there is any more pending work.
// Skip handling device facilities if they haven't even been created (or failed doing so)
- if (state != State::BeingCreated) {
+ if (mState != State::BeingCreated) {
// The device is being destroyed so it will be lost, call the application callback.
HandleDeviceLost(wgpu::DeviceLostReason::Destroyed, "Device was destroyed.");
@@ -577,41 +577,9 @@
// DestroyImpl() as it may relinquish resources that will be freed by backends in the
// DestroyImpl() call.
DestroyObjects();
- }
- // Disconnect the device, depending on which state we are currently in.
- switch (state) {
- // The GPU timeline was never started so we don't have to wait.
- case State::BeingCreated:
- // The GPU is no longer functional so we don't wait.
- case State::Disconnected:
- break;
-
- // The device is alive so we need to wait for all in-flight work.
- case State::Alive:
- // The device was placed in an error state as a result of unexpected errors which we can no
- // longer recover from.
- case State::BeingDisconnected:
- // Wait for all GPU work to complete before proceeding with destruction.
- // Ignore errors so that we can continue with destruction.
- IgnoreErrors(mQueue->WaitForIdleForDestruction());
-
- // Call TickImpl once last time to clean up resources
- // Ignore errors so that we can continue with destruction
- IgnoreErrors(TickImpl());
- break;
-
- // If we are already destroyed we should've skipped this work entirely.
- case State::Destroyed:
- DAWN_UNREACHABLE();
- break;
- }
-
- if (state != State::BeingCreated) {
- // The GPU timeline is finished.
- mQueue->AssumeCommandsComplete();
- DAWN_CHECK(mQueue->GetCompletedCommandSerial() >= mQueue->GetLastSubmittedCommandSerial());
- mQueue->Tick(mQueue->GetCompletedCommandSerial());
+ // Disconnect the GPU timeline if it hasn't been disconnected already.
+ Disconnect();
}
// At this point GPU operations are always finished, so we are in the disconnected state.
@@ -685,10 +653,7 @@
}
if (type == InternalErrorType::DeviceLost) {
- // Transition to a non-alive state if we are currently alive so that the application can no
- // longer use the device.
- State prev = State::Alive;
- mState.compare_exchange_strong(prev, State::BeingDisconnected, std::memory_order::acq_rel);
+ SetDisconnectingIfAlive();
}
// Re-enable validation on device loss or OOM to avoid unpredictable behaviors afterwards.
@@ -946,6 +911,39 @@
return mState != State::Alive;
}
+void DeviceBase::SetDisconnectingIfAlive() {
+ State state = State::Alive;
+ mState.compare_exchange_strong(state, State::Disconnecting, std::memory_order::acq_rel);
+}
+
+void DeviceBase::Disconnect() {
+ DAWN_ASSERT(IsLockedByCurrentThreadIfNeeded());
+ switch (mState) {
+ case State::Alive:
+ case State::Destroyed:
+ DAWN_UNREACHABLE();
+ case State::BeingCreated:
+ case State::Disconnected:
+ return;
+ case State::Disconnecting:
+ break;
+ }
+ DAWN_ASSERT(mQueue != nullptr);
+
+ // Wait for all GPU work to complete before proceeding with destruction.
+ IgnoreErrors(mQueue->WaitForIdleForDestruction());
+
+ // Wait for all GPU work to complete before proceeding with destruction.
+ IgnoreErrors(TickImpl());
+
+ // The GPU timeline is finished.
+ mQueue->AssumeCommandsComplete();
+ DAWN_CHECK(mQueue->GetCompletedCommandSerial() >= mQueue->GetLastSubmittedCommandSerial());
+ mQueue->Tick(mQueue->GetCompletedCommandSerial());
+
+ mState = State::Disconnected;
+}
+
ApiObjectList* DeviceBase::GetObjectTrackingList(ObjectType type) {
return &mObjectLists[type];
}
@@ -1598,6 +1596,9 @@
}
MaybeError DeviceBase::Tick() {
+ if (mState == State::Disconnecting) {
+ Disconnect();
+ }
if (IsLost() || !mQueue->HasScheduledCommands()) {
return {};
}
diff --git a/src/dawn/native/Device.h b/src/dawn/native/Device.h
index f9afdba..fb6411d 100644
--- a/src/dawn/native/Device.h
+++ b/src/dawn/native/Device.h
@@ -350,7 +350,7 @@
// (both for the application calling WebGPU, or re-entrant calls). No work exists on
// the GPU timeline.
// - Alive: the device is usable and might have work happening on the GPU timeline.
- // - BeingDisconnected: the device is no longer usable because we are waiting for all
+ // - Disconnecting: the device is no longer usable because we are waiting for all
// work on the GPU timeline to finish. (this is to make validation prevent the
// application from adding more work during the transition from Alive to
// Disconnected)
@@ -360,12 +360,14 @@
enum class State {
BeingCreated,
Alive,
- BeingDisconnected,
+ Disconnecting,
Disconnected,
Destroyed,
};
State GetState() const;
bool IsLost() const;
+ void SetDisconnectingIfAlive();
+ void Disconnect();
ApiObjectList* GetObjectTrackingList(ObjectType type);
const ApiObjectList* GetObjectTrackingList(ObjectType type) const;
diff --git a/src/dawn/native/ExecutionQueue.cpp b/src/dawn/native/ExecutionQueue.cpp
index 69c9a7d..563c10d 100644
--- a/src/dawn/native/ExecutionQueue.cpp
+++ b/src/dawn/native/ExecutionQueue.cpp
@@ -93,20 +93,34 @@
return {};
}
+ // Check whether the serial has even been flushed. If it hasn't we need to flush it before we
+ // can even wait for it.
+ if (waitSerial > GetLastSubmittedCommandSerial()) {
+ auto deviceGuard = GetDevice()->GetGuard();
+ switch (GetDevice()->GetState()) {
+ case DeviceBase::State::Alive:
+ break;
+ case DeviceBase::State::Disconnecting:
+ GetDevice()->Disconnect();
+ return {};
+ case DeviceBase::State::Disconnected:
+ case DeviceBase::State::Destroyed:
+ return {};
+ case DeviceBase::State::BeingCreated:
+ DAWN_UNREACHABLE();
+ }
+ // Check submitted command serial again since it could have been incremented already.
+ if (waitSerial > GetLastSubmittedCommandSerial()) {
+ // Serial has not been submitted yet. Submit it now.
+ DAWN_TRY(EnsureCommandsFlushed(waitSerial));
+ }
+ }
+
// We currently have two differing implementations for this function depending on whether the
// backend supports thread safe waits. Note that while currently only the Metal backend
// explicitly enables thread safe wait, the main blocking backend is D3D11 which is using the
// value of |mCompletedSerial| within it's implementation of |CheckAndUpdateCompletedSerials|.
if (GetDevice()->IsToggleEnabled(Toggle::WaitIsThreadSafe)) {
- if (waitSerial > GetLastSubmittedCommandSerial()) {
- auto deviceGuard = GetDevice()->GetGuard();
- // Check submitted command serial again since it could have been incremented already.
- if (waitSerial > GetLastSubmittedCommandSerial()) {
- // Serial has not been submitted yet. Submit it now.
- DAWN_TRY(EnsureCommandsFlushed(waitSerial));
- }
- }
-
if (timeout > Nanoseconds(0u)) {
// We should never need to wait for queue serials after the device has been
// Disconnected.
@@ -122,11 +136,6 @@
} else {
// Otherwise, we need to acquire the device lock first.
auto deviceGuard = GetDevice()->GetGuard();
- if (waitSerial > GetLastSubmittedCommandSerial()) {
- // Serial has not been submitted yet. Submit it now.
- DAWN_TRY(EnsureCommandsFlushed(waitSerial));
- }
-
if (timeout > Nanoseconds(0u)) {
// We should never need to wait for queue serials after the device has been
// Disconnected.
Original Bug Report
Potential Sandbox Escape via Un-gated Queue Flush on Active Vulkan Render Pass in Dawn
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 structural invariant violation in Dawn’s Vulkan backend leaves a command buffer in an active render pass after an early return during command recording. By exploiting the asynchronous event polling mechanism via onSubmittedWorkDone, an attacker can bypass the ensuing DeviceLost state and force the un-gated execution of EnsureCommandsFlushed(). This submits invalid Vulkan commands (vkCmdPipelineBarrier and vkEndCommandBuffer) into the active render pass, leading to undefined behavior and a potential memory corruption primitive in the unsandboxed GPU process.
Affected files:
third_party/dawn/src/dawn/native/vulkan/CommandBufferVk.cppthird_party/dawn/src/dawn/native/vulkan/CommandBufferVk.hthird_party/dawn/src/dawn/native/vulkan/QueueVk.cppthird_party/dawn/src/dawn/native/Device.cppthird_party/dawn/src/dawn/native/Queue.cpp
Estimated timestamp from git blame: 2026-04-01
1. Summary of the Issue (Meant for Human Triage)
A structural state desynchronization vulnerability exists in Dawn’s Vulkan backend during render pass command recording. Within CommandBuffer::RecordRenderPass, RecordBeginRenderPass and RecordEndRenderPass are invoked without RAII or scoped guards. If a DAWN_TRY or DAWN_TRY_ASSIGN macro triggers an early return after the render pass has begun—for instance, due to an out-of-memory (OOM) error during specialized descriptor set allocation for external Android textures—the render pass is never explicitly ended. This leaves the persistent Queue::mRecordingContext command buffer structurally malformed within an active Vulkan render pass.
While Dawn’s downstream defense-in-depth typically catches the unexpected internal error, promotes it to DeviceLost, and gates further queue submissions, an attacker-reachable bypass exists via Dawn’s asynchronous event polling mechanism (EventManager). By registering a queue.onSubmittedWorkDone() event when pending commands exist, the event is tracked against an unsubmitted serial. Following the early-return and DeviceLost promotion, ProcessPollEvents will poll the tracked event. Because the event is not ready, it invokes EnsureCommandsFlushed(), which crucially lacks an IsLost() guard. This blindly executes SubmitPendingCommandsImpl(), emitting vkCmdPipelineBarrier and vkEndCommandBuffer into the active render pass.
This violates core Vulkan specification invariants (VUID-vkCmdPipelineBarrier-None-09553 and VUID-vkEndCommandBuffer-commandBuffer-00060). Because the GPU process is unsandboxed on Android and the malformed VkCommandBuffer triggers driver-level undefined behavior, this flaw constitutes a potential High (S1) sandbox escape / GPU memory corruption primitive.
2. Proof-of-Concept & Detailed Execution Flow
The following is a step-by-step trace of how an attacker can trigger this vulnerability (note: these are potential steps mapped directly to validated code paths):
Phase 1: Setup & Event Registration
- External Texture Import: The attacker uses
device.importExternalTexture({source: videoElement})on Android to import anAHardwareBuffer-backed video frame (Feature::OpaqueYCbCrAndroidForExternalTexture). - Pipeline Creation: The attacker creates a
GPURenderPipelinebound to this external texture, causingRenderPipelineVk::InitializeImplto setmRequiresSpecialization = true(third_party/dawn/src/dawn/native/vulkan/RenderPipelineVk.cpp:400-403). - Pending Commands Activation: The attacker executes a minor GPU operation (e.g., a buffer creation that triggers a lazy clear) to ensure
Queue::HasPendingCommands()returnstrue(i.e.,mRecordingContext.needsSubmit = true). - Event Tracking: The attacker calls
device.queue.onSubmittedWorkDone().QueueBase::APIOnSubmittedWorkDonehandles this (Queue.cpp:181). BecauseHasPendingCommands()is true,GetScheduledWorkDoneSerial()(ExecutionQueue.cpp:393-395) returns the pending command serial (GetLastSubmittedCommandSerial() + 1). An event is registered inEventManagerwaiting on this future serial.
Phase 2: Error Injection & Invariant Violation
5. Resource Exhaustion: The attacker intentionally exhausts Vulkan descriptor pools or device memory by allocating many large GPU buffers and distinct external-texture bind groups.
6. Command Recording: The attacker records a primary render pass (beginRenderPass, setPipeline, draw, endRenderPass) and submits it: device.queue.submit([commandBuffer]).
7. Vulkan Render Pass Started: During Queue::SubmitImpl (QueueVk.cpp:102), commands are translated. CommandBuffer::RecordRenderPass emits vkCmdBeginRenderPass into the active command buffer (CommandBufferVk.cpp:1748).
8. OOM Failure Triggered: Processing the Draw command invokes state.SyncAndRun (CommandBufferVk.cpp:1799). Since mRequiresSpecialization == true, it branches to RunWithSpecialization. Due to the attacker’s prior resource exhaustion, vkAllocateDescriptorSets fails, returning VK_ERROR_OUT_OF_DEVICE_MEMORY.
9. Early Return (The Bug): Dawn’s CheckVkSuccessImpl converts this to DAWN_INTERNAL_ERROR (InternalErrorType::Internal). The DAWN_TRY_ASSIGN macro causes an immediate early return from RunWithSpecialization and RecordRenderPass, completely skipping the required RecordEndRenderPass call (CommandBufferVk.cpp:2036).
10. State Leakage: Queue::SubmitImpl also early-returns, bypassing DAWN_TRY(SubmitPendingCommandsImpl()) (QueueVk.cpp:110). The absl::Cleanup block that resets the context is skipped, leaking the active render pass state inside mRecordingContext (with needsSubmit = true).
Phase 3: Escalation via Un-gated Event Polling
11. Device Lost Promotion: QueueBase::APISubmit consumes the error (Queue.cpp:177). DeviceBase::ConsumeError identifies InternalErrorType::Internal, concludes the backend cannot recover, and promotes it to DeviceLost (Device.cpp:689), transitioning the device to State::BeingDisconnected.
12. Event Polling: InstanceBase::ProcessEvents() eventually calls EventManager::ProcessPollEvents() (EventManager.cpp:223).
13. Bypassing the Guard: The WorkDoneEvent from Step 4 evaluates IsReadyToComplete(). Since the pending serial was never submitted (due to the early return), waitSerial > GetLastSubmittedCommandSerial() remains true. The event is not ready.
14. Flushing Commands: ProcessPollEvents calls WaitQueueSerials (EventManager.cpp:256), which calls queue->WaitForQueueSerial. Because waitSerial > GetLastSubmittedCommandSerial(), it invokes DAWN_TRY(EnsureCommandsFlushed(waitSerial)) (ExecutionQueue.cpp:106).
15. Undefined Behavior Reached: EnsureCommandsFlushed (ExecutionQueue.cpp:330-338) lacks an IsLost() check. It unconditionally calls SubmitPendingCommands(), invoking SubmitPendingCommandsImpl() (QueueVk.cpp:296).
16. Invalid Vulkan Emission: Because mRecordingContext.needsSubmit is still true, SubmitPendingCommandsImpl processes the leaked context. It calls Buffer::TransitionMappableBuffersEagerly (emitting vkCmdPipelineBarrier) and directly invokes vkEndCommandBuffer (QueueVk.cpp:321). Emitting these commands while a render pass is active violates Vulkan specs and triggers driver-level undefined behavior/crashes.
Suggested Fix:
- Ensure
CommandBuffer::RecordRenderPassutilizes an RAII wrapper (e.g.,absl::CleanupwithinRenderPassState) to guaranteevkCmdEndRenderPassis always invoked, even onDAWN_TRYearly returns. - Add an
IsLost()guard toExecutionQueueBase::EnsureCommandsFlushedandSubmitPendingCommandsto prevent the submission of stale pending commands after device loss. - Ensure
mRecordingContextis explicitly reset ifQueue::SubmitImplreturns early due to an error.
3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)
Prior Critic Verdict:
* **Severity:** Low (S3)
* **Brief Notes / Reasoning:**
Rated Low (S3) per severity guidelines for GPU invariant violations caught by defense-in-depth. The DAWN_TRY early returns inside CommandBuffer::RecordRenderPass structurally violate the §G3 invariant by leaving a VkCommandBuffer with an active render pass. An attacker-reachable path via Vulkan descriptor pool exhaustion (VK_ERROR_OUT_OF_DEVICE_MEMORY) exists on Android. However, the resulting DAWN_INTERNAL_ERROR is promoted to DeviceLost at Device.cpp:670-687, preventing any spec-invalid vk* calls. Not an S4 because an attacker-reachable path was demonstrated, but capped at S3 because the observed consequence is a clean device-lost.
Validator Double-Check & Escalation Confirmation:
The prior Critic verdict was re-evaluated against the codebase using exhaustive traces. While the initial determination correctly identified the structural invariant violation and the DeviceLost promotion (capping the severity at S3 for the primary execution paths), further code-reachability proofs confirmed an un-gated asynchronous escalation path that successfully reaches the GPU driver sink.
-
ProcessPollEventstoEnsureCommandsFlushedTrace:- Verified
EventManager.cpp:223-256:ProcessPollEventsprocessesmEventState.events. Non-ready events are added toqueueWaitSerialMapsand passed toWaitQueueSerials(..., Nanoseconds(0u)). - Verified
ExecutionQueue.cpp:88-149:WaitForQueueSerialexecutes. Under both thread-safe and non-thread-safe toggles, ifwaitSerial > GetLastSubmittedCommandSerial(), it acquires the device guard and callsDAWN_TRY(EnsureCommandsFlushed(waitSerial)). - Verified
ExecutionQueue.cpp:330-338:EnsureCommandsFlushedvalidatesserial > GetLastSubmittedCommandSerial()and blindly callsSubmitPendingCommands()(which invokesSubmitPendingCommandsImpl()). Crucially, there is noIsLost()check inEnsureCommandsFlushed.
- Verified
-
Serial State Desync Confirmation:
- Verified
Queue.cpp:181-250:APIOnSubmittedWorkDoneregisters an event. IfHasPendingCommands()is true, thewaitSerialisGetScheduledWorkDoneSerial()(GetLastSubmittedCommandSerial() + 1). - Verified
Device.cpp:649-725:AssumeCommandsComplete()is not called duringHandleDeviceLostorHandleError. The stale recording context genuinely persists untilDestroy()/WaitForIdleForDestruction. - Verified
Queue.cpp:303-311:QueueBase::HandleDeviceLossclearsmTasksInFlightand shifts tasks toCallbackTaskManager, but does not resolve theTrackedEventswithinEventManager. - Therefore, an aborted queue submission leaves
waitSerial > GetLastSubmittedCommandSerial()true, bypassingIsReadyToComplete()checks and ensuring the event is processed byWaitQueueSerials.
- Verified
-
Sink Validation:
- Verified
QueueVk.cpp:296-321:SubmitPendingCommandsImplexecutes.mRecordingContext.needsSubmitremains true due to the early-return inQueue::SubmitImpl. The method unconditionally executesvkCmdPipelineBarrierandvkEndCommandBuffer(mRecordingContext.commandBuffer). - Conclusion: The spec-invalid commands are deterministically passed to the Vulkan driver, upgrading the consequence from a clean
DeviceLostto driver-level undefined behavior. Since the GPU process on Android is unsandboxed, this represents a potential High (S1) issue.
- Verified
Evaluated with Chrome root at commit: b96d2ec58f4f5f92b540a723966b199d6e9951b4
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.