Critical watch ~ plausible CVSS 8.3 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Critical
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Sub-componentEGL
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable — revised by independent review by AI
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.
Independent review by AIPlausible (tier revised from patch-now)
Reviewer notes: Mechanism matches diff, but only ANGLE GL/EGL backend (renderer/gl/egl) is affected. Web/renderer cannot call eglCreateImage/eglDestroyImage; race is between GPU-process threads. 'Reliably triggerable' and web/default reachability are unsupported.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker496445460
Fix commit1b572acd76c4 (angle/angle) +155/-22
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-26 (per vendor advisory)
Fix landed2026-04-24 (public git)
Disclosed2026-05-27
Patch gap33 days — fix public in source before the advisory

Background

ANGLE
Chrome’s GPU-process graphics abstraction layer that translates EGL/OpenGL ES calls onto native backends; the GL backend implements EGLImage objects via rx::ImageEGL.
Unlocked tail call
a deferred callback that ANGLE runs after releasing the global EGL lock, used here to perform eglCreateImageKHR outside the lock so the driver call does not hold contention.
`EGLImage`
an opaque, driver-assigned handle representing a shareable image resource; ANGLE stores it in rx::ImageEGL::mImage.
Predictable handle
EGL object handles are allocated in a guessable pattern, so a second thread can reference an in-flight image by handle before its creating call has completed.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Mechanism matches diff, but only ANGLE GL/EGL backend (renderer/gl/egl) is affected. Web/renderer cannot call eglCreateImage/eglDestroyImage; race is between GPU-process threads. 'Reliably triggerable' and web/default reachability are unsupported.

rx::ImageEGL::initialize scheduled the real createImageKHR work into an unlocked tail call that captured &image = mImage, a raw reference into the ImageEGL object’s memory, and wrote the resulting handle there after the EGL lock was released. Because EGL image handles are predictable, a second thread could call eglDestroyImage on the same, still-initializing ImageEGL — running its destructor and freeing the object — before or while the deferred tail call executed. When the tail call then dereferenced its captured raw reference to store the created handle, it wrote into freed ImageEGL memory, a classic use-after-free spanning the lock boundary. The bug exists precisely because ownership of mImage was tied to the raw object lifetime with no way for the deferred work to detect that the object had been destroyed. The unlocked window between scheduling and running the tail call is the exact race interval an attacker can drive with two threads.

The fix boxes the handle in a std::shared_ptr<EGLImage> and has the tail call capture only a std::weak_ptr, so a lock/upgrade attempt safely fails when the object is gone.

Key insight
Deferring work outside a lock while capturing a raw reference into a caller-owned object silently assumes the object outlives the deferred work — an assumption a second thread can violate through a predictable handle, turning a latency optimization into a cross-thread use-after-free.

Attack Path

  1. Trigger creation Thread A calls eglCreateImage on the GL backend, which constructs an rx::ImageEGL and schedules the unlocked tail call, then releases the EGL lock.
  2. Guess the handle Thread B predicts the just-allocated image handle before the tail call has run.
  3. Race destruction Thread B calls eglDestroyImage on that handle, running ~ImageEGL and freeing the object while the tail call is still pending.
  4. Stale write The deferred tail call executes and writes the createImageKHR result through its captured raw reference into now-freed ImageEGL memory.
  5. Exploit The attacker controls reallocation of that freed region to convert the write/dangling handle into a corrupted-state primitive in the GPU process.

Impact Assessment

This is a use-after-free in the GPU process, reachable from any context that can issue eglCreateImage/eglDestroyImage concurrently, including compromised or scripted renderer-driven GPU work. The stale write into freed memory can corrupt heap state and, with heap grooming, escalate toward controlled memory disclosure or code execution within the GPU process sandbox. Because the race requires only two threads and a guessable handle, it is reliably triggerable rather than a rare timing fluke.

Files Changed

  • src/libANGLE/renderer/gl/egl/ImageEGL.cpp
  • src/libANGLE/renderer/gl/egl/ImageEGL.h
  • src/tests/gl_tests/MultithreadingTest.cpp

