Medium watch CVSS 7.5 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in ANGLE
DescriptionRace condition in ANGLE
ComponentANGLE
Sub-componentVulkan
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker513923164
Fix commit288a6eed28c0 (angle/angle) +95/-4
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-17 (per vendor advisory)
Fix landed2026-07-17 (public git)
Disclosed2026-08-25
Patch gap39 days — fix public in source before the advisory

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.

Key insight
The single mistake is recomputing a supposed “largest index ever allocated” from the currently allocated set, which lets a free-then-reallocate sequence lower the high-water mark; the fix makes the value strictly non-decreasing via std::max.

Attack Path

  1. 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 GlobalFenceSyncMultithreaded regression test).
  2. Release then reallocate the index The context is released (freeing its index in mFreeIndexBitSetArray) and a make-current reallocates a queue index, causing mLargestIndexEverAllocated to be recomputed from .last() and shrink below the index that still has pending work.
  3. Create a global fence sync The main context creates an EGL_SYNC_GLOBAL_FENCE_ANGLE sync, whose wait iterates in-use indices only up to the (now-lowered) mLargestIndexEverAllocated, skipping the still-busy index.
  4. Wait returns prematurely eglClientWaitSyncKHR reports 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

An attacker with the ability to run GPU commands (e.g. WebGL/WebGPU content in the renderer/GPU process) can race context allocation to make a global fence sync signal before all outstanding GPU work finishes, producing a data race on GPU resources. The advisory classifies this as a race condition in ANGLE; any consequent use of not-yet-completed or stale GPU data, and escalation beyond incorrect synchronization, is (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

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

Files Changed

  • src/tests/egl_tests/EGLSyncTest.cpp
  • src/libANGLE/renderer/vulkan/vk_utils.h
  • src/tests/capture_replay_tests/capture_replay_expectations.txt

Audit Directions

  • Misnamed monotonic counters
    Audit 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 bounds
    Review 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 state
    Examine QueueSerialIndexAllocator and 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.
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