CVE-2026-9946
Overview
Background
- ANGLE parallel shader linking
- Under
GL_KHR_parallel_shader_compile,glLinkProgramspawns a background worker (MainLinkTaskinsrc/libANGLE/Program.cpp) that performs the link off the GL thread; the program becomes usable once the job completes. - `Caps` / `Limitations`
- Device/context capability structures (max vertex attributes, etc.) consulted during link to size and bounds-check per-attribute data (e.g.
usedAttribMapinProgram::linkAttributes). They are owned by theContext’s inlinePrivateState. - `Context` lifetime and `ShareGroup`
- Multiple GL contexts can share one ANGLE
ShareGroup, which owns theShaderProgramManagerand thus keeps programs alive even after one context is destroyed. A context’sCaps/Limitationsdie with the context, independent of the share group. - `Context::onDestroy` / `ensureNoPendingLink`
- Teardown only joins link jobs for the currently bound program/pipeline; an unbound-but-still-linking program’s background job is not joined.
Root Cause Analysis
The violated invariant is that a background MainLinkTask must not outlive the storage it dereferences. MainLinkTask held const Caps &mCaps; and const Limitations &mLimitations; — raw references into the originating Context’s PrivateState. When that context is destroyed, its PrivateState (and the referenced Caps/Limitations) is freed. Because Context::onDestroy only forces mState.ensureNoPendingLink(this) for the bound program, a program that is linking but has been unbound (glUseProgram(0)) is skipped, so its worker thread is never joined. The program itself stays alive through a sharing context’s ShareGroup/ShaderProgramManager, so the worker keeps running and later reads mCaps/mLimitations (e.g. caps.maxVertexAttributes) from freed memory — a use-after-free on Context-owned state.
The patch changes the two members from references to by-value copies: const Caps mCaps; and const Limitations mLimitations; (src/libANGLE/Program.cpp), with a comment stating the copy is made “because the context that started the link task may get destroyed before the link job is finished.” Copying these small structs severs the worker’s dependence on the context’s lifetime: even if the originating Context is torn down mid-link, the job operates on its own snapshot. The author notes the caps/limitations relevant to link are effectively equal across a share group, so copying is semantically safe. A regression test ParallelShaderCompileTest.DestroyContextWhileLinkIsInProgress reproduces the scenario: create a shared context, start a link without binding the program, make the other context current, destroy the spawning context, and assert the link still succeeds with no error. The diff substantiates the advisory’s UAF class; it does not itself add thread-joining in onDestroy (the advisory’s alternative fix), relying instead on the by-value copy to make the dangling reference harmless.
Caps/Limitations.Attack Path
- Compromised renderer Attacker drives the command buffer to the GPU process (precondition per advisory; IPC surface not in the diff — (inferred)).
- Create a shared second context
Trigger a path (the advisory cites
CopySharedImageINTERNALlazily creatinglazy_context_) so Context B shares Context A’sShareGroup. - Start a long background link
On Context A, attach complex shaders and
glLinkProgram, dispatching aMainLinkTaskthat captures references to Context A’sCaps/Limitations. - Unbind the program
glUseProgram(0)so the program is not current on Context A, ensuringonDestroy’sensureNoPendingLinkwill skip it. - Destroy Context A
LoseContextCHROMIUM/ destroy the command buffer. Context A (and itsCaps) is freed, but the share group keeps the program and its running link job alive. - UAF read
The worker thread dereferences the freed
mCaps/mLimitations(e.g.caps.maxVertexAttributes). - Corruption / escape (inferred)
With GPU-process heap spraying to control the freed
Caps, forged values such as an inflatedmaxVertexAttributesbypass link-time bounds checks, yielding OOB reads/writes and, per the advisory, a renderer-to-GPU sandbox escape. Heap control and the OOB-to-escape chain are plausible but not shown by the diff.
Impact Assessment
Context-owned Caps/Limitations from a background thread. Escalation to OOB read/write and sandbox escape is (inferred): it depends on controlling the freed allocation and on specific caps feeding bounds checks, which the diff does not demonstrate; ANGLE is a third-party library without MiraclePtr, so the freed memory is not quarantined, raising exploitability. Absent successful heap control the realized effect is a UAF read (likely crash).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
Audit Directions
- Other reference captures in async GL tasksAudit
MainLinkTask, compile tasks, and anyangle::Closure/worker that storesconst T&toContext/PrivateState-owned data; such borrows are unsafe once the context can die mid-job. - Incomplete join on destroyReview
Context::onDestroyandState::ensureNoPendingLinkfor other objects (pipelines, separable programs, transform-feedback) whose background jobs are only joined when bound. - Share-group lifetime skewLook for state the share group keeps alive (programs, shaders) that transitively references per-context storage; the share group outliving a context is the general hazard here.
- Caps as a trust boundarySince forged caps bypass link-time bounds checks, audit all link/validation code that trusts
caps.*for sizing or indexing, independent of this lifetime bug.
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.