CVE-2026-106258
Overview
Files Changed
src/tests/gl_tests/TextureTest.cppsrc/tests/angle_end2end_tests_expectations.txtsrc/libANGLE/renderer/d3d/TextureD3D.cppsrc/libANGLE/renderer/d3d/TextureD3D.h
Patch
From 5aaa642831a241872a8a21b43842316cc6916db3 Mon Sep 17 00:00:00 2001 From: wangra <[email protected]> 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 <[email protected]> Reviewed-by: Geoff Lang <[email protected]> Commit-Queue: Ran Wang <[email protected]> --- 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)
Regression Test / PoC
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)
Original Bug Report
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:
- open
http://localhost:8001/loginonce — this sets the victim origin’s session cookie; - 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
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Information Leak
- http://localhost:8000
- http://localhost:8001/login
- http://localhost:8001/private.png
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium-review.googlesource.com/c/angle/angle/+/7614533
- https://chromium-review.googlesource.com/c/angle/angle/+/8214667
- https://chromium-review.googlesource.com/c/angle/angle/+/8263283
- https://chromium-review.googlesource.com/c/angle/angle/+/8263907
- https://chromium-review.googlesource.com/c/angle/angle/+/8272990