CVE-2026-9929
Overview
Background
- Virtualized GL contexts
- on Android (and wherever the validating command decoder is used), all WebGL contexts on a GPU thread share one real
gl::GLContextdriver context. Each command-buffer client is a virtual context; switching between them means pausing one’s state and restoring another’s. - `gl::GLContext::MakeVirtuallyCurrent`
- the switch routine in
ui/gl/gl_context.cc. It consults the incoming and outgoing contexts’GLStateRestorers to pause/resume driver state (queries, transform feedback, etc.). It only performs restoration when the target’s restorerIsInitialized(). - `GLStateRestorer` / `GLStateRestorerImpl`
- the per-context object that drives state save/restore by delegating to the command decoder (
gl_context_virtual_delegate.h). It is not fully initialized until the decoder is set up — so a brand-new virtual context has an uninitialized restorer the first time it is made current. - Transform Feedback (TF)
- a stateful GLES3 feature active on the driver. If a context leaves TF active and the switch logic fails to pause it, the driver keeps TF running even after Chrome believes the new context has TF off — a state desynchronization between Chrome’s tracking and the real driver.
Root Cause Analysis
The invariant is that whenever MakeVirtuallyCurrent switches away from a context, any transform-feedback session active on the real driver must be paused, so the driver’s stateful TF does not bleed into the next context. The old code paused TF only inside the virtual_context->GetGLStateRestorer()->IsInitialized() branch. When a newly created virtual context is made current for the first time, its restorer is not yet initialized, so that whole branch is skipped — and with it the TF pause. Per the report, the outgoing context (Context A) could therefore be left with TF active on the driver while Chrome’s state tracking for the new context believes TF is inactive, a desynchronization that persists across later switches. The downstream effect is that a cross-origin victim context’s draws execute against the attacker’s still-bound program/VAO and TF buffer, leaking uniform and vertex-capture data (and, per the report, a possible OOB write if the victim’s draw exceeds the attacker’s TF buffer — speculative, not in this diff).
The patch adds an explicit, restorer-initialization-independent TF pause. A new virtual PauseTransformFeedback() is added up the stack: GLStateRestorer (ui/gl/gl_state_restorer.h), GLStateRestorerImpl (which forwards to the decoder delegate), GLContextVirtualDelegate (default no-op), and the concrete decoder override in gles2_cmd_decoder.cc, which calls glPauseTransformFeedbackFn() only when state_.bound_transform_feedback is present, active(), and not already paused(). In MakeVirtuallyCurrent, the current_state lookup is hoisted above the restore branch, and a new block runs before the IsInitialized() gate: if (current_state && !virtual_context->GetGLStateRestorer()->IsInitialized()) current_state->PauseTransformFeedback();. So when switching to an as-yet-uninitialized new context, TF on the outgoing context is paused manually; TransformFeedback::DoBindTransformFeedback resumes it on return. The advisory labels this “Inappropriate implementation / LogicError (CWE-200)”; the diff confirms a logic error whose security consequence is a cross-origin information leak.
Attack Path
- Attacker context (A)
create a WebGL2 context, compile a program that captures varyings,
beginTransformFeedback()andflush()so TF is active on the shared driver. - Force the race
immediately create a second WebGL2 context; during its creation
MakeVirtuallyCurrentruns while its restorer is uninitialized, so the pre-patch code skips pausing A’s TF, leaving it active on the driver but untracked. - Victim draws (B, cross-origin)
a cross-origin WebGL context issues draws; state-restore commands silently fail (
GL_INVALID_OPERATIONwhile TF is active) so the driver stays configured with A’s program/VAO, and B’s uniforms/draw output feed A’s program and TF buffer (inferred — downstream of the diff). - Exfiltrate
attacker returns to Context A,
endTransformFeedback(), and reads the captured cross-origin data viagetBufferSubData(). - Primitive cross-origin disclosure of another origin’s WebGL uniform/vertex data; optionally a GPU-process OOB write if B’s draw count exceeds A’s TF buffer (speculative, not substantiated by this diff).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fgpu/command_buffer/service/gl_context_virtual_unittest.cc |
modified |
Files Changed
gpu/command_buffer/service/gl_context_virtual_delegate.hgpu/command_buffer/service/gl_context_virtual_unittest.ccgpu/command_buffer/service/gl_state_restorer_impl.ccgpu/command_buffer/service/gl_state_restorer_impl.hgpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/gles2_cmd_decoder_mock.hui/gl/gl_context.ccui/gl/gl_state_restorer.h
Audit Directions
- Other state behind the `IsInitialized()` gatequeries and any other stateful driver feature are still only paused when the restorer is initialized; audit whether any of them leak across a switch to a freshly created context the same way TF did.
- Silent state-restore failuresChrome ignores
GL_INVALID_OPERATIONduring state restoration; review whether swallowing these errors can let other driver state desynchronize across virtual contexts. - Virtual-context lifecycle orderingthe root cause is
current_virtual_context_being advanced before the new context’s decoder/restorer exist; audit allMakeVirtuallyCurrent/ForceReleaseVirtuallyCurrenttransitions for other operations that assume an initialized restorer. - Cross-origin TF bounds checkingverify the validating decoder’s TF bounds checks cannot be bypassed when its tracked TF state disagrees with the driver (the reporter’s OOB-write hypothesis).
Patch
From f41b46e83c452e294f4281b8dc2b24938431e0d1 Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Fri, 24 Apr 2026 18:46:49 -0700
Subject: [PATCH] Pause transform feedback when switching to newly created context.
The state restorer wasn't initialized yet for the new context at this
point, and transform feedback could be left on accidentally.
Co-authored with jetski-cli.
Fixed: 501367791
Change-Id: If989f089afebb1029ac648d307e1290adb7b8249
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7794407
Commit-Queue: Kenneth Russell <kbr@chromium.org>
Reviewed-by: Brandon Jones <bajones@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1620604}
---
diff --git a/gpu/command_buffer/service/gl_context_virtual_delegate.h b/gpu/command_buffer/service/gl_context_virtual_delegate.h
index 22f1423e..3cfe0ca 100644
--- a/gpu/command_buffer/service/gl_context_virtual_delegate.h
+++ b/gpu/command_buffer/service/gl_context_virtual_delegate.h
@@ -44,6 +44,8 @@
virtual void RestoreVertexAttribArray(unsigned index) = 0;
virtual void RestoreAllExternalTextureBindingsIfNeeded() = 0;
+ virtual void PauseTransformFeedback() {}
+
virtual QueryManager* GetQueryManager() = 0;
};
diff --git a/gpu/command_buffer/service/gl_context_virtual_unittest.cc b/gpu/command_buffer/service/gl_context_virtual_unittest.cc
index dffbbcca..cd10022 100644
--- a/gpu/command_buffer/service/gl_context_virtual_unittest.cc
+++ b/gpu/command_buffer/service/gl_context_virtual_unittest.cc
@@ -98,6 +98,37 @@
base_context->CheckStickyGraphicsResetStatus());
}
+// Tests that transform feedback is paused on the current context when
+// creating a new virtual context. The state restorer is not set up
+// when the new virtual context is made virtually current for the
+// first time, so transform feedback must be paused manually.
+TEST_F(GLContextVirtualTest, PauseTransformFeedbackOnSwitchToUninitialized) {
+ EXPECT_CALL(*gl_, GetError())
+ .Times(AnyNumber())
+ .WillRepeatedly(Return(GL_NO_ERROR));
+
+ auto base_context = base::MakeRefCounted<gl::GLContextStub>();
+ gl::GLShareGroup* share_group = base_context->share_group();
+ share_group->SetSharedContext(base_context.get());
+
+ auto contextA = base::MakeRefCounted<GLContextVirtual>(
+ share_group, base_context.get(), decoder_->AsWeakPtr());
+ EXPECT_TRUE(contextA->Initialize(GetGLSurface(), gl::GLContextAttribs()));
+ EXPECT_TRUE(contextA->MakeCurrent(GetGLSurface()));
+
+ auto decoderC = std::make_unique<MockGLES2Decoder>(
+ &client_, &command_buffer_service_, &outputter_);
+ auto contextC = base::MakeRefCounted<GLContextVirtual>(
+ share_group, base_context.get(), decoderC->AsWeakPtr());
+
+ EXPECT_CALL(*decoder_, initialized()).WillRepeatedly(Return(true));
+ EXPECT_CALL(*decoderC, initialized()).WillRepeatedly(Return(false));
+
+ EXPECT_CALL(*decoder_, PauseTransformFeedback()).Times(1);
+
+ EXPECT_TRUE(contextC->Initialize(GetGLSurface(), gl::GLContextAttribs()));
+}
+
} // anonymous namespace
} // namespace gles2
} // namespace gpu
diff --git a/gpu/command_buffer/service/gl_state_restorer_impl.cc b/gpu/command_buffer/service/gl_state_restorer_impl.cc
index 314d219a..b36aa0e 100644
--- a/gpu/command_buffer/service/gl_state_restorer_impl.cc
+++ b/gpu/command_buffer/service/gl_state_restorer_impl.cc
@@ -69,6 +69,11 @@
delegate_->RestoreVertexAttribArray(index);
}
+void GLStateRestorerImpl::PauseTransformFeedback() {
+ DCHECK(delegate_.get());
+ delegate_->PauseTransformFeedback();
+}
+
void GLStateRestorerImpl::PauseQueries() {
DCHECK(delegate_.get());
if (auto* query_manager = delegate_->GetQueryManager())
diff --git a/gpu/command_buffer/service/gl_state_restorer_impl.h b/gpu/command_buffer/service/gl_state_restorer_impl.h
index 09243aa..823d1bd4 100644
--- a/gpu/command_buffer/service/gl_state_restorer_impl.h
+++ b/gpu/command_buffer/service/gl_state_restorer_impl.h
@@ -41,6 +41,7 @@
void RestoreProgramBindings() override;
void RestoreBufferBinding(unsigned int target) override;
void RestoreVertexAttribArray(unsigned int index) override;
+ void PauseTransformFeedback() override;
void PauseQueries() override;
void ResumeQueries() override;
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc
index 1c28847..52d5cdd 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc
@@ -602,6 +602,13 @@
void RestoreVertexAttribArray(unsigned index) override {
RestoreStateForAttrib(index, true);
}
+ void PauseTransformFeedback() override {
+ if (state_.bound_transform_feedback.get() &&
+ state_.bound_transform_feedback->active() &&
+ !state_.bound_transform_feedback->paused()) {
+ state_.api()->glPauseTransformFeedbackFn();
+ }
+ }
void RestoreBufferBinding(unsigned int target) override;
void RestoreFramebufferBindings() const override;
void RestoreRenderbufferBindings() override;
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_mock.h b/gpu/command_buffer/service/gles2_cmd_decoder_mock.h
index cc160b57..d7849a5a6 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_mock.h
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_mock.h
@@ -68,6 +68,7 @@
MOCK_METHOD0(GetCapabilities, Capabilities());
MOCK_METHOD0(GetGLCapabilities, GLCapabilities());
MOCK_CONST_METHOD0(HasPendingQueries, bool());
+ MOCK_CONST_METHOD0(initialized, bool());
MOCK_METHOD1(ProcessPendingQueries, void(bool));
MOCK_CONST_METHOD0(HasMoreIdleWork, bool());
MOCK_METHOD0(PerformIdleWork, void());
@@ -80,6 +81,7 @@
MOCK_CONST_METHOD1(
RestoreActiveTextureUnitBinding, void(unsigned int target));
MOCK_METHOD0(RestoreAllExternalTextureBindingsIfNeeded, void());
+ MOCK_METHOD0(PauseTransformFeedback, void());
MOCK_METHOD1(RestoreBufferBinding, void(unsigned int target));
MOCK_CONST_METHOD0(RestoreBufferBindings, void());
MOCK_CONST_METHOD0(RestoreFramebufferBindings, void());
diff --git a/ui/gl/gl_context.cc b/ui/gl/gl_context.cc
index f4084ed..e4655ff3e 100644
--- a/ui/gl/gl_context.cc
+++ b/ui/gl/gl_context.cc
@@ -442,12 +442,24 @@
#endif
// Set all state that is different from the real state
+ GLStateRestorer* current_state =
+ current_virtual_context_ && !current_virtual_context_->context_lost_
+ ? current_virtual_context_->GetGLStateRestorer()
+ : nullptr;
+
+ // Newly created virtual contexts call MakeVirtuallyCurrent before
+ // their command decoder and state restorer are fully initialized.
+ // Separately ensure that transform feedback is paused on the
+ // current context before switching to the newly created one.
+ // TransformFeedback::DoBindTransformFeedback will ensure it's
+ // resumed when coming back to the current context.
+ if (current_state &&
+ !virtual_context->GetGLStateRestorer()->IsInitialized()) {
+ current_state->PauseTransformFeedback();
+ }
+
if (virtual_context->GetGLStateRestorer()->IsInitialized()) {
GLStateRestorer* virtual_state = virtual_context->GetGLStateRestorer();
- GLStateRestorer* current_state =
- current_virtual_context_ && !current_virtual_context_->context_lost_
- ? current_virtual_context_->GetGLStateRestorer()
- : nullptr;
if (current_state)
current_state->PauseQueries();
virtual_state->ResumeQueries();
diff --git a/ui/gl/gl_state_restorer.h b/ui/gl/gl_state_restorer.h
index d2cbe6b..b138e4e 100644
--- a/ui/gl/gl_state_restorer.h
+++ b/ui/gl/gl_state_restorer.h
@@ -36,6 +36,7 @@
virtual void RestoreProgramBindings() = 0;
virtual void RestoreBufferBinding(unsigned int target) = 0;
virtual void RestoreVertexAttribArray(unsigned int index) = 0;
+ virtual void PauseTransformFeedback() = 0;
virtual void PauseQueries() = 0;
virtual void ResumeQueries() = 0;
};
Regression Test / PoC
diff --git a/gpu/command_buffer/service/gl_context_virtual_unittest.cc b/gpu/command_buffer/service/gl_context_virtual_unittest.cc
index dffbbcca..cd10022 100644
--- a/gpu/command_buffer/service/gl_context_virtual_unittest.cc
+++ b/gpu/command_buffer/service/gl_context_virtual_unittest.cc
@@ -98,6 +98,37 @@
base_context->CheckStickyGraphicsResetStatus());
}
+// Tests that transform feedback is paused on the current context when
+// creating a new virtual context. The state restorer is not set up
+// when the new virtual context is made virtually current for the
+// first time, so transform feedback must be paused manually.
+TEST_F(GLContextVirtualTest, PauseTransformFeedbackOnSwitchToUninitialized) {
+ EXPECT_CALL(*gl_, GetError())
+ .Times(AnyNumber())
+ .WillRepeatedly(Return(GL_NO_ERROR));
+
+ auto base_context = base::MakeRefCounted<gl::GLContextStub>();
+ gl::GLShareGroup* share_group = base_context->share_group();
+ share_group->SetSharedContext(base_context.get());
+
+ auto contextA = base::MakeRefCounted<GLContextVirtual>(
+ share_group, base_context.get(), decoder_->AsWeakPtr());
+ EXPECT_TRUE(contextA->Initialize(GetGLSurface(), gl::GLContextAttribs()));
+ EXPECT_TRUE(contextA->MakeCurrent(GetGLSurface()));
+
+ auto decoderC = std::make_unique<MockGLES2Decoder>(
+ &client_, &command_buffer_service_, &outputter_);
+ auto contextC = base::MakeRefCounted<GLContextVirtual>(
+ share_group, base_context.get(), decoderC->AsWeakPtr());
+
+ EXPECT_CALL(*decoder_, initialized()).WillRepeatedly(Return(true));
+ EXPECT_CALL(*decoderC, initialized()).WillRepeatedly(Return(false));
+
+ EXPECT_CALL(*decoder_, PauseTransformFeedback()).Times(1);
+
+ EXPECT_TRUE(contextC->Initialize(GetGLSurface(), gl::GLContextAttribs()));
+}
+
} // anonymous namespace
} // namespace gles2
} // namespace gpu
Original Bug Report
Potential cross-origin WebGL data leak via virtual context 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 Chrome Security team.
Overview: A race condition during virtual context initialization skips pausing Transform Feedback, causing Chrome’s state tracking to desynchronize from the GPU driver. This allows an attacker’s Transform Feedback session to remain active across context switches. Consequently, an attacker can capture sensitive cross-origin uniform and draw data from a victim’s WebGL context.
Affected files:
ui/gl/gl_context.ccgpu/command_buffer/service/context_state.ccgpu/ipc/service/gles2_command_buffer_stub.ccgpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/gl_context_virtual.ccgpu/command_buffer/service/gl_state_restorer_impl.ccgpu/ipc/service/gpu_channel_manager.cc
Estimated timestamp from git blame: 2025-03-04
Description
On Android, Chrome uses the validating command decoder and virtualized GL contexts by default. All virtual contexts on a thread share the same underlying global gl::GLContext (the driver). When switching between virtual contexts, Chrome must pause and restore stateful operations like Transform Feedback (TF).
A vulnerability exists in how Chrome handles virtual context initialization. If an attacker context (Context A) leaves a Transform Feedback session active, and a new virtual context (Context C) is created, the following occurs:
- During Context C’s creation,
gl::GLContext::MakeVirtuallyCurrentis called before Context C’s decoder is fully initialized. - Because the decoder isn’t initialized,
virtual_context->GetGLStateRestorer()->IsInitialized()returnsfalse, causingMakeVirtuallyCurrentto skip callingRestoreState. - Crucially, despite skipping state restoration,
MakeVirtuallyCurrentupdatescurrent_virtual_context_ = virtual_context(pointing to Context C). - Shortly after,
GLES2CommandBufferStub::Initializeforces a release, callingForceReleaseVirtuallyCurrent(). This setscurrent_virtual_context_tonullptr. - When Context C is subsequently made current again,
MakeVirtuallyCurrentattempts to restore state. However, becausecurrent_virtual_context_is nownullptr, it passes a nullprev_statetovirtual_state->RestoreState(nullptr). - Inside
ContextState::RestoreProgramSettings, the code responsible for pausing the active TF session checks ifprev_stateis valid. Since it is null, the call toglPauseTransformFeedback()is skipped.
Impact
At this point, the underlying GL driver has Context A’s TF active, but Chrome’s internal state tracking (Context C) believes TF is inactive. This desynchronization persists across subsequent context switches.
When a victim context (Context B) in a cross-origin iframe or tab attempts to draw:
- Chrome switches to Context B. State restoration commands like
glBindVertexArrayOESandglUseProgramare sent to the driver. - Because TF is still active on the driver (from Context A), the OpenGL ES 3.0 specification dictates that these commands fail with
GL_INVALID_OPERATION. Chrome silently ignores these errors during state restoration. - The driver remains configured with Context A’s VAO and Program.
- When Context B issues
glUniformcalls, the sensitive uniform data is written directly into Context A’s program running on the driver. - When Context B issues
glDrawArrays, Chrome’s validating decoder skips TF bounds checking (because it thinks TF is off for Context B). The driver executes the draw using Context A’s program and active TF buffer, capturing the victim’s data (processed using the leaked uniforms). - If Context B’s draw count exceeds Context A’s TF buffer size, the lack of Chrome bounds checking may result in an out-of-bounds write within the GPU process driver, potentially leading to memory corruption.
Potential Trigger Steps
Note: These steps describe a potential attack path based on code analysis; a full proof-of-concept has not been executed.
- Attacker Context (Context A): Create a WebGL2 context. Compile a shader that captures varyings. Call
gl.beginTransformFeedback()andgl.flush(). - Context C Creation: Immediately create a second WebGL2 context to trigger the initialization race condition, leaving Context A’s TF active on the driver but disconnected from Chrome’s tracking.
- Victim Context (Context B): A cross-origin WebGL context performs draw calls. Due to the driver state failures, its uniforms update Context A’s program, and its draw output is captured by Context A’s TF buffer.
- Exfiltration: The attacker switches back to Context A, calls
gl.endTransformFeedback(), and usesgl.getBufferSubData()to read the cross-origin data.
Suggested Fix
In ui/gl/gl_context.cc, the MakeVirtuallyCurrent method should not update current_virtual_context_ if the state restorer is not yet initialized and state restoration is skipped.
if (switched_real_contexts || virtual_context != current_virtual_context_) {
// ...
if (virtual_context->GetGLStateRestorer()->IsInitialized()) {
// ... perform restore ...
current_virtual_context_ = virtual_context; // ONLY update if initialized
} else {
// Do not update current_virtual_context_ yet.
// Ensure the real context is unbound or handled safely if necessary.
}
}
Alternatively, ContextState::RestoreProgramSettings may need a more robust way to ensure TF is paused on the real context when prev_state is null, rather than assuming it is already paused.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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.