High chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized Use in ANGLE
DescriptionUninitialized Use in ANGLE
ComponentANGLE
Bug ClassUninitialized Memory
Tracker516899138
Fix commit4fb60affdb6d (angle/angle) +113/-5
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-08

Files Changed

  • src/libANGLE/renderer/vulkan/vk_helpers.cpp
  • src/libANGLE/renderer/vulkan/vk_helpers.h
  • src/tests/gl_tests/RobustResourceInitTest.cpp
From 4fb60affdb6d3485f660a8b91bb33e76e4ca079e Mon Sep 17 00:00:00 2001
From: wangra <[email protected]>
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 <[email protected]>
Reviewed-by: Charlie Lao <[email protected]>
Reviewed-by: Shahbaz Youssefi <[email protected]>
---

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)
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/TextureVk.cpp
  • third_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:

  1. The whole-level Clear update’s bounding box is computed. Because mLayerCount is 0 and mExtents is {0,0,0}, the box incorrectly resolves to {0,0,0,1,1,1}.
  2. The layer 0 partial update’s bounding box is computed as {0,0,0,W,H,1}.
  3. Since {0,0,0,W,H,1} fully encloses {0,0,0,1,1,1}, the whole-level robust resource initialization Clear is incorrectly marked as superseded and destroyed.
  4. Once the image is eventually allocated and initialized, only the layer 0 partial update is flushed. Layers 1 to N-1 are 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:

  1. Define a 2D array texture with no data (allocating an invalid ImageHelper and setting the frontend state to MayNeedInit):
    gl.texImage3D(gl.TEXTURE_2D_ARRAY, 0, gl.RGBA8, 2048, 2048, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
    
  2. 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);
    
  3. Force image creation and read back layers 1 to 3 using 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);
    
  4. 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.

View on issue tracker