Audit Directions

  • Raw captures into unlocked/deferred callbacks
    flag any lambda or tail call that captures &member or this and runs after a lock is dropped; require ownership-aware capture (weak_ptr/shared_ptr) that can detect destruction.
  • Predictable-handle concurrency
    audit EGL/GL object paths where a handle becomes referenceable by other threads before its creating call completes, especially create/destroy pairs that can interleave across the lock boundary.
  • Lifetime spanning the EGL lock release
    review all egl::Display unlocked tail calls for members mutated after the lock is released, confirming each mutation cannot outlive its owning object.
From 1b572acd76c407a2822ba789ed78a7c723196729 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Tue, 21 Apr 2026 15:13:09 -0400
Subject: [PATCH] EGL: Don't use unlocked tail call for image creation

It's not thread safe, as another thread can guess the image handle to be
created and simultaneously try to destroy it.

Bug: chromium:496445460
Change-Id: I5a6619af61e3217df7f1c99c0342c85409723fbd
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7783627
Reviewed-by: Geoff Lang <geofflang@chromium.org>
---

diff --git a/src/libANGLE/renderer/gl/egl/ImageEGL.cpp b/src/libANGLE/renderer/gl/egl/ImageEGL.cpp
index be70e7d..9d3e542 100644
--- a/src/libANGLE/renderer/gl/egl/ImageEGL.cpp
+++ b/src/libANGLE/renderer/gl/egl/ImageEGL.cpp
@@ -27,23 +27,23 @@
                    EGLenum target,
                    const egl::AttributeMap &attribs,
                    const FunctionsEGL *egl)
-    : ImageGL(state),
-      mEGL(egl),
-      mContext(EGL_NO_CONTEXT),
-      mTarget(target),
-      mPreserveImage(false),
-      mImage(EGL_NO_IMAGE)
+    : ImageGL(state), mEGL(egl), mContext(EGL_NO_CONTEXT), mTarget(target), mPreserveImage(false)
 {
     if (context)
     {
         mContext = GetImplAs<ContextEGL>(context)->getContext();
     }
+    mImage         = std::make_shared<EGLImage>(EGL_NO_IMAGE);
     mPreserveImage = attribs.get(EGL_IMAGE_PRESERVED, EGL_FALSE) == EGL_TRUE;
 }
 
 ImageEGL::~ImageEGL()
 {
-    mEGL->destroyImageKHR(mImage);
+    if (mImage)
+    {
+        mEGL->destroyImageKHR(*mImage);
+        mImage.reset();
+    }
 }
 
 egl::Error ImageEGL::initialize(const egl::Display *display)
@@ -100,19 +100,26 @@
 
     attributes.push_back(EGL_NONE);
 
-    egl::Display::GetCurrentThreadUnlockedTailCall()->add([egl = mEGL, &image = mImage,
-                                                           context = mContext, target = mTarget,
-                                                           buffer, attributes](void *resultOut) {
-        image = egl->createImageKHR(context, target, buffer, attributes.data());
+    std::weak_ptr<EGLImage> imageRef(mImage);
+    egl::Display::GetCurrentThreadUnlockedTailCall()->add(
+        [egl = mEGL, imageRef = std::move(imageRef), context = mContext, target = mTarget, buffer,
+         attributes](void *resultOut) {
+            std::shared_ptr<EGLImage> image = imageRef.lock();
+            // Protect against a racy thread deleting the image before the tail call is run.
+            if (image)
+            {
+                *image = egl->createImageKHR(context, target, buffer, attributes.data());
 
-        // If image creation failed, force the return value of eglCreateImage to EGL_NO_IMAGE. This
-        // won't delete this image object but a driver error is unexpected at this point.
-        if (image == EGL_NO_IMAGE)
-        {
-            ERR() << "eglCreateImage failed with " << gl::FmtHex(egl->getError());
-            *static_cast<EGLImage *>(resultOut) = EGL_NO_IMAGE;
-        }
-    });
+                // If image creation failed, force the return value of eglCreateImage to
+                // EGL_NO_IMAGE. This won't delete this image object but a driver error is
+                // unexpected at this point.
+                if (*image == EGL_NO_IMAGE)
+                {
+                    ERR() << "eglCreateImage failed with " << gl::FmtHex(egl->getError());
+                    *static_cast<EGLImage *>(resultOut) = EGL_NO_IMAGE;
+                }
+            }
+        });
 
     return egl::NoError();
 }
