Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Bug ClassUAF
Tracker536636648
Fix commitf56a8e4f5f28 (angle/angle) +51/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • src/libANGLE/renderer/vulkan/vk_helpers.cpp
  • src/libANGLE/renderer/vulkan/vk_helpers.h
  • src/tests/gl_tests/VulkanDescriptorSetTest.cpp
From f56a8e4f5f2852b9f58bbe1a9e79414b5246640c Mon Sep 17 00:00:00 2001
From: Charlie Lao <cclao@google.com>
Date: Tue, 21 Jul 2026 15:17:29 -0700
Subject: [PATCH] Vulkan: Fix UAF in DescriptorPoolHelper via unsafe WeakPtr upgrade

When context runs into error during submission, we may bail out early,
leaving some DescriptorSetHelpers has unsubmitted ResourceUse. This
cause potential UAF later on when context is destroyed. This CL prevents
such UAF possibility by force finish
DescriptorPoolHelper::mPendingGarbageList. This should have no behavior
change during normal context runs where submission went through
successfully.

Bug: b/536636648
Change-Id: I608ed2e61796aefc86d1a806472cfd70c2b8c719
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8127746
Commit-Queue: Charlie Lao <cclao@google.com>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Yuxin Hu <yuxinhu@google.com>
---

diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.cpp b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
index c80590c..afb5746 100644
--- a/src/libANGLE/renderer/vulkan/vk_helpers.cpp
+++ b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
@@ -3678,6 +3678,15 @@
     }
 }
 
+void DescriptorPoolHelper::forceFinishPendingGarbage()
+{
+    while (!mPendingGarbageList.empty())
+    {
+        mFinishedGarbageList.push_back(std::move(mPendingGarbageList.front()));
+        mPendingGarbageList.pop_front();
+    }
+}
+
 bool DescriptorPoolHelper::recycleFromGarbage(Renderer *renderer,
                                               DescriptorSetPointer *descriptorSetOut)
 {
@@ -3800,7 +3809,11 @@
 
     for (DescriptorPoolPointer &pool : mDescriptorPools)
     {
-        pool->cleanupPendingGarbage();
+        // Usually all pending garbage should have been finished when DynamicDescriptorPool is
+        // destroyed. But when context runs into error and submit code path early out, we could have
+        // unfinished garbage in the pending list. So force all pending garbage to be cleared rather
+        // than being left in place until the pool itself is deleted.
+        pool->forceFinishPendingGarbage();
         pool->destroyGarbage();
         ASSERT(pool.unique());
     }
diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.h b/src/libANGLE/renderer/vulkan/vk_helpers.h
index b92f33d..fce9a22 100644
--- a/src/libANGLE/renderer/vulkan/vk_helpers.h
+++ b/src/libANGLE/renderer/vulkan/vk_helpers.h
@@ -296,6 +296,7 @@
     bool recycleFromGarbage(Renderer *renderer, DescriptorSetPointer *descriptorSetOut);
     void destroyGarbage();
     void cleanupPendingGarbage();
+    void forceFinishPendingGarbage();
 
     bool hasValidDescriptorSet() const { return mValidDescriptorSets != 0; }
     bool canDestroy() const { return mValidDescriptorSets == 0 && mPendingGarbageList.empty(); }
diff --git a/src/tests/gl_tests/VulkanDescriptorSetTest.cpp b/src/tests/gl_tests/VulkanDescriptorSetTest.cpp
index 72a57ca..af9adaf 100644
--- a/src/tests/gl_tests/VulkanDescriptorSetTest.cpp
+++ b/src/tests/gl_tests/VulkanDescriptorSetTest.cpp
@@ -208,8 +208,43 @@
     mDescriptorSetLayoutCache.destroy(contextVk->getRenderer());
 }
 
