CVE-2026-9920
Overview
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
Patch
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
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.