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 <[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(¤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.