CVE-2026-9906
Overview
Background
- Transform Feedback (TF)
- a GL feature that captures vertex-shader outputs into bound buffers; the GLES2 decoder validates draw sizes against per-program TF varying sizes.
- `GLES2DecoderImpl::DoLinkProgram`
- the validating command decoder’s handler for
glLinkProgram, which callsprogram->Link(...)and updates internal program metadata on success (gles2_cmd_decoder.cc:8185). - `Program::UpdateTransformFeedbackInfo`
- recomputes a program’s effective TF varyings and per-vertex data sizes after a successful link (
program_manager.cc:535). - `TransformFeedback`
- tracks whether TF is
active_/paused_and, after the fix, whichProgramit is active against (transform_feedback_manager.cc:15).
Root Cause Analysis
Pre-fix, DoLinkProgram fetched the program and, with no check of TF state, went straight to program->Link(...) and updated metadata on success (gles2_cmd_decoder.cc:8187-8203). The OpenGL ES spec requires glLinkProgram on a program active for TF to fail and leave the program unchanged, but the decoder never enforced this, so Chrome’s UpdateTransformFeedbackInfo-derived sizes (program_manager.cc:535-570) could be updated to match newly attached shaders even when the native driver rejected the relink and kept the old shaders — a desynchronization between Chrome’s TF size metadata and the driver’s actual program. The invariant violated is that Chrome’s TF per-vertex size must match the shader the driver will actually execute, since draw-time validation sizes TF buffers from that metadata.
The fix adds, in DoLinkProgram, an early if (program->IsActiveForTransformFeedback()) { LOCAL_SET_GL_ERROR(GL_INVALID_OPERATION, "glLinkProgram", ...); return; } before Link runs (added diff at @@ -8190,6). To make that query reliable it adds a per-program counter: DoBeginTransformFeedback now calls transform_feedback->SetActiveProgram(program) (added at @@ -5454,6), which increments Program::active_transform_feedback_count_, and DoEndTransformFeedback calls ClearActiveProgram() (added at @@ -5466,6), which decrements it; IsActiveForTransformFeedback() returns true while the count is positive (program_manager.h, added members). This tightens the link path so a program bound to any active (even paused) TF cannot be relinked, keeping Chrome’s metadata in sync with the driver.
The fix uses count-tracking rather than the reporter’s suggested program == current_program comparison, so it covers TF active against a program that is not the currently-used one.
GL_INVALID_OPERATION.Attack Path
- Link a program with large TF output
A program is linked so Chrome records a large TF per-vertex size via
UpdateTransformFeedbackInfo(program_manager.cc:535). (trigger per reporter) - Begin (then pause) transform feedback TF is started against that program, so the driver holds the large-output shader active. (trigger per reporter)
- Reconfigure and relink
The program’s shaders/varyings are swapped for a small-output configuration and
glLinkProgramis called; pre-fixDoLinkProgramproceeds and, misreading leftoverGL_LINK_STATUS, updates metadata to the small size while the driver keeps the large shader (gles2_cmd_decoder.cc:8193-8203). (inferred; link-status read not in shown decoder body) - Draw into an undersized buffer A small TF buffer sized for the bogus metadata is bound and a draw is issued; the driver writes the larger actual output, overflowing the buffer. (inferred; driver write behavior not in shown material)
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifgpu/command_buffer/service/gles2_cmd_decoder.cc |
modified | |
TEST_Fgpu/command_buffer/service/program_manager_unittest.cc |
modified | |
Buffergpu/command_buffer/service/transform_feedback_manager.h |
modified | |
Programgpu/command_buffer/service/transform_feedback_manager.h |
modified | |
TransformFeedbackManagergpu/command_buffer/service/transform_feedback_manager.h |
modified |
Files Changed
gpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/program_manager.ccgpu/command_buffer/service/program_manager.hgpu/command_buffer/service/program_manager_unittest.ccgpu/command_buffer/service/transform_feedback_manager.ccgpu/command_buffer/service/transform_feedback_manager.h
Audit Directions
- Trusting a cached status across a rejected operationWhen forwarding a call that the driver may reject (relink while TF active), verify success with an error check rather than reading a status that persists from a prior success.
- Metadata/driver state desyncAny place Chrome maintains size/layout metadata mirroring driver state must block, or reconcile after, operations the spec says leave the driver object unchanged.
- Spec-mandated preconditionsEnforce OpenGL ES preconditions (e.g. “no relink while active for transform feedback”) in the command decoder, not only relying on the native driver to reject them.
Patch
From 8617532d26844dc69cbfe200fc6f020a1d2dc388 Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Fri, 24 Apr 2026 20:48:29 -0700
Subject: [PATCH] Reject program relinks if transform feedback is active.
If there is any active transform feedback against this program
(regardless of whether it's paused), don't allow it to be relinked.
This keeps the state in sync between the GL driver and the command
decoder.
Co-authored with jetski-cli.
Fixed: 499005260
Change-Id: If827f84d3ab6f6a5f3c1da760cd06e3299244847
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7794329
Reviewed-by: Brandon Jones <bajones@chromium.org>
Commit-Queue: Kenneth Russell <kbr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1620632}
---
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc
index 52d5cdd..d2132c9 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc
@@ -5454,6 +5454,8 @@
}
transform_feedback->DoBeginTransformFeedback(primitive_mode);
DCHECK(transform_feedback->active());
+
+ transform_feedback->SetActiveProgram(program);
}
void GLES2DecoderImpl::DoEndTransformFeedback() {
@@ -5466,6 +5468,11 @@
}
// TODO(zmo): Validate binding points.
state_.bound_transform_feedback->DoEndTransformFeedback();
+
+ Program* program = state_.bound_transform_feedback->active_program();
+ if (program) {
+ state_.bound_transform_feedback->ClearActiveProgram();
+ }
}
void GLES2DecoderImpl::DoPauseTransformFeedback() {
@@ -8190,6 +8197,12 @@
return;
}
+ if (program->IsActiveForTransformFeedback()) {
+ LOCAL_SET_GL_ERROR(GL_INVALID_OPERATION, "glLinkProgram",
+ "program is active for transform feedback");
+ return;
+ }
+
LogClientServiceForInfo(program, program_id, "glLinkProgram");
if (program->Link(shader_manager(),
client())) {
diff --git a/gpu/command_buffer/service/program_manager.cc b/gpu/command_buffer/service/program_manager.cc
index 49bb9ba..8e457705 100644
--- a/gpu/command_buffer/service/program_manager.cc
+++ b/gpu/command_buffer/service/program_manager.cc
@@ -395,6 +395,7 @@
Program::Program(ProgramManager* manager, GLuint service_id)
: manager_(manager),
use_count_(0),
+ active_transform_feedback_count_(0),
max_attrib_name_length_(0),
max_uniform_name_length_(0),
service_id_(service_id),
@@ -527,6 +528,11 @@
}
}
+void Program::DecrementActiveTransformFeedbackCount() {
+ CHECK_GT(active_transform_feedback_count_, 0);
+ --active_transform_feedback_count_;
+}
+
void Program::SetUniformBlockBinding(GLuint index, GLuint binding) {
DCHECK_GT(uniform_block_size_info_.size(), index);
uniform_block_size_info_[index].binding = binding;
diff --git a/gpu/command_buffer/service/program_manager.h b/gpu/command_buffer/service/program_manager.h
index dd7f8cf5..c65338c3 100644
--- a/gpu/command_buffer/service/program_manager.h
+++ b/gpu/command_buffer/service/program_manager.h
@@ -335,6 +335,16 @@
return use_count_ != 0;
}
+ void IncrementActiveTransformFeedbackCount() {
+ ++active_transform_feedback_count_;
+ }
+
+ void DecrementActiveTransformFeedbackCount();
+
+ bool IsActiveForTransformFeedback() const {
+ return active_transform_feedback_count_ > 0;
+ }
+
// Sets attribute-location binding from a glBindAttribLocation() call.
void SetAttribLocationBinding(const std::string& attrib, GLint location) {
bind_attrib_location_map_[attrib] = location;
@@ -546,6 +556,8 @@
int use_count_;
+ int active_transform_feedback_count_;
+
GLsizei max_attrib_name_length_;
// Attrib by index.
diff --git a/gpu/command_buffer/service/program_manager_unittest.cc b/gpu/command_buffer/service/program_manager_unittest.cc
index 8706897..439dcc2 100644
--- a/gpu/command_buffer/service/program_manager_unittest.cc
+++ b/gpu/command_buffer/service/program_manager_unittest.cc
@@ -1077,6 +1077,25 @@
EXPECT_FALSE(fshader->InUse());
}
+TEST_F(ProgramManagerWithShaderTest, ProgramInfoActiveTransformFeedbackCount) {
+ Program* program =
+ manager_->CreateProgram(kClientProgramId, kServiceProgramId);
+ ASSERT_TRUE(program != nullptr);
+ EXPECT_FALSE(program->IsActiveForTransformFeedback());
+
+ program->IncrementActiveTransformFeedbackCount();
+ EXPECT_TRUE(program->IsActiveForTransformFeedback());
+
+ program->IncrementActiveTransformFeedbackCount();
+ EXPECT_TRUE(program->IsActiveForTransformFeedback());
+
+ program->DecrementActiveTransformFeedbackCount();
+ EXPECT_TRUE(program->IsActiveForTransformFeedback());
+
+ program->DecrementActiveTransformFeedbackCount();
+ EXPECT_FALSE(program->IsActiveForTransformFeedback());
+}
+
TEST_F(ProgramManagerWithShaderTest, ProgramInfoUseCount2) {
Program* program =
manager_->CreateProgram(kClientProgramId, kServiceProgramId);
diff --git a/gpu/command_buffer/service/transform_feedback_manager.cc b/gpu/command_buffer/service/transform_feedback_manager.cc
index 0c9dd74..684c2bf 100644
--- a/gpu/command_buffer/service/transform_feedback_manager.cc
+++ b/gpu/command_buffer/service/transform_feedback_manager.cc
@@ -4,9 +4,11 @@
#include "gpu/command_buffer/service/transform_feedback_manager.h"
+#include "base/check.h"
#include "base/notreached.h"
#include "base/numerics/checked_math.h"
#include "gpu/command_buffer/service/buffer_manager.h"
+#include "gpu/command_buffer/service/program_manager.h"
#include "ui/gl/gl_version_info.h"
namespace gpu {
@@ -71,6 +73,19 @@
}
}
+void TransformFeedback::SetActiveProgram(Program* program) {
+ CHECK(!active_program_);
+ CHECK(program);
+ active_program_ = program;
+ program->IncrementActiveTransformFeedbackCount();
+}
+
+void TransformFeedback::ClearActiveProgram() {
+ CHECK(active_program_);
+ active_program_->DecrementActiveTransformFeedbackCount();
+ active_program_ = nullptr;
+}
+
void TransformFeedback::DoBeginTransformFeedback(GLenum primitive_mode) {
DCHECK(!active_);
DCHECK(primitive_mode == GL_POINTS ||
diff --git a/gpu/command_buffer/service/transform_feedback_manager.h b/gpu/command_buffer/service/transform_feedback_manager.h
index 0eb5014..f2f333e 100644
--- a/gpu/command_buffer/service/transform_feedback_manager.h
+++ b/gpu/command_buffer/service/transform_feedback_manager.h
@@ -18,6 +18,7 @@
namespace gles2 {
class Buffer;
+class Program;
class TransformFeedbackManager;
// Info about TransformFeedbacks currently in the system.
@@ -58,6 +59,11 @@
return paused_;
}
+ void SetActiveProgram(Program* program);
+ void ClearActiveProgram();
+
+ Program* active_program() const { return active_program_.get(); }
+
GLenum primitive_mode() const {
Regression Test / PoC
diff --git a/gpu/command_buffer/service/program_manager_unittest.cc b/gpu/command_buffer/service/program_manager_unittest.cc
index 8706897..439dcc2 100644
--- a/gpu/command_buffer/service/program_manager_unittest.cc
+++ b/gpu/command_buffer/service/program_manager_unittest.cc
@@ -1077,6 +1077,25 @@
EXPECT_FALSE(fshader->InUse());
}
+TEST_F(ProgramManagerWithShaderTest, ProgramInfoActiveTransformFeedbackCount) {
+ Program* program =
+ manager_->CreateProgram(kClientProgramId, kServiceProgramId);
+ ASSERT_TRUE(program != nullptr);
+ EXPECT_FALSE(program->IsActiveForTransformFeedback());
+
+ program->IncrementActiveTransformFeedbackCount();
+ EXPECT_TRUE(program->IsActiveForTransformFeedback());
+
+ program->IncrementActiveTransformFeedbackCount();
+ EXPECT_TRUE(program->IsActiveForTransformFeedback());
+
+ program->DecrementActiveTransformFeedbackCount();
+ EXPECT_TRUE(program->IsActiveForTransformFeedback());
+
+ program->DecrementActiveTransformFeedbackCount();
+ EXPECT_FALSE(program->IsActiveForTransformFeedback());
+}
+
TEST_F(ProgramManagerWithShaderTest, ProgramInfoUseCount2) {
Program* program =
manager_->CreateProgram(kClientProgramId, kServiceProgramId);
Original Bug Report
Potential GPU OOB Write via Transform Feedback Program Link Desynchronization
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 security team.
Overview: The GLES2 validating command decoder fails to verify if Transform Feedback is active during program linking. This allows a compromised renderer to desynchronize Chrome’s internal size calculations from the native GPU driver’s state. By bypassing buffer size validation, an attacker can supply undersized buffers to the native driver, potentially causing an out-of-bounds write in the GPU process.
Affected files:
gpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/program_manager.cc
Estimated timestamp from git blame: 2019-04-03
Vulnerability Description
The GLES2 validating command decoder (used on Android and certain desktop GL configurations) contains a state desynchronization vulnerability involving Transform Feedback (TF) and program linking.
According to the OpenGL ES 3.0 specification (§2.12.6), if glLinkProgram is called on a program object that is active for Transform Feedback, the link should fail, a GL_INVALID_OPERATION error should be generated, and the program’s executable state should remain unchanged.
However, Chrome’s command decoder (GLES2DecoderImpl::DoLinkProgram and Program::Link) does not check if the program is active for TF before passing the glLinkProgram call to the native driver. Furthermore, it blindly trusts glGetProgramiv(..., GL_LINK_STATUS, ...) to determine if the native link succeeded, without checking for GL errors. If a program was previously linked successfully, its GL_LINK_STATUS remains GL_TRUE even after a rejected link attempt. Chrome misinterprets this leftover GL_TRUE status as a successful re-link and updates its internal metadata to match the newly attached shaders, while the native driver retains the old shaders.
Potential Exploitation Steps
(Note: Our tooling cannot execute code yet, so these are suggested/potential steps an attacker would follow to trigger this vulnerability.)
- Initial Link: A compromised renderer creates a program (
P1), attaches a vertex shader outputting a very large varying (e.g., 64 bytes per vertex), and links it. Chrome correctly records the TF requirement as 64 bytes per vertex. - Start Transform Feedback: The attacker allocates a large buffer, binds it, calls
glUseProgram(P1), and begins Transform Feedback. - Pause Transform Feedback: The attacker calls
glPauseTransformFeedback(). - Malicious Reconfiguration: The attacker detaches the original vertex shader and attaches a new one with a very small varying (e.g., 4 bytes per vertex). They then call
glTransformFeedbackVaryingsfor the new small varying. - The Desynchronization: The attacker calls
glLinkProgram(P1).- The native driver correctly rejects the link because TF is active, generating an error and keeping the 64-byte shader active.
- Chrome queries
GL_LINK_STATUS. Because the driver didn’t change the program state, the status remainsGL_TRUEfrom step 1. - Chrome assumes the link succeeded and updates its internal TF metadata to the new 4-byte size.
- Bypassing Validation: The attacker allocates a tiny 400-byte TF buffer, binds it, resumes TF, and issues a draw call for 100 vertices.
- OOB Write Trigger: Chrome validates the draw call using the corrupted 4-byte metadata (100 * 4 = 400 bytes), approving it. The native driver executes the draw using the actual 64-byte shader, attempting to write 6400 bytes into the 400-byte buffer.
While perfectly conformant drivers should natively clamp this write, many real-world mobile GPU drivers (e.g., Adreno, Mali) historically contain bounds-checking flaws and trust the application (Chrome) to size buffers correctly. This can result in a massive out-of-bounds write in the GPU process or kernel driver, leading to a potential sandbox escape.
Suggested Fix
Modify GLES2DecoderImpl::DoLinkProgram (in gpu/command_buffer/service/gles2_cmd_decoder.cc) to explicitly check if the program is active for Transform Feedback before proceeding. If it is active and not paused, or active and paused, the function should immediately generate a GL_INVALID_OPERATION and return early, preventing the state update.
if (state_.bound_transform_feedback.get() &&
state_.bound_transform_feedback->active()) {
// Must check if the program being linked is actually the active one
if (program == state_.current_program.get()) {
LOCAL_SET_GL_ERROR(GL_INVALID_OPERATION, "glLinkProgram",
"program is active for transform feedback");
return;
}
}
Alternatively, Program::Link should check glGetError() after calling glLinkProgram() natively, and fail the link operation if GL_INVALID_OPERATION was generated.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.