High CVSS 3.1 chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized Use in GPU
DescriptionUninitialized Use in GPU
ComponentGPU
Bug ClassUninitialized Memory
Tracker500138014
Fix commita9d6a7900dd2 (chromium/src) +101/-0
CWECWE-457 (Use of uninitialized)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-07 (per vendor advisory)
Fix landed2026-04-28 (public git)
Disclosed2026-05-27
Patch gap29 days — fix public in source before the advisory

Background

`CopyTextureCHROMIUM`
An internal GL extension (glCopyTextureCHROMIUM/glCopySubTextureCHROMIUM) used to copy between textures, sometimes via a draw (e.g. when UNPACK_FLIP_Y_WEBGL forces a flip). The draw path lives in CopyTextureResourceManagerImpl::DoCopyTextureInternal.
Lazy clearing / cleared bookkeeping
The validating decoder tracks a per-level “cleared” flag (SetLevelCleared/SetLevelClearedRect) so uninitialized texture memory is zero-filled before a web page can read it; marking a level cleared tells the decoder the memory is safe and suppresses future clears.
`GL_RASTERIZER_DISCARD`
A GL state that, when enabled, discards all primitives before rasterization, so a glDrawArrays writes no pixels.
Internal draw state scrubbing
DoCopyTextureInternal disables states that could corrupt the copy (GL_BLEND, depth/color masks, etc.) before drawing, relying on RestoreGlobalState() afterward to restore the renderer’s chosen state.

Root Cause Analysis

The decoder marked the destination texture level as cleared before performing the draw-based copy, and the copy’s internal state scrubbing disabled many GL states but not GL_RASTERIZER_DISCARD. A renderer could enable GL_RASTERIZER_DISCARD, then request a draw-based CopyTextureCHROMIUM (e.g. with flip-Y). The decoder updated the cleared bookkeeping, but when DoCopyTextureInternal issued its glDrawArrays, the driver discarded all primitives and wrote nothing to the destination. The destination was thus left with its original uninitialized contents while being recorded as cleared/safe, defeating lazy clearing. A later glReadPixels or shader sample returned stale GPU memory (potentially cross-origin compositor tiles or other processes’ data).

The patch adds, inside CopyTextureResourceManagerImpl::DoCopyTextureInternal alongside the existing glColorMask/glDepthMask/glDisable(GL_BLEND) scrubbing, glDisable(GL_RASTERIZER_DISCARD) guarded by decoder->GetFeatureInfo()->IsWebGL2OrES3OrHigherContext() (the state only exists in ES3+/WebGL2). The subsequent RestoreGlobalState() restores the renderer’s preferred value afterward, so the internal copy always rasterizes and actually initializes the destination. The bug class is UninitializedMemory (CWE-457), matching the advisory. Note the diff implements only the first of the two suggested fixes: it forces the draw to run rather than deferring the SetLevelCleared call until after a confirmed-successful copy, so other silent-failure modes (e.g. OOM during an intermediate allocation) are not addressed by this change.

Key insight
The destination texture was flagged “cleared” before the copy actually ran, and the copy’s draw could be silently suppressed by leftover GL_RASTERIZER_DISCARD state, so uninitialized GPU memory was permanently marked safe and later leaked.

Attack Path

  1. Compromised/cooperating renderer Hold a WebGL/GLES2 context on the validating decoder (inferred: Android/fallback default).
  2. Uninitialized destination Create a destination texture via glTexImage2D(..., nullptr) so its backing memory is uncleared.
  3. Enable discard glEnable(GL_RASTERIZER_DISCARD).
  4. Draw-based copy Issue glCopyTextureCHROMIUM/glCopySubTextureCHROMIUM with flip-Y (GL_TRUE) to force the draw path; the decoder marks the destination cleared, but the internal glDrawArrays is discarded and writes nothing.
  5. Disable discard glDisable(GL_RASTERIZER_DISCARD).
  6. Leak stale memory glReadPixels (or sample) the destination; because it is marked cleared, lazy clearing is skipped and stale/cross-origin GPU memory is returned (primitive: cross-process information disclosure).