@@ -135,7 +142,7 @@
     stateManager->bindTexture(type, texture->getTextureID());
 
     // Bind the image to the texture
-    functionsGL->eGLImageTargetTexture2DOES(ToGLenum(type), mImage);
+    functionsGL->eGLImageTargetTexture2DOES(ToGLenum(type), *mImage);
     *outInternalFormat = mNativeInternalFormat;
 
     return angle::Result::Continue;
@@ -152,7 +159,7 @@
     stateManager->bindRenderbuffer(GL_RENDERBUFFER, renderbuffer->getRenderbufferID());
 
     // Bind the image to the renderbuffer
-    functionsGL->eGLImageTargetRenderbufferStorageOES(GL_RENDERBUFFER, mImage);
+    functionsGL->eGLImageTargetRenderbufferStorageOES(GL_RENDERBUFFER, *mImage);
     *outInternalFormat = mNativeInternalFormat;
 
     return angle::Result::Continue;
diff --git a/src/libANGLE/renderer/gl/egl/ImageEGL.h b/src/libANGLE/renderer/gl/egl/ImageEGL.h
index ef90603..4b00249 100644
--- a/src/libANGLE/renderer/gl/egl/ImageEGL.h
+++ b/src/libANGLE/renderer/gl/egl/ImageEGL.h
@@ -53,7 +53,7 @@
 
     GLenum mNativeInternalFormat;
 
-    EGLImage mImage;
+    std::shared_ptr<EGLImage> mImage;
 };
 
 }  // namespace rx
diff --git a/src/tests/gl_tests/MultithreadingTest.cpp b/src/tests/gl_tests/MultithreadingTest.cpp
index 9443fa5..24daa24 100644
--- a/src/tests/gl_tests/MultithreadingTest.cpp
+++ b/src/tests/gl_tests/MultithreadingTest.cpp
@@ -4514,6 +4514,132 @@
         eglMakeCurrent(dpy, window->getSurface(), window->getSurface(), window->getContext()));
 }
 
