CVE-2026-78906
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
QueueSerialIndexAllocatorsrc/libANGLE/renderer/vulkan/vk_utils.h |
modified |
Files Changed
src/libANGLE/renderer/vulkan/vk_utils.hsrc/tests/capture_replay_tests/capture_replay_expectations.txtsrc/tests/egl_tests/EGLSyncTest.cpp
Patch
From 288a6eed28c054cc1a5cdc08cca257c3968cef78 Mon Sep 17 00:00:00 2001 From: Shahbaz Youssefi <[email protected]> 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 <[email protected]> Reviewed-by: Charlie Lao <[email protected]> --- 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.