Impact Assessment

Runs in the GPU process on the validating command decoder. No special privilege beyond a renderer able to issue command-buffer/WebGL calls; the advisory’s CVSS is low (3.1, C:L, S:U) consistent with an information leak rather than corruption. The achieved primitive is disclosure of uninitialized/stale GPU memory that may contain cross-origin pixel data. This is a confidentiality issue only — no write primitive or control-flow impact is implied by the diff. The upstream test (RasterizerDiscardDoesNotInterfere) primes VRAM with a pattern, allocates the destination without data, enables discard, and verifies the destination ends up fully copied (green) rather than leaking the primed pattern.

Changed Functions

FunctionChangeNotes
TEST_P
gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
modified
if
gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
modified

Files Changed

  • gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc
  • gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc

Audit Directions

  • Premature cleared marking
    Audit all SetLevelCleared/SetLevelClearedRect callers in gles2_cmd_decoder.cc that mark a level cleared before confirming the write succeeded (the deferred-update fix the bug suggested was not taken here).
  • Incomplete internal state scrubbing
    Review other internal draw/blit helpers (mipmap generation, blit, clear emulation) for GL states not disabled before an internal draw (GL_RASTERIZER_DISCARD, GL_SCISSOR_TEST, transform-feedback active, GL_SAMPLE_MASK).
  • Silent-failure clear paths
    Look for copy/clear paths that return success regardless of the driver outcome (OOM on intermediate allocations in DRAW_AND_COPY, driver errors), which can likewise strand uninitialized memory marked safe.
From a9d6a7900dd2f4ff90d46869f57fa7985d1cc1be Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Tue, 28 Apr 2026 06:16:39 -0700
Subject: [PATCH] Disable rasterizer discard during CopyTextureCHROMIUM.

Otherwise if the user sets this state, the copy command would be
silently skipped.

It did not seem practical to implement the large-scale refactor
suggested in the bug report to avoid setting the texture's level as
cleared.

Integrated modified version of the test case from the bug report.

Co-authored with jetski-cli.

Fixed: 500138014
Change-Id: I24c8ddbcfb19e4fadec85bec9f407bde091db8b5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7798563
Commit-Queue: Vasiliy Telezhnikov <vasilyt@chromium.org>
Auto-Submit: Kenneth Russell <kbr@chromium.org>
Reviewed-by: Vasiliy Telezhnikov <vasilyt@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1621709}
---

diff --git a/gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc b/gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc
index a73cca8..f06fba6a 100644
--- a/gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc
+++ b/gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc
@@ -1424,6 +1424,9 @@
     glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
     glDepthMask(GL_FALSE);
     glDisable(GL_BLEND);
+    if (decoder->GetFeatureInfo()->IsWebGL2OrES3OrHigherContext()) {
+      glDisable(GL_RASTERIZER_DISCARD);
+    }
 
     bool need_scissor =
         xoffset || yoffset || width != dest_width || height != dest_height;
diff --git a/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc b/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
index 0e05828..28135403 100644
--- a/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
+++ b/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
@@ -1850,4 +1850,102 @@
   glDeleteFramebuffers(1, &framebuffer_id_);
 }
 