+// Verify that DynamicDescriptorPool::destroy tears down cleanly even when descriptor sets that
+// were returned to the pool are still tagged with an outstanding queue serial.
+TEST_P(VulkanDescriptorSetLayoutDescTest, DestroyWithPendingGarbage)
+{
+    rx::ContextVk *contextVk = hackANGLE();
+
+    rx::vk::DescriptorSetLayoutDesc desc;
+    addBindings({0}, &desc);
+
+    rx::vk::DescriptorSetLayoutPtr descriptorSetLayout;
+    angle::Result result =
+        mDescriptorSetLayoutCache.getDescriptorSetLayout(contextVk, desc, &descriptorSetLayout);
+    ASSERT_EQ(result, angle::Result::Continue);
+
+    VkDescriptorPoolSize poolSize = {VK_DESCRIPTOR_TYPE_SAMPLER, 1};
+    rx::vk::DynamicDescriptorPool dynamicPool;
+    result = dynamicPool.init(contextVk, &poolSize, 1, *descriptorSetLayout);
+    ASSERT_EQ(result, angle::Result::Continue);
+
+    rx::vk::DescriptorSetPointer descriptorSet;
+    result = dynamicPool.allocateDescriptorSet(contextVk, *descriptorSetLayout, &descriptorSet);
+    ASSERT_EQ(result, angle::Result::Continue);
+    ASSERT_TRUE(descriptorSet);
+
+    // Tag the descriptor set with a queue serial that has not been reached so that it stays on the
+    // pool's pending garbage list once released.
+    descriptorSet->setQueueSerial(rx::QueueSerial(0, rx::Serial::Infinite()));
+    descriptorSet.reset();
+
+    dynamicPool.destroy(contextVk->getDevice());
+
+    descriptorSetLayout.reset();
+    mDescriptorSetLayoutCache.destroy(contextVk->getRenderer());
+}
+
 ANGLE_INSTANTIATE_TEST(VulkanDescriptorSetTest, ES31_VULKAN(), ES31_VULKAN_SWIFTSHADER());
-ANGLE_INSTANTIATE_TEST(VulkanDescriptorSetLayoutDescTest, ES31_VULKAN());
+ANGLE_INSTANTIATE_TEST(VulkanDescriptorSetLayoutDescTest, ES31_VULKAN(), ES31_VULKAN_SWIFTSHADER());
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(VulkanDescriptorSetLayoutDescTest);
 
 }  // namespace
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/VulkanDescriptorSetTest.cpp b/src/tests/gl_tests/VulkanDescriptorSetTest.cpp
index 72a57ca..af9adaf 100644
--- a/src/tests/gl_tests/VulkanDescriptorSetTest.cpp
+++ b/src/tests/gl_tests/VulkanDescriptorSetTest.cpp
@@ -208,8 +208,43 @@
     mDescriptorSetLayoutCache.destroy(contextVk->getRenderer());
 }
 
+// Verify that DynamicDescriptorPool::destroy tears down cleanly even when descriptor sets that
+// were returned to the pool are still tagged with an outstanding queue serial.
+TEST_P(VulkanDescriptorSetLayoutDescTest, DestroyWithPendingGarbage)
+{
+    rx::ContextVk *contextVk = hackANGLE();
+
+    rx::vk::DescriptorSetLayoutDesc desc;
+    addBindings({0}, &desc);
+
+    rx::vk::DescriptorSetLayoutPtr descriptorSetLayout;
+    angle::Result result =
+        mDescriptorSetLayoutCache.getDescriptorSetLayout(contextVk, desc, &descriptorSetLayout);
+    ASSERT_EQ(result, angle::Result::Continue);
+
+    VkDescriptorPoolSize poolSize = {VK_DESCRIPTOR_TYPE_SAMPLER, 1};
+    rx::vk::DynamicDescriptorPool dynamicPool;
+    result = dynamicPool.init(contextVk, &poolSize, 1, *descriptorSetLayout);
+    ASSERT_EQ(result, angle::Result::Continue);
+
+    rx::vk::DescriptorSetPointer descriptorSet;
+    result = dynamicPool.allocateDescriptorSet(contextVk, *descriptorSetLayout, &descriptorSet);
+    ASSERT_EQ(result, angle::Result::Continue);
+    ASSERT_TRUE(descriptorSet);
+
+    // Tag the descriptor set with a queue serial that has not been reached so that it stays on the
+    // pool's pending garbage list once released.
+    descriptorSet->setQueueSerial(rx::QueueSerial(0, rx::Serial::Infinite()));
+    descriptorSet.reset();
+
+    dynamicPool.destroy(contextVk->getDevice());
+
+    descriptorSetLayout.reset();
+    mDescriptorSetLayoutCache.destroy(contextVk->getRenderer());
+}
+
 ANGLE_INSTANTIATE_TEST(VulkanDescriptorSetTest, ES31_VULKAN(), ES31_VULKAN_SWIFTSHADER());
