High chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized resource in ANGLE
DescriptionUninitialized resource in ANGLE
ComponentANGLE
Sub-componentD3D
Bug ClassUninitialized Memory
Tracker553857574
Fix commit5aaa642831a2 (angle/angle) +140/-10
CISA KEVNot listed
Creditedweihengqiuu
Disclosed2026-10-06

Files Changed

  • src/tests/gl_tests/TextureTest.cpp
  • src/tests/angle_end2end_tests_expectations.txt
  • src/libANGLE/renderer/d3d/TextureD3D.cpp
  • src/libANGLE/renderer/d3d/TextureD3D.h
From 5aaa642831a241872a8a21b43842316cc6916db3 Mon Sep 17 00:00:00 2001
From: wangra <wangra@google.com>
Date: Tue, 08 Sep 2026 20:34:14 -0400
Subject: [PATCH] D3D: Release 2D array storage on base level layer count change

Release texture storage when changing base level on a 2D array texture
whose new base level declares a different layer count, preventing stale
slice retention and memory leaks.

Test:
    angle_end2end_tests --gtest_filter=*Texture2DArrayTestES3.BaseLevelChange*

Bug: b/553857574
Change-Id: I683f61c8f31234eeb2c0c605bb0f27e68de52ebf
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8374349
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Ran Wang <wangra@google.com>
---
diff --git a/src/libANGLE/renderer/d3d/TextureD3D.cpp b/src/libANGLE/renderer/d3d/TextureD3D.cpp
index 4b82538..2141213 100644
--- a/src/libANGLE/renderer/d3d/TextureD3D.cpp
+++ b/src/libANGLE/renderer/d3d/TextureD3D.cpp
@@ -212,6 +212,11 @@
     return 1;
 }
 
