CVE-2026-15109
Overview
Files Changed
src/libANGLE/renderer/vulkan/vk_helpers.cppsrc/libANGLE/renderer/vulkan/vk_helpers.hsrc/tests/gl_tests/RobustResourceInitTest.cpp
Patch
From 4fb60affdb6d3485f660a8b91bb33e76e4ca079e Mon Sep 17 00:00:00 2001
From: wangra <wangra@google.com>
Date: Thu, 18 Jun 2026 16:07:07 -0400
Subject: [PATCH] Vulkan: Defer update pruning until image is valid
Prevents exposure of uninitialized memory and GPU memory disclosure
by deferring staged update pruning calculations until the physical
VkImage has been allocated.
Test: angle_end2end_tests --gtest_filter="*Texture2DArrayPrunedSupersededUpdatesLeak*"
Bug: b/516899138
Change-Id: Ib414934f746d75ffd7f0bd47751d0d3be41db079
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7963962
Commit-Queue: Ran Wang <wangra@google.com>
Reviewed-by: Charlie Lao <cclao@google.com>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.cpp b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
index 3cfebc8..0915613 100644
--- a/src/libANGLE/renderer/vulkan/vk_helpers.cpp
+++ b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
@@ -8874,7 +8874,8 @@
const uint32_t baseArrayLayer = isArray ? offset.z : layerIndex;
const gl::Box updateBoundingBox =
MakeUpdateBoundingBox(offset, glExtents, baseArrayLayer, layerCount);
- pruneSupersededUpdatesForLevelImpl(contextVk, updateLevelGL, updateBoundingBox);
+ pruneSupersededUpdatesForLevelImpl(contextVk, updateLevelGL, updateBoundingBox,
+ PruneReason::MinimizeWorkBeforeFlush);
// If there are still pending updates to this subresource, cannot overwrite it.
if (hasStagedUpdatesForSubresource(updateLevelGL, baseArrayLayer, layerCount))
@@ -10818,12 +10819,13 @@
return;
}
- pruneSupersededUpdatesForLevelImpl(contextVk, level, {});
+ pruneSupersededUpdatesForLevelImpl(contextVk, level, {}, reason);
}
void ImageHelper::pruneSupersededUpdatesForLevelImpl(ContextVk *contextVk,
const gl::LevelIndex level,
- const gl::Box &upcomingUpdateBoundingBox)
+ const gl::Box &upcomingUpdateBoundingBox,
+ const PruneReason reason)
{
SubresourceUpdates *levelUpdates = getLevelUpdates(level);
if (levelUpdates == nullptr || levelUpdates->size() == 0)
@@ -10847,8 +10849,12 @@
VkDeviceSize supersededUpdateSize = 0;
std::array<gl::Box, 2> boundingBox = {upcomingUpdateBoundingBox, upcomingUpdateBoundingBox};
- auto canDropUpdate = [this, contextVk, level, &supersededUpdateSize,
+ auto canDropUpdate = [this, contextVk, level, reason, &supersededUpdateSize,
&boundingBox](SubresourceUpdate &update) {
+ if (IsClear(update.updateSource) && reason == PruneReason::MemoryOptimization)
+ {
+ return false;
+ }
VkDeviceSize updateSize = 0;
VkImageAspectFlags aspectMask = update.getDestAspectFlags();
diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.h b/src/libANGLE/renderer/vulkan/vk_helpers.h
index fdef802..0f894aa 100644
--- a/src/libANGLE/renderer/vulkan/vk_helpers.h
+++ b/src/libANGLE/renderer/vulkan/vk_helpers.h
@@ -3345,7 +3345,8 @@
const PruneReason reason);
void pruneSupersededUpdatesForLevelImpl(ContextVk *contextVk,
const gl::LevelIndex level,
- const gl::Box &upcomingUpdateBoundingBox);
+ const gl::Box &upcomingUpdateBoundingBox,
+ const PruneReason reason);
// Whether there are any updates in [start, end).
bool hasStagedUpdatesInLevels(gl::LevelIndex levelStart, gl::LevelIndex levelEnd) const;
diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp
index 0125832..77598fc 100644
--- a/src/tests/gl_tests/RobustResourceInitTest.cpp
+++ b/src/tests/gl_tests/RobustResourceInitTest.cpp
@@ -3005,6 +3005,107 @@
ASSERT_GL_NO_ERROR();
}
+// Test that robust init is done correctly for array textures when updates pruning threshold is met.
+TEST_P(RobustResourceInitTestES3, Texture2DArrayPrunedSupersededUpdatesLeak)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ // We rely on AllocateNonZeroMemory configuration to verify this bug. This configuration
+ // overrides all new allocations with non-zero values, which allows us to catch if
+ // robust resource initialization Clear is bypassed.
+ constexpr int kLocalWidth = 512;
+ constexpr int kLocalHeight = 512;
+ constexpr int kLayers = 4;
+
+ GLFramebuffer fb;
+ glBindFramebuffer(GL_FRAMEBUFFER, fb);
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLocalWidth, kLocalHeight, kLayers, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ // Perform multiple sub-image updates to layer 0.
+ // Each update is 512 * 512 * 4 = 1 MiB.
+ // We do 17 updates (17 MiB staged), which exceeds the 16 MiB pruning threshold.
+ constexpr int kUpdateCount = 17;
+ std::vector<GLColor> zeroData(kLocalWidth * kLocalHeight, GLColor::transparentBlack);
+ for (int i = 0; i < kUpdateCount; ++i)
+ {
+ glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, kLocalWidth, kLocalHeight, 1, GL_RGBA,
+ GL_UNSIGNED_BYTE, zeroData.data());
+ }
+
+ for (int layer = 1; layer < kLayers; ++layer)
+ {
+ glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, layer);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+ checkCustomFramebufferNonZeroPixels(kLocalWidth, kLocalHeight, 0, 0, 0, 0,
+ GLColor::transparentBlack);
+ }
+
+ ASSERT_GL_NO_ERROR();
+}
+
+// Test that robust init is done correctly when an array texture is redefined to a larger layer
+// count
+TEST_P(RobustResourceInitTestES3, Texture2DArrayRedefinePrunedSupersededUpdatesLeak)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ constexpr int kLocalWidth = 512;
+ constexpr int kLocalHeight = 512;
+ constexpr int kLayers1 = 2;
+ constexpr int kLayers2 = 4;
+
+ GLFramebuffer fb;
+ glBindFramebuffer(GL_FRAMEBUFFER, fb);
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
+
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLocalWidth, kLocalHeight, kLayers1, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kLocalWidth / 2, kLocalHeight / 2, kLayers1, 0,
+ GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, 0);
+ ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+ glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+ ASSERT_GL_NO_ERROR();
+
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLocalWidth, kLocalHeight, kLayers2, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+
+ // Perform multiple sub-image updates to layers 0 and 1 to trigger pruning.
+ // Each update is 512 * 512 * 4 = 1 MB. Staging 17 updates (17 MB) exceeds the 16 MB
+ // pruning threshold and triggers staging-time updates pruning.
+ constexpr int kUpdateCount = 17;
+ std::vector<GLColor> zeroData(kLocalWidth * kLocalHeight, GLColor::transparentBlack);
+ for (int i = 0; i < kUpdateCount; ++i)
+ {
+ int layer = i % kLayers1;
+ glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layer, kLocalWidth, kLocalHeight, 1, GL_RGBA,
+ GL_UNSIGNED_BYTE, zeroData.data());
+ }
+
+ for (int layer = kLayers1; layer < kLayers2; ++layer)
+ {
+ glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, layer);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+ checkCustomFramebufferNonZeroPixels(kLocalWidth, kLocalHeight, 0, 0, 0, 0,
+ GLColor::transparentBlack);
+ }
+
+ ASSERT_GL_NO_ERROR();
+}
+
// Test that redefining a 2D array texture to a compatible size (same size)
// doesn't bypass robust resource initialization.
TEST_P(RobustResourceInitTestES3, Texture2DArrayRedefineCompatible)
Regression Test / PoC
diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp
index 0125832..77598fc 100644
--- a/src/tests/gl_tests/RobustResourceInitTest.cpp
+++ b/src/tests/gl_tests/RobustResourceInitTest.cpp
@@ -3005,6 +3005,107 @@
ASSERT_GL_NO_ERROR();
}
+// Test that robust init is done correctly for array textures when updates pruning threshold is met.
+TEST_P(RobustResourceInitTestES3, Texture2DArrayPrunedSupersededUpdatesLeak)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ // We rely on AllocateNonZeroMemory configuration to verify this bug. This configuration
+ // overrides all new allocations with non-zero values, which allows us to catch if
+ // robust resource initialization Clear is bypassed.
+ constexpr int kLocalWidth = 512;
+ constexpr int kLocalHeight = 512;
+ constexpr int kLayers = 4;
+
+ GLFramebuffer fb;
+ glBindFramebuffer(GL_FRAMEBUFFER, fb);
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLocalWidth, kLocalHeight, kLayers, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ // Perform multiple sub-image updates to layer 0.
+ // Each update is 512 * 512 * 4 = 1 MiB.
+ // We do 17 updates (17 MiB staged), which exceeds the 16 MiB pruning threshold.
+ constexpr int kUpdateCount = 17;
+ std::vector<GLColor> zeroData(kLocalWidth * kLocalHeight, GLColor::transparentBlack);
+ for (int i = 0; i < kUpdateCount; ++i)
+ {
+ glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, kLocalWidth, kLocalHeight, 1, GL_RGBA,
+ GL_UNSIGNED_BYTE, zeroData.data());
+ }
+
+ for (int layer = 1; layer < kLayers; ++layer)
+ {
+ glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, layer);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+ checkCustomFramebufferNonZeroPixels(kLocalWidth, kLocalHeight, 0, 0, 0, 0,
+ GLColor::transparentBlack);
+ }
+
+ ASSERT_GL_NO_ERROR();
+}
+
+// Test that robust init is done correctly when an array texture is redefined to a larger layer
+// count
+TEST_P(RobustResourceInitTestES3, Texture2DArrayRedefinePrunedSupersededUpdatesLeak)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ constexpr int kLocalWidth = 512;
+ constexpr int kLocalHeight = 512;
+ constexpr int kLayers1 = 2;
+ constexpr int kLayers2 = 4;
+
+ GLFramebuffer fb;
+ glBindFramebuffer(GL_FRAMEBUFFER, fb);
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
+
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLocalWidth, kLocalHeight, kLayers1, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, kLocalWidth / 2, kLocalHeight / 2, kLayers1, 0,
+ GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, 0);
+ ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+ glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+ ASSERT_GL_NO_ERROR();
+
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kLocalWidth, kLocalHeight, kLayers2, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+
+ // Perform multiple sub-image updates to layers 0 and 1 to trigger pruning.
+ // Each update is 512 * 512 * 4 = 1 MB. Staging 17 updates (17 MB) exceeds the 16 MB
+ // pruning threshold and triggers staging-time updates pruning.
+ constexpr int kUpdateCount = 17;
+ std::vector<GLColor> zeroData(kLocalWidth * kLocalHeight, GLColor::transparentBlack);
+ for (int i = 0; i < kUpdateCount; ++i)
+ {
+ int layer = i % kLayers1;
+ glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layer, kLocalWidth, kLocalHeight, 1, GL_RGBA,
+ GL_UNSIGNED_BYTE, zeroData.data());
+ }
+
+ for (int layer = kLayers1; layer < kLayers2; ++layer)
+ {
+ glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, layer);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+ checkCustomFramebufferNonZeroPixels(kLocalWidth, kLocalHeight, 0, 0, 0, 0,
+ GLColor::transparentBlack);
+ }
+
+ ASSERT_GL_NO_ERROR();
+}
+
// Test that redefining a 2D array texture to a compatible size (same size)
// doesn't bypass robust resource initialization.
TEST_P(RobustResourceInitTestES3, Texture2DArrayRedefineCompatible)
Original Bug Report
Robust-init bypass in ANGLE Vulkan backend allows GPU memory disclosure
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic flaw in ANGLE’s Vulkan backend update pruning logic can potentially cause robust resource initialization to be bypassed for multi-layered textures. This occurs because update bounding box calculations are evaluated using stale, uninitialized properties of an invalid ImageHelper. As a result, uncleared GPU memory layers can be exposed and read back via WebGL2, potentially disclosing sensitive graphics data from other processes.
Affected files:
third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cppthird_party/angle/src/libANGLE/renderer/vulkan/TextureVk.cppthird_party/angle/src/libANGLE/Texture.cpp
Estimated timestamp from git blame: 2024-06-10
Root Cause
Inside the ANGLE Vulkan backend, ImageHelper::pruneSupersededUpdatesForLevelImpl (third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp) optimizes performance by discarding older staged updates whose update regions are fully enclosed by a more recent update.
For a whole-level Clear update (such as one staged for robust resource initialization), the update is staged with a layerCount of kEntireLevel (0xFFFFFFFF). When evaluating this update’s bounding box, getDestSubresource resolves the layer count using the cached mLayerCount of the ImageHelper:
if (*layerCountOut == static_cast<uint32_t>(gl::ImageIndex::kEntireLevel))
{
*layerCountOut = imageLayerCount; // imageLayerCount is passed as mLayerCount
}
Additionally, the clear’s spatial dimensions are resolved via getLevelExtents using the cached mExtents:
uint32_t width = std::max(mExtents.width >> levelVk.get(), 1u);
uint32_t height = std::max(mExtents.height >> levelVk.get(), 1u);
uint32_t depth = std::max(mExtents.depth >> levelVk.get(), 1u);
However, when a texture has just been defined (for instance, via glTexImage3D with null pixels), the backend allocates a vk::ImageHelper but defers allocating the physical VkImage. Consequently, mImage->valid() is false, and the cached properties mExtents and mLayerCount remain at their default values ({0,0,0} and 0 respectively).
If the application then stages a partial update targeting only layer 0 (such as a glTexSubImage3D call exceeding the 16 MB pruning threshold), the pruning logic is triggered:
- The whole-level
Clearupdate’s bounding box is computed. BecausemLayerCountis0andmExtentsis{0,0,0}, the box incorrectly resolves to{0,0,0,1,1,1}. - The layer 0 partial update’s bounding box is computed as
{0,0,0,W,H,1}. - Since
{0,0,0,W,H,1}fully encloses{0,0,0,1,1,1}, the whole-level robust resource initializationClearis incorrectly marked as superseded and destroyed. - Once the image is eventually allocated and initialized, only the layer 0 partial update is flushed. Layers
1toN-1are never cleared, leaving uninitialized GPU memory intact.
Potential Trigger Steps
Note: Due to internal tooling limitations, this scenario has not been dynamically executed and is based on a static analysis of the source code. The following are potential steps to reproduce the issue:
- Define a 2D array texture with no data (allocating an invalid
ImageHelperand setting the frontend state toMayNeedInit):gl.texImage3D(gl.TEXTURE_2D_ARRAY, 0, gl.RGBA8, 2048, 2048, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); - Perform a partial sub-image update to layer 0 large enough to exceed the 16 MB pruning threshold (which forces robust-init staging and subsequent pruning):
gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, 0, 2048, 2048, 1, gl.RGBA, gl.UNSIGNED_BYTE, uploadData); - Force image creation and read back layers
1to3using framebuffers:gl.bindFramebuffer(gl.READ_FRAMEBUFFER, fb); gl.framebufferTextureLayer(gl.READ_FRAMEBUFFER, gl.COLOR_ATTACHMENT0, tex, 0, 1); gl.readPixels(0, 0, 2048, 2048, gl.RGBA, gl.UNSIGNED_BYTE, pixels); - Observe whether the readback contains residual graphics memory instead of being initialized to zero.
Suggested Fix
To prevent this, the update bounding box calculations during pruning must not rely on stale mExtents or mLayerCount when valid() is false. A potential solution is to retrieve the true texture level dimensions and layer count from the frontend texture descriptor (e.g., passing them through to pruneSupersededUpdatesForLevel), or to bypass/disable update pruning entirely for any level where !valid() is true.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.