+TEST_P(GLCopyTextureCHROMIUMES3Test, RasterizerDiscardDoesNotInterfere) {
+#if !BUILDFLAG(ENABLE_VALIDATING_COMMAND_DECODER)
+  GTEST_SKIP() << "Test only reproduces with validating decoder";
+#else
+  if (gl_.gpu_preferences().use_passthrough_cmd_decoder) {
+    GTEST_SKIP() << "Skipping test because it's run with the passthrough "
+                    "command decoder";
+  }
+
+  if (!gl_.IsInitialized()) {
+    GTEST_SKIP() << "ES3 context unavailable";
+  }
+
+  constexpr GLsizei kW = 64, kH = 64;
+
+  // --- Step 1: prime VRAM with a recognisable pattern, then free it. ---
+  {
+    GLuint prime;
+    glGenTextures(1, &prime);
+    glBindTexture(GL_TEXTURE_2D, prime);
+    std::vector<uint8_t> pat(kW * kH * 4, 0xCA);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA,
+                 GL_UNSIGNED_BYTE, pat.data());
+    glFinish();
+    glDeleteTextures(1, &prime);
+  }
+
+  // --- Step 2: dest texture, allocated WITHOUT data → uninitialized VRAM. ---
+  GLuint dest;
+  glGenTextures(1, &dest);
+  glBindTexture(GL_TEXTURE_2D, dest);
+  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+               nullptr);
+
+  // --- Step 3: trivial source texture (contents irrelevant). ---
+  GLuint src;
+  glGenTextures(1, &src);
+  glBindTexture(GL_TEXTURE_2D, src);
+  std::vector<uint8_t> green(kW * kH * 4, 0);
+  for (size_t i = 0; i < green.size(); i += 4) {
+    green[i + 1] = 0xFF;  // G
+    green[i + 3] = 0xFF;  // A
+  }
+  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+               green.data());
+
+  // --- Step 4: enable RASTERIZER_DISCARD on the command-buffer context. ---
+  glEnable(GL_RASTERIZER_DISCARD);
+  ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+  // --- Step 5: issue the copy. flip_y=GL_TRUE forces a draw-based path ---
+  CopyType copy_type = GetParam();
+  if (copy_type == TexImage) {
+    glCopyTextureCHROMIUM(src, 0, GL_TEXTURE_2D, dest, 0, GL_RGBA8,
+                          GL_UNSIGNED_BYTE, GL_TRUE, GL_FALSE, GL_FALSE);
+  } else {
+    glCopySubTextureCHROMIUM(src, 0, GL_TEXTURE_2D, dest, 0, 0, 0, 0, 0, kW, kH,
+                             GL_TRUE, GL_FALSE, GL_FALSE);
+  }
+  ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+  glDisable(GL_RASTERIZER_DISCARD);
+
+  // --- Step 6: read back. ---
+  GLuint fbo;
+  glGenFramebuffers(1, &fbo);
+  glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+  glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+                         dest, 0);
+  ASSERT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+            glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+  std::vector<uint8_t> pixels(kW * kH * 4, 0);
+  glReadPixels(0, 0, kW, kH, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
+  ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+  // --- Step 7: prove the leak is gone (copy succeeded). ---
+  size_t nonzero = 0, green_px = 0;
+  for (size_t i = 0; i < pixels.size(); i += 4) {
+    if (pixels[i] || pixels[i + 1] || pixels[i + 2] || pixels[i + 3]) {
+      ++nonzero;
+    }
+    if (pixels[i] == 0x00 && pixels[i + 1] == 0xFF && pixels[i + 2] == 0x00 &&
+        pixels[i + 3] == 0xFF) {
+      ++green_px;
+    }
+  }
+
+  // Expect all pixels to be green.
+  EXPECT_EQ(green_px, static_cast<size_t>(kW * kH));
+  EXPECT_GT(nonzero, 0u);
+
+  glDeleteFramebuffers(1, &fbo);
+  glDeleteTextures(1, &src);
+  glDeleteTextures(1, &dest);
+#endif  // !BUILDFLAG(ENABLE_VALIDATING_COMMAND_DECODER)
+}
+
 }  // namespace gpu
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc b/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
index 0e05828..28135403 100644
--- a/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
+++ b/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
@@ -1850,4 +1850,102 @@
   glDeleteFramebuffers(1, &framebuffer_id_);
 }
 
