Overview

Medium
Severity
3.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized resource in GPU
DescriptionUninitialized resource in GPU
ComponentGPU
Bug ClassUninitialized Memory
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker506385755
Fix commit08d25878ae2d (chromium/src) +68/-0
CWECWE-908
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-25 (per vendor advisory)
Fix landed2026-07-29 (public git)
Disclosed2026-09-08
Patch gap41 days — fix public in source before the advisory

Background

`CheckFramebufferValid`
the GLES2DecoderImpl method (named in the commit message; the @@ header does not include it) that validates the currently bound framebuffer before a draw/read operation and, when the default framebuffer is bound but marked dirty, clears it to initialize its contents.
`backbuffer_needs_clear_bits_`
a member flag recording that the default framebuffer (backbuffer) has invalidated/uninitialized attachments that must be cleared before the backbuffer is read or sampled.
`GL_DRAW_FRAMEBUFFER` / `GL_READ_FRAMEBUFFER`
in GLES3 the draw and read framebuffer bindings are independent, so glClear (which acts on GL_DRAW_FRAMEBUFFER) and a read such as CopyTexImage2D (which acts on GL_READ_FRAMEBUFFER) can refer to different framebuffers at the same time.
`GetBackbufferServiceId`
returns the driver-side service id of the default framebuffer (0 for the real backbuffer), used to bind the backbuffer explicitly.

Root Cause Analysis

When the default framebuffer needs clearing, the if (backbuffer_needs_clear_bits_) block in CheckFramebufferValid issued glClearColorFn/glClear to initialize the backbuffer, but glClear operates on whatever is bound to GL_DRAW_FRAMEBUFFER. The code assumed the default framebuffer was that binding, which is only guaranteed when draw and read are the same; in GLES3 an application can bind a client framebuffer to GL_DRAW_FRAMEBUFFER while leaving the default framebuffer bound as GL_READ_FRAMEBUFFER. In that state the clear was applied to the client draw framebuffer and the default framebuffer was never actually cleared, yet backbuffer_needs_clear_bits_ was still zeroed, so a subsequent read of the default framebuffer (e.g. CopyTexImage2D) sampled uninitialized backbuffer memory.

The fix queries GetBoundDrawFramebuffer(); if a client draw framebuffer is bound it binds the backbuffer (GetBackbufferServiceId()) to GL_DRAW_FRAMEBUFFER before clearing, then restores the client draw framebuffer afterward, so the clear reliably targets and initializes the backbuffer.

Key insight
The clear meant to initialize the default framebuffer was directed at whatever happened to be bound to GL_DRAW_FRAMEBUFFER, so a client-bound draw framebuffer silently absorbed the clear and left the backbuffer uninitialized; the fix forces the backbuffer to be the draw binding for the duration of the clear and then restores the caller’s binding.

Attack Path

  1. Invalidate the default framebuffer From WebGL2/GLES3 the attacker calls InvalidateFramebuffer on the default framebuffer’s color attachment, setting backbuffer_needs_clear_bits_ and leaving the backbuffer contents undefined.
  2. Split the draw and read bindings The attacker binds a client framebuffer to GL_DRAW_FRAMEBUFFER while the default framebuffer remains bound as GL_READ_FRAMEBUFFER.
  3. Trigger the mis-targeted clear A read of the default framebuffer routes through CheckFramebufferValid; the pre-patch clear is applied to the client draw framebuffer instead of the backbuffer, and backbuffer_needs_clear_bits_ is cleared even though the backbuffer was not initialized.
  4. Copy out uninitialized memory CopyTexImage2D reads from the still-uninitialized default read framebuffer into an attacker-owned texture, capturing stale GPU memory contents.
  5. Read back the leaked contents The attacker samples/reads the texture back to the renderer to observe the uninitialized bytes (inferred: standard texture readback beyond what the diff itself shows).

Impact Assessment

An attacker running WebGL2 code in a renderer can cause uninitialized GPU (backbuffer) memory to be copied into a texture and read back, disclosing stale contents in the GPU process. Preconditions are a GLES3/WebGL2 context, invalidating the default framebuffer, and binding a client framebuffer to GL_DRAW_FRAMEBUFFER while the default framebuffer stays bound for reading. The advisory classifies this as an uninitialized-resource issue in the GPU and does not report in-the-wild exploitation or any escalation beyond information disclosure.

Changed Functions

FunctionChangeNotes
if
gpu/command_buffer/service/gles2_cmd_decoder.cc
modified
TEST_P
gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
modified

Files Changed

  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc

Audit Directions

  • Implicit `GL_DRAW_FRAMEBUFFER`/`GL_READ_FRAMEBUFFER` assumptions
    Audit every internal glClear, glDrawBuffers, blit, or clear-helper in the command decoder that assumes the default framebuffer is the active draw target; in GLES3 the draw and read bindings are independent and may point at client framebuffers.
  • Dirty-flag cleared without verified initialization
    Look for places that reset lazy-clear/initialization flags (like backbuffer_needs_clear_bits_) around an operation whose success is not confirmed to have touched the intended resource.
  • Binding save/restore around internal GL ops
    Review internal operations that temporarily need a specific binding but do not save and restore the caller’s framebuffer/texture/buffer bindings, which can both corrupt client state and misdirect side effects.
From 08d25878ae2d2a3000654a593824ff03e0a9261a Mon Sep 17 00:00:00 2001
From: Brandon Jones <bajones@chromium.org>
Date: Wed, 29 Jul 2026 13:57:52 -0700
Subject: [PATCH] Ensure default framebuffer is bound when clearing

This changes checks to make sure the default framebuffer is bound
to GL_DRAW_FRAMEBUFFER prior to clearing in CheckFramebufferValid
to avoid accidentally clearing a user-bound framebuffer.

Bug: 506385755
Change-Id: Ib4d2494ea32eb10e7c9a953bf8cacaaf21857c2f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8162608
Reviewed-by: Zhenyao Mo <zmo@chromium.org>
Commit-Queue: Brandon Jones <bajones@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1670566}
---
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc
index c768c87d..7360f83 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc
@@ -4153,6 +4153,12 @@
     if (surfaceless_)
       return false;
     if (backbuffer_needs_clear_bits_) {
+      // glClear and glDrawBuffers operate on GL_DRAW_FRAMEBUFFER, so make
+      // sure the backbuffer is bound there before clearing it.
+      Framebuffer* draw_framebuffer = GetBoundDrawFramebuffer();
+      if (draw_framebuffer) {
+        BindFramebuffer(GL_DRAW_FRAMEBUFFER, GetBackbufferServiceId());
+      }
       api()->glClearColorFn(0, 0, 0, 1.0f);
       state_.SetDeviceColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
       api()->glClearStencilFn(0);
@@ -4182,6 +4188,10 @@
       }
       backbuffer_needs_clear_bits_ = 0;
       RestoreClearState();
+      // Restore any previously bound GL_DRAW_FRAMEBUFFER.
+      if (draw_framebuffer) {
+        BindFramebuffer(GL_DRAW_FRAMEBUFFER, draw_framebuffer->service_id());
+      }
     }
     return true;
   }
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
index fb69f96..949087f 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
@@ -3499,6 +3499,64 @@
             GetAndClearBackbufferClearBitsForTest());
 }
 
+TEST_P(GLES3DecoderTest,
+       InvalidatedDefaultReadFramebufferClearedBeforeCopyTexImage2D) {
+  // Invalidate the color buffer of the default framebuffer.
+  const GLsizei count = 1;
+  GLenum attachments[] = {GL_COLOR_EXT};
+  EXPECT_CALL(*gl_, InvalidateFramebuffer(GL_FRAMEBUFFER, count, _))
+      .Times(1)
+      .RetiresOnSaturation();
+  auto& invalidate_cmd =
+      *GetImmediateAs<cmds::InvalidateFramebufferImmediate>();
+  invalidate_cmd.Init(GL_FRAMEBUFFER, count, attachments);
+  EXPECT_EQ(error::kNoError,
+            ExecuteImmediateCmd(invalidate_cmd, sizeof(attachments)));
+  EXPECT_EQ(GL_NO_ERROR, GetGLError());
+
+  // Bind a client framebuffer as the draw framebuffer; the default
+  // framebuffer remains bound as the read framebuffer.
+  DoBindFramebuffer(GL_DRAW_FRAMEBUFFER, client_framebuffer_id_,
+                    kServiceFramebufferId);
+
+  DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+
+  // CopyTexImage2D reads from the default framebuffer. The decoder must
+  // bind it to GL_DRAW_FRAMEBUFFER while clearing it after invalidation
+  // and restore the client draw framebuffer afterwards.
+  EXPECT_CALL(*gl_, BindFramebufferEXT(GL_DRAW_FRAMEBUFFER, 0))
+      .Times(1)
+      .RetiresOnSaturation();
+  EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 1.0f)).Times(1).RetiresOnSaturation();
+  SetupExpectationsForColorMask(true, true, true, true);
+  EXPECT_CALL(*gl_, ClearStencil(0)).Times(1).RetiresOnSaturation();
+  SetupExpectationsForStencilMask(GLES2Decoder::kDefaultStencilMask,
+                                  GLES2Decoder::kDefaultStencilMask);
+  EXPECT_CALL(*gl_, ClearDepth(1.0f)).Times(1).RetiresOnSaturation();
+  SetupExpectationsForDepthMask(true);
+  SetupExpectationsForEnableDisable(GL_SCISSOR_TEST, false);
+  EXPECT_CALL(*gl_, Clear(GL_COLOR_BUFFER_BIT)).Times(1).RetiresOnSaturation();
+  SetupExpectationsForRestoreClearState(0.0f, 0.0f, 0.0f, 0.0f, 0, 1.0f, false,
+                                        0, 0, kBackBufferWidth,
+                                        kBackBufferHeight);
+  EXPECT_CALL(*gl_,
+              BindFramebufferEXT(GL_DRAW_FRAMEBUFFER, kServiceFramebufferId))
+      .Times(1)
+      .RetiresOnSaturation();
+
+  EXPECT_CALL(*gl_, CopyTexImage2D(GL_TEXTURE_2D, 0, _, 0, 0, 1, 1, 0))
+      .Times(1)
+      .RetiresOnSaturation();
+  EXPECT_CALL(*gl_, GetError())
+      .WillOnce(Return(GL_NO_ERROR))
+      .WillOnce(Return(GL_NO_ERROR))
+      .RetiresOnSaturation();
+  cmds::CopyTexImage2D copy_cmd;
+  copy_cmd.Init(GL_TEXTURE_2D, 0, GL_RGB, 0, 0, 1, 1);
+  EXPECT_EQ(error::kNoError, ExecuteCmd(copy_cmd));
+  EXPECT_EQ(GL_NO_ERROR, GetGLError());
+}
+
 TEST_P(GLES2DecoderTest, DiscardFramebufferEXTUnsupported) {
   const GLenum target = GL_FRAMEBUFFER;
   const GLsizei count = 1;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
index fb69f96..949087f 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
@@ -3499,6 +3499,64 @@
             GetAndClearBackbufferClearBitsForTest());
 }
 
