CVE-2026-87576
Overview
Background
- `CheckFramebufferValid`
- the
GLES2DecoderImplmethod (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 onGL_DRAW_FRAMEBUFFER) and a read such asCopyTexImage2D(which acts onGL_READ_FRAMEBUFFER) can refer to different framebuffers at the same time. - `GetBackbufferServiceId`
- returns the driver-side service id of the default framebuffer (
0for 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.
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
- Invalidate the default framebuffer
From WebGL2/GLES3 the attacker calls
InvalidateFramebufferon the default framebuffer’s color attachment, settingbackbuffer_needs_clear_bits_and leaving the backbuffer contents undefined. - Split the draw and read bindings
The attacker binds a client framebuffer to
GL_DRAW_FRAMEBUFFERwhile the default framebuffer remains bound asGL_READ_FRAMEBUFFER. - 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, andbackbuffer_needs_clear_bits_is cleared even though the backbuffer was not initialized. - Copy out uninitialized memory
CopyTexImage2Dreads from the still-uninitialized default read framebuffer into an attacker-owned texture, capturing stale GPU memory contents. - 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
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
| Function | Change | Notes |
|---|---|---|
ifgpu/command_buffer/service/gles2_cmd_decoder.cc |
modified | |
TEST_Pgpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc |
modified |
Files Changed
gpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
Audit Directions
- Implicit `GL_DRAW_FRAMEBUFFER`/`GL_READ_FRAMEBUFFER` assumptionsAudit 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 initializationLook 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 opsReview 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.
Patch
From 08d25878ae2d2a3000654a593824ff03e0a9261a Mon Sep 17 00:00:00 2001 From: Brandon Jones <[email protected]> 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 <[email protected]> Commit-Queue: Brandon Jones <[email protected]> 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;
Regression Test / PoC
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;
Original Bug Report
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:
- Initialize a WebGL 2 / GLES 3 context to enable separate read/draw framebuffer bindings.
- Call
glInvalidateFramebuffer(GL_FRAMEBUFFER, ...)targeting the default framebuffer. This setsbackbuffer_needs_clear_bits_and flags the memory as discarded to the GPU driver. - Generate a user FBO and bind it strictly to
GL_DRAW_FRAMEBUFFER. - Ensure the default framebuffer is bound to
GL_READ_FRAMEBUFFER. - Call
glBlitFramebufferto copy from the default framebuffer to the user FBO. - 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. - The blit operation proceeds, copying uninitialized, stale GPU memory from the backbuffer into the user FBO.
- The attacker binds the user FBO to
GL_READ_FRAMEBUFFERand callsglReadPixelsto 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.