Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized Use in WebGL
DescriptionUninitialized Use in WebGL
ComponentWebGL
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.
Tracker516778390
Fix commit7daeec48402d (chromium/src) +125/-2
CWECWE-457 (Use of uninitialized)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-26 (per vendor advisory)
Fix landed2026-06-08 (public git)
Disclosed2026-07-29
Patch gap51 days — fix public in source before the advisory

Background

Lazy clear
Chrome’s GPU service defers zero-initializing framebuffer attachments until they are first read, so uncleared attachments must be cleared on-demand before any read exposes their contents.
Draw vs. read framebuffer
GLES3 splits the framebuffer binding into GL_DRAW_FRAMEBUFFER (write target) and GL_READ_FRAMEBUFFER (read source), which may point at different FBOs simultaneously.
`glDrawBuffersARB`
a GL call that selects which color attachments a glClear (and other draw operations) will affect; it acts on the currently bound GL_DRAW_FRAMEBUFFER.
`readPixels`/`copyTexImage`
WebGL read operations that copy pixels out of the read framebuffer, where uninitialized attachment memory would be disclosed.

Root Cause Analysis

When clearing uncleared color attachments, GLES2DecoderImpl::ClearUnclearedAttachments() calls PrepareDrawBuffersForClearingUninitializedAttachments(), which issues glDrawBuffersARB to enable the uncleared attachments as clear targets. Because glDrawBuffersARB operates on GL_DRAW_FRAMEBUFFER, the draw-buffer setup only affects whichever FBO is currently bound as the draw target. On the read-triggered clear path, the read target is GL_READ_FRAMEBUFFER and the framebuffer being cleared is the read FBO, but the currently bound draw FBO (draw_framebuffer) can be a different framebuffer.

As a result, glDrawBuffersARB configured the draw buffers of the wrong (draw) FBO, and the subsequent glClear — which also targets the draw framebuffer — cleared the wrong attachments (or none of the intended ones), leaving the read FBO’s attachments uninitialized.

The fix rebinds GL_DRAW_FRAMEBUFFER to the framebuffer being cleared (framebuffer->service_id()) before preparing draw buffers, when target == GL_READ_FRAMEBUFFER and draw_framebuffer != framebuffer.

Key insight
glDrawBuffersARB and glClear implicitly act on GL_DRAW_FRAMEBUFFER, so lazily clearing a framebuffer reached via GL_READ_FRAMEBUFFER silently mis-targets whatever draw FBO happens to be bound unless the draw binding is first pointed at the framebuffer actually being cleared.

Attack Path

  1. Set up divergent bindings In WebGL2, bind one FBO with an uncleared color attachment to GL_READ_FRAMEBUFFER and a different FBO to GL_DRAW_FRAMEBUFFER.
  2. Trigger read-path lazy clear Issue a read against the read FBO (e.g. readPixels or copyTexImage), invoking the on-read lazy-clear path.
  3. Mis-targeted clear PrepareDrawBuffersForClearingUninitializedAttachments() and the following glClear operate on the wrong draw FBO, so the read FBO’s uncleared attachment is never zeroed.
  4. Disclose uninitialized memory The read then returns the attachment’s prior GPU memory contents, leaking data from other contexts or prior allocations.

Impact Assessment

An attacker-controlled WebGL2 page can read uninitialized GPU memory, disclosing residual pixel data left by other GPU allocations or processes. This is an information-disclosure bug that can leak cross-origin image content, prior framebuffer contents, or sensitive data resident in reused GPU memory, and may serve as an ASLR/heap-layout oracle to aid further exploitation.

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
if
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 draw-target dependence
    Audit every lazy-clear/auto-clear path that uses glDrawBuffersARB, glClear, blits, or glDrawArrays-style ops against a framebuffer reached via GL_READ_FRAMEBUFFER, confirming the correct FBO is bound as the draw target first.
  • Split read/draw framebuffer handling
    Review code that assumes a single framebuffer binding but runs under GLES3 where draw_framebuffer and read_framebuffer can diverge, looking for operations applied to the wrong binding.
  • Clear/restore symmetry
    Verify that any temporary rebinding done for clearing (reset_draw_buffers, the new rebound_draw_for_clear restore logic) correctly restores the original draw framebuffer and draw-buffer state on all exit paths.
