Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in ANGLE
DescriptionOut of bounds read in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker499091328
Fix commit97933d458974 (angle/angle) +46/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Files Changed

  • src/libANGLE/renderer/vulkan/TextureVk.cpp
  • src/libANGLE/renderer/vulkan/vk_helpers.cpp
  • src/libANGLE/renderer/vulkan/vk_helpers.h
  • src/tests/angle_end2end_tests_expectations.txt
  • src/tests/gl_tests/TextureTest.cpp
From 97933d4589741ecd7fca1bac808b85c6f585b237 Mon Sep 17 00:00:00 2001
From: Antonio Maiorano <[email protected]>
Date: Mon, 25 May 2026 13:45:23 -0400
Subject: [PATCH] Vulkan: fix potential OOB read in reformatStagedBufferUpdates (again)

When I landed the following CL:
https://chromium-review.git.corp.google.com/c/angle/angle/+/7818817, I
made it so that reformatStagedBufferUpdates would use mImageType to
determine whether to use the depth or the layerCount when copying data.
However, mImageType is initialized to 2D, and not up-to-date when this
function is called.  Indeed, this function is called when `valid()`
returns false, so we should not be reading this state. My fix would work
for 2D textures, but fails for 3D textures.

The fix is to pass in the valid texture type into this function from the
calling code.

Bug: b/499091328, b/512995370
Change-Id: Ibb4dbadc6c3675c2f4deaa600e8c1fb4befcce7e
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7874937
Reviewed-by: Shahbaz Youssefi <[email protected]>
Commit-Queue: Antonio Maiorano <[email protected]>
---

diff --git a/src/libANGLE/renderer/vulkan/TextureVk.cpp b/src/libANGLE/renderer/vulkan/TextureVk.cpp
index 40af3ad..15a535e 100644
--- a/src/libANGLE/renderer/vulkan/TextureVk.cpp
+++ b/src/libANGLE/renderer/vulkan/TextureVk.cpp
@@ -4901,7 +4901,7 @@
             // First try to convert any staged buffer updates from old format to new format using
             // CPU.
             ANGLE_TRY(mImage->reformatStagedBufferUpdates(contextVk, previousActualFormatID,
-                                                          actualFormatID));
+                                                          actualFormatID, mState.getType()));
         }
     }
 
diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.cpp b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
index 672ca4d..eb04b1f 100644
--- a/src/libANGLE/renderer/vulkan/vk_helpers.cpp
+++ b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
@@ -8811,7 +8811,8 @@
 
 angle::Result ImageHelper::reformatStagedBufferUpdates(ContextVk *contextVk,
                                                        angle::FormatID srcFormatID,
-                                                       angle::FormatID dstFormatID)
+                                                       angle::FormatID dstFormatID,
+                                                       gl::TextureType dstTextureType)
 {
     const angle::Format &srcFormat = angle::Format::Get(srcFormatID);
     const angle::Format &dstFormat = angle::Format::Get(dstFormatID);
@@ -8838,7 +8839,7 @@
                 const size_t srcDataDepthPitch = srcDataRowPitch * copy.imageExtent.height;
                 const size_t dstDataDepthPitch = dstDataRowPitch * copy.imageExtent.height;
 
-                const uint32_t depthOrLayerCount = mImageType == VK_IMAGE_TYPE_3D
+                const uint32_t depthOrLayerCount = dstTextureType == gl::TextureType::_3D
                                                        ? copy.imageExtent.depth
                                                        : copy.imageSubresource.layerCount;
 
diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.h b/src/libANGLE/renderer/vulkan/vk_helpers.h
index adea774..765ddea 100644
--- a/src/libANGLE/renderer/vulkan/vk_helpers.h
+++ b/src/libANGLE/renderer/vulkan/vk_helpers.h
@@ -2952,7 +2952,8 @@
                                           uint32_t layerCount);
     angle::Result reformatStagedBufferUpdates(ContextVk *contextVk,
                                               angle::FormatID srcFormatID,
-                                              angle::FormatID dstFormatID);
+                                              angle::FormatID dstFormatID,
+                                              gl::TextureType dstTextureType);
     bool hasStagedImageUpdatesWithMismatchedFormat(gl::LevelIndex levelStart,
                                                    gl::LevelIndex levelEnd,
                                                    angle::FormatID formatID) const;
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 854517d..57066c5 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2777,7 +2777,8 @@
 513536751 VULKAN SWIFTSHADER : TransformFeedbackTest.InstancedOverflowIncompletePrimitive/* = SKIP
 
 // Fails test on Metal
-499091328 MAC AMD METAL : Texture2DArrayTestES3.ReformatStagedBufferUpdatesLayerCountOOB/* = SKIP
+499091328 MAC AMD METAL : Texture2DArrayTestES3.ReformatStagedBufferUpdatesLayerCountOOB_2DArray/* = SKIP
+512995370 MAC AMD METAL : Texture2DArrayTestES3.ReformatStagedBufferUpdatesLayerCountOOB_3D/* = SKIP
 
 // Timeout on Intel
 509829264 VULKAN WIN INTEL : SimpleStateChangeTestES31.TransformFeedbackThenReadWithCompute/* = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 6ec348a..988c349 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -9293,7 +9293,7 @@
 // (W * H * 4 bytes) exceeds ANGLE's kMaxBufferSizeForSuballocation (8 MiB). That
 // forces a dedicated VkBuffer of exactly W*H*4 bytes; vkCmdCopyBufferToImage with
 // layerCount=D, which used to then read (D-1)*W*H*4 bytes past the end of that VkBuffer.
-TEST_P(Texture2DArrayTestES3, ReformatStagedBufferUpdatesLayerCountOOB)
+TEST_P(Texture2DArrayTestES3, ReformatStagedBufferUpdatesLayerCountOOB_2DArray)
 {
     const int W = 1500;
     const int H = 1500;
@@ -9327,6 +9327,43 @@
     EXPECT_PIXEL_RECT_EQ(0, 0, W, H, GLColor::magenta);
 }
 
+// Similar to ReformatStagedBufferUpdatesLayerCountOOB_2DArray, but creates a 3D texture
+// to test that we correctly read the depth and not layerCount in reformatStagedBufferUpdates.
+TEST_P(Texture2DArrayTestES3, ReformatStagedBufferUpdatesLayerCountOOB_3D)
+{
+    const int W = 1500;
+    const int H = 1500;
+    const int D = 4;
+
+    // Create TEXTURE_3D and upload RGB8 data. On drivers where VK_FORMAT_R8G8B8_UNORM is sampleable
+    // but not color-attachable, ANGLE stages this as a Buffer update with formatID=R8G8B8_UNORM,
+    // imageExtent.depth=D, layerCount=1.
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_3D, tex);
+    glTexImage3D(GL_TEXTURE_3D, 0, GL_RGB8, W, H, D, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr);
+    ASSERT_GL_NO_ERROR();
+
+    // Stage a buffer update covering all D layers. Because this is a 3D texture,
+    // copy.imageExtent.depth = D and copy.imageSubresource.layerCount = 1.
+    // Fill with 0xAA so we can test for this pattern when reading back the last slice.
+    std::vector<uint8_t> pixels(W * H * D * 3, 0xAA);
+    glTexSubImage3D(GL_TEXTURE_3D, 0, 0, 0, 0, W, H, D, GL_RGB, GL_UNSIGNED_BYTE, pixels.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Bind the texture as a framebuffer attachment. This sets hasBeenBoundAsAttachment(); the next
+    // syncState() will call ensureRenderable() -> reformatStagedBufferUpdates().
+    GLFramebuffer fb;
+    glBindFramebuffer(GL_FRAMEBUFFER, fb);
+    glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, 0, D - 1);
+    ASSERT_GL_NO_ERROR();
+    ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    // Force flush of the now-reformatted, and once undersized (before bug fix), staged update via
+    // vkCmdCopyBufferToImage. readPixels syncs the framebuffer, which ensures the attached texture
+    // is initialized -> flushStagedUpdates -> potential GPU OOB read.
+    EXPECT_PIXEL_RECT_EQ(0, 0, W, H, (GLColor{0xAA, 0xAA, 0xAA, 0xFF}));
+}
+
 // Test shadow sampler and regular non-shadow sampler coexisting in the same shader.
 // This test is needed especially to confirm that sampler registers get assigned correctly on
 // the HLSL backend even when there's a mix of different HLSL sampler and texture types.
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 854517d..57066c5 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2777,7 +2777,8 @@
 513536751 VULKAN SWIFTSHADER : TransformFeedbackTest.InstancedOverflowIncompletePrimitive/* = SKIP
 
 // Fails test on Metal
-499091328 MAC AMD METAL : Texture2DArrayTestES3.ReformatStagedBufferUpdatesLayerCountOOB/* = SKIP
+499091328 MAC AMD METAL : Texture2DArrayTestES3.ReformatStagedBufferUpdatesLayerCountOOB_2DArray/* = SKIP
+512995370 MAC AMD METAL : Texture2DArrayTestES3.ReformatStagedBufferUpdatesLayerCountOOB_3D/* = SKIP
 
 // Timeout on Intel
 509829264 VULKAN WIN INTEL : SimpleStateChangeTestES31.TransformFeedbackThenReadWithCompute/* = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 6ec348a..988c349 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -9293,7 +9293,7 @@
 // (W * H * 4 bytes) exceeds ANGLE's kMaxBufferSizeForSuballocation (8 MiB). That
 // forces a dedicated VkBuffer of exactly W*H*4 bytes; vkCmdCopyBufferToImage with
 // layerCount=D, which used to then read (D-1)*W*H*4 bytes past the end of that VkBuffer.
-TEST_P(Texture2DArrayTestES3, ReformatStagedBufferUpdatesLayerCountOOB)
+TEST_P(Texture2DArrayTestES3, ReformatStagedBufferUpdatesLayerCountOOB_2DArray)
 {
     const int W = 1500;
     const int H = 1500;
@@ -9327,6 +9327,43 @@
     EXPECT_PIXEL_RECT_EQ(0, 0, W, H, GLColor::magenta);
 }
 
+// Similar to ReformatStagedBufferUpdatesLayerCountOOB_2DArray, but creates a 3D texture
+// to test that we correctly read the depth and not layerCount in reformatStagedBufferUpdates.
+TEST_P(Texture2DArrayTestES3, ReformatStagedBufferUpdatesLayerCountOOB_3D)
+{
+    const int W = 1500;
+    const int H = 1500;
+    const int D = 4;
+
+    // Create TEXTURE_3D and upload RGB8 data. On drivers where VK_FORMAT_R8G8B8_UNORM is sampleable
+    // but not color-attachable, ANGLE stages this as a Buffer update with formatID=R8G8B8_UNORM,
+    // imageExtent.depth=D, layerCount=1.
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_3D, tex);
+    glTexImage3D(GL_TEXTURE_3D, 0, GL_RGB8, W, H, D, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr);
+    ASSERT_GL_NO_ERROR();
+
+    // Stage a buffer update covering all D layers. Because this is a 3D texture,
+    // copy.imageExtent.depth = D and copy.imageSubresource.layerCount = 1.
+    // Fill with 0xAA so we can test for this pattern when reading back the last slice.
+    std::vector<uint8_t> pixels(W * H * D * 3, 0xAA);
+    glTexSubImage3D(GL_TEXTURE_3D, 0, 0, 0, 0, W, H, D, GL_RGB, GL_UNSIGNED_BYTE, pixels.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Bind the texture as a framebuffer attachment. This sets hasBeenBoundAsAttachment(); the next
+    // syncState() will call ensureRenderable() -> reformatStagedBufferUpdates().
+    GLFramebuffer fb;
+    glBindFramebuffer(GL_FRAMEBUFFER, fb);
+    glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, 0, D - 1);
+    ASSERT_GL_NO_ERROR();
+    ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    // Force flush of the now-reformatted, and once undersized (before bug fix), staged update via
+    // vkCmdCopyBufferToImage. readPixels syncs the framebuffer, which ensures the attached texture
+    // is initialized -> flushStagedUpdates -> potential GPU OOB read.
+    EXPECT_PIXEL_RECT_EQ(0, 0, W, H, (GLColor{0xAA, 0xAA, 0xAA, 0xFF}));
+}
+
 // Test shadow sampler and regular non-shadow sampler coexisting in the same shader.
 // This test is needed especially to confirm that sampler registers get assigned correctly on
 // the HLSL backend even when there's a mix of different HLSL sampler and texture types.
Loading diff…

Original Bug Report

reported by [email protected]

ANGLE Vulkan: Potential OOB read in reformatStagedBufferUpdates due to layerCount mismatch

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 without the security team.

Overview: ImageHelper::reformatStagedBufferUpdates in ANGLE’s Vulkan backend incorrectly sizes destination staging buffers for 2D array textures by ignoring the layerCount field. When these updates are later flushed, the GPU performs an out-of-bounds read from the shared staging pool, potentially leaking sensitive data from other contexts within the share group.

Affected files:

  • src/libANGLE/renderer/vulkan/vk_helpers.cpp
  • src/libANGLE/renderer/vulkan/TextureVk.cpp

Estimated timestamp from git blame: 2023-12-11

Description

In ANGLE’s Vulkan backend, ImageHelper::reformatStagedBufferUpdates is responsible for re-encoding staged buffer updates when a texture’s actual Vulkan format changes (e.g., when a texture initially using a sample-only format is first bound as a framebuffer attachment and requires a renderable fallback format).

A potential vulnerability exists in how the destination staging buffer is sized during this reformatting process. Specifically, for 2D array textures, ANGLE sets copy.imageExtent.depth to 1 and stores the actual number of layers in copy.imageSubresource.layerCount. However, reformatStagedBufferUpdates calculates the required buffer size using only copy.imageExtent.depth:

// From vk_helpers.cpp
size_t dstBufferSize = dstDataDepthPitch * copy.imageExtent.depth;

Because imageExtent.depth is 1 for array textures, the newly allocated buffer is only large enough for a single layer, regardless of how many layers were actually staged. The function subsequently calls CopyImageCHROMIUM with a depth of 1, reformatting only the first layer. It then updates the staged update’s bufferOffset to point to the new buffer but fails to modify imageSubresource.layerCount.

When the staged updates are later flushed in ImageHelper::flushStagedUpdatesImpl, vkCmdCopyBufferToImage is called using the modified copyRegion which still retains the original layerCount. Because the newly allocated buffer is only sized for one layer, the GPU reads layerCount - 1 layers out-of-bounds from the buffer.

The staging buffer is suballocated from a BufferPool via the Vulkan Memory Allocator (VMA). Because the BufferPool is shared across all contexts within a ShareGroupVk, these adjacent memory blocks may contain uninitialized GPU memory, data from other textures in the same share group, or stale data from previously freed buffers.

Impact

An attacker can potentially exfiltrate the contents of the GPU staging pool. By staging a multi-layer update, triggering a format re-encoding, and then reading back the texture layers using glReadPixels, the attacker can retrieve the out-of-bounds memory. This represents a GPU-process information leak.

Suggested Reproduction Steps

Note: These are suggested steps based on code analysis, as our tooling does not currently run code to provide a working proof of concept.

  1. Environment: Run on a Vulkan implementation where a specific format (e.g., GL_RGBA4 or GL_RGB5_A1) has different sample-only and renderable formats (common on mobile GPUs like Mali/Adreno or Intel iGPUs).
  2. Define Texture: Create a TEXTURE_2D_ARRAY with format GL_RGBA4 using glTexImage3D (passing null for pixels so it remains uninitialized on the GPU).
  3. Stage Update: Call glTexSubImage3D with multiple layers (e.g., depth = 4). This stages a buffer update with layerCount = 4 and imageExtent.depth = 1.
  4. Trigger Reformat: Bind the texture to a framebuffer and perform a rendering operation (like glClear). This triggers TextureVk::ensureRenderable, which calls reformatStagedBufferUpdates, incorrectly allocating a 1-layer buffer for the 4-layer update.
  5. Exfiltrate: Read back layers 1 through 3 using glReadPixels. The resulting data will be the contents of the memory adjacent to the allocated staging buffer in the VMA pool.

Suggested Fix

In ImageHelper::reformatStagedBufferUpdates, calculate the buffer size by multiplying by the number of layers if the update is for an array texture. Additionally, the CopyImageCHROMIUM call should be updated to loop over all layers, or bufferRowLength and bufferImageHeight must be updated correctly if the data layout changes.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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