CVE-2026-87555
Overview
Background
- Lazy clearing
- A GPU optimization where a newly allocated texture level is not zeroed immediately but instead flagged as needing a clear before it can be safely read from or rendered.
- PBO (Pixel Buffer Object)
- A GL buffer bound to
GL_PIXEL_UNPACK_BUFFERfrom which texture pixel data is sourced during uploads instead of client memory. - Driver bug workaround
- A code path (e.g.
unpack_overlapping_rows_separately_unpack_bufferorunpack_alignment_workaround_with_unpack_buffer) that rewrites a texture upload into multipleTexSubImage2Dcalls to sidestep buggy driver behavior. - `SetLevelCleared`
- A
TextureManagercall that marks a texture level as fully initialized, allowing it to passSafeToRenderFromchecks without a lazy clear.
Root Cause Analysis
In TextureManager::ValidateAndDoTexImage, when a PBO-based driver-bug workaround runs — the row-by-row path (DoTexSubImageRowByRowWorkaround) or the alignment path (DoTexSubImageWithAlignmentWorkaround) — the code unconditionally called SetLevelCleared(texture_ref, args.target, args.level, true) immediately after issuing the split TexSubImage2D uploads. The invariant that “a level may only be marked cleared once its entire contents have actually been written” was violated because the workaround could fail partway through (for example a driver returning GL_OUT_OF_MEMORY), leaving some rows of the level never written. Because the level was still flagged as cleared, the lazy-clear step was permanently bypassed and the texture reported SafeToRenderFrom() == true.
The fix wraps each SetLevelCleared call in a check of ERRORSTATE_PEEK_GL_ERROR(error_state, function_name) == GL_NO_ERROR, so the level is marked cleared only when the workaround completed without a GL error; otherwise the level stays uncleared and the normal lazy-clear path will zero it before use.
SetLevelCleared on ERRORSTATE_PEEK_GL_ERROR returning GL_NO_ERROR, preserving the uncleared state whenever the uploads fail.Attack Path
- Trigger a workaround-enabled context
On hardware where
unpack_overlapping_rows_separately_unpack_bufferorunpack_alignment_workaround_with_unpack_bufferis active, a page obtains a WebGL2 context that routes uploads through the vulnerable path. - Bind a PBO and set pixel-store state
The attacker binds a buffer to
GL_PIXEL_UNPACK_BUFFERand configuresGL_UNPACK_ROW_LENGTH/alignment soValidateAndDoTexImageselects the split-upload workaround. - Force a mid-upload GL error
By sizing the texture, buffer, or state such that one of the split
TexSubImage2Dcalls fails (e.g. driver returnsGL_OUT_OF_MEMORY), the level is left only partially written. - Bypass lazy clear
The pre-patch code still calls
SetLevelCleared(..., true), so the level is flagged cleared and never zeroed despite containing uninitialized VRAM. - Read back the stale contents
The attacker samples or reads back the texture (which now passes
SafeToRenderFrom), disclosing previously-freed GPU memory.
Impact Assessment
unpack_overlapping_rows_separately_unpack_buffer or unpack_alignment_workaround_with_unpack_buffer workaround is enabled, use of a PBO-sourced upload, and inducing a GL error mid-workaround.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pgpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc |
modified |
Files Changed
gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.ccgpu/command_buffer/service/texture_manager.cc
Audit Directions
- Unconditional `SetLevelCleared` after uploadsSearch for
SetLevelCleared(..., true)calls that follow texture-write helpers without an intervening GL-error check, since any failed or partial write followed by a cleared flag reopens this uninitialized-memory class. - Error handling in driver-bug workaround pathsReview each
GpuDriverBugWorkarounds-gated split-upload path (row-by-row, alignment, and similar) to confirm it propagates or peeks GL errors before updating clear/initialized state. - Lazy-clear invariant enforcementAudit places where
SafeToRenderFromand level-cleared state can diverge from actual written coverage, ensuring partially-completed uploads never leave a level marked as fully initialized.
Patch
From b4c415c65c314e40b6b9c65af05ed758e8c26a22 Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Wed, 12 Aug 2026 09:14:28 -0700
Subject: [PATCH] [agy][gpu] Fix PBO workaround lazy-clear bypass
Do not mark texture level as cleared if an error occurred during
the PBO workarounds (row-by-row or alignment workarounds) in
ValidateAndDoTexImage.
Fixed: 517337579
Test: gpu_unittests
Change-Id: If54f50ff98fb55435fae3ad3cf145caf5fb41d78
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8239634
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Tzarial <zork@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1678093}
---
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 70e377be..6b7d756 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
@@ -594,6 +594,119 @@
texture->GetLevelSize(GL_TEXTURE_2D, level, &width, &height, nullptr));
}
+TEST_P(GLES2DecoderManualInitTest,
+ TexImage2DUnpackOverlappingRowsWorkaroundGLError) {
+ gpu::GpuDriverBugWorkarounds workarounds;
+ workarounds.unpack_overlapping_rows_separately_unpack_buffer = true;
+ InitState init;
+ init.gl_version = "OpenGL ES 3.0";
+ init.context_type = CONTEXT_TYPE_OPENGLES3;
+ InitDecoderWithWorkarounds(init, workarounds);
+
+ const GLsizei kWidth = 2;
+ const GLsizei kHeight = 2;
+ const GLint kRowLength = 1;
+ const GLenum kFormat = GL_RGBA;
+ const GLenum kType = GL_UNSIGNED_BYTE;
+ const GLint kBufferSize = 16;
+
+ DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+
+ cmds::PixelStorei pixel_store_cmd;
+ pixel_store_cmd.Init(GL_UNPACK_ROW_LENGTH, kRowLength);
+ EXPECT_EQ(error::kNoError, ExecuteCmd(pixel_store_cmd));
+
+ EXPECT_CALL(*gl_, PixelStorei(GL_UNPACK_ROW_LENGTH, kRowLength))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, PixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+ DoBindBuffer(GL_PIXEL_UNPACK_BUFFER, client_buffer_id_, kServiceBufferId);
+ DoBufferData(GL_PIXEL_UNPACK_BUFFER, kBufferSize);
+
+ TextureManager* manager = group().texture_manager();
+ TextureRef* texture_ref = manager->GetTexture(client_texture_id_);
+ ASSERT_TRUE(texture_ref != nullptr);
+ Texture* texture = texture_ref->texture();
+
+ EXPECT_CALL(*gl_, PixelStorei(_, _)).Times(AnyNumber());
+ EXPECT_CALL(*gl_, BindBuffer(GL_PIXEL_UNPACK_BUFFER, _)).Times(AnyNumber());
+ EXPECT_CALL(*gl_, TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kWidth, kHeight, 0,
+ kFormat, kType, nullptr))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(
+ *gl_, TexSubImage2D(GL_TEXTURE_2D, 0, 0, _, kWidth, 1, kFormat, kType, _))
+ .Times(kHeight)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_OUT_OF_MEMORY))
+ .RetiresOnSaturation();
+ cmds::TexImage2D cmd;
+ cmd.Init(GL_TEXTURE_2D, 0, GL_RGBA, kWidth, kHeight, kFormat, kType, 0, 0);
+ EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
+ EXPECT_FALSE(texture->IsLevelCleared(GL_TEXTURE_2D, 0));
+ EXPECT_FALSE(texture->SafeToRenderFrom());
+ EXPECT_EQ(GL_OUT_OF_MEMORY, GetGLError());
+}
+
+TEST_P(GLES2DecoderManualInitTest, TexImage2DUnpackAlignmentWorkaroundGLError) {
+ gpu::GpuDriverBugWorkarounds workarounds;
+ workarounds.unpack_alignment_workaround_with_unpack_buffer = true;
+ InitState init;
+ init.gl_version = "OpenGL ES 3.0";
+ init.context_type = CONTEXT_TYPE_OPENGLES3;
+ InitDecoderWithWorkarounds(init, workarounds);
+
+ const GLsizei kWidth = 2;
+ const GLsizei kHeight = 2;
+ const GLenum kFormat = GL_RGB;
+ const GLenum kType = GL_UNSIGNED_BYTE;
+ // Padded row size 8 (alignment 4), unpadded row size 6; pixels_size = 14.
+ const GLint kBufferSize = 14;
+
+ DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+
+ EXPECT_CALL(*gl_, PixelStorei(GL_UNPACK_ROW_LENGTH, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, PixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+ DoBindBuffer(GL_PIXEL_UNPACK_BUFFER, client_buffer_id_, kServiceBufferId);
+ DoBufferData(GL_PIXEL_UNPACK_BUFFER, kBufferSize);
+
+ TextureManager* manager = group().texture_manager();
+ TextureRef* texture_ref = manager->GetTexture(client_texture_id_);
+ ASSERT_TRUE(texture_ref != nullptr);
+ Texture* texture = texture_ref->texture();
+
+ EXPECT_CALL(*gl_, PixelStorei(_, _)).Times(AnyNumber());
+ EXPECT_CALL(*gl_, BindBuffer(GL_PIXEL_UNPACK_BUFFER, _)).Times(AnyNumber());
+ EXPECT_CALL(*gl_, TexImage2D(GL_TEXTURE_2D, 0, GL_RGB, kWidth, kHeight, 0,
+ kFormat, kType, nullptr))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(
+ *gl_, TexSubImage2D(GL_TEXTURE_2D, 0, 0, _, kWidth, _, kFormat, kType, _))
+ .Times(2)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_OUT_OF_MEMORY))
+ .RetiresOnSaturation();
+ cmds::TexImage2D cmd;
+ cmd.Init(GL_TEXTURE_2D, 0, GL_RGB, kWidth, kHeight, kFormat, kType, 0, 0);
+ EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
+ EXPECT_FALSE(texture->IsLevelCleared(GL_TEXTURE_2D, 0));
+ EXPECT_FALSE(texture->SafeToRenderFrom());
+ EXPECT_EQ(GL_OUT_OF_MEMORY, GetGLError());
+}
+
TEST_P(GLES2DecoderTest, TexSubImage2DGLErrorDoesNotMarkLevelAsCleared) {
DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
DoTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0,
diff --git a/gpu/command_buffer/service/texture_manager.cc b/gpu/command_buffer/service/texture_manager.cc
index 5c9b27bc9..c9a764c 100644
--- a/gpu/command_buffer/service/texture_manager.cc
+++ b/gpu/command_buffer/service/texture_manager.cc
@@ -2688,7 +2688,12 @@
DoTexSubImageRowByRowWorkaround(texture_state, state, sub_args,
unpack_params);
- SetLevelCleared(texture_ref, args.target, args.level, true);
+ // https://crbug.com/517337579: Only mark the level as cleared if the
+ // workaround succeeded, to prevent leaking uninitialized VRAM if
+ // sub-image uploads failed.
+ if (ERRORSTATE_PEEK_GL_ERROR(error_state, function_name) == GL_NO_ERROR) {
+ SetLevelCleared(texture_ref, args.target, args.level, true);
+ }
return;
}
}
@@ -2722,7 +2727,12 @@
: DoTexSubImageArguments::CommandType::kTexSubImage2D};
DoTexSubImageWithAlignmentWorkaround(texture_state, state, sub_args);
- SetLevelCleared(texture_ref, args.target, args.level, true);
+ // https://crbug.com/517337579: Only mark the level as cleared if the
+ // workaround succeeded, to prevent leaking uninitialized VRAM if
+ // sub-image uploads failed.
+ if (ERRORSTATE_PEEK_GL_ERROR(error_state, function_name) == GL_NO_ERROR) {
+ SetLevelCleared(texture_ref, args.target, args.level, true);
+ }
return;
}
}
Regression Test / PoC
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 70e377be..6b7d756 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
@@ -594,6 +594,119 @@
texture->GetLevelSize(GL_TEXTURE_2D, level, &width, &height, nullptr));
}
+TEST_P(GLES2DecoderManualInitTest,
+ TexImage2DUnpackOverlappingRowsWorkaroundGLError) {
+ gpu::GpuDriverBugWorkarounds workarounds;
+ workarounds.unpack_overlapping_rows_separately_unpack_buffer = true;
+ InitState init;
+ init.gl_version = "OpenGL ES 3.0";
+ init.context_type = CONTEXT_TYPE_OPENGLES3;
+ InitDecoderWithWorkarounds(init, workarounds);
+
+ const GLsizei kWidth = 2;
+ const GLsizei kHeight = 2;
+ const GLint kRowLength = 1;
+ const GLenum kFormat = GL_RGBA;
+ const GLenum kType = GL_UNSIGNED_BYTE;
+ const GLint kBufferSize = 16;
+
+ DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+
+ cmds::PixelStorei pixel_store_cmd;
+ pixel_store_cmd.Init(GL_UNPACK_ROW_LENGTH, kRowLength);
+ EXPECT_EQ(error::kNoError, ExecuteCmd(pixel_store_cmd));
+
+ EXPECT_CALL(*gl_, PixelStorei(GL_UNPACK_ROW_LENGTH, kRowLength))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, PixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+ DoBindBuffer(GL_PIXEL_UNPACK_BUFFER, client_buffer_id_, kServiceBufferId);
+ DoBufferData(GL_PIXEL_UNPACK_BUFFER, kBufferSize);
+
+ TextureManager* manager = group().texture_manager();
+ TextureRef* texture_ref = manager->GetTexture(client_texture_id_);
+ ASSERT_TRUE(texture_ref != nullptr);
+ Texture* texture = texture_ref->texture();
+
+ EXPECT_CALL(*gl_, PixelStorei(_, _)).Times(AnyNumber());
+ EXPECT_CALL(*gl_, BindBuffer(GL_PIXEL_UNPACK_BUFFER, _)).Times(AnyNumber());
+ EXPECT_CALL(*gl_, TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kWidth, kHeight, 0,
+ kFormat, kType, nullptr))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(
+ *gl_, TexSubImage2D(GL_TEXTURE_2D, 0, 0, _, kWidth, 1, kFormat, kType, _))
+ .Times(kHeight)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_OUT_OF_MEMORY))
+ .RetiresOnSaturation();
+ cmds::TexImage2D cmd;
+ cmd.Init(GL_TEXTURE_2D, 0, GL_RGBA, kWidth, kHeight, kFormat, kType, 0, 0);
+ EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
+ EXPECT_FALSE(texture->IsLevelCleared(GL_TEXTURE_2D, 0));
+ EXPECT_FALSE(texture->SafeToRenderFrom());
+ EXPECT_EQ(GL_OUT_OF_MEMORY, GetGLError());
+}
+
+TEST_P(GLES2DecoderManualInitTest, TexImage2DUnpackAlignmentWorkaroundGLError) {
+ gpu::GpuDriverBugWorkarounds workarounds;
+ workarounds.unpack_alignment_workaround_with_unpack_buffer = true;
+ InitState init;
+ init.gl_version = "OpenGL ES 3.0";
+ init.context_type = CONTEXT_TYPE_OPENGLES3;
+ InitDecoderWithWorkarounds(init, workarounds);
+
+ const GLsizei kWidth = 2;
+ const GLsizei kHeight = 2;
+ const GLenum kFormat = GL_RGB;
+ const GLenum kType = GL_UNSIGNED_BYTE;
+ // Padded row size 8 (alignment 4), unpadded row size 6; pixels_size = 14.
+ const GLint kBufferSize = 14;
+
+ DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+
+ EXPECT_CALL(*gl_, PixelStorei(GL_UNPACK_ROW_LENGTH, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, PixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+ DoBindBuffer(GL_PIXEL_UNPACK_BUFFER, client_buffer_id_, kServiceBufferId);
+ DoBufferData(GL_PIXEL_UNPACK_BUFFER, kBufferSize);
+
+ TextureManager* manager = group().texture_manager();
+ TextureRef* texture_ref = manager->GetTexture(client_texture_id_);
+ ASSERT_TRUE(texture_ref != nullptr);
+ Texture* texture = texture_ref->texture();
+
+ EXPECT_CALL(*gl_, PixelStorei(_, _)).Times(AnyNumber());
+ EXPECT_CALL(*gl_, BindBuffer(GL_PIXEL_UNPACK_BUFFER, _)).Times(AnyNumber());
+ EXPECT_CALL(*gl_, TexImage2D(GL_TEXTURE_2D, 0, GL_RGB, kWidth, kHeight, 0,
+ kFormat, kType, nullptr))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(
+ *gl_, TexSubImage2D(GL_TEXTURE_2D, 0, 0, _, kWidth, _, kFormat, kType, _))
+ .Times(2)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GetError())
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_NO_ERROR))
+ .WillOnce(Return(GL_OUT_OF_MEMORY))
+ .RetiresOnSaturation();
+ cmds::TexImage2D cmd;
+ cmd.Init(GL_TEXTURE_2D, 0, GL_RGB, kWidth, kHeight, kFormat, kType, 0, 0);
+ EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
+ EXPECT_FALSE(texture->IsLevelCleared(GL_TEXTURE_2D, 0));
+ EXPECT_FALSE(texture->SafeToRenderFrom());
+ EXPECT_EQ(GL_OUT_OF_MEMORY, GetGLError());
+}
+
TEST_P(GLES2DecoderTest, TexSubImage2DGLErrorDoesNotMarkLevelAsCleared) {
DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
DoTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0,