CVE-2026-19146
Overview
Background
- `Copy{Sub}TextureCHROMIUM`
- Chrome GL command-buffer extensions that copy one texture into another; certain conversions (e.g. flip, cube-map destination) take a
DRAW_AND_COPY/DRAW_AND_READBACKpath that allocates an intermediateGL_TEXTURE_2D. - `GL_PIXEL_UNPACK_BUFFER` (PBO)
- a client-bound GL buffer that, when bound, becomes the implicit data source for
glTexImage2D/glTexSubImage2Duploads instead of a client-memory pointer. - Validating command decoder
- the GPU-process decoder that services untrusted client GL commands; the bug reproduces only here (not the passthrough decoder).
- `IsES3Capable()`
- feature check for ES3, the level at which pixel-unpack-buffer bindings exist; used here to gate the fix so it matches
RestoreBufferBindings().
Root Cause Analysis
On the DRAW_AND_COPY / DRAW_AND_READBACK paths, DoCopyTexture and DoCopySubTexture allocate an intermediate texture with glTexImage2D(dest_target, 0, ..., nullptr), intending fresh, driver-allocated storage. But the client’s GL_PIXEL_UNPACK_BUFFER binding was still applied to the driver, so with a bound PBO the driver treats the nullptr as an offset into that buffer and sources the texture’s storage from the client buffer rather than allocating new storage. If the client bound an undersized PBO, the glTexImage2D fails with GL_INVALID_OPERATION, leaving the intermediate texture with no storage; the subsequent draw/readback then operates on an incomplete framebuffer / unbacked texture, exposing uninitialized data.
The fix unbinds the buffer with glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0) immediately before the intermediate glTexImage2D in both functions, gated on decoder->GetFeatureInfo()->IsES3Capable() so it only runs where a PBO binding can exist and matches the condition under which RestoreBufferBindings() later restores the client’s binding. With the buffer unbound, the intermediate texture is allocated from real storage and the copy proceeds correctly regardless of the client’s PBO.
GL_PIXEL_UNPACK_BUFFER binding, so a glTexImage2D(..., nullptr) sourced storage from (or failed against) the client buffer instead of allocating fresh memory; the fix unbinds the PBO before that allocation, gated on IsES3Capable().Attack Path
- Bind a tiny unpack buffer
A renderer (WebGL2 client) binds a small
GL_PIXEL_UNPACK_BUFFERviaglBindBuffer+glBufferDataand leaves it bound. - Trigger an intermediate copy
It calls
glCopyTextureCHROMIUM/glCopySubTextureCHROMIUMwith parameters (e.g.flip_yplus a cube-map destination) that force theDRAW_AND_COPY/DRAW_AND_READBACKpath, which allocates an intermediateGL_TEXTURE_2D. - Storage allocation subverted
Pre-patch,
glTexImage2D(..., nullptr)uses the still-bound undersized PBO as its data source and failsGL_INVALID_OPERATION, so the intermediate texture is left without storage. - Operate on uninitialized/incomplete storage The subsequent draw and copy/readback run against the incomplete framebuffer / unbacked intermediate texture, letting the client observe uninitialized GPU memory in the destination. (The regression test confirms the destination should instead receive the real source pixels and the client PBO binding must be preserved.)
Impact Assessment
Copy{Sub}TextureCHROMIUM that needs an intermediate texture, cause the intermediate to lack proper storage and expose uninitialized GPU memory through the copy result, an information disclosure in the GPU process. The advisory classifies it as uninitialized use in GPU and states no exploited-in-the-wild status; the passthrough decoder is not affected. Any cross-context nature of the leaked memory is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pgpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc |
modified | |
forgpu/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/tests/gl_copy_texture_CHROMIUM_unittest.ccgpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc
Audit Directions
- Implicit binding leakage into internal allocationsLook for GPU-process code that issues
glTexImage2D/glTexSubImage2D/glReadPixelswith anullptr/offset while a clientGL_PIXEL_UNPACK_BUFFER/GL_PIXEL_PACK_BUFFERmay be bound, since the client binding silently alters the operation’s data source. - Intermediate/scratch resource setupAudit paths that create temporary textures, buffers, or framebuffers on behalf of a client command to ensure they reset all relevant client GL state (bindings, pixel-store params) before allocating.
- Unchecked allocation successVerify that internal
glTexImage2D-style allocations either check forGL_INVALID_OPERATION/errors or cannot silently produce an unbacked object that later draws/readbacks treat as valid.
Patch
From 870caf4d48738256593ab12d80db2f0d8012b116 Mon Sep 17 00:00:00 2001 From: Ken Russell <[email protected]> Date: Tue, 14 Jul 2026 19:04:38 -0700 Subject: [PATCH] gpu: Unbind unpack buffer for CopyTextureCHROMIUM intermediate When Copy{Sub}TextureCHROMIUM takes the DRAW_AND_COPY or DRAW_AND_READBACK path, the validating decoder allocates an intermediate GL_TEXTURE_2D via glTexImage2D(..., nullptr). The client's GL_PIXEL_UNPACK_BUFFER binding is still applied to the driver at this point, so the driver sources the intermediate texture from the client's buffer instead of allocating fresh storage. With an undersized client buffer this fails with GL_INVALID_OPERATION, leaving the intermediate texture without storage and the subsequent draw / readback operating on an incomplete framebuffer. Unbind GL_PIXEL_UNPACK_BUFFER before allocating the intermediate texture in DoCopyTexture and DoCopySubTexture, gated on IsES3Capable() to match the condition under which RestoreBufferBindings() restores the binding afterwards. Add a gl_tests regression test that binds a tiny unpack buffer, performs a copy that requires an intermediate texture, verifies the destination receives the source pixels, and checks that the client binding is preserved. Fixed: 523713150 Change-Id: I42989c54ecb1a71be228bd457a1378d003feb1e4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8088997 Reviewed-by: Zhenyao Mo <[email protected]> Commit-Queue: Kenneth Russell <[email protected]> Cr-Commit-Position: refs/heads/main@{#1662326} --- 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 fda1e42..16d4f37 100644 --- a/gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc +++ b/gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc @@ -1119,7 +1119,11 @@ adjusted_internal_format); GLenum type = TextureManager::ExtractTypeFromStorageFormat(adjusted_internal_format); - + // Allocate from client memory, not from any currently bound unpack + // buffer. The binding is restored by the calls below. + if (decoder->GetFeatureInfo()->IsES3Capable()) { + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + } glTexImage2D(dest_target, 0, adjusted_internal_format, width, height, 0, format, type, nullptr); dest_texture = intermediate_texture; @@ -1204,6 +1208,11 @@ adjusted_internal_format); GLenum type = TextureManager::ExtractTypeFromStorageFormat(adjusted_internal_format); + // Allocate from client memory, not from any currently bound unpack + // buffer. The binding is restored by the calls below. + if (decoder->GetFeatureInfo()->IsES3Capable()) { + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + } glTexImage2D(dest_target, 0, adjusted_internal_format, width, height, 0, format, type, nullptr); dest_texture = intermediate_texture; 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 28135403..6d564708 100644 --- a/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc +++ b/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc @@ -1948,4 +1948,104 @@ #endif // !BUILDFLAG(ENABLE_VALIDATING_COMMAND_DECODER) } +// A bound GL_PIXEL_UNPACK_BUFFER must not be picked up when allocating the +// intermediate texture used by the DRAW_AND_COPY / DRAW_AND_READBACK paths. +TEST_P(GLCopyTextureCHROMIUMES3Test, PixelUnpackBufferDoesNotInterfere) { +#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"; + } + +#if BUILDFLAG(IS_ANDROID) && defined(ARCH_CPU_X86_FAMILY) + GTEST_SKIP() << "Skipping test on Android x86/x64"; +#else + + constexpr GLsizei kW = 8, kH = 8; + const uint8_t kGreen[4] = {0u, 255u, 0u, 255u}; + + GLuint src; + glGenTextures(1, &src); + glBindTexture(GL_TEXTURE_2D, src); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + std::vector<uint8_t> green(kW * kH * 4); + for (size_t i = 0; i < green.size(); i += 4) { + green[i + 0] = kGreen[0]; + green[i + 1] = kGreen[1]; + green[i + 2] = kGreen[2]; + green[i + 3] = kGreen[3]; + } + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA, GL_UNSIGNED_BYTE, + green.data()); + ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError()); + + // Cube-map destination so the copy goes through an intermediate texture. + GLuint dest; + glGenTextures(1, &dest); + glBindTexture(GL_TEXTURE_CUBE_MAP, dest); + glTexParameterf(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameterf(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + std::vector<uint8_t> zeros(kW * kH * 4, 0); + for (int face = 0; face < 6; ++face) { + glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA8, kW, kH, 0, + GL_RGBA, GL_UNSIGNED_BYTE, zeros.data()); + } + ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError()); + + // Bind a tiny pixel-unpack buffer and leave it bound across the copy. The + // copy must not source the intermediate texture's storage from it. + GLuint pbo; + glGenBuffers(1, &pbo); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo); + const uint8_t kByte = 0; + glBufferData(GL_PIXEL_UNPACK_BUFFER, sizeof(kByte), &kByte, GL_STATIC_DRAW); + ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError()); + + // flip_y forces a draw-based path; cube-map dest requires an intermediate. + CopyType copy_type = GetParam(); + if (copy_type == TexImage) { + glCopyTextureCHROMIUM(src, 0, GL_TEXTURE_CUBE_MAP_POSITIVE_X, dest, 0, + GL_RGBA8, GL_UNSIGNED_BYTE, GL_TRUE, GL_FALSE, + GL_FALSE); + } else { + glCopySubTextureCHROMIUM(src, 0, GL_TEXTURE_CUBE_MAP_POSITIVE_X, dest, 0, 0, + 0, 0, 0, kW, kH, GL_TRUE, GL_FALSE, GL_FALSE); + } + EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError()); + + // The client binding must be preserved. + GLint bound_pbo = 0; + glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &bound_pbo); + EXPECT_EQ(pbo, static_cast<GLuint>(bound_pbo)); + + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + + GLuint fbo; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_CUBE_MAP_POSITIVE_X, dest, 0); + ASSERT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE), + glCheckFramebufferStatus(GL_FRAMEBUFFER)); + + EXPECT_TRUE(GLTestHelper::CheckPixels(0, 0, kW, kH, 0, kGreen, nullptr)); + EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError()); + + glDeleteFramebuffers(1, &fbo); + glDeleteBuffers(1, &pbo); + glDeleteTextures(1, &src); + glDeleteTextures(1, &dest); +#endif // BUILDFLAG(IS_ANDROID) && defined(ARCH_CPU_X86_FAMILY) +#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 28135403..6d564708 100644
--- a/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
+++ b/gpu/command_buffer/tests/gl_copy_texture_CHROMIUM_unittest.cc
@@ -1948,4 +1948,104 @@
#endif // !BUILDFLAG(ENABLE_VALIDATING_COMMAND_DECODER)
}
+// A bound GL_PIXEL_UNPACK_BUFFER must not be picked up when allocating the
+// intermediate texture used by the DRAW_AND_COPY / DRAW_AND_READBACK paths.
+TEST_P(GLCopyTextureCHROMIUMES3Test, PixelUnpackBufferDoesNotInterfere) {
+#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";
+ }
+
+#if BUILDFLAG(IS_ANDROID) && defined(ARCH_CPU_X86_FAMILY)
+ GTEST_SKIP() << "Skipping test on Android x86/x64";
+#else
+
+ constexpr GLsizei kW = 8, kH = 8;
+ const uint8_t kGreen[4] = {0u, 255u, 0u, 255u};
+
+ GLuint src;
+ glGenTextures(1, &src);
+ glBindTexture(GL_TEXTURE_2D, src);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ std::vector<uint8_t> green(kW * kH * 4);
+ for (size_t i = 0; i < green.size(); i += 4) {
+ green[i + 0] = kGreen[0];
+ green[i + 1] = kGreen[1];
+ green[i + 2] = kGreen[2];
+ green[i + 3] = kGreen[3];
+ }
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kW, kH, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ green.data());
+ ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+ // Cube-map destination so the copy goes through an intermediate texture.
+ GLuint dest;
+ glGenTextures(1, &dest);
+ glBindTexture(GL_TEXTURE_CUBE_MAP, dest);
+ glTexParameterf(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+ glTexParameterf(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ std::vector<uint8_t> zeros(kW * kH * 4, 0);
+ for (int face = 0; face < 6; ++face) {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA8, kW, kH, 0,
+ GL_RGBA, GL_UNSIGNED_BYTE, zeros.data());
+ }
+ ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+ // Bind a tiny pixel-unpack buffer and leave it bound across the copy. The
+ // copy must not source the intermediate texture's storage from it.
+ GLuint pbo;
+ glGenBuffers(1, &pbo);
+ glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo);
+ const uint8_t kByte = 0;
+ glBufferData(GL_PIXEL_UNPACK_BUFFER, sizeof(kByte), &kByte, GL_STATIC_DRAW);
+ ASSERT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+ // flip_y forces a draw-based path; cube-map dest requires an intermediate.
+ CopyType copy_type = GetParam();
+ if (copy_type == TexImage) {
+ glCopyTextureCHROMIUM(src, 0, GL_TEXTURE_CUBE_MAP_POSITIVE_X, dest, 0,
+ GL_RGBA8, GL_UNSIGNED_BYTE, GL_TRUE, GL_FALSE,
+ GL_FALSE);
+ } else {
+ glCopySubTextureCHROMIUM(src, 0, GL_TEXTURE_CUBE_MAP_POSITIVE_X, dest, 0, 0,
+ 0, 0, 0, kW, kH, GL_TRUE, GL_FALSE, GL_FALSE);
+ }
+ EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+ // The client binding must be preserved.
+ GLint bound_pbo = 0;
+ glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &bound_pbo);
+ EXPECT_EQ(pbo, static_cast<GLuint>(bound_pbo));
+
+ glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
+
+ GLuint fbo;
+ glGenFramebuffers(1, &fbo);
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
+ GL_TEXTURE_CUBE_MAP_POSITIVE_X, dest, 0);
+ ASSERT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+ glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+ EXPECT_TRUE(GLTestHelper::CheckPixels(0, 0, kW, kH, 0, kGreen, nullptr));
+ EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+ glDeleteFramebuffers(1, &fbo);
+ glDeleteBuffers(1, &pbo);
+ glDeleteTextures(1, &src);
+ glDeleteTextures(1, &dest);
+#endif // BUILDFLAG(IS_ANDROID) && defined(ARCH_CPU_X86_FAMILY)
+#endif // !BUILDFLAG(ENABLE_VALIDATING_COMMAND_DECODER)
+}
+
} // namespace gpu