CVE-2026-9921
Overview
Background
- `DoCopyTexSubImage2D`
- the
GLES2DecoderImplhandler for the WebGL/GLES2copyTexSubImage2Dcommand, which copies a rectangle of pixels from the current framebuffer into an existing texture level. - Level cleared flag
- per-level texture state in
TextureManager(SetLevelCleared/SetLevelClearedRect) marking a texture level’s storage as fully or partially initialized, so the decoder can skip zero-clearing before a read/sample. - `LOCAL_PEEK_GL_ERROR` / `LOCAL_COPY_REAL_GL_ERRORS_TO_WRAPPER`
- decoder macros that inspect and drain the underlying driver’s GL error state, letting the handler know whether the real allocation/copy succeeded.
- Uninitialized use
- reading GPU memory that was never written by the app or cleared by the service, disclosing residual data from prior allocations.
Root Cause Analysis
In the vulnerable DoCopyTexSubImage2D, the code called SetLevelCleared / SetLevelClearedRect on the texture level before or without confirming that the underlying GL texture allocation and copy actually succeeded. When allocation or the copy raised a GL error, the driver left the texture level’s storage untouched (still holding uninitialized GPU memory), yet the TextureManager recorded the level as cleared/initialized. Because the cleared flag is the signal the decoder uses to decide whether lazy zero-clearing is needed, a level wrongly marked cleared bypasses that safety clear on subsequent operations. A later read, sample, or copy of that texture therefore consumed uninitialized memory.
The fix drains pending errors first with LOCAL_COPY_REAL_GL_ERRORS_TO_WRAPPER(func_name), records the intended cleared state only in locals (set_cleared / set_cleared_rect), and applies them via SetLevelCleared / SetLevelClearedRect only when LOCAL_PEEK_GL_ERROR(func_name) == GL_NO_ERROR. This ties the cleared-flag update to genuine success of the GL allocation and copy.
Attack Path
- Provoke a failing allocation
Craft a WebGL
copyTexSubImage2Dagainst a texture level whose backing allocation or copy triggers a GL error (e.g. resource/format conditions that make the driver fail). - Skip the clear
The vulnerable path marks the level cleared despite the error, leaving its GPU storage uninitialized while
TextureManagerbelieves it is initialized. - Read back the texture Sample or read the texture from a shader or via a readback/copy path, which no longer triggers lazy zero-clearing because the level is flagged cleared.
- Exfiltrate residual memory Recover the returned pixel values into script, disclosing uninitialized GPU memory contents.
Impact Assessment
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
Audit Directions
- Success-assumed state updatesAudit decoder handlers that set a level’s cleared/initialized flag; ensure the flag is applied only after checking
LOCAL_PEEK_GL_ERRORfor the real GL outcome, not on the mere fact that the code path ran. - Copy/allocation error handlingReview other
DoCopyTex*/DoTex*Image*paths ingles2_cmd_decoder.ccfor cases where allocation or copy can fail whileTextureManagerstate is still advanced as if it succeeded. - Stale error stateCheck that handlers drain prior driver errors (e.g.
LOCAL_COPY_REAL_GL_ERRORS_TO_WRAPPER) before peeking, so a laterLOCAL_PEEK_GL_ERRORreflects only this operation’s result.
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.