-ANGLE_INSTANTIATE_TEST(VulkanDescriptorSetLayoutDescTest, ES31_VULKAN());
+ANGLE_INSTANTIATE_TEST(VulkanDescriptorSetLayoutDescTest, ES31_VULKAN(), ES31_VULKAN_SWIFTSHADER());
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(VulkanDescriptorSetLayoutDescTest);
 
 }  // namespace
Loading diff…

Original Bug Report

reported by aw...@chromium.org

Potential GPU-HOST UAF/double-free in ANGLE DescriptorPoolHelper via unsafe WeakPtr upgrade

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 re-entrant double-free vulnerability exists in the ANGLE Vulkan backend’s descriptor set pool management. When a context is destroyed following a failed command submission, an unsafe WeakPtr-to-SharedPtr upgrade resurrects a parent allocation mid-delete. This results in a Use-After-Free write and a double-free on the host heap within the GPU process.

Affected files:

  • third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/vk_utils.h

Estimated timestamp from git blame: 2024-12-02

1. Summary of the Issue (Meant for Human Triage)

A potential severe memory-safety vulnerability exists in the ANGLE Vulkan backend due to unsafe object lifetime management and the lack of release-build validation checks when upgrading weak pointers. Specifically, DescriptorSetHelper::destroy() upgrades a vk::WeakPtr<DescriptorPoolHelper> to a SharedPtr to return descriptor sets to the pool’s garbage list. In release builds, this upgrade lacks checks to verify if the underlying RefCounted object is currently being destroyed or has already been freed, as the assertIsReferenced() check compiles to a no-op.

Under certain conditions—such as context destruction following a failed vkQueueSubmit—a DynamicDescriptorPool will be cleared while still holding undrained pending garbage. This initiates a recursive destruction sequence: deleting the DescriptorPoolHelper calls the destructor on its std::deque of garbage items, which invokes DescriptorSetHelper::destroy(). This function resurrects the mid-destruction RefCounted<DescriptorPoolHelper> allocation (re-incrementing the refcount from 0 to 1), mutates the std::deque under active destruction, and subsequently invokes delete on the same RefCounted object a second time when the temporary SharedPtr goes out of scope.

The result is a re-entrant host-heap double-free / Use-After-Free (UAF) in the GPU process. Because ANGLE does not utilize MiraclePtr (BackupRefPtr) protection for these ref-counted structures, this heap corruption is exploitable. On platforms like Android where the GPU process is unsandboxed, this could lead to a browser-tier compromise.

Note: These steps are based on exhaustive static analysis of the codebase. Our tooling agent does not yet have the ability to run code to produce a live proof-of-concept.

2. Proof-of-Concept & Detailed Execution Flow

The potential vulnerability is triggered through the following sequence of events an attacker might control:

Step 1: Allocating Descriptor Sets with GPU Conversion A WebGL page issues draw calls that require vertex format conversion on the GPU (e.g., using gl.vertexAttribPointer with non-normalized gl.SHORT values).

  • VertexArrayVk::convertVertexBufferGPU -> UtilsVk::allocateDescriptorSetWithLayout (third_party/angle/src/libANGLE/renderer/vulkan/UtilsVk.cpp:5189).
  • commandBufferHelper->retainResource(descriptorSet.get()) stamps a DescriptorSetHelper with the current queue serial (e.g., N).
  • The local DescriptorSetPointer goes out of scope, calling DescriptorSetHelper::destroy(), which places it into the mPendingGarbageList of the pool via emplace_back.

