CVE-2026-87650
Overview
Background
- ANGLE
- the graphics abstraction layer Chrome uses to translate WebGL/OpenGL ES calls onto the host platform’s native GL/D3D/Vulkan driver, running inside the GPU process.
- `GL_TEXTURE_BASE_LEVEL` / `GL_TEXTURE_MAX_LEVEL`
- per-texture integer parameters that select the lowest and highest mipmap levels the sampler is allowed to use.
- Mipmap level
- a downsized copy of a texture image indexed by an integer; a texture of size
Nhas onlylog2(N) + 1valid levels, so a level beyond that has no backing storage. - `texParameteri`
- the native driver entry point ANGLE calls to push a texture parameter down to the underlying GL driver.
Root Cause Analysis
In TextureGL, whenever WebGL set GL_TEXTURE_BASE_LEVEL or GL_TEXTURE_MAX_LEVEL, the value stored in ANGLE’s front-end state was forwarded verbatim to the native driver via functions->texParameteri — both in the deferred syncState path (the DIRTY_BIT_BASE_LEVEL / DIRTY_BIT_MAX_LEVEL switch cases) and in the eager setBaseLevel / setMaxLevel setters. ANGLE’s front-end permits arbitrarily large level values (the spec clamps sampling to the levels that actually exist, so a wild base/max level is legal to set), but the native driver trusts these numbers and uses them to index its per-level image arrays. The invariant violated is that the level handed to the driver must not exceed the largest mipmap level the texture’s dimensions can support (log2(maxTextureSize)); a value like 10000 walks the driver’s level table far past its end, producing a heap out-of-bounds read.
The fix adds a GetMaxMipmapLevel helper that computes that ceiling per texture type and clamps the value with std::min at every point where a level is pushed to the driver, so the driver only ever receives an in-range index.
log2(maxTextureSize) before the texParameteri call so the driver can no longer be steered out of bounds.Attack Path
- Create and populate a texture
Web content uses a WebGL2/ES3.2 context to bind a texture (e.g.
GL_TEXTURE_CUBE_MAP_ARRAY) and define level 0 with a small size such as2x2x6, so only level 1 is valid. - Set a wild level parameter
The page calls
glTexParameteri(..., GL_TEXTURE_BASE_LEVEL, 10000)and/orGL_TEXTURE_MAX_LEVEL, 10000; ANGLE accepts it without error and, pre-patch, records it unclamped inmAppliedBaseLevel/mAppliedMaxLevel. - Push the value to the driver
Either
setBaseLevel/setMaxLevelor theDIRTY_BIT_*cases in the sync path callfunctions->texParameteriwith the raw10000, so the native driver stores an out-of-range level. - Trigger a draw/sample Issuing a draw that samples the texture makes the driver index its per-level image array with the wild level, dereferencing memory past the array’s end — an out-of-bounds read (inferred sink: the OOB occurs inside the closed-source native GL driver, which the diff does not include).
Impact Assessment
(inferred); the advisory reports no in-the-wild exploitation.Files Changed
src/tests/gl_tests/TextureTest.cppsrc/libANGLE/renderer/gl/TextureGL.cpp
Audit Directions
- Front-end value passed to native driver as an indexAudit every ANGLE renderer backend (
gl,d3d,vulkan,metal) for texture/sampler parameters (base level, max level, LOD bias, layer counts) forwarded to the driver without a bound derived from the resource’s real dimensions. - Permissive front-end clamps sampling but not the set valueWhere a spec allows setting a parameter that is only clamped at sample time, verify the backend does not treat the stored value as a trusted array index into per-level or per-layer tables.
- Divergent eager vs deferred pathsWhen the same state is applied both immediately (setters) and lazily (
syncStatedirty bits), confirm both paths enforce identical validation, since this bug required patching both.
Patch
From 0e80263dfe758c132ec20c5ef649d587de3942f0 Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Thu, 16 Jul 2026 15:24:02 +0000
Subject: [PATCH] GL: Clamp BASE/MAX texture levels
Clamp base_level and max_level to device limits in TextureGL before
passing to the driver. This prevents driver crashes (heap OOB) when
wild values are set for these parameters. Handles 2D, 2DArray, 3D,
CubeMap, and CubeMapArray textures.
Bug: b/534912743
Test: angle_unittests, angle_end2end_tests --gtest_filter=*CubeMapArrayBaseLevelOutOfRange*
Change-Id: I8ca8c53a9902d3a9af40ccda98e792655a2d79da
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8110757
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
---
diff --git a/src/libANGLE/renderer/gl/TextureGL.cpp b/src/libANGLE/renderer/gl/TextureGL.cpp
index 44bbb71..be2ccee 100644
--- a/src/libANGLE/renderer/gl/TextureGL.cpp
+++ b/src/libANGLE/renderer/gl/TextureGL.cpp
@@ -7,6 +7,7 @@
// TextureGL.cpp: Implements the class methods for TextureGL.
#include "libANGLE/renderer/gl/TextureGL.h"
+#include "common/mathutil.h"
#include "common/unsafe_buffers.h"
#include "common/bitset_utils.h"
@@ -42,6 +43,23 @@
namespace
{
+GLuint GetMaxMipmapLevel(const gl::Caps &caps, gl::TextureType target)
+{
+ switch (target)
+ {
+ case gl::TextureType::_2D:
+ case gl::TextureType::_2DArray:
+ return static_cast<GLuint>(gl::log2(caps.max2DTextureSize));
+ case gl::TextureType::_3D:
+ return static_cast<GLuint>(gl::log2(caps.max3DTextureSize));
+ case gl::TextureType::CubeMap:
+ case gl::TextureType::CubeMapArray:
+ return static_cast<GLuint>(gl::log2(caps.maxCubeMapTextureSize));
+ default:
+ return 0u;
+ }
+}
+
// For use with the uploadTextureDataInChunks feature. See http://crbug.com/1181068
constexpr const size_t kUploadTextureDataInChunksUploadSize = (120 * 1024) - 1;
@@ -2055,21 +2073,31 @@
&mAppliedSwizzle.swizzleAlpha));
break;
case gl::Texture::DIRTY_BIT_BASE_LEVEL:
- if (mAppliedBaseLevel != mState.getEffectiveBaseLevel())
{
- mAppliedBaseLevel = mState.getEffectiveBaseLevel();
- ANGLE_GL_TRY(context, functions->texParameteri(
- nativegl::GetTextureBindingTarget(getType()),
- GL_TEXTURE_BASE_LEVEL, mAppliedBaseLevel));
+ const GLuint maxLevelLimit = GetMaxMipmapLevel(context->getCaps(), getType());
+ const GLuint clampedBaseLevel =
+ std::min(mState.getEffectiveBaseLevel(), maxLevelLimit);
+ if (mAppliedBaseLevel != clampedBaseLevel)
+ {
+ mAppliedBaseLevel = clampedBaseLevel;
+ ANGLE_GL_TRY(context, functions->texParameteri(
+ nativegl::GetTextureBindingTarget(getType()),
+ GL_TEXTURE_BASE_LEVEL, mAppliedBaseLevel));
+ }
}
break;
case gl::Texture::DIRTY_BIT_MAX_LEVEL:
- if (mAppliedMaxLevel != mState.getEffectiveMaxLevel())
{
- mAppliedMaxLevel = mState.getEffectiveMaxLevel();
- ANGLE_GL_TRY(context, functions->texParameteri(
- nativegl::GetTextureBindingTarget(getType()),
- GL_TEXTURE_MAX_LEVEL, mAppliedMaxLevel));
+ const GLuint maxLevelLimit = GetMaxMipmapLevel(context->getCaps(), getType());
+ const GLuint clampedMaxLevel =
+ std::min(mState.getEffectiveMaxLevel(), maxLevelLimit);
+ if (mAppliedMaxLevel != clampedMaxLevel)
+ {
+ mAppliedMaxLevel = clampedMaxLevel;
+ ANGLE_GL_TRY(context, functions->texParameteri(
+ nativegl::GetTextureBindingTarget(getType()),
+ GL_TEXTURE_MAX_LEVEL, mAppliedMaxLevel));
+ }
}
break;
case gl::Texture::DIRTY_BIT_DEPTH_STENCIL_TEXTURE_MODE:
@@ -2124,12 +2152,14 @@
angle::Result TextureGL::setBaseLevel(const gl::Context *context, GLuint baseLevel)
{
- if (baseLevel != mAppliedBaseLevel)
+ const GLuint maxLevelLimit = GetMaxMipmapLevel(context->getCaps(), getType());
+ const GLuint clampedBaseLevel = std::min(baseLevel, maxLevelLimit);
+ if (clampedBaseLevel != mAppliedBaseLevel)
{
const FunctionsGL *functions = GetFunctionsGL(context);
StateManagerGL *stateManager = GetStateManagerGL(context);
- mAppliedBaseLevel = baseLevel;
+ mAppliedBaseLevel = clampedBaseLevel;
mLocalDirtyBits.set(gl::Texture::DIRTY_BIT_BASE_LEVEL);
// Signal to the GL layer that the Impl has dirty bits.
@@ -2137,27 +2167,29 @@
stateManager->bindTexture(getType(), mTextureID);
ANGLE_GL_TRY(context, functions->texParameteri(ToGLenum(getType()), GL_TEXTURE_BASE_LEVEL,
- baseLevel));
+ clampedBaseLevel));
}
return angle::Result::Continue;
}
angle::Result TextureGL::setMaxLevel(const gl::Context *context, GLuint maxLevel)
{
- if (maxLevel != mAppliedMaxLevel)
+ const GLuint maxLevelLimit = GetMaxMipmapLevel(context->getCaps(), getType());
+ const GLuint clampedMaxLevel = std::min(maxLevel, maxLevelLimit);
+ if (clampedMaxLevel != mAppliedMaxLevel)
{
const FunctionsGL *functions = GetFunctionsGL(context);
StateManagerGL *stateManager = GetStateManagerGL(context);
- mAppliedMaxLevel = maxLevel;
+ mAppliedMaxLevel = clampedMaxLevel;
mLocalDirtyBits.set(gl::Texture::DIRTY_BIT_MAX_LEVEL);
// Signal to the GL layer that the Impl has dirty bits.
onStateChange(angle::SubjectMessage::DirtyBitsFlagged);
stateManager->bindTexture(getType(), mTextureID);
- ANGLE_GL_TRY(context,
- functions->texParameteri(ToGLenum(getType()), GL_TEXTURE_MAX_LEVEL, maxLevel));
+ ANGLE_GL_TRY(context, functions->texParameteri(ToGLenum(getType()), GL_TEXTURE_MAX_LEVEL,
+ clampedMaxLevel));
}
return angle::Result::Continue;
}
@@ -2297,7 +2329,7 @@
mAppliedSampler = gl::SamplerState::CreateDefaultForTarget(getType());
mAppliedBaseLevel = 0;
- mAppliedBaseLevel = gl::kInitialMaxLevel;
+ mAppliedMaxLevel = gl::kInitialMaxLevel;
mLocalDirtyBits = mAllModifiedDirtyBits;
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 8ebcd4d..579c4be 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -16639,6 +16639,55 @@
EXPECT_GL_NO_ERROR();
}
+// Test that cube map array texture base level and max level are clamped.
+TEST_P(TextureCubeTestES32, CubeMapArrayBaseLevelOutOfRange)
+{
+ // Create a program with samplerCubeArray.
+ const char *essl32_fs =
+ R"(#version 320 es
+ precision highp float;
+ precision highp samplerCubeArray;
+ out vec4 my_FragColor;
+ uniform samplerCubeArray texCubeArray;
+ void main()
+ {
+ my_FragColor = texture(texCubeArray, vec4(0.0, 0.0, 0.0, 0.0));
+ })";
+
+ const char *essl32_vs =
+ R"(#version 320 es
+ in vec4 position;
+ void main()
+ {
+ gl_Position = position;
+ })";
+
+ ANGLE_GL_PROGRAM(program, essl32_vs, essl32_fs);
+ glUseProgram(program);
+ GLint textureLocation = glGetUniformLocation(program, "texCubeArray");
+ ASSERT_NE(-1, textureLocation);
+ glUniform1i(textureLocation, 0);
+
+ GLTexture cubeMapArrayTexture;
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_CUBE_MAP_ARRAY, cubeMapArrayTexture);
+
+ // Define level 0.
+ glTexImage3D(GL_TEXTURE_CUBE_MAP_ARRAY, 0, GL_RGBA8, 2, 2, 6, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ nullptr);
+
+ // Set wild base/max level.
+ glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_BASE_LEVEL, 10000);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_MAX_LEVEL, 10000);
+
+ EXPECT_GL_NO_ERROR();
+
+ // Draw to trigger syncState.
+ drawQuad(program, "position", 0.5f);
+
+ EXPECT_GL_NO_ERROR();
+}
+
// Tests defining a cube map array texture using glTexStorage3D() and filling all levels using
// glTexSubImage3D().
TEST_P(TextureCubeTestES32, ValidateCubeMapArrayTexStorage)
Regression Test / PoC
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 8ebcd4d..579c4be 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -16639,6 +16639,55 @@
EXPECT_GL_NO_ERROR();
}
+// Test that cube map array texture base level and max level are clamped.
+TEST_P(TextureCubeTestES32, CubeMapArrayBaseLevelOutOfRange)
+{
+ // Create a program with samplerCubeArray.
+ const char *essl32_fs =
+ R"(#version 320 es
+ precision highp float;
+ precision highp samplerCubeArray;
+ out vec4 my_FragColor;
+ uniform samplerCubeArray texCubeArray;
+ void main()
+ {
+ my_FragColor = texture(texCubeArray, vec4(0.0, 0.0, 0.0, 0.0));
+ })";
+
+ const char *essl32_vs =
+ R"(#version 320 es
+ in vec4 position;
+ void main()
+ {
+ gl_Position = position;
+ })";
+
+ ANGLE_GL_PROGRAM(program, essl32_vs, essl32_fs);
+ glUseProgram(program);
+ GLint textureLocation = glGetUniformLocation(program, "texCubeArray");
+ ASSERT_NE(-1, textureLocation);
+ glUniform1i(textureLocation, 0);
+
+ GLTexture cubeMapArrayTexture;
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_CUBE_MAP_ARRAY, cubeMapArrayTexture);
+
+ // Define level 0.
+ glTexImage3D(GL_TEXTURE_CUBE_MAP_ARRAY, 0, GL_RGBA8, 2, 2, 6, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ nullptr);
+
+ // Set wild base/max level.
+ glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_BASE_LEVEL, 10000);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_MAX_LEVEL, 10000);
+
+ EXPECT_GL_NO_ERROR();
+
+ // Draw to trigger syncState.
+ drawQuad(program, "position", 0.5f);
+
+ EXPECT_GL_NO_ERROR();
+}
+
// Tests defining a cube map array texture using glTexStorage3D() and filling all levels using
// glTexSubImage3D().
TEST_P(TextureCubeTestES32, ValidateCubeMapArrayTexStorage)
Original Bug Report
IMG: OOB heap read via copyTexImage2D and TEXTURE_BASE_LEVEL
- Attack surface: WebGL2 texture state and
copyTexImage2D, reachable from an untrusted web page in the default Chrome for Android configuration. - Impact: Heap out-of-bounds read in the unsandboxed Chrome GPU process, convertible to an out-of-bounds write with adjacent-allocation grooming.
Android internal bug: b/533551753