+TEST_P(GLES3DecoderTest,
+       InvalidatedDefaultReadFramebufferClearedBeforeCopyTexImage2D) {
+  // Invalidate the color buffer of the default framebuffer.
+  const GLsizei count = 1;
+  GLenum attachments[] = {GL_COLOR_EXT};
+  EXPECT_CALL(*gl_, InvalidateFramebuffer(GL_FRAMEBUFFER, count, _))
+      .Times(1)
+      .RetiresOnSaturation();
+  auto& invalidate_cmd =
+      *GetImmediateAs<cmds::InvalidateFramebufferImmediate>();
+  invalidate_cmd.Init(GL_FRAMEBUFFER, count, attachments);
+  EXPECT_EQ(error::kNoError,
+            ExecuteImmediateCmd(invalidate_cmd, sizeof(attachments)));
+  EXPECT_EQ(GL_NO_ERROR, GetGLError());
+
+  // Bind a client framebuffer as the draw framebuffer; the default
+  // framebuffer remains bound as the read framebuffer.
+  DoBindFramebuffer(GL_DRAW_FRAMEBUFFER, client_framebuffer_id_,
+                    kServiceFramebufferId);
+
+  DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+
+  // CopyTexImage2D reads from the default framebuffer. The decoder must
+  // bind it to GL_DRAW_FRAMEBUFFER while clearing it after invalidation
+  // and restore the client draw framebuffer afterwards.
+  EXPECT_CALL(*gl_, BindFramebufferEXT(GL_DRAW_FRAMEBUFFER, 0))
+      .Times(1)
+      .RetiresOnSaturation();
+  EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 1.0f)).Times(1).RetiresOnSaturation();
+  SetupExpectationsForColorMask(true, true, true, true);
+  EXPECT_CALL(*gl_, ClearStencil(0)).Times(1).RetiresOnSaturation();
+  SetupExpectationsForStencilMask(GLES2Decoder::kDefaultStencilMask,
+                                  GLES2Decoder::kDefaultStencilMask);
+  EXPECT_CALL(*gl_, ClearDepth(1.0f)).Times(1).RetiresOnSaturation();
+  SetupExpectationsForDepthMask(true);
+  SetupExpectationsForEnableDisable(GL_SCISSOR_TEST, false);
+  EXPECT_CALL(*gl_, Clear(GL_COLOR_BUFFER_BIT)).Times(1).RetiresOnSaturation();
+  SetupExpectationsForRestoreClearState(0.0f, 0.0f, 0.0f, 0.0f, 0, 1.0f, false,
+                                        0, 0, kBackBufferWidth,
+                                        kBackBufferHeight);
+  EXPECT_CALL(*gl_,
+              BindFramebufferEXT(GL_DRAW_FRAMEBUFFER, kServiceFramebufferId))
+      .Times(1)
+      .RetiresOnSaturation();
+
+  EXPECT_CALL(*gl_, CopyTexImage2D(GL_TEXTURE_2D, 0, _, 0, 0, 1, 1, 0))
+      .Times(1)
+      .RetiresOnSaturation();
+  EXPECT_CALL(*gl_, GetError())
+      .WillOnce(Return(GL_NO_ERROR))
+      .WillOnce(Return(GL_NO_ERROR))
+      .RetiresOnSaturation();
+  cmds::CopyTexImage2D copy_cmd;
+  copy_cmd.Init(GL_TEXTURE_2D, 0, GL_RGB, 0, 0, 1, 1);
+  EXPECT_EQ(error::kNoError, ExecuteCmd(copy_cmd));
+  EXPECT_EQ(GL_NO_ERROR, GetGLError());
+}
+
 TEST_P(GLES2DecoderTest, DiscardFramebufferEXTUnsupported) {
   const GLenum target = GL_FRAMEBUFFER;
   const GLsizei count = 1;
Loading diff…

Original Bug Report

reported by vm...@google.com

GPU info leak via misdirected lazy clear of default framebuffer

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 https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in the GLES2 validating decoder allows a compromised renderer to read uninitialized GPU memory. When the default backbuffer requires a lazy clear during a read validation, the clear command mistakenly targets the currently bound draw framebuffer. This allows an attacker to bypass the safety clear and extract stale pixel data from the backbuffer.

Affected files:

  • gpu/command_buffer/service/gles2_cmd_decoder.cc

Estimated timestamp from git blame: 2024-06-13

Summary

A logic flaw exists in GLES2DecoderImpl::CheckFramebufferValid within the GLES2 validating command decoder. When the default framebuffer (backbuffer) is invalidated, the decoder defers clearing it until it is used. However, if the backbuffer is used as a read target while a different Framebuffer Object (FBO) is bound to the draw target, the deferred clear operation incorrectly clears the draw FBO instead of the backbuffer. This leaves the backbuffer uninitialized, allowing an attacker to read potentially sensitive or cross-origin stale GPU memory.

Technical Details

In gpu/command_buffer/service/gles2_cmd_decoder.cc, CheckFramebufferValid handles the deferred clear for the default framebuffer (when framebuffer == nullptr):

  if (!framebuffer) {
    // ... 
    if (backbuffer_needs_clear_bits_) {
      // ...
      api()->glClearFn(backbuffer_needs_clear_bits_); 
      // ...
      backbuffer_needs_clear_bits_ = 0;
    }
    return true;
  }

The function calls api()->glClearFn(), which in OpenGL strictly operates on the currently bound GL_DRAW_FRAMEBUFFER.

In ES3 or WebGL 2 contexts, GL_READ_FRAMEBUFFER and GL_DRAW_FRAMEBUFFER can be bound to different targets. If CheckBoundReadFramebufferValid invokes this logic (e.g., during a glBlitFramebuffer or glReadPixels call) while the backbuffer is bound to GL_READ_FRAMEBUFFER and a user FBO is bound to GL_DRAW_FRAMEBUFFER, the glClear will erroneously target the user FBO. The backbuffer_needs_clear_bits_ flag is then reset to 0, despite the backbuffer remaining uninitialized.

Potential Attacker Steps

Note: Our tooling agent does not have the ability to run code, so the following exploit steps are theoretical, though strongly supported by static analysis of the state machine.

An attacker starting from a compromised renderer could theoretically trigger this vulnerability by sending direct GLES2 IPC commands:

  1. Initialize a WebGL 2 / GLES 3 context to enable separate read/draw framebuffer bindings.
  2. Call glInvalidateFramebuffer(GL_FRAMEBUFFER, ...) targeting the default framebuffer. This sets backbuffer_needs_clear_bits_ and flags the memory as discarded to the GPU driver.
  3. Generate a user FBO and bind it strictly to GL_DRAW_FRAMEBUFFER.
  4. Ensure the default framebuffer is bound to GL_READ_FRAMEBUFFER.
  5. Call glBlitFramebuffer to copy from the default framebuffer to the user FBO.
  6. During validation, the decoder notices the backbuffer needs a clear and calls glClear. Due to the bug, this clears the user FBO instead of the backbuffer, but clears the pending flag.
  7. The blit operation proceeds, copying uninitialized, stale GPU memory from the backbuffer into the user FBO.
  8. The attacker binds the user FBO to GL_READ_FRAMEBUFFER and calls glReadPixels to extract the uninitialized memory into shared memory.

Suggested Fix

In GLES2DecoderImpl::CheckFramebufferValid, the backbuffer clearing block should be updated to ensure the backbuffer is actively bound to GL_DRAW_FRAMEBUFFER before calling glClearFn or glDrawBuffersARBFn.

If the currently bound draw framebuffer is not the backbuffer (e.g., GetBoundDrawFramebuffer() != nullptr), the code should temporarily bind the backbuffer’s service ID to GL_DRAW_FRAMEBUFFER, execute the clear, and then restore the original draw framebuffer binding. This pattern is already correctly implemented for user FBOs in GLES2DecoderImpl::ClearUnclearedAttachments.

Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f


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