CVE-2026-79149
Overview
Background
- `DescriptorPoolHelper`
- an ANGLE Vulkan-backend object that owns a
VkDescriptorPoolplus lists of released descriptor sets awaiting reuse or destruction. - `mPendingGarbageList` / `mFinishedGarbageList`
- queues of released descriptor sets: pending garbage is still tagged with a
ResourceUse/queue serial not yet known to have completed on the GPU, while finished garbage is safe to recycle or destroy. - `ResourceUse` / `QueueSerial`
- a tag recording the GPU submission that must complete before a resource may be freed; garbage is only reclaimed once its serial is reached.
- WeakPtr upgrade
- obtaining a usable pointer to a descriptor set from a weak reference; if the object was already freed, the upgrade dereferences freed memory.
Root Cause Analysis
DynamicDescriptorPool’s destroy path iterated its mDescriptorPools and called pool->cleanupPendingGarbage(), which only reclaims garbage whose queue serial has been observed as finished. When a context hit an error during submission and took an early-out path, the released descriptor sets retained an unsubmitted ResourceUse whose serial would never be reached, so cleanupPendingGarbage left those entries on mPendingGarbageList. The pool was then torn down while descriptor sets were still pending, so a later unsafe WeakPtr upgrade to those descriptor sets (per the commit title) dereferenced memory that had been freed, a use-after-free at context destruction.
The fix adds DescriptorPoolHelper::forceFinishPendingGarbage, which unconditionally moves every entry from mPendingGarbageList to mFinishedGarbageList, and the destroy loop now calls it before pool->destroyGarbage() so no garbage lingers past pool teardown. The commit notes this is a no-op for normal runs where submission completed successfully.
destroyGarbage removes the dangling reference that a later WeakPtr upgrade would resurrect.Attack Path
- Provoke a submission error Drive the Vulkan backend into an error during command submission so the context takes its early-out path (specific trigger not shown in the diff, inferred).
- Leave garbage pending
The early-out leaves released descriptor sets on
mPendingGarbageListtagged with an unsubmittedResourceUsewhose serial is never reached. - Destroy the context
DynamicDescriptorPool::destroyruns; pre-patch,cleanupPendingGarbageskips the still-pending entries and the pool is freed with references outstanding. - Dangling access A later unsafe WeakPtr upgrade to a freed descriptor set dereferences freed memory, the use-after-free (inferred exploitation of the freed object).
Impact Assessment
Files Changed
src/libANGLE/renderer/vulkan/vk_helpers.hsrc/tests/gl_tests/VulkanDescriptorSetTest.cppsrc/libANGLE/renderer/vulkan/vk_helpers.cpp
Audit Directions
- Serial-gated cleanup on teardownReview any destructor or
destroypath that reclaims GPU resources only when a queue serial has completed, since a never-completing serial strands the resource. - Error / early-out submission pathsAudit submission code that bails out early for resources left tagged with an unsubmitted
ResourceUse. - WeakPtr upgrades to GPU-lifetime objectsCheck every WeakPtr upgrade of descriptor sets and similar objects to confirm the target cannot have been freed while the weak reference persisted.
Patch
From f56a8e4f5f2852b9f58bbe1a9e79414b5246640c Mon Sep 17 00:00:00 2001 From: Charlie Lao <[email protected]> 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 <[email protected]> Reviewed-by: Shahbaz Youssefi <[email protected]> Reviewed-by: Yuxin Hu <[email protected]> --- 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
Regression Test / PoC
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
Original Bug Report
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.cppthird_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 aDescriptorSetHelperwith the current queue serial (e.g.,N).- The local
DescriptorSetPointergoes out of scope, callingDescriptorSetHelper::destroy(), which places it into themPendingGarbageListof the pool viaemplace_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.
vkQueueSubmitfails and returnsVK_ERROR_DEVICE_LOSTorVK_ERROR_OUT_OF_DEVICE_MEMORYinsideCommandQueue::queueSubmitLocked(CommandQueue.cpp:1024).- Wrapped in
ANGLE_VK_TRY, the error propagates immediately, skippingpushInFlightBatchLocked()atCommandQueue.cpp:1029. - The batch serial
Nnever entersmInFlightCommands. WhenhandleDeviceLost()executes, it only advancesmLastCompletedSerialsfrom the existingmInFlightCommandsqueue. SerialNis 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 becausemRenderer->hasResourceUseFinished(garbage->getResourceUse())evaluates to false for serialN.pool->destroyGarbage()only flushes elements inmFinishedGarbageList.- The pending list remains fully intact.
mDescriptorPools.clear()is called, releasing the lastSharedPtr<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, invokingDescriptorSetHelper::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
WeakPtrtoSharedPtr(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,
ASSERTexpands toANGLE_EAT_STREAM_PARAMETERS << !(condition). BecauseANGLE_EAT_STREAM_PARAMETERSuses the compile-time constant ternary operatortrue ? static_cast<void>(0) : ...(third_party/angle/src/common/log_utils.h:260), theassertIsReferenced()check compiles out to zero instructions. The refcount is unconditionally incremented from0to1on a heap allocation currently executing its owndeleteoperator. pool->addPendingGarbage(...)invokesemplace_backon themPendingGarbageList(std::deque), which is actively executing its own~deque(), causing a UAF write.- Finally, when the local
poolobject goes out of scope at the end ofDescriptorSetHelper::destroy(),~SharedPtr()drops the refcount back to0, executingSafeDelete(mRefCounted)for a second time. This nesteddeleteon the sameRefCountedobject triggers a catastrophic double-free.
Suggested Fix:
- Modify
DynamicDescriptorPool::destroyto unconditionally force-drain and clearmPendingGarbageListwhen tearing down, regardless of whetherhasResourceUseFinishedreturns true (since the context is being destroyed). - Alternatively, advance
mLastCompletedSerialsfor dropped batches duringvkQueueSubmitfailures so that standard garbage collection processes them. - Revisit the ANGLE
WeakPtrimplementation to include a distinct weak reference count or a validity boolean rather than relying entirely on a debug-onlyASSERTto 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:
vkQueueSubmitError Handling: Codebase investigator tracedCommandQueue::queueSubmitLocked(CommandQueue.cpp:953-1035). ThevkQueueSubmitcall is wrapped inANGLE_VK_TRY(vk_utils.h:1775-1784), which early-returnsangle::Result::Stopon failure. The subsequentpushInFlightBatchLocked(line 1029) is permanently skipped.- Garbage Collection Halt: Codebase investigator traced
DescriptorPoolHelper::cleanupPendingGarbage(vk_helpers.cpp:3664). It breaks the loop early ifmRenderer->hasResourceUseFinished(garbage->getResourceUse())is false, leaving the pending deque populated. - ASSERT compilation: Codebase investigator verified
log_utils.h:260-271. WhenANGLE_ENABLE_ASSERTSis disabled (Release config),ASSERTmaps toANGLE_EAT_STREAM_PARAMETERS, which utilizestrue ? static_cast<void>(0) : .... The compiler completely optimizes away the condition. Therefore,mRefCounted->assertIsReferenced()inside theWeakPtr->SharedPtrconstructor (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::WeakPtrandvk::SharedPtrinternally utilize rawRefCountedStorage *mRefCountedandVkDevice mDevicefields, notraw_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.