+TEST_P(GLCopyTextureCHROMIUMES3Test, RasterizerDiscardDoesNotInterfere) {
+#if !BUILDFLAG(ENABLE_VALIDATING_COMMAND_DECODER)
+  GTEST_SKIP() << "Test only reproduces with validating decoder";
+#else
+  if (gl_.gpu_preferences().use_passthrough_cmd_decoder) {
+    GTEST_SKIP() << "Skipping test because it's run with the passthrough "
+                    "command decoder";
+  }
+
+  if (!gl_.IsInitialized()) {
+    GTEST_SKIP() << "ES3 context unavailable";
+  }
+
+  constexpr GLsizei kW = 64, kH = 64;
+
+  // --- Step 1: prime VRAM with a recognisable pattern, then free it. ---
+  {
+    GLuint prime;
+    glGenTextures(1, &prime);
+    glBindTexture(GL_TEXTURE_2D, prime);
+    std::vector<uint8_t> pat(kW * kH * 4, 0xCA);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA,
+                 GL_UNSIGNED_BYTE, pat.data());
+    glFinish();
+    glDeleteTextures(1, &prime);
+  }
+
+  // --- Step 2: dest texture, allocated WITHOUT data → uninitialized VRAM. ---
+  GLuint dest;
+  glGenTextures(1, &dest);
+  glBindTexture(GL_TEXTURE_2D, dest);
+  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+               nullptr);
+
+  // --- Step 3: trivial source texture (contents irrelevant). ---
+  GLuint src;
+  glGenTextures(1, &src);
+  glBindTexture(GL_TEXTURE_2D, src);
+  std::vector<uint8_t> green(kW * kH * 4, 0);
+  for (size_t i = 0; i < green.size(); i += 4) {
+    green[i + 1] = 0xFF;  // G
+    green[i + 3] = 0xFF;  // A
+  }
+  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+               green.data());
+
+  // --- Step 4: enable RASTERIZER_DISCARD on the command-buffer context. ---
+  glEnable(GL_RASTERIZER_DISCARD);
+  ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+  // --- Step 5: issue the copy. flip_y=GL_TRUE forces a draw-based path ---
+  CopyType copy_type = GetParam();
+  if (copy_type == TexImage) {
+    glCopyTextureCHROMIUM(src, 0, GL_TEXTURE_2D, dest, 0, GL_RGBA8,
+                          GL_UNSIGNED_BYTE, GL_TRUE, GL_FALSE, GL_FALSE);
+  } else {
+    glCopySubTextureCHROMIUM(src, 0, GL_TEXTURE_2D, dest, 0, 0, 0, 0, 0, kW, kH,
+                             GL_TRUE, GL_FALSE, GL_FALSE);
+  }
+  ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+  glDisable(GL_RASTERIZER_DISCARD);
+
+  // --- Step 6: read back. ---
+  GLuint fbo;
+  glGenFramebuffers(1, &fbo);
+  glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+  glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+                         dest, 0);
+  ASSERT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+            glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+  std::vector<uint8_t> pixels(kW * kH * 4, 0);
+  glReadPixels(0, 0, kW, kH, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
+  ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+  // --- Step 7: prove the leak is gone (copy succeeded). ---
+  size_t nonzero = 0, green_px = 0;
+  for (size_t i = 0; i < pixels.size(); i += 4) {
+    if (pixels[i] || pixels[i + 1] || pixels[i + 2] || pixels[i + 3]) {
+      ++nonzero;
+    }
+    if (pixels[i] == 0x00 && pixels[i + 1] == 0xFF && pixels[i + 2] == 0x00 &&
+        pixels[i + 3] == 0xFF) {
+      ++green_px;
+    }
+  }
+
+  // Expect all pixels to be green.
+  EXPECT_EQ(green_px, static_cast<size_t>(kW * kH));
+  EXPECT_GT(nonzero, 0u);
+
+  glDeleteFramebuffers(1, &fbo);
+  glDeleteTextures(1, &src);
+  glDeleteTextures(1, &dest);
+#endif  // !BUILDFLAG(ENABLE_VALIDATING_COMMAND_DECODER)
+}
+
 }  // namespace gpu
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential uninitialized GPU memory disclosure in Validating Decoder via CopyTextureCHROMIUM

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 preemptively marks destination textures as cleared before executing CopyTextureCHROMIUM operations. A compromised renderer can silently suppress the internal copy by enabling GL_RASTERIZER_DISCARD, leaving the texture uninitialized but marked as safe. This bypasses lazy-clearing mechanisms and potentially leaks stale, cross-origin GPU memory.

