Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in WebGL
DescriptionOut of bounds write in WebGL
ComponentWebGL
Sub-componentGL
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker548127218
Fix commit107da744f62a (angle/angle) +439/-0
CWECWE-787 (Out-of-bounds write)
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-08-18 (per vendor advisory)
Fix landed2026-08-28 (public git)
Disclosed2026-09-08
Patch gap11 days — fix public in source before the advisory

Background

ANGLE
Chrome’s translation layer that implements OpenGL ES / WebGL on top of native graphics APIs, here targeting a native desktop/mobile GL backend.
Mip level
one image in a texture’s mipmap chain, whose dimensions are normally constrained by level 0 per the OpenGL ES rules (ceilPow2(level0) >> level).
Oversized level
a non-zero mip level whose supplied width/height exceed the size implied by level 0, violating the ES mipmap sizing rules.
PBO (pixel unpack buffer)
a GPU-side buffer bound to GL_PIXEL_UNPACK_BUFFER from which the driver reads pixel data, routing an upload through a driver path distinct from immediate client-memory upload.

Root Cause Analysis

The vulnerable path is TextureGL::setImageHelper in TextureGL.cpp, which forwarded a client texImage2D/texSubImage2D for a non-zero mip level straight into the driver’s immediate software upload. When level 0 of a _2D texture is already defined and the application supplies an “oversized” non-zero level — one whose size.width/size.height exceed the slot dimensions max(1, ceilPow2(level0.size.width) >> level) / max(1, ceilPow2(level0.size.height) >> level) — certain drivers (PowerVR) size their destination allocation from level 0’s implied dimensions but copy using the larger client-supplied dimensions. The violated invariant is that a mip level’s dimensions must not exceed what level 0 dictates, so the driver’s write ran past the allocated backing store, producing an out-of-bounds write.

The fix detects this oversized-level condition in setImageHelper (gated on the new uploadOversizedMipLevelsViaUnpackBuffer feature, _2D type, level > 0, no bound unpackBuffer, and no depth/stencil format) and reroutes the upload to setImageViaScratchUnpackBuffer. That helper allocates a scratch GL_PIXEL_UNPACK_BUFFER of the exact expected uploadBytes, binds it, and issues the texImage2D with a nullptr data pointer so the driver reads from the PBO through a path that handles the sizing safely.

Key insight
The single mistake is trusting client-supplied, spec-violating dimensions for a non-zero mip level on the immediate upload path, where the driver allocates from level 0 but writes using the larger dimensions. The fix stages such oversized uploads through a scratch pixel unpack buffer, whose driver path bounds the write correctly instead of overrunning the allocation.

Attack Path

  1. Define level 0 A malicious WebGL page creates a TEXTURE_2D and defines mip level 0 with modest dimensions, establishing the texture’s expected storage footprint.
  2. Supply an oversized non-zero level The page calls texImage2D for level > 0 with width/height larger than the ceilPow2(level0) >> level slot, violating the ES mipmap rules.
  3. Reach the immediate upload With no unpack buffer bound and a color (non depth/stencil) format, setImageHelper forwarded the call directly to the driver’s software upload.
  4. Trigger the driver overrun On an affected (PowerVR) driver, the destination is sized from level 0 but written using the oversized dimensions, causing an out-of-bounds write in GPU-process memory.

Impact Assessment

An attacker who can run WebGL content gains an out-of-bounds write inside the GPU process (via ANGLE’s GL backend on affected drivers), corrupting memory beyond a texture’s allocation. The only precondition is the ability to issue WebGL texture-upload calls plus an affected driver stack; no special privileges are required. Such GPU-process memory corruption is a strong primitive toward sandbox escape or further exploitation.

Changed Functions

FunctionChangeNotes
Texture2DTestES3_OversizedMipLevels
src/tests/gl_tests/TextureTest.cpp
modified

Files Changed

  • src/tests/gl_tests/TextureTest.cpp
  • src/libANGLE/renderer/gl/TextureGL.h
  • util/autogen/angle_features_autogen.h
  • src/libANGLE/renderer/gl/renderergl_utils.cpp
  • include/platform/autogen/FeaturesGL_autogen.h
  • util/autogen/angle_features_autogen.cpp
  • src/tests/angle_end2end_tests_expectations.txt
  • src/libANGLE/renderer/gl/TextureGL.cpp
  • include/platform/gl_features.json

