CVE-2026-9921
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifgpu/command_buffer/service/gles2_cmd_decoder.cc |
modified |
Files Changed
gpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/gles2_cmd_decoder_unittest_1.ccgpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
Patch
From f786ca0ffae2c4740af8c81063e85c588f1b8a9d Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Fri, 01 May 2026 17:54:21 -0700
Subject: [PATCH] Don't prematurely set texture's cleared flag.
In DoCopyTexSubImage2D, watch for GL errors during texture allocation
and do not set the texture's cleared flag in this case.
Co-authored with jetski-cli.
Fixed: 500150338
Change-Id: I76a363f5c241d50c9c697bbea596b1b0cdb8df64
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7807563
Reviewed-by: Brandon Jones <bajones@chromium.org>
Commit-Queue: Kenneth Russell <kbr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1624199}
---
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc
index 7ea21c6..1710e44 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc
@@ -13653,6 +13653,9 @@
GLint dy = src.y() - y;
GLint destX = xoffset + dx;
GLint destY = yoffset + dy;
+
+ LOCAL_COPY_REAL_GL_ERRORS_TO_WRAPPER(func_name);
+
// It's only legal to skip clearing the level of the target texture
// if the entire level is being redefined.
GLsizei level_width = 0;
@@ -13662,10 +13665,15 @@
target, level, &level_width, &level_height, &level_depth);
// Validated above.
DCHECK(have_level);
+
+ bool set_cleared = false;
+ gfx::Rect cleared_rect_to_set;
+ bool set_cleared_rect = false;
+
if (destX == 0 && destY == 0 &&
src.width() == level_width && src.height() == level_height) {
// Write all pixels in below.
- texture_manager()->SetLevelCleared(texture_ref, target, level, true);
+ set_cleared = true;
} else {
gfx::Rect cleared_rect;
if (TextureManager::CombineAdjacentRects(
@@ -13674,8 +13682,8 @@
&cleared_rect)) {
DCHECK_GE(cleared_rect.size().GetArea(),
texture->GetLevelClearedRect(target, level).size().GetArea());
- texture_manager()->SetLevelClearedRect(texture_ref, target, level,
- cleared_rect);
+ cleared_rect_to_set = cleared_rect;
+ set_cleared_rect = true;
} else {
// Otherwise clear part of texture level that is not already cleared.
if (!texture_manager()->ClearTextureLevel(this, texture_ref, target,
@@ -13702,6 +13710,15 @@
src.width(), src.height());
}
+ if (LOCAL_PEEK_GL_ERROR(func_name) == GL_NO_ERROR) {
+ if (set_cleared) {
+ texture_manager()->SetLevelCleared(texture_ref, target, level, true);
+ } else if (set_cleared_rect) {
+ texture_manager()->SetLevelClearedRect(texture_ref, target, level,
+ cleared_rect_to_set);
+ }
+ }
+
// This may be a slow command. Exit command processing to allow for
// context preemption and GPU watchdog checks.
ExitCommandProcessingEarly();
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_1.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_1.cc
index 55849419..d33e425 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_1.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_1.cc
@@ -110,6 +110,10 @@
DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
DoTexImage2D(GL_TEXTURE_2D, 2, GL_RGBA, 16, 16, 0, GL_RGBA,
GL_UNSIGNED_BYTE, shared_memory_id_, kSharedMemoryOffset);
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
}
}
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
index 6c63c9d323..def979a3d 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
@@ -450,6 +450,10 @@
CopyTexSubImage2D(GL_TEXTURE_2D, 1, 0, 0, 0, 0, kWidth, kHeight))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 1, 0, 0, 0, 0, kWidth, kHeight);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2765,6 +2769,10 @@
EXPECT_CALL(*gl_, CopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 2, 1))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 2, 1);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2775,6 +2783,10 @@
EXPECT_CALL(*gl_, CopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 1, 0, 0, 2, 1))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 0, 1, 0, 0, 2, 1);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2797,6 +2809,10 @@
EXPECT_CALL(*gl_, CopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 2, 1))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 2, 1);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2812,6 +2828,10 @@
EXPECT_CALL(*gl_, CopyTexSubImage2D(GL_TEXTURE_2D, 0, 1, 1, 0, 0, 1, 1))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 1, 1, 0, 0, 1, 1);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2833,6 +2853,10 @@
kBackBufferWidth, kBackBufferHeight))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 0, 0, 0, 0, kBackBufferWidth, kBackBufferHeight);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
Regression Test / PoC
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_1.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_1.cc
index 55849419..d33e425 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_1.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_1.cc
@@ -110,6 +110,10 @@
DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
DoTexImage2D(GL_TEXTURE_2D, 2, GL_RGBA, 16, 16, 0, GL_RGBA,
GL_UNSIGNED_BYTE, shared_memory_id_, kSharedMemoryOffset);
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
}
}
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
index 6c63c9d323..def979a3d 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
@@ -450,6 +450,10 @@
CopyTexSubImage2D(GL_TEXTURE_2D, 1, 0, 0, 0, 0, kWidth, kHeight))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 1, 0, 0, 0, 0, kWidth, kHeight);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2765,6 +2769,10 @@
EXPECT_CALL(*gl_, CopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 2, 1))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 2, 1);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2775,6 +2783,10 @@
EXPECT_CALL(*gl_, CopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 1, 0, 0, 2, 1))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 0, 1, 0, 0, 2, 1);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2797,6 +2809,10 @@
EXPECT_CALL(*gl_, CopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 2, 1))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 2, 1);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2812,6 +2828,10 @@
EXPECT_CALL(*gl_, CopyTexSubImage2D(GL_TEXTURE_2D, 0, 1, 1, 0, 0, 1, 1))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 1, 1, 0, 0, 1, 1);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
@@ -2833,6 +2853,10 @@
kBackBufferWidth, kBackBufferHeight))
.Times(1)
.RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .RetiresOnSaturation();
cmds::CopyTexSubImage2D cmd;
cmd.Init(GL_TEXTURE_2D, 0, 0, 0, 0, 0, kBackBufferWidth, kBackBufferHeight);
EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
Original Bug Report
Potential VRAM Info Leak via Premature Cleared Flag in DoCopyTexSubImage2D
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: GLES2DecoderImpl::DoCopyTexSubImage2D updates a texture’s cleared state before ensuring the copy operation succeeds. If the copy fails (e.g., due to an induced GPU out-of-memory error), the texture remains uninitialized but is marked as cleared, allowing an attacker to read stale, potentially cross-origin VRAM.
Affected files:
gpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/gles2_cmd_copy_tex_image.cc
Estimated timestamp from git blame: 2016-10-12
Description
There is a logic error in GLES2DecoderImpl::DoCopyTexSubImage2D where the ‘cleared’ state of a destination texture is updated prematurely. The function updates the cleared_rect (via SetLevelCleared or SetLevelClearedRect) before performing the actual copy operation.
If the subsequent setup for the copy or the copy itself fails, the function exits early without rolling back the cleared state. Because WebGL contexts are created with lose_context_when_out_of_memory set to false, an attacker can survive an induced Out-Of-Memory (OOM) error and subsequently sample from the uninitialized texture, leading to a cross-origin leakage of GPU memory.
Potential Trigger Steps
- Initialize Context: The attacker creates a WebGL context. By default,
lose_context_when_out_of_memoryisfalsefor WebGL contexts (gpu/ipc/common/gpu_channel.mojom), allowing the context to survive aGL_OUT_OF_MEMORYevent. - Induce Memory Pressure: The attacker aggressively allocates large WebGL textures to push the GPU driver close to its memory limit.
- Allocate Target Texture: The attacker calls
gl.texImage2D(..., null)with a format likeGL_LUMINANCE(which requires blit emulation on GLES3 devices).TextureManager::DoTexImageallocates VRAM but correctly leaves itscleared_rectempty, marking it uncleared. - Execute Copy: The attacker calls
gl.copyTexSubImage2Dwith dimensions that exactly cover the full size of the destination texture level. - Premature State Change: In
GLES2DecoderImpl::DoCopyTexSubImage2D, the code evaluates the bounds (destX == 0 && destY == 0 && src.width() == level_width && src.height() == level_height) and immediately executestexture_manager()->SetLevelCleared(..., true). The texture is now formally considered initialized and safe to read from. - OOM Failure: Because
GL_LUMINANCErequires a blit, the code callsInitializeCopyTexImageBlitter. InsideCopyTexImageResourceManager::Initialize, GL resource allocations (e.g., shaders, framebuffers) fail due to the induced memory pressure, triggering aGL_OUT_OF_MEMORYerror. - Early Exit:
InitializeCopyTexImageBlitterdetects the error viaLOCAL_PEEK_GL_ERRORand returnsfalse.DoCopyTexSubImage2Dthen immediately exits via an earlyreturn;. - VRAM Leak: The actual copy is skipped. The texture remains physically uninitialized in VRAM but is tracked as cleared. The attacker binds the texture and reads its contents (e.g., via
glReadPixels), successfully leaking cross-origin VRAM.
Suggested Fix
Defer the state updates to the Texture’s cleared state until after the copy operation has been successfully executed. Specifically, move the calls to texture_manager()->SetLevelCleared and texture_manager()->SetLevelClearedRect to occur only after InitializeCopyTexImageBlitter (if applicable) and the subsequent copy commands (DoCopyTexSubImageToLUMACompatibilityTexture or glCopyTexSubImage2DFn) have completed without critical errors. This aligns the logic with GLES2DecoderImpl::DoCopyTexImage2D, which safely sets the cleared state only after verifying GL_NO_ERROR.
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.