Step 2: Inducing a Vulkan Submission Failure The attacker induces a GPU hang (via an expensive fragment shader) or exhausts Vulkan device memory, causing the next command buffer submission to fail.

Step 3: Incomplete Serial Tracking on Submit Failure During the context destruction (ContextVk::onDestroy in third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp:1250), finishImpl() attempts to submit pending command buffers.

  • vkQueueSubmit fails and returns VK_ERROR_DEVICE_LOST or VK_ERROR_OUT_OF_DEVICE_MEMORY inside CommandQueue::queueSubmitLocked (CommandQueue.cpp:1024).
  • Wrapped in ANGLE_VK_TRY, the error propagates immediately, skipping pushInFlightBatchLocked() at CommandQueue.cpp:1029.
  • The batch serial N never enters mInFlightCommands. When handleDeviceLost() executes, it only advances mLastCompletedSerials from the existing mInFlightCommands queue. Serial N is left uncompleted.

Step 4: Failure to Clear Pending Garbage on Teardown During teardown of DynamicDescriptorPool (vk_helpers.cpp:3793-3798):

for (DescriptorPoolPointer &pool : mDescriptorPools)
{
    pool->cleanupPendingGarbage();
    pool->destroyGarbage();
    ASSERT(pool.unique()); // Debug only
}
mDescriptorPools.clear();
  • pool->cleanupPendingGarbage() (vk_helpers.cpp:3659-3671) halts immediately because mRenderer->hasResourceUseFinished(garbage->getResourceUse()) evaluates to false for serial N.
  • pool->destroyGarbage() only flushes elements in mFinishedGarbageList.
  • The pending list remains fully intact. mDescriptorPools.clear() is called, releasing the last SharedPtr<DescriptorPoolHelper>.

Step 5: Re-entrant Destruction and Re-referencing

  • mDescriptorPools.clear() triggers ~SharedPtr() -> releaseRef() -> SafeDelete(mRefCounted) -> delete mRefCounted -> ~DescriptorPoolHelper -> ~std::deque<DescriptorSetPointer> (mPendingGarbageList).
  • While destructing the elements of mPendingGarbageList, ~SharedPtr<DescriptorSetHelper> runs, invoking DescriptorSetHelper::destroy(device) (vk_helpers.cpp:3560-3573):
void DescriptorSetHelper::destroy(VkDevice device)
{
    if (valid())
    {
        DescriptorPoolPointer pool(device, mPool);              // WeakPtr -> SharedPtr Upgrade!
        DescriptorSetPointer garbage(device, std::move(*this));
        pool->addPendingGarbage(std::move(garbage));            // Corrupts std::deque mid-destruction
        ASSERT(!valid());
    }
}
  • The constructor for upgrading WeakPtr to SharedPtr (vk_utils.h:854-864) runs:
SharedPtr(VkDevice device, const WeakPtr<T> &other)
    : mRefCounted(other.mRefCounted), mDevice(device)
{
    if (mRefCounted)
    {
        mRefCounted->assertIsReferenced(); // Maps to ASSERT(mRefCount != 0)
        mRefCounted->addRef();             // Resurrects mid-delete object!
    }
}
  • Critical Flaw: In Release builds, ASSERT expands to ANGLE_EAT_STREAM_PARAMETERS << !(condition). Because ANGLE_EAT_STREAM_PARAMETERS uses the compile-time constant ternary operator true ? static_cast<void>(0) : ... (third_party/angle/src/common/log_utils.h:260), the assertIsReferenced() check compiles out to zero instructions. The refcount is unconditionally incremented from 0 to 1 on a heap allocation currently executing its own delete operator.
  • pool->addPendingGarbage(...) invokes emplace_back on the mPendingGarbageList (std::deque), which is actively executing its own ~deque(), causing a UAF write.
  • Finally, when the local pool object goes out of scope at the end of DescriptorSetHelper::destroy(), ~SharedPtr() drops the refcount back to 0, executing SafeDelete(mRefCounted) for a second time. This nested delete on the same RefCounted object triggers a catastrophic double-free.