Audit Directions

  • Client-supplied texture dimensions
    Audit every backend upload path where per-level width/height/imageSize from the application flow into a native texImage/compressedTexImage call, and confirm they are validated against the storage implied by level 0 rather than trusted.
  • Immediate vs. PBO upload divergence
    Review other cases distinguishing immediate client-memory uploads from pixel-unpack-buffer uploads, since driver bugs are frequently isolated to only one path and workarounds may be missing for _3D, cube-map, or compressed variants.
  • Driver-specific workaround gating
    Check that feature flags like uploadOversizedMipLevelsViaUnpackBuffer cover all drivers exhibiting the sizing bug and that excluded conditions (depth/stencil formats, bound unpackBuffer, level == 0) do not leave an equivalent overrun reachable.
From 107da744f62a319b3c6851694740d9ebf247d048 Mon Sep 17 00:00:00 2001
From: Ken Russell <[email protected]>
Date: Thu, 27 Aug 2026 16:34:18 -0700
Subject: [PATCH] GL: upload oversized mip levels through PBO.

Add a workaround for the following scenario: if uploading data to a
texture's mipmap level which is "oversized" compared to level 0 - in
other words, larger than the OpenGL ES specification's mipmap rules -
then upload the data via a pixel unpack buffer. Apply this workaround
to PowerVR drivers.

Due to another driver bug, the new workaround can not completely solve
the issue for compressed textures; modify the new tests to accommodate
this fact. It is believed this will not affect applications in the
field.

