Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in WebGL
DescriptionInappropriate implementation in WebGL
ComponentWebGL
Bug ClassLogic Error
Tracker501367791
Fix commitf41b46e83c45 (chromium/src) +65/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
TEST_F
gpu/command_buffer/service/gl_context_virtual_unittest.cc
modified

Files Changed

  • gpu/command_buffer/service/gl_context_virtual_delegate.h
  • gpu/command_buffer/service/gl_context_virtual_unittest.cc
  • gpu/command_buffer/service/gl_state_restorer_impl.cc
  • gpu/command_buffer/service/gl_state_restorer_impl.h
  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/service/gles2_cmd_decoder_mock.h
  • ui/gl/gl_context.cc
  • ui/gl/gl_state_restorer.h
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;
 };
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • gpu/command_buffer/service/context_state.cc
  • gpu/ipc/service/gles2_command_buffer_stub.cc
  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/service/gl_context_virtual.cc
  • gpu/command_buffer/service/gl_state_restorer_impl.cc
  • gpu/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:

  1. During Context C’s creation, gl::GLContext::MakeVirtuallyCurrent is called before Context C’s decoder is fully initialized.
  2. Because the decoder isn’t initialized, virtual_context->GetGLStateRestorer()->IsInitialized() returns false, causing MakeVirtuallyCurrent to skip calling RestoreState.
  3. Crucially, despite skipping state restoration, MakeVirtuallyCurrent updates current_virtual_context_ = virtual_context (pointing to Context C).
  4. Shortly after, GLES2CommandBufferStub::Initialize forces a release, calling ForceReleaseVirtuallyCurrent(). This sets current_virtual_context_ to nullptr.
  5. When Context C is subsequently made current again, MakeVirtuallyCurrent attempts to restore state. However, because current_virtual_context_ is now nullptr, it passes a null prev_state to virtual_state->RestoreState(nullptr).
  6. Inside ContextState::RestoreProgramSettings, the code responsible for pausing the active TF session checks if prev_state is valid. Since it is null, the call to glPauseTransformFeedback() 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:

  1. Chrome switches to Context B. State restoration commands like glBindVertexArrayOES and glUseProgram are sent to the driver.
  2. 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.
  3. The driver remains configured with Context A’s VAO and Program.
  4. When Context B issues glUniform calls, the sensitive uniform data is written directly into Context A’s program running on the driver.
  5. 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).
  6. 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.

  1. Attacker Context (Context A): Create a WebGL2 context. Compile a shader that captures varyings. Call gl.beginTransformFeedback() and gl.flush().
  2. 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.
  3. 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.
  4. Exfiltration: The attacker switches back to Context A, calls gl.endTransformFeedback(), and uses gl.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.

View on issue tracker