Suggested Fix:

  1. Modify DynamicDescriptorPool::destroy to unconditionally force-drain and clear mPendingGarbageList when tearing down, regardless of whether hasResourceUseFinished returns true (since the context is being destroyed).
  2. Alternatively, advance mLastCompletedSerials for dropped batches during vkQueueSubmit failures so that standard garbage collection processes them.
  3. Revisit the ANGLE WeakPtr implementation to include a distinct weak reference count or a validity boolean rather than relying entirely on a debug-only ASSERT to detect dead objects.

3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)

> The vulnerability is a valid GPU-HOST-UAF (double-free) in the ANGLE Vulkan backend. The root cause is that DescriptorSetHelper::destroy() resurrects a DescriptorPoolHelper object during its own destruction by upgrading a WeakPtr to a SharedPtr (which lacks a runtime validity check in release builds). This path is reachable if a WebGL page issues a draw that requires format conversion and subsequently induces a vkQueueSubmit failure (e.g., via GPU hang or memory exhaustion). The failed batch’s queue serial is never pushed to mInFlightCommands, so handleDeviceLost() never marks it as completed. During ContextVk::onDestroy, the mPendingGarbageList fails to drain. When mDescriptorPools.clear() triggers delete on the pool’s RefCounted object, the undrained std::deque elements are destructed, calling DescriptorSetHelper::destroy(). This resurrects the mid-delete pool, calls emplace_back on the std::deque currently in its destructor (a heap UAF write), and upon the SharedPtr going out of scope, calls delete a second time on the same RefCounted object (double-free). > > ANGLE does not use MiraclePtr (BRP) for these ref-counted structures, so the UAF is unprotected. The Vulkan backend is default on Android, where the GPU process is UNSANDBOXED, meaning a memory corruption is treated as browser-tier. Per the severity guidelines, a GPU-process write primitive reachable by web content alone (A-SERVER) on an unsandboxed platform warrants Critical (S0). However, because triggering this requires inducing a device-lost or OOM condition and exploiting the issue during the teardown of the lost device before the GPU process is recycled, a -1 downgrade is applied for trigger complexity (similar to ‘Requiring browser shutdown’). Thus, the final severity is High (S1).

Automated Codebase Analysis Confirmations:

  • vkQueueSubmit Error Handling: Codebase investigator traced CommandQueue::queueSubmitLocked (CommandQueue.cpp:953-1035). The vkQueueSubmit call is wrapped in ANGLE_VK_TRY (vk_utils.h:1775-1784), which early-returns angle::Result::Stop on failure. The subsequent pushInFlightBatchLocked (line 1029) is permanently skipped.
  • Garbage Collection Halt: Codebase investigator traced DescriptorPoolHelper::cleanupPendingGarbage (vk_helpers.cpp:3664). It breaks the loop early if mRenderer->hasResourceUseFinished(garbage->getResourceUse()) is false, leaving the pending deque populated.
  • ASSERT compilation: Codebase investigator verified log_utils.h:260-271. When ANGLE_ENABLE_ASSERTS is disabled (Release config), ASSERT maps to ANGLE_EAT_STREAM_PARAMETERS, which utilizes true ? static_cast<void>(0) : .... The compiler completely optimizes away the condition. Therefore, mRefCounted->assertIsReferenced() inside the WeakPtr -> SharedPtr constructor (vk_utils.h:854) is definitively a no-op, permitting the 0->1 reference count increment on a deleted object.
  • MiraclePtr Status: ANGLE’s vk::WeakPtr and vk::SharedPtr internally utilize raw RefCountedStorage *mRefCounted and VkDevice mDevice fields, not raw_ptr. They fall outside the scope of PartitionAlloc’s BackupRefPtr protection.

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