Affected files:

  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc
  • gpu/command_buffer/service/texture_manager.cc

Estimated timestamp from git blame: 2017-01-17

Summary

A potential vulnerability exists in the Chromium validating command decoder (used on Android and fallback environments) that could allow a compromised renderer to read uninitialized GPU memory. The issue occurs because the decoder commits the ‘cleared’ state of a destination texture before the actual copy operation completes. If the renderer manipulates GL state to silently abort the internal draw call, the destination texture remains uninitialized but is no longer protected by the decoder’s lazy-clearing mechanism.

Note: The steps and exploitability described below are based on static code analysis. They are suggested/potential steps, as our tooling does not yet have the ability to run code and execute a live proof of concept.

Technical Details

In gpu/command_buffer/service/gles2_cmd_decoder.cc, both GLES2DecoderImpl::DoCopyTextureCHROMIUM and GLES2DecoderImpl::CopySubTextureHelper preemptively update the destination texture’s cleared status (via SetLevelCleared or SetLevelClearedRect) before delegating the actual work to copy_texture_chromium_->DoCopyTexture.

When a draw-based copy is required (for instance, when the UNPACK_FLIP_Y_WEBGL pixel store parameter is true), the copy logic eventually reaches CopyTextureResourceManagerImpl::DoCopyTextureInternal. This function binds the destination to an internal framebuffer and issues a glDrawArrays call. While it carefully disables various GL states that might interfere with the copy (like GL_DEPTH_TEST, GL_BLEND, and GL_CULL_FACE), it fails to disable GL_RASTERIZER_DISCARD.

If the renderer previously enabled GL_RASTERIZER_DISCARD, the graphics driver will discard all primitives during the internal glDrawArrays call. No pixels are written to the uninitialized memory. However, because the TextureManager metadata was already updated, the texture is incorrectly treated as “cleared”. Subsequent reads (e.g., via glReadPixels) will bypass TextureManager::ClearTextureLevel and return stale GPU memory, which may contain cross-origin compositor tiles or data from other processes.

Suggested Reproduction Steps

An attacker with a compromised renderer process could potentially trigger this by following these steps:

  1. Establish a WebGL or GLES2 context that utilizes the validating command decoder.
  2. Create a source texture and initialize it with arbitrary valid data to ensure it is marked as cleared.
  3. Create a destination texture and allocate it without initialization data (e.g., glTexImage2D(..., nullptr)).
  4. Enable rasterizer discard via glEnable(GL_RASTERIZER_DISCARD).
  5. Issue a glCopyTextureCHROMIUM command from the source to the destination, ensuring a draw-based path is used (e.g., by enabling GL_UNPACK_FLIP_Y_WEBGL).
    • Result: The decoder marks the destination as cleared, but the internal draw call is silently discarded. The texture memory remains uninitialized.
  6. Disable rasterizer discard via glDisable(GL_RASTERIZER_DISCARD).
  7. Read the destination texture contents via glReadPixels or shader sampling. Stale GPU memory is returned.

Proposed Fix

There are two main areas to address to fix this robustness issue:

  1. Disable GL_RASTERIZER_DISCARD internally: In gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc, update CopyTextureResourceManagerImpl::DoCopyTextureInternal to explicitly call glDisable(GL_RASTERIZER_DISCARD) alongside the other state disables (around line 1410) prior to drawing. State restoration at the end of the function (decoder->RestoreGlobalState()) will correctly restore the renderer’s preferred state.
  2. Defer Cleared Status Update: In gles2_cmd_decoder.cc, ideally SetLevelCleared should only be called after the copy operation has successfully completed. This prevents other silent failure modes (such as an Out-of-Memory failure during intermediate texture allocation for DRAW_AND_COPY paths) from improperly marking uninitialized memory as safe.

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.

View on issue tracker