CVE-2026-9877
Overview
Files Changed
src/libANGLE/renderer/gl/egl/ImageEGL.cppsrc/libANGLE/renderer/gl/egl/ImageEGL.hsrc/tests/gl_tests/MultithreadingTest.cpp
Patch
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(¤tStep, &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();
Regression Test / PoC
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(¤tStep, &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(¤tStep, &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(),
Original Bug Report
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.cppthird_party/angle/src/libGLESv2/entry_points_egl_autogen.cppthird_party/angle/src/libGLESv2/entry_points_egl_ext_autogen.cppthird_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.
- Predict ID: An attacker in the Renderer process predicts the next
ImageIDto be allocated, as ANGLE’sHandleAllocatorallocates IDs sequentially from 1. - Trigger Creation: The attacker triggers an IPC call that invokes
EGL_CreateImageon Thread A in the GPU process. Thread A allocates the object, queues the lambda, adds the image tomImageMap, and releases the global EGL lock. - Concurrent Destruction: Before Thread A executes the tail call, the attacker uses a concurrent Web Worker to trigger
eglDestroyImageon Thread B, providing the predictedImageID. - Free Memory: Thread B successfully acquires the lock, finds the image in
mImageMap, and destroys it. The memory forImageEGL(which falls into the 64-byte PartitionAlloc bucket) is freed. - 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.
- UAF Write: Thread A executes the pending
UnlockedTailCalllambda. The native driver creates the EGL image and writes its 8-byte handle into the captured&imagereference, 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:
- Extend Lifetime: Have the lambda capture a strong reference to the frontend
egl::Imageobject to keep theImageEGLbackend object alive until the tail call completes. - Avoid Reference Captures: Do not capture
mImageby reference. Instead, have the tail call return the driver handle via theresultOutpointer, and let the caller assign it safely if the object still exists. - Defer Map Insertion: Do not insert the newly created
egl::Imageinto the globalmImageMapuntil after theUnlockedTailCallhas 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.