CVE-2026-87576
Overview
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
Patch
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;
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.