+// Test that EGL image creation and destruction don't race.
+TEST_P(MultithreadingTest, EGLImageRaceCreateAndDestroy)
+{
+    // While the EGL backend doesn't technically support multithreading, it's expected to be
+    // thread-safe for image creation to support Chrome.
+    ANGLE_SKIP_TEST_IF(!platformSupportsMultithreading() && !IsOpenGLES());
+    ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_OES_EGL_image"));
+
+    EGLWindow *window = getEGLWindow();
+    EGLDisplay dpy    = window->getDisplay();
+
+    ANGLE_SKIP_TEST_IF(!IsEGLDisplayExtensionEnabled(dpy, "EGL_KHR_image_base"));
+    ANGLE_SKIP_TEST_IF(!IsEGLDisplayExtensionEnabled(dpy, "EGL_KHR_gl_texture_2D_image"));
+
+    constexpr GLsizei kTexSize = 64;
+
+    std::mutex mutex;
+    std::condition_variable condVar;
+
+    enum class Step
+    {
+        Start,
+        T0CreatedImage,
+        T1DestroyLoopStarted,
+        T0RecreatedImage,
+        Finish,
+        Abort,
+    };
+    Step currentStep = Step::Start;
+
+    EGLImage predictedEglImage = EGL_NO_IMAGE_KHR;
+
+    auto thread0 = [&](EGLDisplay dpy, EGLSurface surface, EGLContext context) {
+        ThreadSynchronization<Step> threadSynchronization(&currentStep, &mutex, &condVar);
+
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, surface, surface, context));
+        EXPECT_EGL_SUCCESS();
+
+        // Create source texture and the EGLImage
+        GLuint sourceTex = 0;
+        glGenTextures(1, &sourceTex);
+        glBindTexture(GL_TEXTURE_2D, sourceTex);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kTexSize, kTexSize, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                     nullptr);
+        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+        ASSERT_GL_NO_ERROR();
+
+        predictedEglImage = eglCreateImageKHR(
+            dpy, context, EGL_GL_TEXTURE_2D_KHR,
+            reinterpret_cast<EGLClientBuffer>(static_cast<uintptr_t>(sourceTex)), nullptr);
+        ASSERT_EGL_SUCCESS();
+        ASSERT_NE(predictedEglImage, EGL_NO_IMAGE_KHR);
+
+        // Immediately delete the image.  This puts the |predictedEglImage| handle in a recycle
+        // list.
+        EXPECT_EGL_TRUE(eglDestroyImageKHR(dpy, predictedEglImage));
+
+        // Let the other thread get into a loop that tries to destroy the predicted image, to be
+        // created by this thread simultaneously.
+        threadSynchronization.nextStep(Step::T0CreatedImage);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::T1DestroyLoopStarted));
+
+        // Create another EGL image.  In ANGLE, this would return the same handle as
+        // |predictedEglImage|.
+        GLuint sourceTex2 = 0;
+        glGenTextures(1, &sourceTex2);
+        glBindTexture(GL_TEXTURE_2D, sourceTex2);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kTexSize, kTexSize, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                     nullptr);
+        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+        ASSERT_GL_NO_ERROR();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/MultithreadingTest.cpp b/src/tests/gl_tests/MultithreadingTest.cpp
index 9443fa5..24daa24 100644
--- a/src/tests/gl_tests/MultithreadingTest.cpp
+++ b/src/tests/gl_tests/MultithreadingTest.cpp
@@ -4514,6 +4514,132 @@
         eglMakeCurrent(dpy, window->getSurface(), window->getSurface(), window->getContext()));
 }
 
