CVE-2026-9946
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
Tasksrc/tests/gl_tests/ParallelShaderCompileTest.cpp |
modified | |
ParallelShaderCompileTestES31src/tests/gl_tests/ParallelShaderCompileTest.cpp |
modified |
Files Changed
src/libANGLE/Program.cppsrc/tests/gl_tests/ParallelShaderCompileTest.cpp
Patch
From eb897fef02f149711350e6fe21c1ef96162aa570 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Tue, 28 Apr 2026 13:11:40 -0400
Subject: [PATCH] Fix race between context destroy and link job
The link job held references to Caps and Limitations of the context that
started the link. This change makes that a copy in case the context is
destroyed while the link job is in progress.
In reality, the caps and limitations that matter for link should be
properties of the share group, because the program is shared between
them and in practice the caps and limitations that are checked during
link are equal between all contexts of the share group. A potential
future change could refactor Caps and Limitations to be per share group
or even device, with a few caps specific to the context (e.g. where
GLES1 and GLES2+ caps differ).
Bug: chromium:503596863
Change-Id: Ib876f574cd30ee7f21e58be4dea7bd756706cfe2
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7799786
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Geoff Lang <geofflang@chromium.org>
---
diff --git a/src/libANGLE/Program.cpp b/src/libANGLE/Program.cpp
index add1e25..bbd2f4c 100644
--- a/src/libANGLE/Program.cpp
+++ b/src/libANGLE/Program.cpp
@@ -633,9 +633,10 @@
private:
angle::Result linkImpl();
- // State needed for link
- const Caps &mCaps;
- const Limitations &mLimitations;
+ // State needed for link. Note that Caps and Limitations are copied because the context that
+ // started the link task may get destroyed before the link job is finished.
+ const Caps mCaps;
+ const Limitations mLimitations;
const Version mClientVersion;
const bool mIsWebGL;
Program *mProgram;
diff --git a/src/tests/gl_tests/ParallelShaderCompileTest.cpp b/src/tests/gl_tests/ParallelShaderCompileTest.cpp
index 062591b..fae24c3 100644
--- a/src/tests/gl_tests/ParallelShaderCompileTest.cpp
+++ b/src/tests/gl_tests/ParallelShaderCompileTest.cpp
@@ -38,20 +38,6 @@
setConfigAlphaBits(8);
}
- bool ensureParallelShaderCompileExtensionAvailable()
- {
- if (IsGLExtensionRequestable("GL_KHR_parallel_shader_compile"))
- {
- glRequestExtensionANGLE("GL_KHR_parallel_shader_compile");
- }
-
- if (!IsGLExtensionEnabled("GL_KHR_parallel_shader_compile"))
- {
- return false;
- }
- return true;
- }
-
class Task
{
public:
@@ -388,7 +374,7 @@
// Test basic functionality of GL_KHR_parallel_shader_compile
TEST_P(ParallelShaderCompileTest, Basic)
{
- ANGLE_SKIP_TEST_IF(!ensureParallelShaderCompileExtensionAvailable());
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
GLint count = 0;
glMaxShaderCompilerThreadsKHR(8);
@@ -401,7 +387,7 @@
// Test to compile and link many programs in parallel.
TEST_P(ParallelShaderCompileTest, LinkAndDrawManyPrograms)
{
- ANGLE_SKIP_TEST_IF(!ensureParallelShaderCompileExtensionAvailable());
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
TaskRunner<ClearColorWithDraw> runner;
runner.run(this, kPollInterval);
@@ -412,12 +398,86 @@
// crbug.com/1317673
TEST_P(ParallelShaderCompileTest, LinkProgramAndRecompileShader)
{
- ANGLE_SKIP_TEST_IF(!ensureParallelShaderCompileExtensionAvailable());
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
TaskRunner<ClearColorWithDrawRecompile> runner;
runner.run(this, 0);
}
+// Tests no crash in case the program is being linked, is not current, and the context that spawned
+// the task is destroyed.
+TEST_P(ParallelShaderCompileTest, DestroyContextWhileLinkIsInProgress)
+{
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
+
+ // Create a context shared with the current one
+ EGLWindow *window = getEGLWindow();
+ EGLDisplay display = window->getDisplay();
+ EGLConfig config = window->getConfig();
+ EGLSurface surface = window->getSurface();
+ EGLint contextAttributes[] = {
+ EGL_CONTEXT_MAJOR_VERSION_KHR,
+ GetParam().majorVersion,
+ EGL_CONTEXT_MINOR_VERSION_KHR,
+ GetParam().minorVersion,
+ EGL_NONE,
+ };
+ EGLContext context1 = eglGetCurrentContext();
+ EGLContext context2 = eglCreateContext(display, config, context1, contextAttributes);
+ ASSERT_NE(context2, EGL_NO_CONTEXT);
+ eglMakeCurrent(display, surface, surface, context2);
+
+ constexpr char kVS[] = R"(precision mediump float;
+uniform vec4 u;
+attribute vec4 a;
+varying vec4 b;
+void main()
+{
+ gl_Position = a;
+ b = u;
+})";
+ constexpr char kFS[] = R"(precision mediump float;
+uniform sampler2D s;
+varying vec4 b;
+void main()
+{
+ gl_FragColor = texture2D(s, vec2(0)) + b;
+})";
+
+ GLuint program = glCreateProgram();
+
+ GLuint vs = CompileShader(GL_VERTEX_SHADER, kVS);
+ GLuint fs = CompileShader(GL_FRAGMENT_SHADER, kFS);
+
+ EXPECT_NE(0u, vs);
+ EXPECT_NE(0u, fs);
+
+ glAttachShader(program, vs);
+ glDeleteShader(vs);
+
+ glAttachShader(program, fs);
+ glDeleteShader(fs);
+
+ // Start the link job, but don't wait for it to finish. Don't bind the program to the context.
+ glLinkProgram(program);
+
+ // Make context1 current again.
+ eglMakeCurrent(display, surface, surface, context1);
+
+ // Destroy the context that spawned the link job
+ eglDestroyContext(display, context2);
+
+ // Destroy the shader
+ glDetachShader(program, vs);
+ glDetachShader(program, fs);
+
+ GLint linkStatus;
+ glGetProgramiv(program, GL_LINK_STATUS, &linkStatus);
+ EXPECT_GL_TRUE(linkStatus);
+
+ EXPECT_GL_NO_ERROR();
+}
+
class ParallelShaderCompileTestES31 : public ParallelShaderCompileTest
{};
@@ -431,7 +491,7 @@
// TODO(http://anglebug.com/42264192): Fails on Linux+Intel+OpenGL
ANGLE_SKIP_TEST_IF(IsLinux() && IsIntel() && IsOpenGL());
- ANGLE_SKIP_TEST_IF(!ensureParallelShaderCompileExtensionAvailable());
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
TaskRunner<ImageLoadStore> runner;
runner.run(this, kPollInterval);
Regression Test / PoC
diff --git a/src/tests/gl_tests/ParallelShaderCompileTest.cpp b/src/tests/gl_tests/ParallelShaderCompileTest.cpp
index 062591b..fae24c3 100644
--- a/src/tests/gl_tests/ParallelShaderCompileTest.cpp
+++ b/src/tests/gl_tests/ParallelShaderCompileTest.cpp
@@ -38,20 +38,6 @@
setConfigAlphaBits(8);
}
- bool ensureParallelShaderCompileExtensionAvailable()
- {
- if (IsGLExtensionRequestable("GL_KHR_parallel_shader_compile"))
- {
- glRequestExtensionANGLE("GL_KHR_parallel_shader_compile");
- }
-
- if (!IsGLExtensionEnabled("GL_KHR_parallel_shader_compile"))
- {
- return false;
- }
- return true;
- }
-
class Task
{
public:
@@ -388,7 +374,7 @@
// Test basic functionality of GL_KHR_parallel_shader_compile
TEST_P(ParallelShaderCompileTest, Basic)
{
- ANGLE_SKIP_TEST_IF(!ensureParallelShaderCompileExtensionAvailable());
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
GLint count = 0;
glMaxShaderCompilerThreadsKHR(8);
@@ -401,7 +387,7 @@
// Test to compile and link many programs in parallel.
TEST_P(ParallelShaderCompileTest, LinkAndDrawManyPrograms)
{
- ANGLE_SKIP_TEST_IF(!ensureParallelShaderCompileExtensionAvailable());
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
TaskRunner<ClearColorWithDraw> runner;
runner.run(this, kPollInterval);
@@ -412,12 +398,86 @@
// crbug.com/1317673
TEST_P(ParallelShaderCompileTest, LinkProgramAndRecompileShader)
{
- ANGLE_SKIP_TEST_IF(!ensureParallelShaderCompileExtensionAvailable());
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
TaskRunner<ClearColorWithDrawRecompile> runner;
runner.run(this, 0);
}
+// Tests no crash in case the program is being linked, is not current, and the context that spawned
+// the task is destroyed.
+TEST_P(ParallelShaderCompileTest, DestroyContextWhileLinkIsInProgress)
+{
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
+
+ // Create a context shared with the current one
+ EGLWindow *window = getEGLWindow();
+ EGLDisplay display = window->getDisplay();
+ EGLConfig config = window->getConfig();
+ EGLSurface surface = window->getSurface();
+ EGLint contextAttributes[] = {
+ EGL_CONTEXT_MAJOR_VERSION_KHR,
+ GetParam().majorVersion,
+ EGL_CONTEXT_MINOR_VERSION_KHR,
+ GetParam().minorVersion,
+ EGL_NONE,
+ };
+ EGLContext context1 = eglGetCurrentContext();
+ EGLContext context2 = eglCreateContext(display, config, context1, contextAttributes);
+ ASSERT_NE(context2, EGL_NO_CONTEXT);
+ eglMakeCurrent(display, surface, surface, context2);
+
+ constexpr char kVS[] = R"(precision mediump float;
+uniform vec4 u;
+attribute vec4 a;
+varying vec4 b;
+void main()
+{
+ gl_Position = a;
+ b = u;
+})";
+ constexpr char kFS[] = R"(precision mediump float;
+uniform sampler2D s;
+varying vec4 b;
+void main()
+{
+ gl_FragColor = texture2D(s, vec2(0)) + b;
+})";
+
+ GLuint program = glCreateProgram();
+
+ GLuint vs = CompileShader(GL_VERTEX_SHADER, kVS);
+ GLuint fs = CompileShader(GL_FRAGMENT_SHADER, kFS);
+
+ EXPECT_NE(0u, vs);
+ EXPECT_NE(0u, fs);
+
+ glAttachShader(program, vs);
+ glDeleteShader(vs);
+
+ glAttachShader(program, fs);
+ glDeleteShader(fs);
+
+ // Start the link job, but don't wait for it to finish. Don't bind the program to the context.
+ glLinkProgram(program);
+
+ // Make context1 current again.
+ eglMakeCurrent(display, surface, surface, context1);
+
+ // Destroy the context that spawned the link job
+ eglDestroyContext(display, context2);
+
+ // Destroy the shader
+ glDetachShader(program, vs);
+ glDetachShader(program, fs);
+
+ GLint linkStatus;
+ glGetProgramiv(program, GL_LINK_STATUS, &linkStatus);
+ EXPECT_GL_TRUE(linkStatus);
+
+ EXPECT_GL_NO_ERROR();
+}
+
class ParallelShaderCompileTestES31 : public ParallelShaderCompileTest
{};
@@ -431,7 +491,7 @@
// TODO(http://anglebug.com/42264192): Fails on Linux+Intel+OpenGL
ANGLE_SKIP_TEST_IF(IsLinux() && IsIntel() && IsOpenGL());
- ANGLE_SKIP_TEST_IF(!ensureParallelShaderCompileExtensionAvailable());
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_KHR_parallel_shader_compile"));
TaskRunner<ImageLoadStore> runner;
runner.run(this, kPollInterval);
Original Bug Report
Potential UAF in ANGLE MainLinkTask via dangling Caps reference
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A potential Use-After-Free vulnerability exists in ANGLE’s parallel shader linking when a background worker thread (MainLinkTask) holds a raw reference to a destroyed Context’s capabilities. An attacker can trigger this by utilizing a shared context group and forcing context loss while an unbound program is linking in the background. This allows a compromised renderer to achieve arbitrary code execution in the GPU process.
Affected files:
third_party/angle/src/libANGLE/Program.cppthird_party/angle/src/libANGLE/Context.cppgpu/command_buffer/service/gles2_cmd_decoder_passthrough.ccthird_party/angle/src/libANGLE/State.h
Estimated timestamp from git blame: 2023-10-24
Summary
A potential Use-After-Free (UAF) vulnerability has been identified in ANGLE’s parallel shader compilation logic. The MainLinkTask class, which handles background program linking, captures raw C++ references to the Caps and Limitations structures (const Caps &mCaps;) owned by the Context’s inline PrivateState.
When a Context is destroyed, its PrivateState and the associated Caps memory are freed. However, Context::onDestroy only ensures that pending link tasks for the currently bound program (or pipeline) are explicitly joined. If a linking program is unbound and the context is destroyed, the background thread will continue executing and eventually dereference the freed Caps memory, resulting in a UAF.
Potential Trigger Steps
An attacker with a compromised renderer process could theoretically trigger this via the command buffer IPCs to the GPU process:
- Setup Shared Context: The attacker requests a GLES2 command buffer (creating Context A). They then send an IPC like
CopySharedImageINTERNAL, which lazily initializeslazy_context_in the passthrough decoder. This creates Context B, which explicitly shares the same ANGLEShareGroupas Context A. - Start Background Link: The attacker creates a shader program, attaches complex shaders (to prolong linking), and calls
glLinkProgram. This creates aMainLinkTaskthat captures a reference to Context A’sCapsand is dispatched to a background worker thread. - Unbind Program: The attacker calls
glUseProgram(0)to ensure the program is no longer currently bound to Context A. - Force Context Teardown: The attacker sends a
LoseContextCHROMIUMcommand and destroys the command buffer. Context A is destroyed. - Bypass Link Resolution: Because the program is unbound,
Context::onDestroy’s call tomState.ensureNoPendingLink(this)ignores it, failing to join the background thread. - Program Leakage: Context A is destroyed, freeing its
Caps. However, Context B keeps theShareGroup(and thus theShaderProgramManagerand the linking program) alive. - UAF: The worker thread continues execution and accesses fields like
caps.maxVertexAttributesfrom the freed memory.
Note: These are suggested steps based on source code analysis; our tooling agent cannot execute code to provide a live proof-of-concept.
Impact
By utilizing heap spraying techniques in the GPU process before Context A is fully destroyed, an attacker could potentially control the freed Caps memory. Modifying values like maxVertexAttributes bypasses bounds checks during linking (e.g., when populating usedAttribMap in Program::linkAttributes), leading to arbitrary out-of-bounds reads and writes. Because ANGLE is a third-party library without MiraclePtr protections, this UAF can be reliably exploited for a renderer-to-GPU sandbox escape.
Suggested Fix
- Capture by Value: Update
MainLinkTask(and related structures) to captureCaps,Limitations, andVersionby value rather than by reference. These structures are relatively small and copying them would guarantee the worker thread does not depend on the lifetime of theContext. - Complete Thread Joining: Ensure that
Context::onDestroyorShaderProgramManagerproperly joins all background compilation/linking threads belonging to the context/share group upon destruction, even if the program is not currently bound.
Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09
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. And please feel free to reach out to me directly if you have concerns or feedback on the project.