CVE-2026-9877
Overview
Background
- ANGLE
- Chrome’s GPU-process graphics abstraction layer that translates EGL/OpenGL ES calls onto native backends; the GL backend implements
EGLImageobjects viarx::ImageEGL. - Unlocked tail call
- a deferred callback that ANGLE runs after releasing the global EGL lock, used here to perform
eglCreateImageKHRoutside 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
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.
Attack Path
- Trigger creation
Thread A calls
eglCreateImageon the GL backend, which constructs anrx::ImageEGLand schedules the unlocked tail call, then releases the EGL lock. - Guess the handle Thread B predicts the just-allocated image handle before the tail call has run.
- Race destruction
Thread B calls
eglDestroyImageon that handle, running~ImageEGLand freeing the object while the tail call is still pending. - Stale write
The deferred tail call executes and writes the
createImageKHRresult through its captured raw reference into now-freedImageEGLmemory. - 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
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.cppsrc/libANGLE/renderer/gl/egl/ImageEGL.hsrc/tests/gl_tests/MultithreadingTest.cpp
Audit Directions
- Raw captures into unlocked/deferred callbacksflag any lambda or tail call that captures
&memberorthisand runs after a lock is dropped; require ownership-aware capture (weak_ptr/shared_ptr) that can detect destruction. - Predictable-handle concurrencyaudit 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 releasereview all
egl::Displayunlocked tail calls for members mutated after the lock is released, confirming each mutation cannot outlive its owning object.
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.