+// Test that EGL image creation and destruction don't race.
+TEST_P(MultithreadingTest, EGLImageRaceCreateAndDestroy)
+{
+    // While the EGL backend doesn't technically support multithreading, it's expected to be
+    // thread-safe for image creation to support Chrome.
+    ANGLE_SKIP_TEST_IF(!platformSupportsMultithreading() && !IsOpenGLES());
+    ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_OES_EGL_image"));
+
+    EGLWindow *window = getEGLWindow();
+    EGLDisplay dpy    = window->getDisplay();
+
+    ANGLE_SKIP_TEST_IF(!IsEGLDisplayExtensionEnabled(dpy, "EGL_KHR_image_base"));
+    ANGLE_SKIP_TEST_IF(!IsEGLDisplayExtensionEnabled(dpy, "EGL_KHR_gl_texture_2D_image"));
+
+    constexpr GLsizei kTexSize = 64;
+
+    std::mutex mutex;
+    std::condition_variable condVar;
+
+    enum class Step
+    {
+        Start,
+        T0CreatedImage,
+        T1DestroyLoopStarted,
+        T0RecreatedImage,
+        Finish,
+        Abort,
+    };
+    Step currentStep = Step::Start;
+
+    EGLImage predictedEglImage = EGL_NO_IMAGE_KHR;
+
+    auto thread0 = [&](EGLDisplay dpy, EGLSurface surface, EGLContext context) {
+        ThreadSynchronization<Step> threadSynchronization(&currentStep, &mutex, &condVar);
+
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, surface, surface, context));
+        EXPECT_EGL_SUCCESS();
+
+        // Create source texture and the EGLImage
+        GLuint sourceTex = 0;
+        glGenTextures(1, &sourceTex);
+        glBindTexture(GL_TEXTURE_2D, sourceTex);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kTexSize, kTexSize, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                     nullptr);
+        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+        ASSERT_GL_NO_ERROR();
+
+        predictedEglImage = eglCreateImageKHR(
+            dpy, context, EGL_GL_TEXTURE_2D_KHR,
+            reinterpret_cast<EGLClientBuffer>(static_cast<uintptr_t>(sourceTex)), nullptr);
+        ASSERT_EGL_SUCCESS();
+        ASSERT_NE(predictedEglImage, EGL_NO_IMAGE_KHR);
+
+        // Immediately delete the image.  This puts the |predictedEglImage| handle in a recycle
+        // list.
+        EXPECT_EGL_TRUE(eglDestroyImageKHR(dpy, predictedEglImage));
+
+        // Let the other thread get into a loop that tries to destroy the predicted image, to be
+        // created by this thread simultaneously.
+        threadSynchronization.nextStep(Step::T0CreatedImage);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::T1DestroyLoopStarted));
+
+        // Create another EGL image.  In ANGLE, this would return the same handle as
+        // |predictedEglImage|.
+        GLuint sourceTex2 = 0;
+        glGenTextures(1, &sourceTex2);
+        glBindTexture(GL_TEXTURE_2D, sourceTex2);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kTexSize, kTexSize, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                     nullptr);
+        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+        ASSERT_GL_NO_ERROR();
+
+        EGLImage eglImage = eglCreateImageKHR(
+            dpy, context, EGL_GL_TEXTURE_2D_KHR,
+            reinterpret_cast<EGLClientBuffer>(static_cast<uintptr_t>(sourceTex2)), nullptr);
+        ASSERT_EGL_SUCCESS();
+        ASSERT_NE(eglImage, EGL_NO_IMAGE_KHR);
+
+        // Wait for the destroy loop to stop.
+        threadSynchronization.nextStep(Step::T0RecreatedImage);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Finish));
+
+        if (eglImage != predictedEglImage)
+        {
+            WARN() << "New EGL image does not have the same handle as destroyed image, test is "
+                      "ineffective";
+            EXPECT_EGL_TRUE(eglDestroyImageKHR(dpy, eglImage));
+        }
+
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT));
+    };
+
+    auto thread1 = [&](EGLDisplay dpy, EGLSurface surface, EGLContext context) {
+        ThreadSynchronization<Step> threadSynchronization(&currentStep, &mutex, &condVar);
+
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::T0CreatedImage));
+        Timer timer;
+        timer.start();
+        threadSynchronization.nextStep(Step::T1DestroyLoopStarted);
+        // Try to destroy the image with a handle that is expected to be recycled.  The destroy call
+        // will fail before T0 creates the image, and will succeed right after, assuming there are
+        // no race conditions.
+        while (!eglDestroyImageKHR(dpy, predictedEglImage) && timer.getElapsedWallClockTime() < 1.0)
+            ;
+
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::T0RecreatedImage));
+        threadSynchronization.nextStep(Step::Finish);
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT));
+    };
+
+    std::array<LockStepThreadFunc, 2> threadFuncs = {
+        std::move(thread0),
+        std::move(thread1),
+    };
+
+    RunLockStepThreads(getEGLWindow(), threadFuncs.size(), threadFuncs.data());
+
+    ASSERT_NE(currentStep, Step::Abort);
+
+    // Restore the fixture's context for teardown.
+    EXPECT_EGL_TRUE(
+        eglMakeCurrent(dpy, window->getSurface(), window->getSurface(), window->getContext()));
+}
+
 ANGLE_INSTANTIATE_TEST(
     MultithreadingTest,
     ES2_METAL(),
Loading diff…

Original Bug Report

reported by vm...@google.com

UAF write in ANGLE ImageEGL::initialize via UnlockedTailCall race

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential Use-After-Free (UAF) write vulnerability exists in ANGLE’s EGL image creation logic. A deferred lambda captures a member variable by reference and executes after the global EGL lock is released, allowing a concurrent thread to destroy the object and trigger a write into freed memory.

Affected files:

  • third_party/angle/src/libANGLE/renderer/gl/egl/ImageEGL.cpp
  • third_party/angle/src/libGLESv2/entry_points_egl_autogen.cpp
  • third_party/angle/src/libGLESv2/entry_points_egl_ext_autogen.cpp
  • third_party/angle/src/libANGLE/renderer/gl/egl/ImageEGL.h

Estimated timestamp from git blame: 2024-04-29

Vulnerability Details

In ANGLE’s GL/EGL backend, ImageEGL::initialize defers the actual driver-level image creation using UnlockedTailCall to avoid holding the global EGL lock during potentially slow operations. The deferred lambda captures the mImage member variable by reference (&image = mImage):

// third_party/angle/src/libANGLE/renderer/gl/egl/ImageEGL.cpp
egl::Display::GetCurrentThreadUnlockedTailCall()->add([egl = mEGL, &image = mImage, ...](void *resultOut) {
    image = egl->createImageKHR(context, target, buffer, attributes.data());
    // ...
});

In the autogenerated EGL entry points (e.g., EGL_CreateImage), the ANGLE_SCOPED_GLOBAL_LOCK() is released just before UnlockedTailCall::run() is invoked.

Because the newly created frontend egl::Image is inserted into the display’s global mImageMap before the lock is released, it becomes visible to other threads. A concurrent thread can guess the predictable ImageID (allocated sequentially by HandleAllocator) and call eglDestroyImage. This destroys the egl::Image and its backend ImageEGL object, freeing its memory. When the original thread resumes and executes the tail call lambda, it writes the newly created 8-byte driver handle into the image reference, resulting in a Use-After-Free write.

Potential Trigger Steps

Note: These are suggested steps generated by an AI agent that does not have the ability to run or verify code. They represent a theoretical exploitation sequence.

  1. Predict ID: An attacker in the Renderer process predicts the next ImageID to be allocated, as ANGLE’s HandleAllocator allocates IDs sequentially from 1.
  2. Trigger Creation: The attacker triggers an IPC call that invokes EGL_CreateImage on Thread A in the GPU process. Thread A allocates the object, queues the lambda, adds the image to mImageMap, and releases the global EGL lock.
  3. Concurrent Destruction: Before Thread A executes the tail call, the attacker uses a concurrent Web Worker to trigger eglDestroyImage on Thread B, providing the predicted ImageID.
  4. Free Memory: Thread B successfully acquires the lock, finds the image in mImageMap, and destroys it. The memory for ImageEGL (which falls into the 64-byte PartitionAlloc bucket) is freed.
  5. Heap Grooming: The attacker immediately allocates controlled data of the same size (e.g., by supplying a 16-element attribute list to another EGL function or using WebGL queries) to reclaim the freed 64-byte chunk.
  6. UAF Write: Thread A executes the pending UnlockedTailCall lambda. The native driver creates the EGL image and writes its 8-byte handle into the captured &image reference, corrupting the attacker’s overlapping object.

Impact

This flaw provides a controlled 8-byte UAF write in the GPU process. An attacker can overlap the freed ImageEGL object with another object to overwrite a critical pointer or size field, leading to arbitrary read/write and potentially a sandbox escape (Remote Code Execution in the GPU process). MiraclePtr (BackupRefPtr) does not mitigate this because the dangling pointer is held as a native C++ reference inside the std::function closure.

Suggested Fix

There are a few ways to resolve this race condition:

  1. Extend Lifetime: Have the lambda capture a strong reference to the frontend egl::Image object to keep the ImageEGL backend object alive until the tail call completes.
  2. Avoid Reference Captures: Do not capture mImage by reference. Instead, have the tail call return the driver handle via the resultOut pointer, and let the caller assign it safely if the object still exists.
  3. Defer Map Insertion: Do not insert the newly created egl::Image into the global mImageMap until after the UnlockedTailCall has successfully finished executing.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker