Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in ANGLE
DescriptionRace condition in ANGLE
ComponentANGLE
Bug ClassRace
Tracker513923164
Fix commit288a6eed28c0 (angle/angle) +95/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
QueueSerialIndexAllocator
src/libANGLE/renderer/vulkan/vk_utils.h
modified

Files Changed

  • src/libANGLE/renderer/vulkan/vk_utils.h
  • src/tests/capture_replay_tests/capture_replay_expectations.txt
  • src/tests/egl_tests/EGLSyncTest.cpp
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by vm...@google.com

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.h
  • third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/vk_renderer.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/SyncVk.cpp
  • ui/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:

  1. Trigger a high-index allocation via a one-off GPU operation.
  2. Submit work that writes to a specific buffer.
  3. Release the index and trigger an allocation that causes the high-water mark to regress.
  4. Create a global sync fence and wait for it to signal.
  5. 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.

View on issue tracker