High chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
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
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

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
From a9d6a7900dd2f4ff90d46869f57fa7985d1cc1be Mon Sep 17 00:00:00 2001
From: Ken Russell <[email protected]>
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 <[email protected]>
Auto-Submit: Kenneth Russell <[email protected]>
Reviewed-by: Vasiliy Telezhnikov <[email protected]>
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 [email protected]

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