CVE-2026-11088
Overview
Files Changed
extensions/CHROMIUM_copy_texture.txtsrc/libANGLE/validationES2.cppsrc/tests/gl_tests/CopyTextureTest.cpp
Patch
From fd958ac6533eb789e56801b558c9de15af17792c Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Thu, 09 Apr 2026 15:20:45 -0400
Subject: [PATCH] Restrict Copy*TextureCHROMIUM sourceLevel to [BASE,MAX] range
Bug: chromium:500144879
Change-Id: Ia267fb2e6af2991d51977cc8726af20231c8fbb4
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7745268
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/extensions/CHROMIUM_copy_texture.txt b/extensions/CHROMIUM_copy_texture.txt
index 28c2366..f5f9e28 100644
--- a/extensions/CHROMIUM_copy_texture.txt
+++ b/extensions/CHROMIUM_copy_texture.txt
@@ -145,6 +145,10 @@
INVALID_VALUE is generated if <sourceLevel> of the source texture is not
defined.
+ INVALID_VALUE is generated if <sourceLevel> of the source texture is less
+ than the source texture's value for TEXTURE_BASE_LEVEL or more than the
+ source texture's value for TEXTURE_MAX_LEVEL.
+
The command
CopySubTextureCHROMIUM
@@ -184,6 +188,16 @@
INVALID_VALUE is generated if (<xoffset> + <width>) > destWidth,
or (<yoffset> + <height>) > destHeight.
+ INVALID_VALUE is generated if <sourceLevel> is not 0 for ES 2.0, or if
+ <sourceLevel> or <destLevel> is less than 0 for ES 3.0.
+
+ INVALID_VALUE is generated if <sourceLevel> of the source texture is not
+ defined.
+
+ INVALID_VALUE is generated if <sourceLevel> of the source texture is less
+ than the source texture's value for TEXTURE_BASE_LEVEL or more than the
+ source texture's value for TEXTURE_MAX_LEVEL.
+
Table 1.0 Valid internal formats for CopyTextureCHROMIUM:
<internalFormat>
@@ -298,3 +312,4 @@
19/1/2017 Added TEXTURE_CUBE_MAP as valid dest_id target.
24/3/2017 Clean up naming and move formats into tables.
3/9/2025 Add source copy formats from ANGLE_yuv_internal_format
+ 9/4/2026 Add restriction that sourceLevel is in [BASE, MAX] range
diff --git a/src/libANGLE/validationES2.cpp b/src/libANGLE/validationES2.cpp
index 840c35b..199e470 100644
--- a/src/libANGLE/validationES2.cpp
+++ b/src/libANGLE/validationES2.cpp
@@ -241,7 +241,10 @@
}
}
-bool IsValidCopyTextureSourceLevel(const Context *context, TextureType type, GLint level)
+bool IsValidCopyTextureSourceLevel(const Context *context,
+ const Texture *texture,
+ TextureType type,
+ GLint level)
{
if (!ValidMipLevel(context, type, level))
{
@@ -253,6 +256,12 @@
return false;
}
+ if (level < 0 || static_cast<GLuint>(level) < texture->getBaseLevel() ||
+ static_cast<GLuint>(level) > texture->getMaxLevel())
+ {
+ return false;
+ }
+
return true;
}
@@ -3287,7 +3296,7 @@
ASSERT(sourceType != TextureType::CubeMap);
TextureTarget sourceTarget = NonCubeTextureTypeToTarget(sourceType);
- if (!IsValidCopyTextureSourceLevel(context, sourceType, sourceLevel))
+ if (!IsValidCopyTextureSourceLevel(context, source, sourceType, sourceLevel))
{
ANGLE_VALIDATION_ERROR(GL_INVALID_VALUE, kInvalidSourceTextureLevel);
return false;
@@ -3403,7 +3412,7 @@
ASSERT(sourceType != TextureType::CubeMap);
TextureTarget sourceTarget = NonCubeTextureTypeToTarget(sourceType);
- if (!IsValidCopyTextureSourceLevel(context, sourceType, sourceLevel))
+ if (!IsValidCopyTextureSourceLevel(context, source, sourceType, sourceLevel))
{
ANGLE_VALIDATION_ERROR(GL_INVALID_VALUE, kInvalidMipLevel);
return false;
diff --git a/src/tests/gl_tests/CopyTextureTest.cpp b/src/tests/gl_tests/CopyTextureTest.cpp
index 44effc4..0a7c97d 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -2092,11 +2092,7 @@
// Test to ensure that CopyTexture will fail with a non-zero level and NPOT texture in WebGL
TEST_P(CopyTextureTestWebGL, NPOT)
{
- if (IsGLExtensionRequestable("GL_CHROMIUM_copy_texture"))
- {
- glRequestExtensionANGLE("GL_CHROMIUM_copy_texture");
- }
- ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_CHROMIUM_copy_texture"));
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_CHROMIUM_copy_texture"));
std::vector<GLColor> pixelData(10 * 10, GLColor::red);
@@ -3286,6 +3282,70 @@
EXPECT_PIXEL_RECT32I_EQ(0, kTexHeight / 2, kTexWidth, kTexHeight / 2, kIntGreen);
}
+// Test that glCopyTextureCHROMIUM and glCopySubTextureCHROMIUM fail validation if the source level
+// is outside the [BASE, MAX] range.
+TEST_P(CopyTextureTestES3, VerifySourceLevelInBaseMaxRange)
+{
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_CHROMIUM_copy_texture"));
+
+ // Define level 0 for src texture, but base it at level 2.
+ GLTexture src;
+ glBindTexture(GL_TEXTURE_2D, src);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 64, 64, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexImage2D(GL_TEXTURE_2D, 2, GL_RGBA8, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexImage2D(GL_TEXTURE_2D, 3, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 2);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 3);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ GLTexture dst;
+ glBindTexture(GL_TEXTURE_2D, dst);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 64, 64, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ ASSERT_GL_NO_ERROR();
+
+ for (int32_t srcLevel = -1; srcLevel < 5; ++srcLevel)
+ {
+ // Copy from src at level -1 is invalid, it's negative
+ // Copy from src at level 0 is invalid, it's under the BASE level
+ // Copy from src at level 1 is invalid, it's never defined
+ // Copy from src at level 2 and 3 are valid, with or without mipmapping
+ // Copy from src at level 4+ are invalid, it's over the MAX level
+ bool valid = srcLevel >= 2 && srcLevel <= 3;
+
+ uint32_t size = srcLevel > 0 ? 64 >> srcLevel : 64;
+ glCopySubTextureCHROMIUM(src, srcLevel, GL_TEXTURE_2D, dst, 0, 0, 0, 0, 0, size, size,
+ GL_FALSE, GL_FALSE, GL_FALSE);
+ if (valid)
+ {
+ EXPECT_GL_NO_ERROR() << srcLevel;
+ }
+ else
+ {
+ EXPECT_GL_ERROR(GL_INVALID_VALUE) << srcLevel;
+ }
+ glCopyTextureCHROMIUM(src, srcLevel, GL_TEXTURE_2D, dst, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ GL_FALSE, GL_FALSE, GL_FALSE);
+ if (valid)
+ {
+ EXPECT_GL_NO_ERROR() << srcLevel;
+ }
+ else
+ {
+ EXPECT_GL_ERROR(GL_INVALID_VALUE) << srcLevel;
+ }
+ }
+
+ // Enable mipmapping and copy from level 3. Still valid.
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
+ glCopySubTextureCHROMIUM(src, 3, GL_TEXTURE_2D, dst, 0, 0, 0, 0, 0, 8, 8, GL_FALSE, GL_FALSE,
+ GL_FALSE);
+ EXPECT_GL_NO_ERROR();
+ glCopyTextureCHROMIUM(src, 3, GL_TEXTURE_2D, dst, 0, GL_RGBA, GL_UNSIGNED_BYTE, GL_FALSE,
+ GL_FALSE, GL_FALSE);
+ EXPECT_GL_NO_ERROR();
+}
+
ANGLE_INSTANTIATE_TEST_ES2(CopyTextureTest);
ANGLE_INSTANTIATE_TEST_COMBINE_6(CopyTextureVariationsTest,
CopyTextureVariationsTestPrint,
Regression Test / PoC
diff --git a/src/tests/gl_tests/CopyTextureTest.cpp b/src/tests/gl_tests/CopyTextureTest.cpp
index 44effc4..0a7c97d 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -2092,11 +2092,7 @@
// Test to ensure that CopyTexture will fail with a non-zero level and NPOT texture in WebGL
TEST_P(CopyTextureTestWebGL, NPOT)
{
- if (IsGLExtensionRequestable("GL_CHROMIUM_copy_texture"))
- {
- glRequestExtensionANGLE("GL_CHROMIUM_copy_texture");
- }
- ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_CHROMIUM_copy_texture"));
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_CHROMIUM_copy_texture"));
std::vector<GLColor> pixelData(10 * 10, GLColor::red);
@@ -3286,6 +3282,70 @@
EXPECT_PIXEL_RECT32I_EQ(0, kTexHeight / 2, kTexWidth, kTexHeight / 2, kIntGreen);
}
+// Test that glCopyTextureCHROMIUM and glCopySubTextureCHROMIUM fail validation if the source level
+// is outside the [BASE, MAX] range.
+TEST_P(CopyTextureTestES3, VerifySourceLevelInBaseMaxRange)
+{
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_CHROMIUM_copy_texture"));
+
+ // Define level 0 for src texture, but base it at level 2.
+ GLTexture src;
+ glBindTexture(GL_TEXTURE_2D, src);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 64, 64, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexImage2D(GL_TEXTURE_2D, 2, GL_RGBA8, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexImage2D(GL_TEXTURE_2D, 3, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 2);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 3);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ GLTexture dst;
+ glBindTexture(GL_TEXTURE_2D, dst);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 64, 64, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ ASSERT_GL_NO_ERROR();
+
+ for (int32_t srcLevel = -1; srcLevel < 5; ++srcLevel)
+ {
+ // Copy from src at level -1 is invalid, it's negative
+ // Copy from src at level 0 is invalid, it's under the BASE level
+ // Copy from src at level 1 is invalid, it's never defined
+ // Copy from src at level 2 and 3 are valid, with or without mipmapping
+ // Copy from src at level 4+ are invalid, it's over the MAX level
+ bool valid = srcLevel >= 2 && srcLevel <= 3;
+
+ uint32_t size = srcLevel > 0 ? 64 >> srcLevel : 64;
+ glCopySubTextureCHROMIUM(src, srcLevel, GL_TEXTURE_2D, dst, 0, 0, 0, 0, 0, size, size,
+ GL_FALSE, GL_FALSE, GL_FALSE);
+ if (valid)
+ {
+ EXPECT_GL_NO_ERROR() << srcLevel;
+ }
+ else
+ {
+ EXPECT_GL_ERROR(GL_INVALID_VALUE) << srcLevel;
+ }
+ glCopyTextureCHROMIUM(src, srcLevel, GL_TEXTURE_2D, dst, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ GL_FALSE, GL_FALSE, GL_FALSE);
+ if (valid)
+ {
+ EXPECT_GL_NO_ERROR() << srcLevel;
+ }
+ else
+ {
+ EXPECT_GL_ERROR(GL_INVALID_VALUE) << srcLevel;
+ }
+ }
+
+ // Enable mipmapping and copy from level 3. Still valid.
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
+ glCopySubTextureCHROMIUM(src, 3, GL_TEXTURE_2D, dst, 0, 0, 0, 0, 0, 8, 8, GL_FALSE, GL_FALSE,
+ GL_FALSE);
+ EXPECT_GL_NO_ERROR();
+ glCopyTextureCHROMIUM(src, 3, GL_TEXTURE_2D, dst, 0, GL_RGBA, GL_UNSIGNED_BYTE, GL_FALSE,
+ GL_FALSE, GL_FALSE);
+ EXPECT_GL_NO_ERROR();
+}
+
ANGLE_INSTANTIATE_TEST_ES2(CopyTextureTest);
ANGLE_INSTANTIATE_TEST_COMBINE_6(CopyTextureVariationsTest,
CopyTextureVariationsTestPrint,
Original Bug Report
Integer underflow in ANGLE Vulkan via glCopySubTextureCHROMIUM leads to OOB GPU read/write
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: A potential vulnerability in ANGLE’s Vulkan backend allows for an integer underflow when copying textures via glCopySubTextureCHROMIUM. If a requested source or destination mip level is less than the texture’s effective base level, an underflow occurs, leading to an extremely large mip level index passed to Vulkan. This can result in out-of-bounds GPU memory reads (information disclosure) or writes (RCE/sandbox escape in the GPU process).
Affected files:
third_party/angle/src/libANGLE/renderer/vulkan/TextureVk.cppthird_party/angle/src/libANGLE/renderer/vulkan/vk_utils.cppthird_party/angle/src/libANGLE/validationES2.cppthird_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp
Estimated timestamp from git blame: 2023-07-13
Vulnerability Details
A potential integer underflow vulnerability exists in ANGLE’s Vulkan backend implementation of the GL_CHROMIUM_copy_texture and GL_CHROMIUM_copy_sub_texture extensions. The issue stems from insufficient validation of the sourceLevel and destLevel parameters relative to the texture’s current state, combined with an unsafe level conversion in the Vulkan backend.
-
Validation Bypass: In
third_party/angle/src/libANGLE/validationES2.cpp, the functionsValidateCopySubTextureCHROMIUMandValidateCopyTextureCHROMIUMuseIsValidCopyTextureSourceLevelandIsValidCopyTextureDestinationLevelto validate the mip levels. These checks only ensure the level is within a generic valid range (e.g.,>= 0and<= log2(MAX_TEXTURE_SIZE)); they do not verify that the requested level is at or above the texture’sGL_TEXTURE_BASE_LEVEL(effective base level). -
Vulkan Backend Allocation: For non-immutable textures, the Vulkan backend (
TextureVk::initImageinTextureVk.cpp) allocates the underlyingVkImagestarting frommState.getEffectiveBaseLevel(). This starting level is recorded asmFirstAllocatedLevelwithin thevk::ImageHelperobject. -
Integer Underflow: During the copy operation,
TextureVk::copySubImageImplWithTransfer(and similar functions) translates the GL mip level to a Vulkan-relative mip level usingsrcImage->toVkLevel(sourceLevelGL). This callsgl_vk::GetLevelIndexinvk_utils.cpp:vk::LevelIndex GetLevelIndex(gl::LevelIndex levelGL, gl::LevelIndex baseLevel) { ASSERT(baseLevel <= levelGL); return vk::LevelIndex(levelGL.get() - baseLevel.get()); }When
sourceLevelGLis less thanbaseLevel(e.g., source level 0 with a base level of 2), the subtraction underflows. Sincevk::LevelIndexwraps auint32_t, the result becomes a very large value (e.g.,-2becomes0xFFFFFFFE). In release builds, theASSERTis removed, and this invalid index is returned. -
Out-of-Bounds Access: The massive underflowed
mipLevelis assigned toVkImageSubresourceLayers::mipLeveland passed directly into Vulkan commands such asvkCmdCopyImage. Because the value exceeds the actual number of mip levels in theVkImage, it causes the GPU driver to compute an out-of-bounds offset into GPU memory.
Security Impact
- Out-of-Bounds Read: By requesting a copy from an underflowed
sourceLevel, an attacker can instruct the GPU to read uninitialized or adjacent GPU memory into a destination texture. Reading this destination texture back to the compromised renderer process allows exfiltration of sensitive cross-origin or cross-process data from GPU memory. - Out-of-Bounds Write (RCE): The vulnerability is symmetrical. If the destination texture has a
baseLevel> 0 and the attacker requests copying intodestLevel0, an underflow occurs on the destination side. Writing to an OOB GPU memory offset viavkCmdCopyImagecan corrupt adjacent GPU buffers, driver structures, or shader code, potentially leading to arbitrary Remote Code Execution (RCE) and a sandbox escape in the highly privileged GPU process.
Suggested Reproduction Steps
Note: These are potential steps as our tooling agent does not have the ability to run code to verify.
OOB Read (Information Disclosure):
- From a compromised renderer, create a source texture (
src) and allocate multiple mip levels (e.g., levels 0 and 2) viaglTexImage2D. - Set the source texture’s base level to 2:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 2). - Create a destination texture (
dst) and allocate mip level 0. - Trigger the underflow via
glCopySubTextureCHROMIUM(src, 0, GL_TEXTURE_2D, dst, 0, 0, 0, 0, 0, 64, 64, GL_FALSE, GL_FALSE, GL_FALSE). This requests reading fromsrcat level 0 (which is now below the base level of 2). - Bind
dstto an FBO and callglReadPixelsto read back the exfiltrated OOB GPU memory.
OOB Write (RCE / Sandbox Escape):
- Reverse the setup: Create a destination texture (
dst), allocate levels 0 and 2, and set itsGL_TEXTURE_BASE_LEVELto 2. - Create a source texture (
src) with attacker-controlled payload data at level 0. - Call
glCopySubTextureCHROMIUM(src, 0, GL_TEXTURE_2D, dst, 0, 0, 0, 0, 0, 64, 64, GL_FALSE, GL_FALSE, GL_FALSE). This triggers an underflow for thedestLevel, causing the GPU to write the payload out-of-bounds.
Suggested Fix
The validation logic for the Chromium copy texture extensions must be hardened to respect the texture’s effective base level and max level.
In third_party/angle/src/libANGLE/validationES2.cpp:
- Update
IsValidCopyTextureSourceLevelto enforce thatlevel >= source->getTextureState().getEffectiveBaseLevel()andlevel <= source->getTextureState().getEffectiveMaxLevel(). - Update
IsValidCopyTextureDestinationLevelsimilarly for the destination texture. - Ensure that any generic checks (like
ValidMipLevel) do not override the strict requirements of the currently configured base/max levels when performing copies.
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.