High watch CVSS 9.6 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in WebGL
DescriptionOut of bounds read in WebGL
ComponentWebGL
Sub-componentGL
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker534912743
Fix commit0e80263dfe75 (angle/angle) +99/-18
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-14 (per vendor advisory)
Fix landed2026-08-04 (public git)
Disclosed2026-09-08
Patch gap35 days — fix public in source before the advisory

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 N has only log2(N) + 1 valid 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.

Key insight
The single mistake was forwarding an unvalidated, attacker-controllable mipmap level straight to the native driver, which trusts it as an array index; the fix clamps every such value to log2(maxTextureSize) before the texParameteri call so the driver can no longer be steered out of bounds.

Attack Path

  1. 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 as 2x2x6, so only level 1 is valid.
  2. Set a wild level parameter The page calls glTexParameteri(..., GL_TEXTURE_BASE_LEVEL, 10000) and/or GL_TEXTURE_MAX_LEVEL, 10000; ANGLE accepts it without error and, pre-patch, records it unclamped in mAppliedBaseLevel / mAppliedMaxLevel.
  3. Push the value to the driver Either setBaseLevel / setMaxLevel or the DIRTY_BIT_* cases in the sync path call functions->texParameteri with the raw 10000, so the native driver stores an out-of-range level.
  4. 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

An attacker who can run WebGL from a web page gains an out-of-bounds read inside the GPU process, where ANGLE and the native GL driver execute. The advisory classifies this as an out-of-bounds read in WebGL; realizing it requires only a standard WebGL2/ES3.2 context and setting an oversized base/max texture level, with no special permissions. Whether the OOB read can be turned into a usable cross-origin information leak versus a GPU-process crash is not shown by the patch and is (inferred); the advisory reports no in-the-wild exploitation.

Files Changed

  • src/tests/gl_tests/TextureTest.cpp
  • src/libANGLE/renderer/gl/TextureGL.cpp

Audit Directions

  • Front-end value passed to native driver as an index
    Audit 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 value
    Where 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 paths
    When the same state is applied both immediately (setters) and lazily (syncState dirty bits), confirm both paths enforce identical validation, since this bug required patching both.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by xi...@google.com

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

View on issue tracker