From 7daeec48402d93417c03d59a9f8e4fcd84188b68 Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Mon, 08 Jun 2026 12:44:42 -0700
Subject: [PATCH] Rebind draw framebuffer before lazy clear.

Fix a code path where the lazy clear could target the wrong
framebuffer.

Integrate unit test from the bug report.

Co-authored with jetski-cli.

Fixed: 516778390
Change-Id: I207c71cb0d97aac93cb989e6b7c610c2db1924dc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7903549
Reviewed-by: Zhenyao Mo <zmo@chromium.org>
Commit-Queue: Zhenyao Mo <zmo@chromium.org>
Auto-Submit: Kenneth Russell <kbr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1643408}
---

diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc
index 4252a6e..7b387bf 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc
@@ -7094,6 +7094,7 @@
 
   GLbitfield clear_bits = 0;
   bool reset_draw_buffers = false;
+  bool rebound_draw_for_clear = cleared_int_renderbuffers;
   if (framebuffer->HasUnclearedColorAttachments()) {
     // We should always use alpha == 0 here, because 1) some draw buffers may
     // have alpha and some may not; 2) we won't have the same situation as the
@@ -7103,6 +7104,14 @@
     clear_bits |= GL_COLOR_BUFFER_BIT;
 
     if (SupportsDrawBuffers()) {
+      // Ensure |framebuffer| is bound as DRAW before preparing draw buffers.
+      // Otherwise glDrawBuffersARB mutates the wrong FBO's state, causing
+      // the glClear to skip clearing uncleared attachments.
+      if (!rebound_draw_for_clear && target == GL_READ_FRAMEBUFFER &&
+          draw_framebuffer != framebuffer) {
+        BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer->service_id());
+        rebound_draw_for_clear = true;
+      }
       reset_draw_buffers =
           framebuffer->PrepareDrawBuffersForClearingUninitializedAttachments();
     }
