CVE-2026-9904
Overview
Background
- `ResourceSerialFactory::issueSerial`
- issues a monotonically increasing unique serial for ANGLE Vulkan resources by incrementing an atomic counter (
vk_utils.cpp:1054). - `mCurrentUniqueSerial`
- the atomic counter backing
issueSerial; pre-fix declaredstd::atomic<uint32_t>(vk_utils.h, pre-fix declaration). - `Type##Serial`
- the per-resource serial value type generated by the
ANGLE_VK_SERIAL_OPmacro; pre-fix it stored auint32_t mSerial(vk_utils.h, pre-fix macro body). - `DescriptorInfoDesc`
- a descriptor-set cache key component that identifies buffers/samplers by serial, pre-fix using
uint32_tfields (vk_cache_utils.h:1805-1811).
Root Cause Analysis
issueSerial incremented a 32-bit atomic counter and returned it: uint32_t newSerial = ++mCurrentUniqueSerial; guarded only by ASSERT(newSerial > 0); (vk_utils.cpp:1056-1058), and that assertion is compiled out in release builds. These serials become the identity of resources inside descriptor-set cache keys: DescriptorInfoDesc stored a buffer/sampler serial in a uint32_t samplerOrBufferSerialOrStorageFormat field (vk_cache_utils.h:1807), and each Type##Serial held a uint32_t mSerial (vk_utils.h, pre-fix macro). The invariant that a serial uniquely and permanently identifies one resource for the process lifetime was therefore only true for the first 2^32 issuances; after wraparound a freshly created resource can receive a serial that previously belonged to a destroyed resource, producing a false cache-key match.
The fix changes issueSerial to return uint64_t and mCurrentUniqueSerial to std::atomic<uint64_t> (diff at vk_utils.cpp @@ -1051,9 and vk_utils.h @@ -1314,10), widens Type##Serial’s stored value and getValue() to uint64_t (diff at vk_utils.h @@ -1274,7 and @@ -1296,8), and widens the DescriptorInfoDesc serial fields to uint64_t with the size assertion updated from 16 to 24 (diff at vk_cache_utils.h @@ -1804,13). It also updates the serial hash to std::hash<uint64_t>()(key.getValue()) (diff at vk_cache_utils.h @@ -2450,7) and adjusts struct padding/size asserts for the widened keys. This tightens the invariant so wraparound cannot occur in any realistic process lifetime. The commit is a reland of the original 64-bit-serial change.
Attack Path
- Prime a descriptor-set cache entry A buffer is bound to a shader program, caching a descriptor set keyed by that buffer’s serial. (trigger per reporter)
- Destroy the buffer while its cache entry survives The buffer’s Vulkan handle is destroyed, but the descriptor-set cache entry referencing it is not immediately invalidated. (inferred; the cache-skip code is not in the shown material)
- Force serial wraparound
The 32-bit
mCurrentUniqueSerialis driven past 2^32 so the counter wraps (vk_utils.cpp:1056). - Collide a new resource onto the stale key A new buffer is assigned the wrapped serial that matches the destroyed buffer, so a cache lookup returns the stale descriptor set still referencing the freed handle. (inferred)
- Driver dereferences the freed handle
Binding that stale descriptor set during rendering makes the driver use a destroyed
VkBuffer, a use-after-free in the GPU process. (inferred)
Impact Assessment
(inferred).Files Changed
src/libANGLE/renderer/vulkan/vk_cache_utils.cppsrc/libANGLE/renderer/vulkan/vk_cache_utils.hsrc/libANGLE/renderer/vulkan/vk_helpers.cppsrc/libANGLE/renderer/vulkan/vk_utils.cppsrc/libANGLE/renderer/vulkan/vk_utils.h
Audit Directions
- Fixed-width monotonic counters as identityAny counter whose value is reused as a cache key or handle identity must be wide enough that it cannot wrap during a process lifetime; a release-stripped
ASSERTis not a wraparound guard. - Serial-based keys vs. real handlesWhere a cache keys on a serial rather than the opaque object handle, confirm serials are never recycled, or key on the handle, to avoid collisions after destruction.
- Padding in hashed/compared key structsWhen widening fields in a struct used with byte-wise hashing or
memcmp, re-check padding and size static-asserts so uninitialized padding cannot cause false matches.
Patch
From a9554b2e5af8c3a7f3ca9052dca8060e880dbffd Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Mon, 04 May 2026 14:14:49 -0400
Subject: [PATCH] Reland "Vulkan: Use 64-bit resource serials"
This is a reland of commit d0875fb5c481dfc471ab0a6b4911dbc50c20987a
Original change's description:
> Vulkan: Use 64-bit resource serials
>
> This makes serial wraparound impossible until the next species evolves.
> If an instance of ANGLE is running till then, it might crash.
>
> So you know, remember to restart every million years.
>
> Bug: chromium:498804020
> Change-Id: I1baf152196fed0f228f40deaa452abca19224bbf
> Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7812906
> Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
> Auto-Submit: Shahbaz Youssefi <syoussefi@chromium.org>
> Reviewed-by: Charlie Lao <cclao@google.com>
Bug: chromium:498804020
Change-Id: I18457e9be05b6d16c9f32776824997be73c8af27
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7818820
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Charlie Lao <cclao@google.com>
---
diff --git a/src/libANGLE/renderer/vulkan/vk_cache_utils.cpp b/src/libANGLE/renderer/vulkan/vk_cache_utils.cpp
index 5faeab1..7ac75d9 100644
--- a/src/libANGLE/renderer/vulkan/vk_cache_utils.cpp
+++ b/src/libANGLE/renderer/vulkan/vk_cache_utils.cpp
@@ -5345,6 +5345,7 @@
void FramebufferDesc::reset()
{
+ mPadding = 0;
mMaxIndex = 0;
mHasColorFramebufferFetch = false;
mLayerCount = 0;
diff --git a/src/libANGLE/renderer/vulkan/vk_cache_utils.h b/src/libANGLE/renderer/vulkan/vk_cache_utils.h
index c2f8080..1680a58 100644
--- a/src/libANGLE/renderer/vulkan/vk_cache_utils.h
+++ b/src/libANGLE/renderer/vulkan/vk_cache_utils.h
@@ -1740,6 +1740,8 @@
return *this;
}
+ANGLE_ENABLE_STRUCT_PADDING_WARNINGS
+
struct ImageSubresourceRange
{
// GL max is 1000 (fits in 10 bits).
@@ -1780,8 +1782,11 @@
{
ImageOrBufferViewSerial viewSerial;
ImageSubresourceRange subresource;
+ uint32_t padding;
};
+static_assert(sizeof(ImageOrBufferViewSubresourceSerial) == 16, "Size check failed");
+
inline bool operator==(const ImageOrBufferViewSubresourceSerial &a,
const ImageOrBufferViewSubresourceSerial &b)
{
@@ -1789,7 +1794,7 @@
}
constexpr ImageOrBufferViewSubresourceSerial kInvalidImageOrBufferViewSubresourceSerial = {
- kInvalidImageOrBufferViewSerial, kInvalidImageSubresourceRange};
+ kInvalidImageOrBufferViewSerial, kInvalidImageSubresourceRange, 0};
// Always starts with array element zero, with descriptorCount descriptors.
struct WriteDescriptorDesc
@@ -1804,13 +1809,15 @@
struct DescriptorInfoDesc
{
- uint32_t samplerOrBufferSerialOrStorageFormat;
- uint32_t imageViewSerialOrOffset;
+ uint64_t samplerOrBufferSerialOrStorageFormat;
+ uint64_t imageViewSerialOrOffset;
uint32_t imageLayoutOrRange;
uint32_t imageSubresourceRange;
};
-static_assert(sizeof(DescriptorInfoDesc) == 16, "Size mismatch");
+static_assert(sizeof(DescriptorInfoDesc) == 24, "Size mismatch");
+
+ANGLE_DISABLE_STRUCT_PADDING_WARNINGS
// Generic description of a descriptor set. Used as a key when indexing descriptor set caches. The
// key storage is an angle:FixedVector. Beyond a certain fixed size we'll end up using heap memory
@@ -2266,11 +2273,12 @@
// Used by SharedFramebufferCacheKey to indicate if this cache key is valid or not.
uint16_t mIsValid : 1;
+ uint32_t mPadding;
+
FramebufferAttachmentArray<ImageOrBufferViewSubresourceSerial> mSerials;
};
-constexpr size_t kFramebufferDescSize = sizeof(FramebufferDesc);
-static_assert(kFramebufferDescSize == 156, "Size check failed");
+static_assert(sizeof(FramebufferDesc) == 312, "Size check failed");
// Disable warnings about struct padding.
ANGLE_DISABLE_STRUCT_PADDING_WARNINGS
@@ -2450,7 +2458,7 @@
{ \
size_t operator()(const rx::vk::Type##Serial &key) const \
{ \
- return key.getValue(); \
+ return std::hash<uint64_t>()(key.getValue()); \
} \
};
diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.cpp b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
index a0f9149..b70b7d7 100644
--- a/src/libANGLE/renderer/vulkan/vk_helpers.cpp
+++ b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
@@ -12819,7 +12819,7 @@
{
ASSERT(mImageViewSerial.valid());
- ImageOrBufferViewSubresourceSerial serial;
+ ImageOrBufferViewSubresourceSerial serial = {};
serial.viewSerial = mImageViewSerial;
serial.subresource = MakeImageSubresourceReadRange(levelGL, levelCount, layer, layerMode,
readColorspace, mWriteColorspace);
diff --git a/src/libANGLE/renderer/vulkan/vk_utils.cpp b/src/libANGLE/renderer/vulkan/vk_utils.cpp
index 5d30b62..765b3e2 100644
--- a/src/libANGLE/renderer/vulkan/vk_utils.cpp
+++ b/src/libANGLE/renderer/vulkan/vk_utils.cpp
@@ -1051,9 +1051,9 @@
ResourceSerialFactory::~ResourceSerialFactory() {}
-uint32_t ResourceSerialFactory::issueSerial()
+uint64_t ResourceSerialFactory::issueSerial()
{
- uint32_t newSerial = ++mCurrentUniqueSerial;
+ uint64_t newSerial = ++mCurrentUniqueSerial;
// make sure serial does not wrap
ASSERT(newSerial > 0);
return newSerial;
diff --git a/src/libANGLE/renderer/vulkan/vk_utils.h b/src/libANGLE/renderer/vulkan/vk_utils.h
index 2a561a1..9c2b533 100644
--- a/src/libANGLE/renderer/vulkan/vk_utils.h
+++ b/src/libANGLE/renderer/vulkan/vk_utils.h
@@ -1274,7 +1274,7 @@
{ \
public: \
constexpr Type##Serial() : mSerial(kInvalid) {} \
- constexpr explicit Type##Serial(uint32_t serial) : mSerial(serial) {} \
+ constexpr explicit Type##Serial(uint64_t serial) : mSerial(serial) {} \
\
constexpr bool operator==(const Type##Serial &other) const \
{ \
@@ -1286,7 +1286,7 @@
ASSERT(mSerial != kInvalid || other.mSerial != kInvalid); \
return mSerial != other.mSerial; \
} \
- constexpr uint32_t getValue() const \
+ constexpr uint64_t getValue() const \
{ \
return mSerial; \
} \
@@ -1296,8 +1296,8 @@
} \
\
private: \
- uint32_t mSerial; \
- static constexpr uint32_t kInvalid = 0; \
+ uint64_t mSerial; \
+ static constexpr uint64_t kInvalid = 0; \
}; \
static constexpr Type##Serial kInvalid##Type##Serial = Type##Serial();
@@ -1314,10 +1314,10 @@
ANGLE_VK_SERIAL_OP(ANGLE_DECLARE_GEN_VK_SERIAL)
private:
- uint32_t issueSerial();
+ uint64_t issueSerial();
// Kept atomic so it can be accessed from multiple Context threads at once.
- std::atomic<uint32_t> mCurrentUniqueSerial;
+ std::atomic<uint64_t> mCurrentUniqueSerial;
};
#if defined(ANGLE_ENABLE_PERF_COUNTER_OUTPUT)
Original Bug Report
Potential UAF in ANGLE Vulkan via 32-bit ResourceSerial wrap and stale dynamic descriptor set
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 without the security team.
Overview: A Use-After-Free (UAF) vulnerability exists in ANGLE’s Vulkan backend due to a 32-bit serial counter wraparound combined with skipped cache invalidation for dynamic uniform buffers. An attacker can force a 32-bit serial wraparound to trigger a descriptor set cache collision, causing the Vulkan driver to dereference a destroyed VkBuffer handle. This results in memory corruption within the highly privileged GPU process.
Affected files:
src/libANGLE/renderer/vulkan/vk_utils.cppsrc/libANGLE/renderer/vulkan/ProgramExecutableVk.cppsrc/libANGLE/renderer/vulkan/vk_cache_utils.cppsrc/libANGLE/renderer/vulkan/vk_helpers.cppsrc/libANGLE/renderer/vulkan/Suballocation.cpp
Estimated timestamp from git blame: 2026-02-13
Technical Details
A combination of three architectural behaviors in ANGLE’s Vulkan backend allows for a potential driver-level Use-After-Free or cross-resource memory corruption triggered from a WebGL context.
1. 32-bit Serial Wraparound (vk_utils.cpp)
ResourceSerialFactory::issueSerial() increments a 32-bit atomic counter (mCurrentUniqueSerial) to generate unique IDs for resources like buffers and images. While it contains an ASSERT(newSerial > 0), this assertion compiles out in release builds. After 2^32 increments, the counter wraps, allowing new resources to be assigned serial numbers previously used by destroyed resources.
2. Intentional Skip of Cache Invalidation (ProgramExecutableVk.cpp)
When using dynamic uniform buffer descriptors (VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC), ANGLE deliberately skips registering descriptor set cache entries with the buffer’s DescriptorSetCacheManager (lines 2140-2161). The code explicitly notes that maintaining this cache is too expensive, acknowledging that cached descriptor sets will not be immediately destroyed when the underlying BufferBlock is destroyed. Instead, it relies on an LRU cache that evicts entries after 10 frames of inactivity.
3. Cache Key Collision (vk_cache_utils.cpp)
The DescriptorSetDesc used as a cache key identifies buffers by their 32-bit BlockSerial rather than their actual opaque Vulkan VkBuffer handle. Consequently, if a new buffer is assigned a serial number that matches a destroyed buffer, a cache hit will occur even if the underlying Vulkan handles differ.
Potential Exploit Scenario
An attacker can potentially trigger a Use-After-Free via the following steps:
- Prime the Cache: Create a GL buffer (Buffer A) and bind it to a shader program as a uniform buffer. This causes ANGLE to allocate a
VkDescriptorSet(Set D) containing theVkBufferhandle for Buffer A and cache it using Buffer A’s serial. - Destroy the Buffer: Delete Buffer A in JavaScript. ANGLE destroys the underlying
VkBuffer. However, due to the intentional invalidation skip (Issue 2), Set D remains in the LRU cache, referencing the destroyed handle. - Defeat LRU Eviction & Wrap Serial: The attacker initiates a tight
while(true)loop across multiple Web Workers, repeatedly callinggl.bufferDatawith empty arrays to trigger new suballocations and serial generation.- By doing this in a tight loop without yielding to the browser event loop, no frame boundaries are triggered (ANGLE’s
currentFramecount does not increment). - Because the frame count does not increase, Set D’s age never reaches the 10-frame staleness threshold (
kDescriptorSetCacheRetireAge), rendering it immune to eviction. - The loop eventually forces the 32-bit
mCurrentUniqueSerialto wrap around (approximately 4.3 billion calls).
- By doing this in a tight loop without yielding to the browser event loop, no frame boundaries are triggered (ANGLE’s
- Trigger Collision: The attacker stops the loop and creates a new buffer (Buffer B). Due to the wraparound, Buffer B is assigned the same serial number previously held by Buffer A.
- Trigger UAF: Use Buffer B with the original shader program. The descriptor set cache returns the stale Set D because the serial-based keys perfectly match. ANGLE skips updating the descriptor set, and the Vulkan driver binds Set D, which still contains the destroyed handle for Buffer A.
- Result: The driver dereferences the destroyed handle during rendering. Because the handle is an opaque Vulkan type and the dereference happens at the driver level, MiraclePtr provides no protection, leading to memory corruption in the GPU process.
Note: Our tooling agent cannot run code, so these steps are suggested based on static code analysis.
Suggested Fix
Upgrade the mCurrentUniqueSerial counter in ResourceSerialFactory to a 64-bit atomic counter (std::atomic<uint64_t>). This guarantees the serial space will never wrap around during the lifetime of a renderer process, permanently preventing the cache collision.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.