CVE-2026-9920
Overview
Background
- `CopyTextureCHROMIUM`
- An internal GL extension (
glCopyTextureCHROMIUM/glCopySubTextureCHROMIUM) used to copy between textures, sometimes via a draw (e.g. whenUNPACK_FLIP_Y_WEBGLforces a flip). The draw path lives inCopyTextureResourceManagerImpl::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
glDrawArrayswrites no pixels. - Internal draw state scrubbing
DoCopyTextureInternaldisables states that could corrupt the copy (GL_BLEND, depth/color masks, etc.) before drawing, relying onRestoreGlobalState()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.
GL_RASTERIZER_DISCARD state, so uninitialized GPU memory was permanently marked safe and later leaked.Attack Path
- Compromised/cooperating renderer Hold a WebGL/GLES2 context on the validating decoder (inferred: Android/fallback default).
- Uninitialized destination
Create a destination texture via
glTexImage2D(..., nullptr)so its backing memory is uncleared. - Enable discard
glEnable(GL_RASTERIZER_DISCARD). - Draw-based copy
Issue
glCopyTextureCHROMIUM/glCopySubTextureCHROMIUMwith flip-Y (GL_TRUE) to force the draw path; the decoder marks the destination cleared, but the internalglDrawArraysis discarded and writes nothing. - Disable discard
glDisable(GL_RASTERIZER_DISCARD). - 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
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
| Function | Change | Notes |
|---|---|---|
TEST_Pgpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc |
modified | |
ifgpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc |
modified |
Files Changed
gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.ccgpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
Audit Directions
- Premature cleared markingAudit all
SetLevelCleared/SetLevelClearedRectcallers ingles2_cmd_decoder.ccthat mark a level cleared before confirming the write succeeded (the deferred-update fix the bug suggested was not taken here). - Incomplete internal state scrubbingReview 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 pathsLook 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.
Patch
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
Regression Test / PoC
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
Original Bug Report
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.ccgpu/command_buffer/service/gles2_cmd_copy_texture_chromium.ccgpu/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:
- Establish a WebGL or GLES2 context that utilizes the validating command decoder.
- Create a source texture and initialize it with arbitrary valid data to ensure it is marked as cleared.
- Create a destination texture and allocate it without initialization data (e.g.,
glTexImage2D(..., nullptr)). - Enable rasterizer discard via
glEnable(GL_RASTERIZER_DISCARD). - Issue a
glCopyTextureCHROMIUMcommand from the source to the destination, ensuring a draw-based path is used (e.g., by enablingGL_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.
- Disable rasterizer discard via
glDisable(GL_RASTERIZER_DISCARD). - Read the destination texture contents via
glReadPixelsor shader sampling. Stale GPU memory is returned.
Proposed Fix
There are two main areas to address to fix this robustness issue:
- Disable
GL_RASTERIZER_DISCARDinternally: Ingpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc, updateCopyTextureResourceManagerImpl::DoCopyTextureInternalto explicitly callglDisable(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. - Defer Cleared Status Update: In
gles2_cmd_decoder.cc, ideallySetLevelClearedshould 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 forDRAW_AND_COPYpaths) 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.