Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in Dawn
DescriptionIncomplete cleanup in Dawn
ComponentDawn
Sub-componentNative
Bug ClassLogic Error
Tracker536507840
Fix commit85124738f573 (dawn) +68/-56
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
src/dawn/native/Device.cpp
modified
switch
src/dawn/native/Device.cpp
modified

Files Changed

  • src/dawn/native/Device.cpp
  • src/dawn/native/Device.h
  • src/dawn/native/ExecutionQueue.cpp
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.
Loading diff…

Original Bug Report

reported by aw...@chromium.org

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.cpp
  • third_party/dawn/src/dawn/native/vulkan/CommandBufferVk.h
  • third_party/dawn/src/dawn/native/vulkan/QueueVk.cpp
  • third_party/dawn/src/dawn/native/Device.cpp
  • third_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

  1. External Texture Import: The attacker uses device.importExternalTexture({source: videoElement}) on Android to import an AHardwareBuffer-backed video frame (Feature::OpaqueYCbCrAndroidForExternalTexture).
  2. Pipeline Creation: The attacker creates a GPURenderPipeline bound to this external texture, causing RenderPipelineVk::InitializeImpl to set mRequiresSpecialization = true (third_party/dawn/src/dawn/native/vulkan/RenderPipelineVk.cpp:400-403).
  3. Pending Commands Activation: The attacker executes a minor GPU operation (e.g., a buffer creation that triggers a lazy clear) to ensure Queue::HasPendingCommands() returns true (i.e., mRecordingContext.needsSubmit = true).
  4. Event Tracking: The attacker calls device.queue.onSubmittedWorkDone(). QueueBase::APIOnSubmittedWorkDone handles this (Queue.cpp:181). Because HasPendingCommands() is true, GetScheduledWorkDoneSerial() (ExecutionQueue.cpp:393-395) returns the pending command serial (GetLastSubmittedCommandSerial() + 1). An event is registered in EventManager waiting 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::RecordRenderPass utilizes an RAII wrapper (e.g., absl::Cleanup within RenderPassState) to guarantee vkCmdEndRenderPass is always invoked, even on DAWN_TRY early returns.
  • Add an IsLost() guard to ExecutionQueueBase::EnsureCommandsFlushed and SubmitPendingCommands to prevent the submission of stale pending commands after device loss.
  • Ensure mRecordingContext is explicitly reset if Queue::SubmitImpl returns 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.

  • ProcessPollEvents to EnsureCommandsFlushed Trace:

    • Verified EventManager.cpp:223-256: ProcessPollEvents processes mEventState.events. Non-ready events are added to queueWaitSerialMaps and passed to WaitQueueSerials(..., Nanoseconds(0u)).
    • Verified ExecutionQueue.cpp:88-149: WaitForQueueSerial executes. Under both thread-safe and non-thread-safe toggles, if waitSerial > GetLastSubmittedCommandSerial(), it acquires the device guard and calls DAWN_TRY(EnsureCommandsFlushed(waitSerial)).
    • Verified ExecutionQueue.cpp:330-338: EnsureCommandsFlushed validates serial > GetLastSubmittedCommandSerial() and blindly calls SubmitPendingCommands() (which invokes SubmitPendingCommandsImpl()). Crucially, there is no IsLost() check in EnsureCommandsFlushed.
  • Serial State Desync Confirmation:

    • Verified Queue.cpp:181-250: APIOnSubmittedWorkDone registers an event. If HasPendingCommands() is true, the waitSerial is GetScheduledWorkDoneSerial() (GetLastSubmittedCommandSerial() + 1).
    • Verified Device.cpp:649-725: AssumeCommandsComplete() is not called during HandleDeviceLost or HandleError. The stale recording context genuinely persists until Destroy()/WaitForIdleForDestruction.
    • Verified Queue.cpp:303-311: QueueBase::HandleDeviceLoss clears mTasksInFlight and shifts tasks to CallbackTaskManager, but does not resolve the TrackedEvents within EventManager.
    • Therefore, an aborted queue submission leaves waitSerial > GetLastSubmittedCommandSerial() true, bypassing IsReadyToComplete() checks and ensuring the event is processed by WaitQueueSerials.
  • Sink Validation:

    • Verified QueueVk.cpp:296-321: SubmitPendingCommandsImpl executes. mRecordingContext.needsSubmit remains true due to the early-return in Queue::SubmitImpl. The method unconditionally executes vkCmdPipelineBarrier and vkEndCommandBuffer(mRecordingContext.commandBuffer).
    • Conclusion: The spec-invalid commands are deterministically passed to the Vulkan driver, upgrading the consequence from a clean DeviceLost to driver-level undefined behavior. Since the GPU process on Android is unsandboxed, this represents a potential High (S1) issue.

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.

View on issue tracker