CVE-2026-9917
Overview
Background
- Robust resource initialization
- Chrome’s guarantee that GPU resources (framebuffer attachments) are zero-cleared before a client can read them, preventing exposure of stale memory.
- Lazy/implicit clear
- The deferred zero-clearing of uninitialized framebuffer attachments performed by
ClearUnclearedAttachmentsat first read/use, rather than at allocation. - `GL_RASTERIZER_DISCARD`
- A client-settable GL capability that, when enabled, makes the driver discard all primitives before rasterization so no fragments (and no draw-based clears) are ever produced.
- `MarkAttachmentsAsCleared`
- The
FramebufferManagerbookkeeping call that flags attachments as initialized so they will not be cleared again.
Root Cause Analysis
ClearUnclearedAttachments implements the lazy zero-clear for framebuffer attachments that cannot be cleared with a plain glClear (such as integer-format renderbuffers) by issuing glClear-style draw operations. The command decoder honored the client’s persistent state_.enable_flags.rasterizer_discard when performing this implicit clear, meaning the driver-level GL_RASTERIZER_DISCARD capability remained enabled throughout. When a client had enabled GL_RASTERIZER_DISCARD, the driver discarded all rasterization, including the implicit clear’s own draws, so no pixels were actually written. Critically, the code still called MarkAttachmentsAsCleared afterward, marking the attachments as initialized even though they were never zeroed.
The result is that uninitialized GPU memory (whatever residual VRAM contents the attachment aliased) remained readable, breaking the robust-resource-init guarantee.
The fix saves and clears GL_RASTERIZER_DISCARD before the clear and restores it afterward, ensuring the implicit clear’s draws are not discarded.
GL_RASTERIZER_DISCARD) silently suppressed an internal security-critical clear operation, while the bookkeeping (MarkAttachmentsAsCleared) unconditionally recorded success — decoupling “marked cleared” from “actually cleared.”Attack Path
- Prime VRAM The attacker uses a secondary GL context (or prior allocations) to leave sensitive/dirty contents in GPU memory that a fresh attachment may alias.
- Create uninitialized FBO
On the target context, allocate a framebuffer with an integer-format attachment (e.g.
RGBA8UIrenderbuffer) that requires draw-based lazy clearing. - Enable discard
Call
glEnable(GL_RASTERIZER_DISCARD)so all subsequent rasterization, including the pending implicit clear, is discarded. - Trigger lazy clear
Call
glReadPixels(or otherwise read the attachment), invokingClearUnclearedAttachments, whose discarded draws leave the attachment un-zeroed yet marked as cleared. - Exfiltrate
Read back the leaked, uninitialized VRAM contents through the same
glReadPixelsresult.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifgpu/command_buffer/service/gles2_cmd_decoder.cc |
modified | |
TEST_Pgpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc |
modified | |
forgpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc |
modified | |
ifgpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc |
modified |
Files Changed
gpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc
Audit Directions
- Client state leaking into internal clearsAudit every internal/implicit clear, blit, or copy path (e.g. other
ClearUnclearedAttachmentscallers,glClearBufferfallbacks) for reliance on client-controlled capabilities likeGL_RASTERIZER_DISCARD,GL_SCISSOR_TEST, color/stencil masks, orGL_COLOR_WRITEMASKthat can silently suppress the operation. - Clear-then-mark orderingFlag any code that calls
MarkAttachmentsAsCleared(or equivalent init-tracking) without verifying the clear actually executed; the mark must be conditioned on effective, un-discarded, un-masked writes. - Draw-based initialization under user stateReview robust-resource-init paths that rely on draw operations rather than
glClear, since draws are subject to the full pipeline (rasterizer discard, scissor, masks, viewport) and are easier for a client to neuter than fixed-function clears.
Patch
From 60172394d4995bd549b56af10128db080e59f8ca Mon Sep 17 00:00:00 2001 From: Ken Russell <[email protected]> Date: Tue, 05 May 2026 08:18:12 -0700 Subject: [PATCH] Disable rasterizer discard during implicit clears. Avoids interference by user-set state. Added ported unit test from the bug report. Co-authored with jetski-cli. Fixed: 500095304 Change-Id: I44267dc1049a0e1a5dd3bed7b33d60c69f72437a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7815560 Reviewed-by: Vasiliy Telezhnikov <[email protected]> Commit-Queue: Vasiliy Telezhnikov <[email protected]> Auto-Submit: Kenneth Russell <[email protected]> Cr-Commit-Position: refs/heads/main@{#1625439} --- diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc index 1710e44..da2a06d 100644 --- a/gpu/command_buffer/service/gles2_cmd_decoder.cc +++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc @@ -7014,6 +7014,11 @@ // Assumes framebuffer is complete. void GLES2DecoderImpl::ClearUnclearedAttachments( GLenum target, Framebuffer* framebuffer) { + bool rasterizer_discard_enabled = state_.enable_flags.rasterizer_discard; + if (rasterizer_discard_enabled) { + state_.SetDeviceCapabilityState(GL_RASTERIZER_DISCARD, false); + } + // Clear textures that we can't use glClear first. These textures will be // marked as cleared after the call and no longer be part of the following // code. @@ -7097,6 +7102,10 @@ framebuffer_manager()->MarkAttachmentsAsCleared( framebuffer, renderbuffer_manager(), texture_manager()); + + if (rasterizer_discard_enabled) { + state_.SetDeviceCapabilityState(GL_RASTERIZER_DISCARD, true); + } } void GLES2DecoderImpl::RestoreClearState() { diff --git a/gpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc b/gpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc index 11ce787c..f2d6f562 100644 --- a/gpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc +++ b/gpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc @@ -633,4 +633,87 @@ ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError()); } +TEST_P(ES3ClearBufferTest, RasterizerDiscardIntegerClearBypass) { + if (ShouldSkipTest()) { + return; + } + + const GLsizei kDirtyWidth = 256; + const GLsizei kDirtyHeight = 256; + + // Step 0: Dirty VRAM using a separate context. + { + GLManager gl2; + gl2.Initialize(GetGlManagerOptions()); + gl2.MakeCurrent(); + + for (int i = 0; i < 8; ++i) { + GLuint rb = 0; + glGenRenderbuffers(1, &rb); + glBindRenderbuffer(GL_RENDERBUFFER, rb); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kDirtyWidth, + kDirtyHeight); + GLuint fb = 0; + glGenFramebuffers(1, &fb); + glBindFramebuffer(GL_FRAMEBUFFER, fb); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, rb); + EXPECT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE), + glCheckFramebufferStatus(GL_FRAMEBUFFER)); + + glClearColor(0.8f, 0.2f, 0.6f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + glDeleteFramebuffers(1, &fb); + glDeleteRenderbuffers(1, &rb); + } + glFinish(); + gl2.Destroy(); + } + + // Restore main context. + gl_.MakeCurrent(); + + // Step 1: Trigger the bug. + GLuint rb = 0; + glGenRenderbuffers(1, &rb); + glBindRenderbuffer(GL_RENDERBUFFER, rb); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8UI, kDirtyWidth, kDirtyHeight); + + GLuint fb = 0; + glGenFramebuffers(1, &fb); + glBindFramebuffer(GL_FRAMEBUFFER, fb); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, rb); + EXPECT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE), + glCheckFramebufferStatus(GL_FRAMEBUFFER)); + + glEnable(GL_RASTERIZER_DISCARD); + ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError()); + + // readPixels to trigger lazy clear. + std::vector<GLuint> pixels(kDirtyWidth * kDirtyHeight * 4, 0xAAAAAAAAu); + glReadPixels(0, 0, kDirtyWidth, kDirtyHeight, GL_RGBA_INTEGER, + GL_UNSIGNED_INT, pixels.data()); + + EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError()); + + // Inspect results. + uint32_t nonzero_components = 0; + for (GLuint val : pixels) { + if (val != 0) { + nonzero_components++; + } + } + + // If bug is present, we expect non-zero components (leak from dirty VRAM). + // If fixed, we expect ALL zero. + EXPECT_EQ(0u, nonzero_components); + + // Cleanup. + glDisable(GL_RASTERIZER_DISCARD); + glDeleteFramebuffers(1, &fb); + glDeleteRenderbuffers(1, &rb); +} + } // namespace gpu
Regression Test / PoC
diff --git a/gpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc b/gpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc
index 11ce787c..f2d6f562 100644
--- a/gpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc
+++ b/gpu/command_buffer/tests/gl_clear_framebuffer_unittest.cc
@@ -633,4 +633,87 @@
ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
}
+TEST_P(ES3ClearBufferTest, RasterizerDiscardIntegerClearBypass) {
+ if (ShouldSkipTest()) {
+ return;
+ }
+
+ const GLsizei kDirtyWidth = 256;
+ const GLsizei kDirtyHeight = 256;
+
+ // Step 0: Dirty VRAM using a separate context.
+ {
+ GLManager gl2;
+ gl2.Initialize(GetGlManagerOptions());
+ gl2.MakeCurrent();
+
+ for (int i = 0; i < 8; ++i) {
+ GLuint rb = 0;
+ glGenRenderbuffers(1, &rb);
+ glBindRenderbuffer(GL_RENDERBUFFER, rb);
+ glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kDirtyWidth,
+ kDirtyHeight);
+ GLuint fb = 0;
+ glGenFramebuffers(1, &fb);
+ glBindFramebuffer(GL_FRAMEBUFFER, fb);
+ glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
+ GL_RENDERBUFFER, rb);
+ EXPECT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+ glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+ glClearColor(0.8f, 0.2f, 0.6f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ glDeleteFramebuffers(1, &fb);
+ glDeleteRenderbuffers(1, &rb);
+ }
+ glFinish();
+ gl2.Destroy();
+ }
+
+ // Restore main context.
+ gl_.MakeCurrent();
+
+ // Step 1: Trigger the bug.
+ GLuint rb = 0;
+ glGenRenderbuffers(1, &rb);
+ glBindRenderbuffer(GL_RENDERBUFFER, rb);
+ glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8UI, kDirtyWidth, kDirtyHeight);
+
+ GLuint fb = 0;
+ glGenFramebuffers(1, &fb);
+ glBindFramebuffer(GL_FRAMEBUFFER, fb);
+ glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
+ GL_RENDERBUFFER, rb);
+ EXPECT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+ glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+ glEnable(GL_RASTERIZER_DISCARD);
+ ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+ // readPixels to trigger lazy clear.
+ std::vector<GLuint> pixels(kDirtyWidth * kDirtyHeight * 4, 0xAAAAAAAAu);
+ glReadPixels(0, 0, kDirtyWidth, kDirtyHeight, GL_RGBA_INTEGER,
+ GL_UNSIGNED_INT, pixels.data());
+
+ EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+ // Inspect results.
+ uint32_t nonzero_components = 0;
+ for (GLuint val : pixels) {
+ if (val != 0) {
+ nonzero_components++;
+ }
+ }
+
+ // If bug is present, we expect non-zero components (leak from dirty VRAM).
+ // If fixed, we expect ALL zero.
+ EXPECT_EQ(0u, nonzero_components);
+
+ // Cleanup.
+ glDisable(GL_RASTERIZER_DISCARD);
+ glDeleteFramebuffers(1, &fb);
+ glDeleteRenderbuffers(1, &rb);
+}
+
} // namespace gpu
Original Bug Report
Potential uninitialized memory leak in GPU via RASTERIZER_DISCARD during clear
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 security team.
Overview: The validating command decoder fails to disable GL_RASTERIZER_DISCARD before performing lazy clears on uninitialized framebuffer attachments. Because underlying drivers (like ANGLE) ignore clear commands when rasterizer discard is active, the buffers remain uninitialized but are erroneously marked as cleared. A subsequent glReadPixels call can then leak this uninitialized GPU VRAM to web content.
Affected files:
gpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/framebuffer_manager.cc
Estimated timestamp from git blame: 2017-11-03
Description
In Chrome’s validating command decoder, uninitialized framebuffer attachments (such as renderbuffers) are lazily cleared to zero before their first use. This prevents leaking sensitive GPU memory to web content. The setup for these clear operations is handled by GLES2DecoderImpl::ClearUnclearedAttachments.
While this function correctly temporarily disables states that could interfere with clearing (such as GL_SCISSOR_TEST), it fails to disable GL_RASTERIZER_DISCARD.
When GL_RASTERIZER_DISCARD is enabled, underlying drivers (such as ANGLE) will silently ignore glClear and glClearBuffer* commands. For instance, ANGLE’s Context::noopClearBuffer returns early if rasterizer discard is active.
If an attacker triggers a lazy clear while GL_RASTERIZER_DISCARD is enabled, Chrome will issue the clear commands (e.g., glClearBufferuiv via Framebuffer::ClearUnclearedIntRenderbufferAttachments), which the driver immediately drops. Chrome then unconditionally updates its internal tracking, marking the attachment as initialized via SetCleared(..., true).
Because glReadPixels is not a rasterization command, it is unaffected by GL_RASTERIZER_DISCARD. A subsequent read operation will successfully execute, extracting the uninitialized GPU VRAM and returning it to the sandboxed renderer process.
Potential Reproduction Steps
Note: These are suggested steps based on static code analysis; we do not yet have a working proof of concept that has been successfully run.
- On a platform using the validating command decoder (e.g., Chrome for Android), create a WebGL 2 context.
- Allocate an integer renderbuffer (e.g.,
gl.RGBA8UI) and attach it to a newly created framebuffer’sgl.COLOR_ATTACHMENT0. - Enable rasterizer discard by calling
gl.enable(gl.RASTERIZER_DISCARD). - Trigger a readback operation that forces a lazy clear, such as
gl.readPixels(...)usinggl.RGBA_INTEGERandgl.UNSIGNED_INT. - The returned TypedArray will contain uninitialized GPU VRAM (potentially cross-origin data or other processes’ memory) instead of the expected zeros.
Suggested Fix
Modify GLES2DecoderImpl::ClearUnclearedAttachments in gpu/command_buffer/service/gles2_cmd_decoder.cc to temporarily disable GL_RASTERIZER_DISCARD before issuing any clear commands, similar to how it currently handles GL_SCISSOR_TEST. Additionally, ensure that GL_RASTERIZER_DISCARD is properly restored to its previous state at the end of the function (e.g., by updating GLES2DecoderImpl::RestoreClearState()).
Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad
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.