+GLint TextureD3D::getBaseLevelStorageDepth() const
+{
+    return getLevelZeroDepth();
+}
+
 GLint TextureD3D::getBaseLevelWidth() const
 {
     const ImageD3D *baseImage = getBaseLevelImage();
@@ -795,7 +800,7 @@
 {
     const int oldStorageWidth  = std::max(1, getLevelZeroWidth());
     const int oldStorageHeight = std::max(1, getLevelZeroHeight());
-    const int oldStorageDepth  = std::max(1, getLevelZeroDepth());
+    const int oldStorageDepth  = std::max(1, getBaseLevelStorageDepth());
     const int oldStorageFormat = getBaseLevelInternalFormat();
     mBaseLevel                 = baseLevel;
 
@@ -808,7 +813,7 @@
     // dimension mismatch and lead to recreating the storage with wrong dimensions.
     const int newStorageWidth  = std::max(1, getLevelZeroWidth());
     const int newStorageHeight = std::max(1, getLevelZeroHeight());
-    const int newStorageDepth  = std::max(1, getLevelZeroDepth());
+    const int newStorageDepth  = std::max(1, getBaseLevelStorageDepth());
     const int newStorageFormat = getBaseLevelInternalFormat();
     if (mTexStorage && !isImmutable() &&
         (newStorageWidth != oldStorageWidth || newStorageHeight != oldStorageHeight ||
@@ -3750,7 +3755,7 @@
 {
     GLsizei width         = getLevelZeroWidth();
     GLsizei height        = getLevelZeroHeight();
-    GLsizei depth         = getLayerCount(getBaseLevel());
+    GLsizei depth         = getBaseLevelStorageDepth();
     GLenum internalFormat = getBaseLevelInternalFormat();
 
     ASSERT(width > 0 && height > 0 && depth > 0);
@@ -3865,6 +3870,16 @@
     return angle::Result::Continue;
 }
 
+GLint TextureD3D_2DArray::getBaseLevelStorageDepth() const
+{
+    const GLuint baseLevel = getBaseLevel();
+    if (baseLevel < gl::IMPLEMENTATION_MAX_TEXTURE_LEVELS)
+    {
+        return getLayerCount(baseLevel);
+    }
+    return 0;
+}
+
 void TextureD3D_2DArray::deleteImages()
 {
     for (int level = 0; level < gl::IMPLEMENTATION_MAX_TEXTURE_LEVELS; ++level)
@@ -3890,14 +3905,8 @@
     // If there currently is a corresponding storage texture image, it has these parameters
     const int storageWidth     = std::max(1, getLevelZeroWidth() >> level);
     const int storageHeight    = std::max(1, getLevelZeroHeight() >> level);
-    const GLuint baseLevel     = getBaseLevel();
     const GLenum storageFormat = getBaseLevelInternalFormat();
-
-    int storageDepth = 0;
-    if (baseLevel < gl::IMPLEMENTATION_MAX_TEXTURE_LEVELS)
-    {
-        storageDepth = getLayerCount(baseLevel);
-    }
+    const int storageDepth     = getBaseLevelStorageDepth();
 
     // Only reallocate the layers if the size doesn't match
     if (size.depth != ANGLE_UNSAFE_TODO(mLayerCounts[level]))
diff --git a/src/libANGLE/renderer/d3d/TextureD3D.h b/src/libANGLE/renderer/d3d/TextureD3D.h
index c3ce67b..3e3e02e 100644
--- a/src/libANGLE/renderer/d3d/TextureD3D.h
+++ b/src/libANGLE/renderer/d3d/TextureD3D.h
@@ -172,6 +172,7 @@
     GLint getLevelZeroWidth() const;
     GLint getLevelZeroHeight() const;
     virtual GLint getLevelZeroDepth() const;
+    virtual GLint getBaseLevelStorageDepth() const;
 
     GLint creationLevels(GLsizei width, GLsizei height, GLsizei depth) const;
     bool isLevelComplete(int level) const;
@@ -753,6 +754,7 @@
     bool isImageComplete(const gl::ImageIndex &index) const override;
     bool isSRGB(GLint level) const;
     angle::Result updateStorageLevel(const gl::Context *context, int level);
+    GLint getBaseLevelStorageDepth() const override;
 
     void deleteImages();
     angle::Result redefineImage(const gl::Context *context,
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 6b72749..a02c550 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -176,6 +176,8 @@
 519062400 WIN INTEL OPENGL : CopyTextureTestES3.ChangeBaseLevel/* = SKIP
 523371282 WIN INTEL OPENGL : TransformFeedbackTest.RenderOnceChangeXfbBufferRenderAgain/* = SKIP
 526503288 WIN INTEL OPENGL : BaseInstanceOverflowTest.BaseInstanceOverflow2/ES3_OpenGL_AlwaysEnableEmulatedMultidrawExtensions = SKIP
+553857574 WIN INTEL OPENGL : Texture2DArrayTestES3.BaseLevelChangeWithFewerLayersReleasesStorage/* = SKIP
+553857574 WIN INTEL OPENGL : Texture2DArrayTestES3.BaseLevelChangeWithMoreLayersReleasesStorage/* = SKIP
 535213600 WIN NVIDIA OPENGL : PbufferTest.BindTexImageAndCopyTexImageSrc/* = SKIP
 535213600 WIN NVIDIA GLES : PbufferTest.BindTexImageAndCopyTexImageSrc/* = SKIP
 535213600 WIN NVIDIA OPENGL : PbufferTestES3.BindTexImageAndCopyTextureSrc/* = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 5966835..1ac2528 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -9360,6 +9360,123 @@
     EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
 }
 
+// Test that changing GL_TEXTURE_BASE_LEVEL to a mip level with fewer layers properly releases
+// storage so that undeclared layers are not sampleable and do not leak stale data.
+TEST_P(Texture2DArrayTestES3, BaseLevelChangeWithFewerLayersReleasesStorage)
+{
+    constexpr GLsizei kLevel0Width         = 4;
+    constexpr GLsizei kLevel0Height        = 4;
+    constexpr GLsizei kLevel0Layers        = 2;
+    constexpr size_t kLevel0PixelsPerLayer = kLevel0Width * kLevel0Height;
+
+    constexpr GLsizei kLevel1Width         = 2;
+    constexpr GLsizei kLevel1Height        = 2;
+    constexpr GLsizei kLevel1Layers        = 1;
+    constexpr size_t kLevel1PixelsPerLayer = kLevel1Width * kLevel1Height;
+
+    glBindTexture(GL_TEXTURE_2D_ARRAY, m2DArrayTexture);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Level 0: 4x4 with 2 layers (layer 0 = red, layer 1 = blue)
+    std::vector<GLColor> level0Data(kLevel0PixelsPerLayer * kLevel0Layers);
+    std::fill_n(level0Data.begin(), kLevel0PixelsPerLayer, GLColor::red);
+    std::fill_n(level0Data.begin() + kLevel0PixelsPerLayer, kLevel0PixelsPerLayer, GLColor::blue);
+
+    // Level 1: 2x2 with 1 layer (layer 0 = green)
+    std::vector<GLColor> level1Data(kLevel1PixelsPerLayer * kLevel1Layers, GLColor::green);
+
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 0);
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLevel0Width, kLevel0Height, kLevel0Layers, 0,
+                 GL_RGBA, GL_UNSIGNED_BYTE, level0Data.data());
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kLevel1Width, kLevel1Height, kLevel1Layers, 0,
+                 GL_RGBA, GL_UNSIGNED_BYTE, level1Data.data());
+
+    glUseProgram(mProgram);
+    glUniform1i(mTextureArrayLocation, 0);
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+
+    glUniform1i(mTextureArraySliceUniformLocation, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+
+    // Change base level to 1, which only has 1 layer.
+    // The texture storage must be released and reallocated with ArraySize = 1.
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 1);
+
+    // Layer 0 is declared at level 1 and should sample green.
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+
+    // Layer 1 is NOT declared at level 1. Because the texture storage was reallocated with only
+    // 1 layer, sampling layer 1 will clamp to layer 0 (green) according to the GLES spec, rather
+    // than reading the stale blue data from layer 1 of the old 2-layer storage.
+    glUniform1i(mTextureArraySliceUniformLocation, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_NE(ReadColor(0, 0), GLColor::blue);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+}
+
+// Test that changing GL_TEXTURE_BASE_LEVEL to a mip level with more layers properly releases
+// storage so that newly exposed layers can be sampled.
+TEST_P(Texture2DArrayTestES3, BaseLevelChangeWithMoreLayersReleasesStorage)
+{
+    constexpr GLsizei kLevel0Width         = 4;
+    constexpr GLsizei kLevel0Height        = 4;
+    constexpr GLsizei kLevel0Layers        = 1;
+    constexpr size_t kLevel0PixelsPerLayer = kLevel0Width * kLevel0Height;
+
+    constexpr GLsizei kLevel1Width         = 2;
+    constexpr GLsizei kLevel1Height        = 2;
+    constexpr GLsizei kLevel1Layers        = 2;
+    constexpr size_t kLevel1PixelsPerLayer = kLevel1Width * kLevel1Height;
+
+    glBindTexture(GL_TEXTURE_2D_ARRAY, m2DArrayTexture);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Level 0: 4x4 with 1 layer (layer 0 = red)
+    std::vector<GLColor> level0Data(kLevel0PixelsPerLayer * kLevel0Layers, GLColor::red);
+
+    // Level 1: 2x2 with 2 layers (layer 0 = green, layer 1 = blue)
+    std::vector<GLColor> level1Data(kLevel1PixelsPerLayer * kLevel1Layers);
+    std::fill_n(level1Data.begin(), kLevel1PixelsPerLayer, GLColor::green);
+    std::fill_n(level1Data.begin() + kLevel1PixelsPerLayer, kLevel1PixelsPerLayer, GLColor::blue);
+
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 0);
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLevel0Width, kLevel0Height, kLevel0Layers, 0,
+                 GL_RGBA, GL_UNSIGNED_BYTE, level0Data.data());
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kLevel1Width, kLevel1Height, kLevel1Layers, 0,
+                 GL_RGBA, GL_UNSIGNED_BYTE, level1Data.data());
+
+    glUseProgram(mProgram);
+    glUniform1i(mTextureArrayLocation, 0);
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+
+    // Change base level to 1, which has 2 layers.
+    // The texture storage must be released and reallocated with ArraySize = 2.
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 1);
+
+    // Layer 0 should sample green.
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+
+    // Layer 1 should sample blue.
+    glUniform1i(mTextureArraySliceUniformLocation, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+}
+
 // Create a 2D array texture and update layers with data and test that pruning
 // of superseded updates works as expected.
 TEST_P(Texture2DArrayTestES3, TextureArrayPruneSupersededUpdates)
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 6b72749..a02c550 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -176,6 +176,8 @@
 519062400 WIN INTEL OPENGL : CopyTextureTestES3.ChangeBaseLevel/* = SKIP
 523371282 WIN INTEL OPENGL : TransformFeedbackTest.RenderOnceChangeXfbBufferRenderAgain/* = SKIP
 526503288 WIN INTEL OPENGL : BaseInstanceOverflowTest.BaseInstanceOverflow2/ES3_OpenGL_AlwaysEnableEmulatedMultidrawExtensions = SKIP
+553857574 WIN INTEL OPENGL : Texture2DArrayTestES3.BaseLevelChangeWithFewerLayersReleasesStorage/* = SKIP
+553857574 WIN INTEL OPENGL : Texture2DArrayTestES3.BaseLevelChangeWithMoreLayersReleasesStorage/* = SKIP
 535213600 WIN NVIDIA OPENGL : PbufferTest.BindTexImageAndCopyTexImageSrc/* = SKIP
 535213600 WIN NVIDIA GLES : PbufferTest.BindTexImageAndCopyTexImageSrc/* = SKIP
 535213600 WIN NVIDIA OPENGL : PbufferTestES3.BindTexImageAndCopyTextureSrc/* = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 5966835..1ac2528 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -9360,6 +9360,123 @@
     EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
 }
 
+// Test that changing GL_TEXTURE_BASE_LEVEL to a mip level with fewer layers properly releases
+// storage so that undeclared layers are not sampleable and do not leak stale data.
+TEST_P(Texture2DArrayTestES3, BaseLevelChangeWithFewerLayersReleasesStorage)
+{
+    constexpr GLsizei kLevel0Width         = 4;
+    constexpr GLsizei kLevel0Height        = 4;
+    constexpr GLsizei kLevel0Layers        = 2;
+    constexpr size_t kLevel0PixelsPerLayer = kLevel0Width * kLevel0Height;
+
+    constexpr GLsizei kLevel1Width         = 2;
+    constexpr GLsizei kLevel1Height        = 2;
+    constexpr GLsizei kLevel1Layers        = 1;
+    constexpr size_t kLevel1PixelsPerLayer = kLevel1Width * kLevel1Height;
+
+    glBindTexture(GL_TEXTURE_2D_ARRAY, m2DArrayTexture);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Level 0: 4x4 with 2 layers (layer 0 = red, layer 1 = blue)
+    std::vector<GLColor> level0Data(kLevel0PixelsPerLayer * kLevel0Layers);
+    std::fill_n(level0Data.begin(), kLevel0PixelsPerLayer, GLColor::red);
+    std::fill_n(level0Data.begin() + kLevel0PixelsPerLayer, kLevel0PixelsPerLayer, GLColor::blue);
+
+    // Level 1: 2x2 with 1 layer (layer 0 = green)
+    std::vector<GLColor> level1Data(kLevel1PixelsPerLayer * kLevel1Layers, GLColor::green);
+
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 0);
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLevel0Width, kLevel0Height, kLevel0Layers, 0,
+                 GL_RGBA, GL_UNSIGNED_BYTE, level0Data.data());
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kLevel1Width, kLevel1Height, kLevel1Layers, 0,
+                 GL_RGBA, GL_UNSIGNED_BYTE, level1Data.data());
+
+    glUseProgram(mProgram);
+    glUniform1i(mTextureArrayLocation, 0);
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+
+    glUniform1i(mTextureArraySliceUniformLocation, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+
+    // Change base level to 1, which only has 1 layer.
+    // The texture storage must be released and reallocated with ArraySize = 1.
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 1);
+
+    // Layer 0 is declared at level 1 and should sample green.
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+
+    // Layer 1 is NOT declared at level 1. Because the texture storage was reallocated with only
+    // 1 layer, sampling layer 1 will clamp to layer 0 (green) according to the GLES spec, rather
+    // than reading the stale blue data from layer 1 of the old 2-layer storage.
+    glUniform1i(mTextureArraySliceUniformLocation, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_NE(ReadColor(0, 0), GLColor::blue);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+}
+
+// Test that changing GL_TEXTURE_BASE_LEVEL to a mip level with more layers properly releases
+// storage so that newly exposed layers can be sampled.
+TEST_P(Texture2DArrayTestES3, BaseLevelChangeWithMoreLayersReleasesStorage)
+{
+    constexpr GLsizei kLevel0Width         = 4;
+    constexpr GLsizei kLevel0Height        = 4;
+    constexpr GLsizei kLevel0Layers        = 1;
+    constexpr size_t kLevel0PixelsPerLayer = kLevel0Width * kLevel0Height;
+
+    constexpr GLsizei kLevel1Width         = 2;
+    constexpr GLsizei kLevel1Height        = 2;
+    constexpr GLsizei kLevel1Layers        = 2;
+    constexpr size_t kLevel1PixelsPerLayer = kLevel1Width * kLevel1Height;
+
+    glBindTexture(GL_TEXTURE_2D_ARRAY, m2DArrayTexture);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Level 0: 4x4 with 1 layer (layer 0 = red)
+    std::vector<GLColor> level0Data(kLevel0PixelsPerLayer * kLevel0Layers, GLColor::red);
+
+    // Level 1: 2x2 with 2 layers (layer 0 = green, layer 1 = blue)
+    std::vector<GLColor> level1Data(kLevel1PixelsPerLayer * kLevel1Layers);
+    std::fill_n(level1Data.begin(), kLevel1PixelsPerLayer, GLColor::green);
+    std::fill_n(level1Data.begin() + kLevel1PixelsPerLayer, kLevel1PixelsPerLayer, GLColor::blue);
+
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 0);
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLevel0Width, kLevel0Height, kLevel0Layers, 0,
+                 GL_RGBA, GL_UNSIGNED_BYTE, level0Data.data());
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kLevel1Width, kLevel1Height, kLevel1Layers, 0,
+                 GL_RGBA, GL_UNSIGNED_BYTE, level1Data.data());
+
+    glUseProgram(mProgram);
+    glUniform1i(mTextureArrayLocation, 0);
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+
+    // Change base level to 1, which has 2 layers.
+    // The texture storage must be released and reallocated with ArraySize = 2.
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 1);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 1);
+
+    // Layer 0 should sample green.
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+
+    // Layer 1 should sample blue.
+    glUniform1i(mTextureArraySliceUniformLocation, 1);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+}
+
 // Create a 2D array texture and update layers with data and test that pruning
 // of superseded updates works as expected.
 TEST_P(Texture2DArrayTestES3, TextureArrayPruneSupersededUpdates)
Loading diff…

Original Bug Report

reported by we...@gmail.com

A TEXTURE_2D_ARRAY keeps a sampler view sized for its old base level, returning another origin's freed image pixels from the slices that view still exposes


Report description

A TEXTURE_2D_ARRAY keeps a sampler view sized for its old base level, returning another origin’s freed image pixels from the slices that view still exposes


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules


The problem

Please describe the technical details of the vulnerability

The problem

Changing GL_TEXTURE_BASE_LEVEL on a TEXTURE_2D_ARRAY can move the texture to a level that declares fewer array layers. ANGLE’s D3D11 backend does not release the texture storage when that happens, because the check that decides compares a depth term that is the constant 1 on both sides for a 2D array. The retained storage keeps a shader resource view whose ArraySize is the old layer count, while robust resource initialization clears only the new one. Every slice between the two is sampleable and never written, and returns whatever the D3D11 allocation holds — including another origin’s decoded image.

In the attached PoC the victim origin’s image is credentialed: http://localhost:8001/private.png returns a private image when that origin’s session cookie is present and a public placeholder otherwise. The victim page fetches it same-origin and uploads it into a 2D-array texture. The attacker page on http://localhost:8000 also draws that image on a canvas of its own, and Chrome correctly refuses to let it read that canvas back — getImageData() and toDataURL() both throw SecurityError. The attacker then allocates its own 2D-array texture, changes its base level, and reads back a slice the new base level never declared. That slice contains the victim’s image.

The same page run twice, differing only in whether the browser holds the victim origin’s cookie:

matches the private image matches the public placeholder
control — readback before anything is loaded 0.0 % 0.0 %
without the victim origin’s cookie 0.0 % 100.0 %
with the victim origin’s cookie 100.0 % 0.0 %

The page recovers whichever image the victim origin actually returned; an attacker’s own server requesting /private.png receives only the placeholder. recovered.png is what the PoC exported in the cookie case and secret.png is what the victim origin served — all 262144 pixels byte-identical. The page chooses the texture dimensions, so one readPixels returns (D/2)^2 pixels per exposed slice, 1 MB per call in the largest configuration measured.

Environment

Build Chrome 154.0.8029.0, win32-release_x64_asan-…-1687465, chromium 5f46c6098e0f, ANGLE 37c976ffba9d
Also reproduces on Chrome 154.0.8012.0 ASAN (…-1682419)
OS / GPU Windows 11 x64, NVIDIA GeForce RTX 4080, D3D11 backend
Renderer string ANGLE (NVIDIA, NVIDIA GeForce RTX 4080 (0x00002704) Direct3D11 vs_5_0 ps_5_0, D3D11)
Flags none

Nothing beyond loading the page is required: no service worker, no cache priming, no user gesture, no extension, no browser restart, and no special response headers on the victim image.

Analysis

Line numbers are ANGLE 37c976ffba9d, the revision the build above pins; unchanged in main as of 2026-08-28.

1. The depth term in setBaseLevel can never fire for a 2D array. src/libANGLE/renderer/d3d/TextureD3D.cpp:794:

    const int oldStorageDepth  = std::max(1, getLevelZeroDepth());     // :798
    mBaseLevel                 = baseLevel;
    const int newStorageDepth  = std::max(1, getLevelZeroDepth());     // :811
    if (mTexStorage && !isImmutable() &&
        (newStorageWidth != oldStorageWidth || newStorageHeight != oldStorageHeight ||
         newStorageDepth != oldStorageDepth || newStorageFormat != oldStorageFormat))  // :813-815
        ... releaseTexStorage(context, gl::TexLevelMask());

TextureD3D_2DArray does not override getLevelZeroDepth, so it uses the base implementation at :210, return 1;. Both sides are that constant, so a base-level change that changes the layer count never reaches releaseTexStorage.

The layer count is available as getLayerCount(int level), and the glTexImage3D path uses it: TextureD3D_2DArray::redefineImage (:3884) sets storageDepth = getLayerCount(baseLevel) (:3899) and releases the storage on size.depth != storageDepth (:3929). It is part of the storage identity there and absent from the equivalent check on the base-level path.

2. The retained storage keeps the old ArraySize. TextureStorage11_2DArray stores the layer count it was built with (d3d11/TextureStorage11.cpp:2814) and builds the sampler view from it — srvDesc.Texture2DArray.ArraySize = mTextureDepth; at :2958 in createSRVForSampler, and again at :2980 in createSRVForImage. After step 1 that is still the old base level’s layer count, so the shader can index every one of those slices.

3. Robust resource initialization is sized by the other number. TextureD3D::initializeContents (:924) clears a 2D array by iterating the GL-level layer count — tempLayerCounts[levelIndex] = getLayerCount(levelIndex); (:942), then ImageIndexIterator::Make2DArray(levelIndex, levelIndex + 1, tempLayerCounts.data()) (:943-944).

The view is sized by mTextureDepth, the clear by mLayerCounts[level]. Every slice in [ mLayerCounts[level], mTextureDepth ) is sampleable and never written. The PoC measures both: at the new base level the declared layer reads 0/65536 non-zero in every round, and the undeclared one reads the victim’s image.

Impact analysis

Bisection

git log -S "newStorageDepth" -- src/libANGLE/renderer/d3d/TextureD3D.cpp in angle/angle returns one commit, and git blame puts the current condition on it and on its follow-up:

commit date change
a314b61c16 2016-05-06 “Determine D3D texture storage size with correct base level” — introduces setBaseLevel and its width/height/depth comparison
87fc71c456 2016-05-16 “D3D: Reallocate storage when texture format changes with base level” — adds the format term

The introducing commit states the intent directly: “Changing the base level can also affect the size of the storage required for the texture. Old storage is now discarded when the base level is changed and the new base level calls for different storage dimensions.” For a 2D array the depth dimension is the layer count, and it is the one dimension the comparison cannot see. git log -S "TextureD3D_2DArray::getLevelZeroDepth" returns nothing — the override that would make the depth term meaningful for this texture type has never existed — and the body of setBaseLevel has never referenced getLayerCount or mLayerCounts.

The condition has been modified once since, by 7614533 (248abdcad1, 2026-03-03), which audited it for the immutable/NPOT case and added !isImmutable(); newStorageDepth != oldStorageDepth was left in place.

Three changes this month fixed neighbouring symptoms of a storage that no longer matches the levels the texture presents, and all three are present in the pinned revision: 8214667 (2026-08-14, b/541546782, “Fix crash and garbage readbacks on incomplete levels”), 8263907 (2026-08-18, b/541715128, “Fix use-after-free when redefining 2D array mip levels”, m153: 8272990), and 8263283 (2026-08-20, b/536664909, “Reject out-of-storage mip levels in completeness check”). The last rewrote the accessor setBaseLevel’s depth term reads, from return getBaseLevelDepth(); to return 1; — the same value for a 2D array, since redefineImage gives each layer its own ImageD3D of depth 1 (:3948), so behaviour did not change; it makes the constant explicit.

Verified in 37c976ffba9d: isValidLevel present, isValidIndex(index) && isImageComplete(index) present in both places 8263907 changed, getLevelZeroDepth() is return 1;, and setBaseLevel at :798 / :811 / :815 unchanged.

Using the PoC

Put the attachments in one directory and run python3 server.py. Then, in the browser under test:

  1. open http://localhost:8001/login once — this sets the victim origin’s session cookie;
  2. open http://localhost:8000/ and let it run.

Everything is rendered into the page; DevTools are not needed. The run takes a few minutes and shows its progress; there is a Run again button. Running step 2 without step 1 is a useful negative control: the page then recovers the public placeholder instead, and says so.

server.py serves the attacker page on :8000 and the victim site on :8001, which exposes /victim.html, /login and /private.png. The victim handler sends no CORS headers — no access-control-allow-origin, no cross-origin-resource-policy, no timing-allow-origin.

$ curl -s http://localhost:8001/private.png                             | wc -c
1500                                       <- the public placeholder: no cookie was sent
$ curl -s -H "Cookie: poc_session=1" http://localhost:8001/private.png  | wc -c
5035                                       <- the private image

Impact analysis

The victim image is cross-origin and the page cannot read it. Both of these must throw, and the page prints what was caught:

getImageData() -> SecurityError: Failed to execute 'getImageData' on 'CanvasRenderingContext2D':
                  The canvas has been tainted by cross-origin data.
toDataURL()    -> SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement':
                  Tainted canvases may not be exported.

Four configurations, each measured on every run; the recovered slice is (attacker allocation / 2)^2 pixels. This is the cookie run — without the cookie the two result columns swap. Both full outputs are attached.

victim texture attacker allocation private image public placeholder note
— 512 0.0 % 0.0 % control, before load; 0/65536 non-zero
512 1024 0.0 % 0.0 % attacker allocation 4x too large
256 256 100.0 % 0.0 %
512 512 100.0 % 0.0 %
1024 1024 100.0 % 0.0 %

The mismatched configuration is measured first, before any configuration has caused a 512x512 slice to be allocated and freed; run last it reads the 1024 configuration’s leftovers and scores high for the wrong reason.

The essential sequence:

const T = gl.TEXTURE_2D_ARRAY;                 // level 0 declares two layers, level 1 one
gl.bindTexture(T, gl.createTexture());
gl.texParameteri(T, gl.TEXTURE_BASE_LEVEL, 0);
gl.texParameteri(T, gl.TEXTURE_MAX_LEVEL, 0);
gl.texImage3D(T, 0, gl.RGBA8, 1024, 1024, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, mine);
gl.texImage3D(T, 1, gl.RGBA8,  512,  512, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
sample(0);                                     // materialises the storage: mTextureDepth = 2

gl.texParameteri(T, gl.TEXTURE_BASE_LEVEL, 1); // storage is not released, ArraySize stays 2
gl.texParameteri(T, gl.TEXTURE_MAX_LEVEL, 1);
sample(0);                                     // the declared layer: cleared, reads zero
sample(1);                                     // the slice only the stale view exposes
gl.readPixels(0, 0, 512, 512, gl.RGBA, gl.UNSIGNED_BYTE, out);   // another origin's pixels

sample(n) draws one full-screen triangle sampling texture(sampler2DArray, vec3(uv, n)) into a 512x512 render target, so a single draw recovers the entire slice.


The cause

What version of Chrome have you found the security issue in?

154.0.8029.0

No, it is not related to a crash.

Choose the type of vulnerability

Information Leak

View on issue tracker