CVE-2026-9929
Overview
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
Patch
From f41b46e83c452e294f4281b8dc2b24938431e0d1 Mon Sep 17 00:00:00 2001 From: Ken Russell <[email protected]> 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 <[email protected]> Reviewed-by: Brandon Jones <[email protected]> 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.