@@ -7122,8 +7131,8 @@
   }
 
   if (clear_bits) {
-    if (!cleared_int_renderbuffers &&
-        target == GL_READ_FRAMEBUFFER && draw_framebuffer != framebuffer) {
+    if (!rebound_draw_for_clear && target == GL_READ_FRAMEBUFFER &&
+        draw_framebuffer != framebuffer) {
       // TODO(zmo): There is no guarantee that an FBO that is complete on the
       // READ attachment will be complete as a DRAW attachment.
       BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer->service_id());
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 b39cc0e..134e4404 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
@@ -2858,6 +2858,120 @@
   EXPECT_EQ(GL_NO_ERROR, GetGLError());
 }
 
+// Regression test: when lazily clearing a READ FBO (with a different DRAW
+// FBO), the decoder must rebind the lazy-clear target as DRAW before
+// calling PrepareDrawBuffersForClearingUninitializedAttachments().
+// Otherwise the wrong FBO's state is mutated, leading to skipped clears
+// and potential memory disclosure.
+TEST_P(GLES3DecoderTest, LazyClearReadFBORebindsDrawBeforePrepareDrawBuffers) {
+  // Setup: fboA with uncleared RGBA8 renderbuffer and drawBuffers=NONE
+  DoBindRenderbuffer(GL_RENDERBUFFER, client_renderbuffer_id_,
+                     kServiceRenderbufferId);
+  DoRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 1, 1, GL_NO_ERROR);
+
+  DoBindFramebuffer(GL_DRAW_FRAMEBUFFER, client_framebuffer_id_,
+                    kServiceFramebufferId);
+  DoFramebufferRenderbuffer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
+                            GL_RENDERBUFFER, client_renderbuffer_id_,
+                            kServiceRenderbufferId, GL_NO_ERROR);
+  {
+    // Set drawBuffers to NONE on fboA.
+    const GLenum bufs[] = {GL_NONE};
+    EXPECT_CALL(*gl_, DrawBuffersARB(1, _)).Times(1).RetiresOnSaturation();
+    auto& db = *GetImmediateAs<cmds::DrawBuffersEXTImmediate>();
+    db.Init(1, bufs);
+    EXPECT_EQ(error::kNoError, ExecuteImmediateCmd(db, sizeof(bufs)));
+  }
+
+  // Bind fboB as DRAW (draw_framebuffer != fboA).
+  EXPECT_CALL(*gl_, GenFramebuffersEXT(1, _))
+      .WillOnce(SetArgPointee<1>(kNewServiceId))
+      .RetiresOnSaturation();
+  GLuint fbo_b_client = client_framebuffer_id_ + 1;
+  GenHelper<cmds::GenFramebuffersImmediate>(fbo_b_client);
+  DoBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo_b_client, kNewServiceId);
+
+  // Bind fboA as READ.
+  DoBindFramebuffer(GL_READ_FRAMEBUFFER, client_framebuffer_id_,
+                    kServiceFramebufferId);
+
+  // Trigger ReadPixels, which should lazy clear fboA.
+  struct Tracker {
+    GLuint bound_draw_fbo;
+    GLuint fbo_at_prepare_drawbuffers;
+    GLuint fbo_at_clear;
+    int drawbuffers_calls;
+  };
+  auto t = std::make_shared<Tracker>();
+  t->bound_draw_fbo = kNewServiceId;
+  t->fbo_at_prepare_drawbuffers = 0xDEADBEEF;
+  t->fbo_at_clear = 0xDEADBEEF;
+  t->drawbuffers_calls = 0;
+
+  EXPECT_CALL(*gl_, CheckFramebufferStatusEXT(GL_READ_FRAMEBUFFER))
+      .WillOnce(Return(GL_FRAMEBUFFER_COMPLETE))
+      .RetiresOnSaturation();
+
+  EXPECT_CALL(*gl_, BindFramebufferEXT(_, _))
+      .WillRepeatedly([t](GLenum target, GLuint id) {
+        if (target == GL_DRAW_FRAMEBUFFER || target == GL_FRAMEBUFFER) {
+          t->bound_draw_fbo = id;
+        }
+      });
+
+  EXPECT_CALL(*gl_, DrawBuffersARB(_, _))
+      .WillRepeatedly([t](GLsizei, const GLenum*) {
+        // First call is PrepareDrawBuffers, second is RestoreDrawBuffers.
+        if (t->drawbuffers_calls++ == 0) {
+          t->fbo_at_prepare_drawbuffers = t->bound_draw_fbo;
+        }
+      });
+
+  EXPECT_CALL(*gl_, Clear(GL_COLOR_BUFFER_BIT))
+      .WillOnce([t](GLbitfield) { t->fbo_at_clear = t->bound_draw_fbo; })
+      .RetiresOnSaturation();
+
+  // Absorb the rest of the lazy-clear / restore-state noise.
+  EXPECT_CALL(*gl_, ClearColor(_, _, _, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, ColorMask(_, _, _, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, ClearStencil(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, ClearDepth(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, StencilMaskSeparate(_, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, DepthMask(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, Disable(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, Enable(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, Scissor(_, _, _, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, WindowRectanglesEXT(_, _, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, GetError()).WillRepeatedly(Return(GL_NO_ERROR));
+  EXPECT_CALL(*gl_, ReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, _))
+      .Times(1)
+      .RetiresOnSaturation();
+
+  auto* result = GetSharedMemoryAs<cmds::ReadPixels::Result*>();
+  cmds::ReadPixels rp;
+  rp.Init(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, shared_memory_id_,
+          kSharedMemoryOffset + sizeof(*result), shared_memory_id_,
+          kSharedMemoryOffset, false);
+  result->success = 0;
+  EXPECT_EQ(error::kNoError, ExecuteCmd(rp));
+
+  // Assertions
+  // PrepareDrawBuffers should have been called.
+  EXPECT_GE(t->drawbuffers_calls, 1)
+      << "PrepareDrawBuffers should have issued glDrawBuffersARB.";
+
+  // glClear must target fboA.
+  EXPECT_EQ(static_cast<GLuint>(kServiceFramebufferId), t->fbo_at_clear)
+      << "glClear targetted wrong FBO.";
+
+  // Verify fboA was bound as DRAW when PrepareDrawBuffers was called.
+  EXPECT_EQ(static_cast<GLuint>(kServiceFramebufferId),
+            t->fbo_at_prepare_drawbuffers)
+      << "PrepareDrawBuffers called with wrong DRAW framebuffer bound: "
+      << t->fbo_at_prepare_drawbuffers << " instead of "
+      << kServiceFramebufferId;
+}
+
 TEST_P(GLES2DecoderWithShaderTest, CopyTexImageWithInCompleteFBOFails) {
   GLenum target = GL_TEXTURE_2D;
   GLint level = 0;
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 b39cc0e..134e4404 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
@@ -2858,6 +2858,120 @@
   EXPECT_EQ(GL_NO_ERROR, GetGLError());
 }
 
+// Regression test: when lazily clearing a READ FBO (with a different DRAW
+// FBO), the decoder must rebind the lazy-clear target as DRAW before
+// calling PrepareDrawBuffersForClearingUninitializedAttachments().
+// Otherwise the wrong FBO's state is mutated, leading to skipped clears
+// and potential memory disclosure.
+TEST_P(GLES3DecoderTest, LazyClearReadFBORebindsDrawBeforePrepareDrawBuffers) {
+  // Setup: fboA with uncleared RGBA8 renderbuffer and drawBuffers=NONE
+  DoBindRenderbuffer(GL_RENDERBUFFER, client_renderbuffer_id_,
+                     kServiceRenderbufferId);
+  DoRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 1, 1, GL_NO_ERROR);
+
+  DoBindFramebuffer(GL_DRAW_FRAMEBUFFER, client_framebuffer_id_,
+                    kServiceFramebufferId);
+  DoFramebufferRenderbuffer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
+                            GL_RENDERBUFFER, client_renderbuffer_id_,
+                            kServiceRenderbufferId, GL_NO_ERROR);
+  {
+    // Set drawBuffers to NONE on fboA.
+    const GLenum bufs[] = {GL_NONE};
+    EXPECT_CALL(*gl_, DrawBuffersARB(1, _)).Times(1).RetiresOnSaturation();
+    auto& db = *GetImmediateAs<cmds::DrawBuffersEXTImmediate>();
+    db.Init(1, bufs);
+    EXPECT_EQ(error::kNoError, ExecuteImmediateCmd(db, sizeof(bufs)));
+  }
+
+  // Bind fboB as DRAW (draw_framebuffer != fboA).
+  EXPECT_CALL(*gl_, GenFramebuffersEXT(1, _))
+      .WillOnce(SetArgPointee<1>(kNewServiceId))
+      .RetiresOnSaturation();
+  GLuint fbo_b_client = client_framebuffer_id_ + 1;
+  GenHelper<cmds::GenFramebuffersImmediate>(fbo_b_client);
+  DoBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo_b_client, kNewServiceId);
+
+  // Bind fboA as READ.
+  DoBindFramebuffer(GL_READ_FRAMEBUFFER, client_framebuffer_id_,
+                    kServiceFramebufferId);
+
+  // Trigger ReadPixels, which should lazy clear fboA.
+  struct Tracker {
+    GLuint bound_draw_fbo;
+    GLuint fbo_at_prepare_drawbuffers;
+    GLuint fbo_at_clear;
+    int drawbuffers_calls;
+  };
+  auto t = std::make_shared<Tracker>();
+  t->bound_draw_fbo = kNewServiceId;
+  t->fbo_at_prepare_drawbuffers = 0xDEADBEEF;
+  t->fbo_at_clear = 0xDEADBEEF;
+  t->drawbuffers_calls = 0;
+
+  EXPECT_CALL(*gl_, CheckFramebufferStatusEXT(GL_READ_FRAMEBUFFER))
+      .WillOnce(Return(GL_FRAMEBUFFER_COMPLETE))
+      .RetiresOnSaturation();
+
+  EXPECT_CALL(*gl_, BindFramebufferEXT(_, _))
+      .WillRepeatedly([t](GLenum target, GLuint id) {
+        if (target == GL_DRAW_FRAMEBUFFER || target == GL_FRAMEBUFFER) {
+          t->bound_draw_fbo = id;
+        }
+      });
+
+  EXPECT_CALL(*gl_, DrawBuffersARB(_, _))
+      .WillRepeatedly([t](GLsizei, const GLenum*) {
+        // First call is PrepareDrawBuffers, second is RestoreDrawBuffers.
+        if (t->drawbuffers_calls++ == 0) {
+          t->fbo_at_prepare_drawbuffers = t->bound_draw_fbo;
+        }
+      });
+
+  EXPECT_CALL(*gl_, Clear(GL_COLOR_BUFFER_BIT))
+      .WillOnce([t](GLbitfield) { t->fbo_at_clear = t->bound_draw_fbo; })
+      .RetiresOnSaturation();
+
+  // Absorb the rest of the lazy-clear / restore-state noise.
+  EXPECT_CALL(*gl_, ClearColor(_, _, _, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, ColorMask(_, _, _, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, ClearStencil(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, ClearDepth(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, StencilMaskSeparate(_, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, DepthMask(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, Disable(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, Enable(_)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, Scissor(_, _, _, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, WindowRectanglesEXT(_, _, _)).Times(AnyNumber());
+  EXPECT_CALL(*gl_, GetError()).WillRepeatedly(Return(GL_NO_ERROR));
+  EXPECT_CALL(*gl_, ReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, _))
+      .Times(1)
+      .RetiresOnSaturation();
+
+  auto* result = GetSharedMemoryAs<cmds::ReadPixels::Result*>();
+  cmds::ReadPixels rp;
+  rp.Init(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, shared_memory_id_,
+          kSharedMemoryOffset + sizeof(*result), shared_memory_id_,
+          kSharedMemoryOffset, false);
+  result->success = 0;
+  EXPECT_EQ(error::kNoError, ExecuteCmd(rp));
+
+  // Assertions
+  // PrepareDrawBuffers should have been called.
+  EXPECT_GE(t->drawbuffers_calls, 1)
+      << "PrepareDrawBuffers should have issued glDrawBuffersARB.";
+
+  // glClear must target fboA.
+  EXPECT_EQ(static_cast<GLuint>(kServiceFramebufferId), t->fbo_at_clear)
+      << "glClear targetted wrong FBO.";
+
+  // Verify fboA was bound as DRAW when PrepareDrawBuffers was called.
+  EXPECT_EQ(static_cast<GLuint>(kServiceFramebufferId),
+            t->fbo_at_prepare_drawbuffers)
+      << "PrepareDrawBuffers called with wrong DRAW framebuffer bound: "
+      << t->fbo_at_prepare_drawbuffers << " instead of "
+      << kServiceFramebufferId;
+}
+
 TEST_P(GLES2DecoderWithShaderTest, CopyTexImageWithInCompleteFBOFails) {
   GLenum target = GL_TEXTURE_2D;
   GLint level = 0;
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential bypass of lazy clear in validating command decoder via misplaced draw framebuffer bind

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

Overview: A potential logic vulnerability exists in the GLES2 validating command decoder where the active draw framebuffer is rebound after preparing draw buffers for clearing. This discrepancy can allow a WebGL context to bypass lazy initialization of color attachments and read back uninitialized GPU memory contents.

Affected files:

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

Estimated timestamp from git blame: 2016-07-15

Description

In gpu/command_buffer/service/gles2_cmd_decoder.cc, the function GLES2DecoderImpl::ClearUnclearedAttachments handles the lazy clearing of framebuffers containing uninitialized attachments. When a framebuffer (fboA) is bound as GL_READ_FRAMEBUFFER and a different framebuffer (fboB) is bound as GL_DRAW_FRAMEBUFFER, the decoder must temporarily rebind fboA to the GL_DRAW_FRAMEBUFFER target to execute the glClear operation.

However, this rebinding is performed after the call to PrepareDrawBuffersForClearingUninitializedAttachments(). Consequently, the underlying driver-level call to glDrawBuffersARB is executed while fboB is still bound to the driver’s GL_DRAW_FRAMEBUFFER target, instead of fboA. This results in two issues:

  1. The draw buffer state of fboB is incorrectly modified/corrupted.
  2. When fboA is subsequently bound and glClear is executed, its active draw buffer state is still the client-defined state (which may be set to GL_NONE by the attacker). As a result, the glClear call executes as a silent no-op.

Despite the clear operation failing to overwrite the uninitialized backing memory of the attachment, the decoder unconditionally marks the attachments of fboA as initialized via MarkAttachmentsAsCleared(). A subsequent read operation (such as glReadPixels) will then return uninitialized GPU memory contents back to the client.

Potential Trigger Path (Theoretical Analysis)

Note: Our tooling does not currently have the capability to run code, and the following steps represent a potential attack vector based on static code analysis.

  1. A WebGL2 page creates a renderbuffer (rb) with an uninitialized backing store (e.g., using gl.renderbufferStorage(gl.RENDERBUFFER, gl.RGBA4, 256, 256)).
  2. The page attaches rb to a framebuffer fboA at GL_COLOR_ATTACHMENT0.
  3. The page configures fboA to disable drawing to the color attachment by calling gl.drawBuffers([gl.NONE]).
  4. The page binds a separate framebuffer fboB to GL_DRAW_FRAMEBUFFER, and binds fboA to GL_READ_FRAMEBUFFER.
  5. The page triggers a read operation (e.g., gl.readPixels) from fboA, forcing a lazy clear check.
  6. During ClearUnclearedAttachments, the driver-side GL_DRAW_FRAMEBUFFER is not rebound to fboA early because the renderbuffer format is not an integer format.
  7. PrepareDrawBuffersForClearingUninitializedAttachments runs, invoking glDrawBuffersARB which applies to the active draw FBO (fboB).
  8. fboA is bound to GL_DRAW_FRAMEBUFFER, and glClear is executed. Because its driver-side draw buffer is still GL_NONE, no clear occurs on rb.
  9. rb is marked as cleared, and the uninitialized contents of rb are read and returned to the attacker’s buffer.

Impact

An attacker in a sandboxed WebGL context could exploit this vulnerability to read uninitialized GPU memory contents. In a shared GPU process environment, this could allow disclosure of sensitive cross-origin graphics data (such as canvas contents, textures, or video frames from other processes/tabs).

Suggested Remediation

To resolve this issue, the driver-side rebinding of the draw framebuffer should occur before evaluating or preparing the draw buffers for clearing.

Specifically, in GLES2DecoderImpl::ClearUnclearedAttachments, the rebinding logic should be consolidated and moved to the beginning of the function if target == GL_READ_FRAMEBUFFER and draw_framebuffer != framebuffer:

// Suggested Fix in gpu/command_buffer/service/gles2_cmd_decoder.cc
void GLES2DecoderImpl::ClearUnclearedAttachments(
    GLenum target, Framebuffer* framebuffer) {
  ...
  Framebuffer* draw_framebuffer = GetBoundDrawFramebuffer();
  bool rebound_draw_framebuffer = false;
  if (target == GL_READ_FRAMEBUFFER && draw_framebuffer != framebuffer) {
    BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer->service_id());
    rebound_draw_framebuffer = true;
  }
  ...

This ensures that any subsequent draw buffer configuration or clear operation is executed against the correct target framebuffer.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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