CVE-2026-78906
Overview
Background
- `QueueSerialIndexAllocator`
- an ANGLE Vulkan helper that hands out a small, bounded set of per-context queue-serial indices from a bitset, tracking which are free and which have ever been used.
- `mLargestIndexEverAllocated`
- a high-water mark that is meant to record the largest queue-serial index ever handed out, used to bound iteration over in-use queues (e.g. when a global fence sync must wait on every context’s outstanding submissions).
- `mFreeIndexBitSetArray`
- the bitset where a set bit means the index is free;
(~mFreeIndexBitSetArray)therefore represents the currently-allocated indices. - Global fence sync
EGL_SYNC_GLOBAL_FENCE_ANGLE, an EGL sync object that must become signaled only after all work submitted on all queue indices (including recently-released contexts) has completed.
Root Cause Analysis
The allocation method reset the freshly-chosen index in mFreeIndexBitSetArray and then recomputed mLargestIndexEverAllocated = (~mFreeIndexBitSetArray).last(), i.e. the highest currently-allocated index rather than the highest ever allocated. When a context released its index and a later allocation picked a lower slot, (~mFreeIndexBitSetArray).last() returned a smaller value, so the “largest ever” high-water mark could decrease. Because the field name promises monotonicity, downstream consumers (such as the global fence sync, which iterates queue indices up to this bound) trusted it to still cover every index that had ever carried GPU work. When the mark shrank, an index whose submission was still in flight fell outside the iterated range and was skipped by the global fence, so the sync could report satisfied while that work was still pending.
The fix initializes the field to 0 and updates it with std::max<size_t>(mLargestIndexEverAllocated, index), guaranteeing it never decreases.
std::max.Attack Path
- Submit expensive work on a second context
A thread makes a second context current, issues a costly draw and a fence, so that context holds a queue-serial index with GPU work still executing (mirrored by the
GlobalFenceSyncMultithreadedregression test). - Release then reallocate the index
The context is released (freeing its index in
mFreeIndexBitSetArray) and a make-current reallocates a queue index, causingmLargestIndexEverAllocatedto be recomputed from.last()and shrink below the index that still has pending work. - Create a global fence sync
The main context creates an
EGL_SYNC_GLOBAL_FENCE_ANGLEsync, whose wait iterates in-use indices only up to the (now-lowered)mLargestIndexEverAllocated, skipping the still-busy index. - Wait returns prematurely
eglClientWaitSyncKHRreports the global fence satisfied even though the other context’s submission has not completed, breaking the ordering guarantee the sync is supposed to provide (inferred use of stale/incomplete GPU results downstream).
Impact Assessment
(inferred) and not demonstrated by the patch. Preconditions are multi-threaded/multi-context use of the Vulkan backend and reliance on the EGL_ANGLE_global_fence_sync extension.Changed Functions
| Function | Change | Notes |
|---|---|---|
QueueSerialIndexAllocatorsrc/libANGLE/renderer/vulkan/vk_utils.h |
modified |
Files Changed
src/tests/egl_tests/EGLSyncTest.cppsrc/libANGLE/renderer/vulkan/vk_utils.hsrc/tests/capture_replay_tests/capture_replay_expectations.txt
Audit Directions
- Misnamed monotonic countersAudit any field named “largest/last/max ever” that is recomputed from a live/mutable set (bitset, free list) rather than accumulated, since frees can silently regress the value.
- Fence/sync coverage boundsReview global or cross-context sync primitives that iterate resources up to a cached bound; verify the bound covers every index that could still hold in-flight work, including released contexts.
- Concurrent allocator stateExamine
QueueSerialIndexAllocatorand similar per-context allocators for state that is read by one thread while another frees/reallocates, where a transiently-shrunk value breaks a correctness invariant.
Patch
From 288a6eed28c054cc1a5cdc08cca257c3968cef78 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Thu, 16 Jul 2026 14:03:10 -0400
Subject: [PATCH] Vulkan: Make sure mLargestIndexEverAllocated never decreases
Bug: chromium:513923164
Change-Id: Ie57adb910843746f9c3eadf47e748ca03089d872
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8102146
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Charlie Lao <cclao@google.com>
---
diff --git a/src/libANGLE/renderer/vulkan/vk_utils.h b/src/libANGLE/renderer/vulkan/vk_utils.h
index 68ccd5b..f3bf004 100644
--- a/src/libANGLE/renderer/vulkan/vk_utils.h
+++ b/src/libANGLE/renderer/vulkan/vk_utils.h
@@ -271,7 +271,7 @@
class QueueSerialIndexAllocator final
{
public:
- QueueSerialIndexAllocator() : mLargestIndexEverAllocated(kInvalidQueueSerialIndex)
+ QueueSerialIndexAllocator() : mLargestIndexEverAllocated(0)
{
// Start with every index is free
mFreeIndexBitSetArray.set();
@@ -289,7 +289,8 @@
SerialIndex index = static_cast<SerialIndex>(mFreeIndexBitSetArray.first());
ASSERT(index < kMaxQueueSerialIndexCount);
mFreeIndexBitSetArray.reset(index);
- mLargestIndexEverAllocated = (~mFreeIndexBitSetArray).last();
+ // Increase mLargestIndexEverAllocated to include the newly allocated index.
+ mLargestIndexEverAllocated = std::max<size_t>(mLargestIndexEverAllocated, index);
return index;
}
diff --git a/src/tests/capture_replay_tests/capture_replay_expectations.txt b/src/tests/capture_replay_tests/capture_replay_expectations.txt
index 9f70303..bfdbd63 100644
--- a/src/tests/capture_replay_tests/capture_replay_expectations.txt
+++ b/src/tests/capture_replay_tests/capture_replay_expectations.txt
@@ -239,7 +239,6 @@
42266906 : Texture2DTestES3Foveation.FoveatedFBDraw/* = SKIP_FOR_CAPTURE
42266906 : Texture2DTestES3Foveation.FoveatedTextureDraw/* = SKIP_FOR_CAPTURE
-42266965 : EGLSyncTest.GlobalFenceSync/* = FAIL
42264309 : Texture2DTestES3.TexStorage2DMultipleYuvSamplersSwitchSamplerUniformValues/* = SKIP_FOR_CAPTURE
42264309 : Texture2DTestES3.TexStorage2DMultipleYuvSamplersSwitchBoundTextures/* = SKIP_FOR_CAPTURE
@@ -254,7 +253,7 @@
42264614 LINUX : VulkanExternalImageTest.UninitializedOnGLImport* = SKIP_FOR_CAPTURE
# error: duplicate case value '1'
-42264614 : EGLSyncTest.GlobalFenceSync/* = COMPILE_FAIL
+42264614 : EGLSyncTest.GlobalFenceSync*/* = COMPILE_FAIL
433331119 : ValidationStateChangeTest.RebindBufferShouldPickupBufferChange/* = COMPILE_FAIL
433331119 : ValidationStateChangeTestES31.RebindVertexBufferShouldPickupBufferChange/* = COMPILE_FAIL
diff --git a/src/tests/egl_tests/EGLSyncTest.cpp b/src/tests/egl_tests/EGLSyncTest.cpp
index 5dd2fa3..cf0f732 100644
--- a/src/tests/egl_tests/EGLSyncTest.cpp
+++ b/src/tests/egl_tests/EGLSyncTest.cpp
@@ -740,6 +740,97 @@
EXPECT_EGL_TRUE(eglDestroyContext(display, context2));
}
+// Test functionality of EGL_ANGLE_global_fence_sync in the presence of multiple threads.
+TEST_P(EGLSyncTest, GlobalFenceSyncMultithreaded)
+{
+ EGLDisplay display = getEGLWindow()->getDisplay();
+
+ ANGLE_SKIP_TEST_IF(!hasFenceSyncExtension());
+ ANGLE_SKIP_TEST_IF(!IsEGLDisplayExtensionEnabled(display, "EGL_ANGLE_global_fence_sync"));
+
+ // Create a second context
+ EGLContext context1 = eglGetCurrentContext();
+ EGLSurface drawSurface1 = eglGetCurrentSurface(EGL_DRAW);
+ EGLSurface readSurface1 = eglGetCurrentSurface(EGL_READ);
+ EGLConfig config = getEGLWindow()->getConfig();
+
+ const EGLint contextAttribs[] = {
+ EGL_CONTEXT_CLIENT_VERSION, getEGLWindow()->getClientMajorVersion(),
+ EGL_CONTEXT_MINOR_VERSION_KHR, getEGLWindow()->getClientMinorVersion(), EGL_NONE};
+
+ EGLContext context2 = eglCreateContext(display, config, context1, contextAttribs);
+ ASSERT_NE(EGL_NO_CONTEXT, context2);
+
+ const EGLint pbufferAttribs[] = {EGL_WIDTH, getWindowWidth(), EGL_HEIGHT, getWindowHeight(),
+ EGL_NONE};
+ EGLSurface drawSurface2 = eglCreatePbufferSurface(display, config, pbufferAttribs);
+ ASSERT_NE(EGL_NO_SURFACE, drawSurface2);
+
+ EGLSyncKHR sync2 = EGL_NO_SYNC_KHR;
+
+ // Do an expensive draw in context 2, in a thread
+ std::thread slowSubmit([&]() {
+ eglMakeCurrent(display, drawSurface2, drawSurface2, context2);
+
+ constexpr char kCostlyVS[] = R"(attribute highp vec4 position;
+ varying highp vec4 testPos;
+ void main(void)
+ {
+ testPos = position;
+ gl_Position = position;
+ })";
+
+ constexpr char kCostlyFS[] = R"(precision highp float;
+ varying highp vec4 testPos;
+ void main(void)
+ {
+ vec4 test = testPos;
+ for (int i = 0; i < 500; i++)
+ {
+ test = sqrt(test);
+ }
+ gl_FragColor = test;
+ })";
+
+ ANGLE_GL_PROGRAM(expensiveProgram, kCostlyVS, kCostlyFS);
+ drawQuad(expensiveProgram, "position", 0.0f);
+
+ // Signal a fence sync for testing
+ sync2 = eglCreateSyncKHR(display, EGL_SYNC_FENCE_KHR, nullptr);
+
+ // Release the context. In the Vulkan backend, this frees the queue index assigned to the
+ // context.
+ eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ });
+ slowSubmit.join();
+
+ // Re-make-current the context. In the Vulkan backend, this realloces the queue index assigned
+ // to the context.
+ eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ eglMakeCurrent(display, drawSurface1, readSurface1, context1);
+
+ // Create a global fence sync
+ EGLSyncKHR sync1 = eglCreateSyncKHR(display, EGL_SYNC_GLOBAL_FENCE_ANGLE, nullptr);
+
+ // Wait for the global fence sync to finish.
+ constexpr GLuint64 kTimeout = 2'000'000'000; // 2 seconds
+ ASSERT_EQ(EGL_CONDITION_SATISFIED_KHR, eglClientWaitSyncKHR(display, sync1, 0, kTimeout));
+
+ // If the global fence sync is signaled, then the signal from context2 must also be signaled.
+ // Note that if sync1 was an EGL_SYNC_FENCE_KHR, this would not necessarily be true.
+ EGLint value = 0;
+ EXPECT_EGL_TRUE(eglGetSyncAttribKHR(display, sync2, EGL_SYNC_STATUS_KHR, &value));
+ EXPECT_EQ(value, EGL_SIGNALED_KHR);
+
+ EXPECT_EQ(EGL_CONDITION_SATISFIED_KHR, eglClientWaitSyncKHR(display, sync2, 0, 0));
+
+ EXPECT_EGL_TRUE(eglDestroySyncKHR(display, sync1));
+ EXPECT_EGL_TRUE(eglDestroySyncKHR(display, sync2));
+
+ EXPECT_EGL_TRUE(eglDestroySurface(display, drawSurface2));
+ EXPECT_EGL_TRUE(eglDestroyContext(display, context2));
+}
+
// Test that leaked fences are cleaned up in a safe way. Regression test for sync objects using tail
// calls for destruction.
TEST_P(EGLSyncTest, DISABLED_LeakSyncToDisplayDestruction)
Regression Test / PoC
diff --git a/src/tests/capture_replay_tests/capture_replay_expectations.txt b/src/tests/capture_replay_tests/capture_replay_expectations.txt
index 9f70303..bfdbd63 100644
--- a/src/tests/capture_replay_tests/capture_replay_expectations.txt
+++ b/src/tests/capture_replay_tests/capture_replay_expectations.txt
@@ -239,7 +239,6 @@
42266906 : Texture2DTestES3Foveation.FoveatedFBDraw/* = SKIP_FOR_CAPTURE
42266906 : Texture2DTestES3Foveation.FoveatedTextureDraw/* = SKIP_FOR_CAPTURE
-42266965 : EGLSyncTest.GlobalFenceSync/* = FAIL
42264309 : Texture2DTestES3.TexStorage2DMultipleYuvSamplersSwitchSamplerUniformValues/* = SKIP_FOR_CAPTURE
42264309 : Texture2DTestES3.TexStorage2DMultipleYuvSamplersSwitchBoundTextures/* = SKIP_FOR_CAPTURE
@@ -254,7 +253,7 @@
42264614 LINUX : VulkanExternalImageTest.UninitializedOnGLImport* = SKIP_FOR_CAPTURE
# error: duplicate case value '1'
-42264614 : EGLSyncTest.GlobalFenceSync/* = COMPILE_FAIL
+42264614 : EGLSyncTest.GlobalFenceSync*/* = COMPILE_FAIL
433331119 : ValidationStateChangeTest.RebindBufferShouldPickupBufferChange/* = COMPILE_FAIL
433331119 : ValidationStateChangeTestES31.RebindVertexBufferShouldPickupBufferChange/* = COMPILE_FAIL
diff --git a/src/tests/egl_tests/EGLSyncTest.cpp b/src/tests/egl_tests/EGLSyncTest.cpp
index 5dd2fa3..cf0f732 100644
--- a/src/tests/egl_tests/EGLSyncTest.cpp
+++ b/src/tests/egl_tests/EGLSyncTest.cpp
@@ -740,6 +740,97 @@
EXPECT_EGL_TRUE(eglDestroyContext(display, context2));
}
+// Test functionality of EGL_ANGLE_global_fence_sync in the presence of multiple threads.
+TEST_P(EGLSyncTest, GlobalFenceSyncMultithreaded)
+{
+ EGLDisplay display = getEGLWindow()->getDisplay();
+
+ ANGLE_SKIP_TEST_IF(!hasFenceSyncExtension());
+ ANGLE_SKIP_TEST_IF(!IsEGLDisplayExtensionEnabled(display, "EGL_ANGLE_global_fence_sync"));
+
+ // Create a second context
+ EGLContext context1 = eglGetCurrentContext();
+ EGLSurface drawSurface1 = eglGetCurrentSurface(EGL_DRAW);
+ EGLSurface readSurface1 = eglGetCurrentSurface(EGL_READ);
+ EGLConfig config = getEGLWindow()->getConfig();
+
+ const EGLint contextAttribs[] = {
+ EGL_CONTEXT_CLIENT_VERSION, getEGLWindow()->getClientMajorVersion(),
+ EGL_CONTEXT_MINOR_VERSION_KHR, getEGLWindow()->getClientMinorVersion(), EGL_NONE};
+
+ EGLContext context2 = eglCreateContext(display, config, context1, contextAttribs);
+ ASSERT_NE(EGL_NO_CONTEXT, context2);
+
+ const EGLint pbufferAttribs[] = {EGL_WIDTH, getWindowWidth(), EGL_HEIGHT, getWindowHeight(),
+ EGL_NONE};
+ EGLSurface drawSurface2 = eglCreatePbufferSurface(display, config, pbufferAttribs);
+ ASSERT_NE(EGL_NO_SURFACE, drawSurface2);
+
+ EGLSyncKHR sync2 = EGL_NO_SYNC_KHR;
+
+ // Do an expensive draw in context 2, in a thread
+ std::thread slowSubmit([&]() {
+ eglMakeCurrent(display, drawSurface2, drawSurface2, context2);
+
+ constexpr char kCostlyVS[] = R"(attribute highp vec4 position;
+ varying highp vec4 testPos;
+ void main(void)
+ {
+ testPos = position;
+ gl_Position = position;
+ })";
+
+ constexpr char kCostlyFS[] = R"(precision highp float;
+ varying highp vec4 testPos;
+ void main(void)
+ {
+ vec4 test = testPos;
+ for (int i = 0; i < 500; i++)
+ {
+ test = sqrt(test);
+ }
+ gl_FragColor = test;
+ })";
+
+ ANGLE_GL_PROGRAM(expensiveProgram, kCostlyVS, kCostlyFS);
+ drawQuad(expensiveProgram, "position", 0.0f);
+
+ // Signal a fence sync for testing
+ sync2 = eglCreateSyncKHR(display, EGL_SYNC_FENCE_KHR, nullptr);
+
+ // Release the context. In the Vulkan backend, this frees the queue index assigned to the
+ // context.
+ eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ });
+ slowSubmit.join();
+
+ // Re-make-current the context. In the Vulkan backend, this realloces the queue index assigned
+ // to the context.
+ eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ eglMakeCurrent(display, drawSurface1, readSurface1, context1);
+
+ // Create a global fence sync
+ EGLSyncKHR sync1 = eglCreateSyncKHR(display, EGL_SYNC_GLOBAL_FENCE_ANGLE, nullptr);
+
+ // Wait for the global fence sync to finish.
+ constexpr GLuint64 kTimeout = 2'000'000'000; // 2 seconds
+ ASSERT_EQ(EGL_CONDITION_SATISFIED_KHR, eglClientWaitSyncKHR(display, sync1, 0, kTimeout));
+
+ // If the global fence sync is signaled, then the signal from context2 must also be signaled.
+ // Note that if sync1 was an EGL_SYNC_FENCE_KHR, this would not necessarily be true.
+ EGLint value = 0;
+ EXPECT_EGL_TRUE(eglGetSyncAttribKHR(display, sync2, EGL_SYNC_STATUS_KHR, &value));
+ EXPECT_EQ(value, EGL_SIGNALED_KHR);
+
+ EXPECT_EQ(EGL_CONDITION_SATISFIED_KHR, eglClientWaitSyncKHR(display, sync2, 0, 0));
+
+ EXPECT_EGL_TRUE(eglDestroySyncKHR(display, sync1));
+ EXPECT_EGL_TRUE(eglDestroySyncKHR(display, sync2));
+
+ EXPECT_EGL_TRUE(eglDestroySurface(display, drawSurface2));
+ EXPECT_EGL_TRUE(eglDestroyContext(display, context2));
+}
+
// Test that leaked fences are cleaned up in a safe way. Regression test for sync objects using tail
// calls for destruction.
TEST_P(EGLSyncTest, DISABLED_LeakSyncToDisplayDestruction)
Original Bug Report
Potential premature signal in ANGLE Vulkan global fences due to non-monotone index tracking
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 logic error in ANGLE’s Vulkan backend allows the high-water mark of allocated queue serial indices to regress. This can cause global sync fences to omit tracking for in-flight GPU work on higher indices, leading to premature signaling. This failure may result in race conditions, memory corruption, or information leaks in the GPU process.
Affected files:
third_party/angle/src/libANGLE/renderer/vulkan/vk_utils.hthird_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cppthird_party/angle/src/libANGLE/renderer/vulkan/vk_renderer.cppthird_party/angle/src/libANGLE/renderer/vulkan/SyncVk.cppui/gl/gl_fence_egl.cc
Estimated timestamp from git blame: 2022-12-05
Root Cause Analysis
The QueueSerialIndexAllocator class in ANGLE’s Vulkan backend manages indices used to track queue serials across different contexts and one-off submissions. It maintains a high-water mark of the largest index ever allocated in mLargestIndexEverAllocated. This value is used by synchronization primitives to ensure all potentially in-flight work is accounted for.
In third_party/angle/src/libANGLE/renderer/vulkan/vk_utils.h, the release() method correctly avoids decrementing this value to ensure that synchronization tracking persists for in-flight resources associated with the released index. However, the allocate() method incorrectly recomputes the high-water mark as the maximum of currently allocated indices:
// third_party/angle/src/libANGLE/renderer/vulkan/vk_utils.h
SerialIndex allocate()
{
// ...
SerialIndex index = static_cast<SerialIndex>(mFreeIndexBitSetArray.first());
mFreeIndexBitSetArray.reset(index);
mLargestIndexEverAllocated = (~mFreeIndexBitSetArray).last(); // <--- Logic Error
return index;
}
If a high index (e.g., index 5) is allocated for a one-off submission (via Renderer::queueSubmitOneOff) and then immediately released, mLargestIndexEverAllocated remains 5. However, if a subsequent call to allocate() occurs while index 5 is free but its work is still in-flight on the GPU, and only lower indices (e.g., 0, 1, 2) are currently held, the last() call will return a lower value (e.g., 2), causing the high-water mark to regress.
Impact on Synchronization
This regression directly impacts EGL_SYNC_GLOBAL_FENCE_ANGLE initialization in ContextVk::onSyncObjectInit. The high-water mark is used to determine the range of queue serials the fence must track:
// third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp
if (scope == SyncFenceScope::AllContextsToAllContexts)
{
const size_t maxIndex = mRenderer->getLargestQueueSerialIndexEverAllocated();
for (SerialIndex index = 0; index <= maxIndex; ++index)
{
syncHelper->setSerial(index, mRenderer->getLastSubmittedSerial(index));
}
}
If maxIndex has regressed below an index that has in-flight work, that work is omitted from the SyncHelper. Consequently, the global fence may report as signaled while the omitted batch is still executing on the GPU.
Furthermore, this regression affects CommandQueue::isBusy and garbage collection logic, potentially leading to Use-After-Free (UAF) scenarios if ANGLE incorrectly determines the GPU is idle and prematurely recycles resources.
Potential Exploitation Scenario
On Android, where the GPU process is unsandboxed, this vulnerability is particularly severe. An attacker could potentially:
- Trigger a high-index allocation via a one-off GPU operation.
- Submit work that writes to a specific buffer.
- Release the index and trigger an allocation that causes the high-water mark to regress.
- Create a global sync fence and wait for it to signal.
- Because the fence signals prematurely, the attacker could attempt to read from the buffer while the GPU is still writing, leading to cross-origin information leaks, or recycle the buffer while it is in use, leading to memory corruption in the GPU process.
Suggested Fix
The allocate() method should be updated to ensure mLargestIndexEverAllocated only ever increases. Using std::max between the current value and the newly calculated maximum allocated index would resolve the issue:
// In QueueSerialIndexAllocator::allocate()
size_t currentMax = (~mFreeIndexBitSetArray).last();
size_t previousMax = mLargestIndexEverAllocated.load(std::memory_order_relaxed);
while (currentMax > previousMax && !mLargestIndexEverAllocated.compare_exchange_weak(previousMax, currentMax))
{
// Retrying if another thread updated it concurrently
}
Or more simply, since it is protected by mMutex:
mLargestIndexEverAllocated = std::max<size_t>(mLargestIndexEverAllocated, (~mFreeIndexBitSetArray).last());
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.