Bug: chromium:548127218
Change-Id: If4e2e8fceb6a77897b50fa2c7c932f0e2c5c5ce1
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8305515
Auto-Submit: Kenneth Russell <[email protected]>
Reviewed-by: Zhenyao Mo <[email protected]>
Reviewed-by: Geoff Lang <[email protected]>
Commit-Queue: Geoff Lang <[email protected]>
---
diff --git a/include/platform/gl_features.json b/include/platform/gl_features.json
index 9bac71d..276028d 100644
--- a/include/platform/gl_features.json
+++ b/include/platform/gl_features.json
@@ -1021,6 +1021,15 @@
             "issue": "http://crbug.com/499602793"
         },
         {
+            "name": "upload_oversized_mip_levels_via_unpack_buffer",
+            "category": "Workarounds",
+            "description": [
+                "Stage oversized nonzero-level texture definitions through a scratch pixel unpack buffer ",
+                "to work around driver bugs."
+            ],
+            "issue": "http://crbug.com/548127218"
+        },
+        {
             "name": "validate_state",
             "category": "Features",
             "description": [
diff --git a/src/libANGLE/renderer/gl/TextureGL.cpp b/src/libANGLE/renderer/gl/TextureGL.cpp
index 25f2644..3b3a0f9 100644
--- a/src/libANGLE/renderer/gl/TextureGL.cpp
+++ b/src/libANGLE/renderer/gl/TextureGL.cpp
@@ -226,6 +226,30 @@
     gl::TextureTarget target = index.getTarget();
     size_t level             = static_cast<size_t>(index.getLevelIndex());
 
+    // Oversized nonzero-level definitions may cause driver issues during immediate software
+    // texture upload when level 0 is already defined. Stage them through a scratch unpack buffer
+    // so the driver handles the upload safely.
+    if (features.uploadOversizedMipLevelsViaUnpackBuffer.enabled &&
+        getType() == gl::TextureType::_2D && level > 0 && unpackBuffer == nullptr &&
+        gl::GetInternalFormatInfo(internalFormat, type).depthBits == 0 &&
+        gl::GetInternalFormatInfo(internalFormat, type).stencilBits == 0)
+    {
+        const gl::ImageDesc &level0 = mState.getImageDesc(gl::TextureTarget::_2D, 0);
+        if (level0.size.width != 0 && level0.size.height != 0)
+        {
+            const int slotW =
+                std::max(1, static_cast<int>(gl::ceilPow2(level0.size.width)) >> level);
+            const int slotH =
+                std::max(1, static_cast<int>(gl::ceilPow2(level0.size.height)) >> level);
+            if (size.width > slotW || size.height > slotH)
+            {
+                return setImageViaScratchUnpackBuffer(context, target, level, internalFormat, size,
+                                                      format, type, unpack, /*isCompressed=*/false,
+                                                      /*imageSize=*/0, pixels);
+            }
+        }
+    }
+
     if (features.unpackOverlappingRowsSeparatelyUnpackBuffer.enabled && unpackBuffer &&
         unpack.rowLength != 0 && unpack.rowLength < size.width)
     {
@@ -371,6 +395,102 @@
     return angle::Result::Continue;
 }
 
+angle::Result TextureGL::setImageViaScratchUnpackBuffer(const gl::Context *context,
+                                                        gl::TextureTarget target,
+                                                        size_t level,
+                                                        GLenum internalFormat,
+                                                        const gl::Extents &size,
+                                                        GLenum format,
+                                                        GLenum type,
+                                                        const gl::PixelUnpackState &unpack,
+                                                        bool isCompressed,
+                                                        size_t imageSize,
+                                                        const uint8_t *pixels)
+{
+    ContextGL *contextGL              = GetImplAs<ContextGL>(context);
+    const FunctionsGL *functions      = GetFunctionsGL(context);
+    StateManagerGL *stateManager      = GetStateManagerGL(context);
+    const angle::FeaturesGL &features = GetFeaturesGL(context);
+
+    if (features.reattachFboDepthStencilOnReallocation.enabled)
+    {
+        onStateChange(angle::SubjectMessage::ObjectReallocated);
+    }
+
+    GLuint uploadBytes = 0;
+    if (isCompressed)
+    {
+        uploadBytes = static_cast<GLuint>(imageSize);
+    }
+    else
+    {
+        ANGLE_CHECK_GL_MATH(contextGL, gl::GetInternalFormatInfo(format, type)
+                                           .computePackUnpackEndByte(type, size, unpack,
+                                                                     /*is3D=*/false, &uploadBytes));
+    }
+
+    GLuint scratch = 0;
+    functions->genBuffers(1, &scratch);
+    stateManager->bindBuffer(gl::BufferBinding::PixelUnpack, scratch);
+    // Regardless of whether the user supplied data (pixels != nullptr), the pixel unpack buffer
+    // must be allocated with the expected amount of data.
+    if (uploadBytes > 0)
+    {
+        ANGLE_GL_TRY(context, functions->bufferData(GL_PIXEL_UNPACK_BUFFER, uploadBytes, pixels,
+                                                    GL_STREAM_DRAW));
+    }
+
+    ANGLE_TRY(stateManager->setPixelUnpackState(context, unpack));
+
+    stateManager->bindTexture(getType(), mTextureID);
+
+    if (features.resetTexImage2DBaseLevel.enabled)
+    {
+        (void)setBaseLevel(context, 0);
+    }
+
+    if (isCompressed)
+    {
+        const gl::InternalFormat &originalInternalFormatInfo =
+            gl::GetSizedInternalFormatInfo(internalFormat);
+        nativegl::CompressedTexImageFormat compressedTexImageFormat =
+            nativegl::GetCompressedTexImageFormat(functions, features, internalFormat);
+
+        ANGLE_GL_TRY_ALWAYS_CHECK(
+            context, functions->compressedTexImage2D(
+                         nativegl::GetTextureBindingTarget(target), static_cast<GLint>(level),
+                         compressedTexImageFormat.internalFormat, size.width, size.height, 0,
+                         static_cast<GLsizei>(uploadBytes), nullptr));
+
+        LevelInfoGL levelInfo = GetLevelInfo(features, originalInternalFormatInfo,
+                                             compressedTexImageFormat.internalFormat);
+        ASSERT(!levelInfo.lumaWorkaround.enabled);
+        setLevelInfo(context, target, level, 1, levelInfo);
+    }
+    else
+    {
+        const gl::InternalFormat &originalInternalFormatInfo =
+            gl::GetInternalFormatInfo(internalFormat, type);
+        nativegl::TexImageFormat texImageFormat =
+            nativegl::GetTexImageFormat(functions, features, internalFormat, format, type);
+
+        ANGLE_GL_TRY_ALWAYS_CHECK(
+            context, functions->texImage2D(nativegl::GetTextureBindingTarget(target),
+                                           static_cast<GLint>(level), texImageFormat.internalFormat,
+                                           size.width, size.height, 0, texImageFormat.format,
+                                           texImageFormat.type, nullptr));
+
+        LevelInfoGL levelInfo =
+            GetLevelInfo(features, originalInternalFormatInfo, texImageFormat.internalFormat);
+        setLevelInfo(context, target, level, 1, levelInfo);
+    }
+
+    stateManager->deleteBuffer(scratch);
+
+    contextGL->markWorkSubmitted();
+    return angle::Result::Continue;
+}
+
 angle::Result TextureGL::reserveTexImageToBeFilled(const gl::Context *context,
                                                    gl::TextureTarget target,
                                                    size_t level,
@@ -713,6 +833,31 @@
     size_t level             = static_cast<size_t>(index.getLevelIndex());
     ASSERT(TextureTargetToType(target) == getType());
 
+    // Oversized nonzero-level definitions may cause driver issues during immediate software
+    // texture upload when level 0 is already defined. Stage them through a scratch unpack buffer
+    // so the driver handles the upload safely.
+    if (features.uploadOversizedMipLevelsViaUnpackBuffer.enabled &&
+        getType() == gl::TextureType::_2D && level > 0 &&
+        context->getState().getTargetBuffer(gl::BufferBinding::PixelUnpack) == nullptr &&
+        gl::GetSizedInternalFormatInfo(internalFormat).depthBits == 0 &&
+        gl::GetSizedInternalFormatInfo(internalFormat).stencilBits == 0)
+    {
+        const gl::ImageDesc &level0 = mState.getImageDesc(gl::TextureTarget::_2D, 0);
+        if (level0.size.width != 0 && level0.size.height != 0)
+        {
+            const int slotW =
+                std::max(1, static_cast<int>(gl::ceilPow2(level0.size.width)) >> level);
+            const int slotH =
+                std::max(1, static_cast<int>(gl::ceilPow2(level0.size.height)) >> level);
+            if (size.width > slotW || size.height > slotH)
+            {
+                return setImageViaScratchUnpackBuffer(context, target, level, internalFormat, size,
+                                                      /*format=*/GL_NONE, /*type=*/GL_NONE, unpack,
+                                                      /*isCompressed=*/true, imageSize, pixels);
+            }
+        }
+    }
+
     const gl::InternalFormat &originalInternalFormatInfo =
         gl::GetSizedInternalFormatInfo(internalFormat);
     nativegl::CompressedTexImageFormat compressedTexImageFormat =
diff --git a/src/libANGLE/renderer/gl/TextureGL.h b/src/libANGLE/renderer/gl/TextureGL.h
index 1162f4c..788d26c 100644
--- a/src/libANGLE/renderer/gl/TextureGL.h
+++ b/src/libANGLE/renderer/gl/TextureGL.h
@@ -244,6 +244,17 @@
                                  GLenum format,
                                  GLenum type,
                                  const uint8_t *pixels);
+    angle::Result setImageViaScratchUnpackBuffer(const gl::Context *context,
+                                                 gl::TextureTarget target,
+                                                 size_t level,
+                                                 GLenum internalFormat,
+                                                 const gl::Extents &size,
+                                                 GLenum format,
+                                                 GLenum type,
+                                                 const gl::PixelUnpackState &unpack,
+                                                 bool isCompressed,
+                                                 size_t imageSize,
+                                                 const uint8_t *pixels);
     // This changes the current pixel unpack state that will have to be reapplied.
     angle::Result reserveTexImageToBeFilled(const gl::Context *context,
                                             gl::TextureTarget target,
diff --git a/src/libANGLE/renderer/gl/renderergl_utils.cpp b/src/libANGLE/renderer/gl/renderergl_utils.cpp
index b000e97..8b20945 100644
--- a/src/libANGLE/renderer/gl/renderergl_utils.cpp
+++ b/src/libANGLE/renderer/gl/renderergl_utils.cpp
@@ -2326,6 +2326,10 @@
     ANGLE_FEATURE_CONDITION(features, splitLevel0PboFullSubImage2D,
                             isPowerVRDriver && powerVRVersion < (std::array<int, 2>{26, 2}));
 
+    // TODO(crbug.com/548127218): conditionalize this workaround on PowerVR
+    // driver version.
+    ANGLE_FEATURE_CONDITION(features, uploadOversizedMipLevelsViaUnpackBuffer, isPowerVRDriver);
+
     ANGLE_FEATURE_CONDITION(features, initializeCurrentVertexAttributes, isNvidia);
 
     ANGLE_FEATURE_CONDITION(features, unpackLastRowSeparatelyForPaddingInclusion,
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index cc11e10..e5c12f8 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2916,6 +2916,12 @@
 549916890 WIN INTEL OPENGL : Texture3DIncreaseDepthTestES3.TexImage3DDepthIncreaseOutsideRangeStagedVerifyByDraw/* = SKIP
 549916890 WIN INTEL OPENGL : Texture3DIncreaseDepthTestES3.TexImage3DDepthIncreaseOutsideRangeStagedVerifyByReadPixels/* = SKIP
 
+// New tests failing on Windows Intel OpenGL
+548127218 WIN INTEL OPENGL : Texture2DTestES3_OversizedMipLevels.Uncompressed/* = SKIP
+548127218 WIN INTEL OPENGL : Texture2DTestES3_OversizedMipLevels.CompressedASTC/* = SKIP
+548127218 WIN INTEL OPENGL : Texture2DTestES3_OversizedMipLevels.CompressedDXT/* = SKIP
+548127218 WIN INTEL OPENGL : Texture2DTestES3_OversizedMipLevels.CompressedETC/* = SKIP
+
 // Various crashes when using Vulkan on Win/ARM64.
 545676921 WIN QUALCOMM VULKAN : GLSLTest_ES31.MixedRowAndColumnMajorMatrices/* = SKIP
 545676921 WIN QUALCOMM VULKAN : MSRTTES3Test.CubeMap/* = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index a7c77fd..51b6704 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -23810,6 +23810,256 @@
     }
 }
 
+class Texture2DTestES3_OversizedMipLevels : public Texture2DTestES3
+{
+  protected:
+    Texture2DTestES3_OversizedMipLevels() : Texture2DTestES3() {}
+
+    void testSetUp() override
+    {
+        Texture2DTestES3::testSetUp();
+        setUpProgram();
+        glUseProgram(mProgram);
+        glUniform1i(mTexture2DUniformLocation, 0);
+        glActiveTexture(GL_TEXTURE0);
+    }
+};
+
+// Test that defining an oversized nonzero mip level on an uncompressed texture succeeds and
+// renders correctly.
+TEST_P(Texture2DTestES3_OversizedMipLevels, Uncompressed)
+{
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Upload Level 0: 64x64 solid red.
+    constexpr int kLevel0Width  = 64;
+    constexpr int kLevel0Height = 64;
+    std::vector<GLColor> redData(kLevel0Width * kLevel0Height, GLColor::red);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kLevel0Width, kLevel0Height, 0, GL_RGBA,
+                 GL_UNSIGNED_BYTE, redData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Upload Level 1: 128x128 solid green (oversized relative to Level 0).
+    constexpr int kLevel1Width  = 128;
+    constexpr int kLevel1Height = 128;
+    std::vector<GLColor> greenData(kLevel1Width * kLevel1Height, GLColor::green);
+    glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kLevel1Width, kLevel1Height, 0, GL_RGBA,
+                 GL_UNSIGNED_BYTE, greenData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Verify Level 0 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::red);
+
+    // Verify Level 1 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::green);
+}
+
+// Test that defining an oversized nonzero mip level on an ETC compressed texture succeeds and
+// renders correctly.
+TEST_P(Texture2DTestES3_OversizedMipLevels, CompressedETC)
+{
+    const bool hasEtcExt = IsGLExtensionEnabled("GL_OES_compressed_ETC2_RGB8_texture");
+    const int clientVer  = getClientMajorVersion();
+    ANGLE_SKIP_TEST_IF(!hasEtcExt && clientVer < 3);
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // ETC2 RGB8: 8 bytes per 4x4 block.
+    // Level 0: 64x64 (256 blocks = 2048 bytes) solid red.
+    // Byte 0 = 0xFF (R1=15, R2=15), Bytes 1..7 = 0x00. Decodes to (255, 2, 2, 255).
+    constexpr int kLevel0Width     = 64;
+    constexpr int kLevel0Height    = 64;
+    constexpr size_t kLevel0Blocks = (kLevel0Width / 4) * (kLevel0Height / 4);
+    constexpr size_t kLevel0Bytes  = kLevel0Blocks * 8;
+    std::vector<uint8_t> redEtcData(kLevel0Bytes, 0);
+    for (size_t b = 0; b < kLevel0Blocks; ++b)
+    {
+        redEtcData[b * 8 + 0] = 0xFF;
+    }
+    glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_COMPRESSED_RGB8_ETC2, kLevel0Width, kLevel0Height,
+                           0, static_cast<GLsizei>(kLevel0Bytes), redEtcData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Level 1: 128x128 (1024 blocks = 8192 bytes) solid green.
+    // Byte 1 = 0xFF (G1=15, G2=15), Bytes 0, 2..7 = 0x00. Decodes to (2, 255, 2, 255).
+    constexpr int kLevel1Width     = 128;
+    constexpr int kLevel1Height    = 128;
+    constexpr size_t kLevel1Blocks = (kLevel1Width / 4) * (kLevel1Height / 4);
+    constexpr size_t kLevel1Bytes  = kLevel1Blocks * 8;
+    std::vector<uint8_t> greenEtcData(kLevel1Bytes, 0);
+    for (size_t b = 0; b < kLevel1Blocks; ++b)
+    {
+        greenEtcData[b * 8 + 1] = 0xFF;
+    }
+    glCompressedTexImage2D(GL_TEXTURE_2D, 1, GL_COMPRESSED_RGB8_ETC2, kLevel1Width, kLevel1Height,
+                           0, static_cast<GLsizei>(kLevel1Bytes), greenEtcData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Verify Level 0 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor(255, 2, 2, 255));
+
+    // Verify Level 1 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    // Bugs in the PowerVR driver prevent full redefinition of oversized compressed mip levels
+    // when level 0 is already defined, because the driver restricts compressed level 1 storage
+    // to the slot allocated by the level 0 chain. Check only the lower-left quadrant of the
+    // rendered output.
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth() / 4, getWindowHeight() / 4,
+                         GLColor(2, 255, 2, 255));
+}
+
+// Test that defining an oversized nonzero mip level on an ASTC compressed texture succeeds and
+// renders correctly.
+TEST_P(Texture2DTestES3_OversizedMipLevels, CompressedASTC)
+{
+    const bool hasAstcLdr = IsGLExtensionEnabled("GL_KHR_texture_compression_astc_ldr");
+    const bool hasAstcOes = IsGLExtensionEnabled("GL_OES_texture_compression_astc");
+    ANGLE_SKIP_TEST_IF(!hasAstcLdr && !hasAstcOes);
+
+    GLTexture tex;
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index cc11e10..e5c12f8 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2916,6 +2916,12 @@
 549916890 WIN INTEL OPENGL : Texture3DIncreaseDepthTestES3.TexImage3DDepthIncreaseOutsideRangeStagedVerifyByDraw/* = SKIP
 549916890 WIN INTEL OPENGL : Texture3DIncreaseDepthTestES3.TexImage3DDepthIncreaseOutsideRangeStagedVerifyByReadPixels/* = SKIP
 
+// New tests failing on Windows Intel OpenGL
+548127218 WIN INTEL OPENGL : Texture2DTestES3_OversizedMipLevels.Uncompressed/* = SKIP
+548127218 WIN INTEL OPENGL : Texture2DTestES3_OversizedMipLevels.CompressedASTC/* = SKIP
+548127218 WIN INTEL OPENGL : Texture2DTestES3_OversizedMipLevels.CompressedDXT/* = SKIP
+548127218 WIN INTEL OPENGL : Texture2DTestES3_OversizedMipLevels.CompressedETC/* = SKIP
+
 // Various crashes when using Vulkan on Win/ARM64.
 545676921 WIN QUALCOMM VULKAN : GLSLTest_ES31.MixedRowAndColumnMajorMatrices/* = SKIP
 545676921 WIN QUALCOMM VULKAN : MSRTTES3Test.CubeMap/* = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index a7c77fd..51b6704 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -23810,6 +23810,256 @@
     }
 }
 
+class Texture2DTestES3_OversizedMipLevels : public Texture2DTestES3
+{
+  protected:
+    Texture2DTestES3_OversizedMipLevels() : Texture2DTestES3() {}
+
+    void testSetUp() override
+    {
+        Texture2DTestES3::testSetUp();
+        setUpProgram();
+        glUseProgram(mProgram);
+        glUniform1i(mTexture2DUniformLocation, 0);
+        glActiveTexture(GL_TEXTURE0);
+    }
+};
+
+// Test that defining an oversized nonzero mip level on an uncompressed texture succeeds and
+// renders correctly.
+TEST_P(Texture2DTestES3_OversizedMipLevels, Uncompressed)
+{
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Upload Level 0: 64x64 solid red.
+    constexpr int kLevel0Width  = 64;
+    constexpr int kLevel0Height = 64;
+    std::vector<GLColor> redData(kLevel0Width * kLevel0Height, GLColor::red);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kLevel0Width, kLevel0Height, 0, GL_RGBA,
+                 GL_UNSIGNED_BYTE, redData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Upload Level 1: 128x128 solid green (oversized relative to Level 0).
+    constexpr int kLevel1Width  = 128;
+    constexpr int kLevel1Height = 128;
+    std::vector<GLColor> greenData(kLevel1Width * kLevel1Height, GLColor::green);
+    glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kLevel1Width, kLevel1Height, 0, GL_RGBA,
+                 GL_UNSIGNED_BYTE, greenData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Verify Level 0 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::red);
+
+    // Verify Level 1 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::green);
+}
+
+// Test that defining an oversized nonzero mip level on an ETC compressed texture succeeds and
+// renders correctly.
+TEST_P(Texture2DTestES3_OversizedMipLevels, CompressedETC)
+{
+    const bool hasEtcExt = IsGLExtensionEnabled("GL_OES_compressed_ETC2_RGB8_texture");
+    const int clientVer  = getClientMajorVersion();
+    ANGLE_SKIP_TEST_IF(!hasEtcExt && clientVer < 3);
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // ETC2 RGB8: 8 bytes per 4x4 block.
+    // Level 0: 64x64 (256 blocks = 2048 bytes) solid red.
+    // Byte 0 = 0xFF (R1=15, R2=15), Bytes 1..7 = 0x00. Decodes to (255, 2, 2, 255).
+    constexpr int kLevel0Width     = 64;
+    constexpr int kLevel0Height    = 64;
+    constexpr size_t kLevel0Blocks = (kLevel0Width / 4) * (kLevel0Height / 4);
+    constexpr size_t kLevel0Bytes  = kLevel0Blocks * 8;
+    std::vector<uint8_t> redEtcData(kLevel0Bytes, 0);
+    for (size_t b = 0; b < kLevel0Blocks; ++b)
+    {
+        redEtcData[b * 8 + 0] = 0xFF;
+    }
+    glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_COMPRESSED_RGB8_ETC2, kLevel0Width, kLevel0Height,
+                           0, static_cast<GLsizei>(kLevel0Bytes), redEtcData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Level 1: 128x128 (1024 blocks = 8192 bytes) solid green.
+    // Byte 1 = 0xFF (G1=15, G2=15), Bytes 0, 2..7 = 0x00. Decodes to (2, 255, 2, 255).
+    constexpr int kLevel1Width     = 128;
+    constexpr int kLevel1Height    = 128;
+    constexpr size_t kLevel1Blocks = (kLevel1Width / 4) * (kLevel1Height / 4);
+    constexpr size_t kLevel1Bytes  = kLevel1Blocks * 8;
+    std::vector<uint8_t> greenEtcData(kLevel1Bytes, 0);
+    for (size_t b = 0; b < kLevel1Blocks; ++b)
+    {
+        greenEtcData[b * 8 + 1] = 0xFF;
+    }
+    glCompressedTexImage2D(GL_TEXTURE_2D, 1, GL_COMPRESSED_RGB8_ETC2, kLevel1Width, kLevel1Height,
+                           0, static_cast<GLsizei>(kLevel1Bytes), greenEtcData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Verify Level 0 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor(255, 2, 2, 255));
+
+    // Verify Level 1 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    // Bugs in the PowerVR driver prevent full redefinition of oversized compressed mip levels
+    // when level 0 is already defined, because the driver restricts compressed level 1 storage
+    // to the slot allocated by the level 0 chain. Check only the lower-left quadrant of the
+    // rendered output.
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth() / 4, getWindowHeight() / 4,
+                         GLColor(2, 255, 2, 255));
+}
+
+// Test that defining an oversized nonzero mip level on an ASTC compressed texture succeeds and
+// renders correctly.
+TEST_P(Texture2DTestES3_OversizedMipLevels, CompressedASTC)
+{
+    const bool hasAstcLdr = IsGLExtensionEnabled("GL_KHR_texture_compression_astc_ldr");
+    const bool hasAstcOes = IsGLExtensionEnabled("GL_OES_texture_compression_astc");
+    ANGLE_SKIP_TEST_IF(!hasAstcLdr && !hasAstcOes);
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // ASTC 4x4 void-extent solid color blocks: 16 bytes per block.
+    // Red void-extent block: {0xFC, 0xFD, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00,
+    // 0x00, 0x00, 0x00, 0xFF, 0xFF} Green void-extent block: {0xFC, 0xFD, 0xFF, 0xFF, 0xFF, 0xFF,
+    // 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF}
+    constexpr uint8_t kAstcRedBlock[16]   = {0xFC, 0xFD, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+                                             0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF};
+    constexpr uint8_t kAstcGreenBlock[16] = {0xFC, 0xFD, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+                                             0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF};
+
+    // Level 0: 64x64 (256 blocks = 4096 bytes) solid red.
+    constexpr int kLevel0Width     = 64;
+    constexpr int kLevel0Height    = 64;
+    constexpr size_t kLevel0Blocks = (kLevel0Width / 4) * (kLevel0Height / 4);
+    constexpr size_t kLevel0Bytes  = kLevel0Blocks * 16;
+    std::vector<uint8_t> redAstcData(kLevel0Bytes);
+    for (size_t b = 0; b < kLevel0Blocks; ++b)
+    {
+        memcpy(&redAstcData[b * 16], kAstcRedBlock, 16);
+    }
+    glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_COMPRESSED_RGBA_ASTC_4x4_KHR, kLevel0Width,
+                           kLevel0Height, 0, static_cast<GLsizei>(kLevel0Bytes),
+                           redAstcData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Level 1: 128x128 (1024 blocks = 16384 bytes) solid green.
+    constexpr int kLevel1Width     = 128;
+    constexpr int kLevel1Height    = 128;
+    constexpr size_t kLevel1Blocks = (kLevel1Width / 4) * (kLevel1Height / 4);
+    constexpr size_t kLevel1Bytes  = kLevel1Blocks * 16;
+    std::vector<uint8_t> greenAstcData(kLevel1Bytes);
+    for (size_t b = 0; b < kLevel1Blocks; ++b)
+    {
+        memcpy(&greenAstcData[b * 16], kAstcGreenBlock, 16);
+    }
+    glCompressedTexImage2D(GL_TEXTURE_2D, 1, GL_COMPRESSED_RGBA_ASTC_4x4_KHR, kLevel1Width,
+                           kLevel1Height, 0, static_cast<GLsizei>(kLevel1Bytes),
+                           greenAstcData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Verify Level 0 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::red);
+
+    // Verify Level 1 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    // Bugs in the PowerVR driver prevent full redefinition of oversized compressed mip levels
+    // when level 0 is already defined, because the driver restricts compressed level 1 storage
+    // to the slot allocated by the level 0 chain. Check only the lower-left quadrant of the
+    // rendered output.
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth() / 4, getWindowHeight() / 4, GLColor::green);
+}
+
+// Test that defining an oversized nonzero mip level on a DXT compressed texture succeeds and
+// renders correctly.
+TEST_P(Texture2DTestES3_OversizedMipLevels, CompressedDXT)
+{
+    const bool hasDxt1  = IsGLExtensionEnabled("GL_EXT_texture_compression_dxt1");
+    const bool hasS3tc  = IsGLExtensionEnabled("GL_EXT_texture_compression_s3tc");
+    const bool hasAngle = IsGLExtensionEnabled("GL_ANGLE_texture_compression_dxt1");
+    ANGLE_SKIP_TEST_IF(!hasDxt1 && !hasS3tc && !hasAngle);
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // DXT1: 8 bytes per 4x4 block.
+    // Red block: {0x00, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+    // Green block: {0xE0, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+    constexpr uint8_t kDxtRedBlock[8]   = {0x00, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+    constexpr uint8_t kDxtGreenBlock[8] = {0xE0, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+    // Level 0: 64x64 (256 blocks = 2048 bytes) solid red.
+    constexpr int kLevel0Width     = 64;
+    constexpr int kLevel0Height    = 64;
+    constexpr size_t kLevel0Blocks = (kLevel0Width / 4) * (kLevel0Height / 4);
+    constexpr size_t kLevel0Bytes  = kLevel0Blocks * 8;
+    std::vector<uint8_t> redDxtData(kLevel0Bytes);
+    for (size_t b = 0; b < kLevel0Blocks; ++b)
+    {
+        memcpy(&redDxtData[b * 8], kDxtRedBlock, 8);
+    }
+    glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_COMPRESSED_RGBA_S3TC_DXT1_EXT, kLevel0Width,
+                           kLevel0Height, 0, static_cast<GLsizei>(kLevel0Bytes), redDxtData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Level 1: 128x128 (1024 blocks = 8192 bytes) solid green.
+    constexpr int kLevel1Width     = 128;
+    constexpr int kLevel1Height    = 128;
+    constexpr size_t kLevel1Blocks = (kLevel1Width / 4) * (kLevel1Height / 4);
+    constexpr size_t kLevel1Bytes  = kLevel1Blocks * 8;
+    std::vector<uint8_t> greenDxtData(kLevel1Bytes);
+    for (size_t b = 0; b < kLevel1Blocks; ++b)
+    {
+        memcpy(&greenDxtData[b * 8], kDxtGreenBlock, 8);
+    }
+    glCompressedTexImage2D(GL_TEXTURE_2D, 1, GL_COMPRESSED_RGBA_S3TC_DXT1_EXT, kLevel1Width,
+                           kLevel1Height, 0, static_cast<GLsizei>(kLevel1Bytes),
+                           greenDxtData.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Verify Level 0 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::red);
+
+    // Verify Level 1 sampling.
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    // Bugs in the PowerVR driver prevent full redefinition of oversized compressed mip levels
+    // when level 0 is already defined, because the driver restricts compressed level 1 storage
+    // to the slot allocated by the level 0 chain. Check only the lower-left quadrant of the
+    // rendered output.
+    EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth() / 4, getWindowHeight() / 4, GLColor::green);
+}
+
 // Test that robust init via nullptr-upload to texture after invalidating an image with emulated
 // alpha works.
 TEST_P(Texture2DTestES3RobustInit, InvalidateEmulatedAlphaThenInitViaEmptyUpload)
@@ -24061,6 +24311,12 @@
                                ES3_OPENGLES().enable(Feature::ResetTexStorage2DBaseLevel),
                                ES3_OPENGLES().disable(Feature::ResetTexStorage2DBaseLevel));
 
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(Texture2DTestES3_OversizedMipLevels);
+ANGLE_INSTANTIATE_TEST_ES3_AND(
+    Texture2DTestES3_OversizedMipLevels,
+    ES3_OPENGL().enable(Feature::UploadOversizedMipLevelsViaUnpackBuffer),
+    ES3_OPENGLES().enable(Feature::UploadOversizedMipLevelsViaUnpackBuffer));
+
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(TextureSizeLimitTest);
 ANGLE_INSTANTIATE_TEST(TextureSizeLimitTest,
                        ES2_D3D11().enable(Feature::LimitMaxTextureBytesTo1MB),
Loading diff…

Original Bug Report

reported by [email protected]

IMG: An out-of-bounds CPU write by two texture-upload calls

  • Attack surface: WebGL2 texture level definitions and uploads from an untrusted web page.
  • Impact: Out-of-bounds CPU write with controlled contents and length in the unsandboxed GPU process.

Android internal bug: b/548124501

View on issue tracker