Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Bug ClassUAF
Tracker496445460
Fix commit1b572acd76c4 (angle/angle) +155/-22
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Files Changed

  • src/libANGLE/renderer/gl/egl/ImageEGL.cpp
  • src/libANGLE/renderer/gl/egl/ImageEGL.h
  • src/tests/gl_tests/MultithreadingTest.cpp
From 1b572acd76c407a2822ba789ed78a7c723196729 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <[email protected]>
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 <[email protected]>
---

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 [email protected]

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