CVE-2026-9940
Overview
Files Changed
src/libANGLE/renderer/renderer_utils.cppsrc/libANGLE/renderer/vulkan/TextureVk.cppsrc/libANGLE/renderer/vulkan/vk_helpers.cpp
Patch
From 4c1ee4df925d3d8edccd8e7dad7d05f9b807b3a4 Mon Sep 17 00:00:00 2001 From: Shahbaz Youssefi <[email protected]> Date: Mon, 04 May 2026 16:52:30 -0400 Subject: [PATCH] Vulkan: Fix removing staged updates during cube map redefine When a texture is redefined, `stageSelfAsSubresourceUpdates` is used to make the old VkImage the source of a data update to the new VkImage. Later, when a subresource is redefined, the staged update is dropped. For this reason, the updates are staged level-by-level. Because cube maps can be redefined face-by-face, these updates can be even more granularly staged (i.e. per face). This was already the case if the cube map was partially redefined, but wasn't done if it's fully redefined. This change makes staging updates always face-by-face for cube maps. This way, `removeSingleSubresourceStagedUpdates` can always drop updates to a face that is being redefined. An assert is added to that function to make sure that no update exists that intersects with the range being dropped. Bug: chromium:502738003 Change-Id: I4df95379a71e3da0fa482111cd44257cd3d103b4 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7818814 Commit-Queue: Shahbaz Youssefi <[email protected]> Reviewed-by: Charlie Lao <[email protected]> --- diff --git a/src/libANGLE/renderer/renderer_utils.cpp b/src/libANGLE/renderer/renderer_utils.cpp index cd0e52d..6c9ff82 100644 --- a/src/libANGLE/renderer/renderer_utils.cpp +++ b/src/libANGLE/renderer/renderer_utils.cpp @@ -2452,9 +2452,20 @@ // so it can be recreated immediately. This is needed so that the texture can be reallocated // with the correct format/size. // - // This is not done for cubemaps because every face may be separately redefined. Note - // that this is not possible for texture arrays in general. - bool shouldReleaseImage = !isCompatibleRedefinition && isUpdateToSingleLevelImage && !isCubeMap; + // For cubemaps, every face may be separately redefined, so only release the image if all faces + // have been redefined. Note that this is not possible for texture arrays in general. + bool shouldReleaseImage = !isCompatibleRedefinition && isUpdateToSingleLevelImage; + if (shouldReleaseImage && isCubeMap) + { + for (uint32_t face = 0; face < 6; ++face) + { + if (!(*redefinedLevels)[face][levelIndexGL.get()]) + { + shouldReleaseImage = false; + break; + } + } + } return shouldReleaseImage; } diff --git a/src/libANGLE/renderer/vulkan/TextureVk.cpp b/src/libANGLE/renderer/vulkan/TextureVk.cpp index a93ac6f..0ad33d8 100644 --- a/src/libANGLE/renderer/vulkan/TextureVk.cpp +++ b/src/libANGLE/renderer/vulkan/TextureVk.cpp @@ -1973,8 +1973,8 @@ const gl::ImageIndex stagingIndex = gl::ImageIndex::Make2DArrayRange(level.get(), baseLayer, layerCount); mImage->stageSubresourceUpdateFromImage(stagingImage.release(), stagingIndex, - vk::LevelIndex(0), dstOffsetModified, extents, - imageType); + vk::LevelIndex(0), 0, dstOffsetModified, extents, + VK_IMAGE_TYPE_2D, imageType); } return angle::Result::Continue; @@ -2148,8 +2148,8 @@ const gl::ImageIndex stagingIndex = gl::ImageIndex::Make2DArrayRange(level.get(), baseLayer, layerCount); mImage->stageSubresourceUpdateFromImage(stagingImage.release(), stagingIndex, - vk::LevelIndex(0), dstOffsetModified, extents, - imageType); + vk::LevelIndex(0), 0, dstOffsetModified, extents, + VK_IMAGE_TYPE_2D, imageType); } return angle::Result::Continue; diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.cpp b/src/libANGLE/renderer/vulkan/vk_helpers.cpp index b70b7d7..064cd64 100644 --- a/src/libANGLE/renderer/vulkan/vk_helpers.cpp +++ b/src/libANGLE/renderer/vulkan/vk_helpers.cpp @@ -8264,6 +8264,10 @@ } else { + // The layer range should either match the update, or not intersect with it. If this + // assertion fails, the update should be pertially removed, but is retained which is + // incorrect. + ASSERT(!update->intersectsLayerRange(layerIndex, layerCount, mLayerCount)); index++; } } @@ -9458,9 +9462,11 @@ void ImageHelper::stageSubresourceUpdateFromImage(RefCounted<ImageHelper> *image, const gl::ImageIndex &index, LevelIndex srcMipLevel, + uint32_t srcLayerIndex, const gl::Offset &destOffset, const gl::Extents &glExtents, - const VkImageType imageType) + const VkImageType srcImageType, + const VkImageType dstImageType) { gl::LevelIndex updateLevelGL(index.getLevelIndex()); VkImageAspectFlags imageAspectFlags = vk::GetFormatAspectFlags(image->get().getActualFormat()); @@ -9468,26 +9474,31 @@ VkImageCopy copyToImage = {}; copyToImage.srcSubresource.aspectMask = imageAspectFlags; copyToImage.srcSubresource.mipLevel = srcMipLevel.get(); + copyToImage.srcSubresource.baseArrayLayer = srcLayerIndex; copyToImage.srcSubresource.layerCount = index.getLayerCount(); copyToImage.dstSubresource.aspectMask = imageAspectFlags; copyToImage.dstSubresource.mipLevel = updateLevelGL.get(); + copyToImage.dstSubresource.layerCount = index.getLayerCount(); - if (imageType == VK_IMAGE_TYPE_3D) + // These values must be set explicitly to follow the Vulkan spec: + // https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkImageCopy.html + // If either of the calling command's srcImage or dstImage parameters are of VkImageType + // VK_IMAGE_TYPE_3D, the baseArrayLayer and layerCount members of the corresponding + // subresource must be 0 and 1, respectively. + if (srcImageType == VK_IMAGE_TYPE_3D) { - // These values must be set explicitly to follow the Vulkan spec: - // https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkImageCopy.html - // If either of the calling command's srcImage or dstImage parameters are of VkImageType - // VK_IMAGE_TYPE_3D, the baseArrayLayer and layerCount members of the corresponding - // subresource must be 0 and 1, respectively - copyToImage.dstSubresource.baseArrayLayer = 0; - copyToImage.dstSubresource.layerCount = 1; + ASSERT(srcLayerIndex == 0); + copyToImage.srcSubresource.layerCount = 1; + } + if (dstImageType == VK_IMAGE_TYPE_3D) + { + copyToImage.dstSubresource.layerCount = 1; // Preserve the assumption that destOffset.z == "dstSubresource.baseArrayLayer" ASSERT(destOffset.z == (index.hasLayer() ? index.getLayerIndex() : 0)); } else { copyToImage.dstSubresource.baseArrayLayer = index.hasLayer() ? index.getLayerIndex() : 0; - copyToImage.dstSubresource.layerCount = index.getLayerCount(); } gl_vk::GetOffset(destOffset, ©ToImage.dstOffset); @@ -9506,9 +9517,9 @@ const gl::ImageIndex index = gl::ImageIndex::Make2DArrayRange(levelGL.get(), 0, image->get().getLayerCount()); - stageSubresourceUpdateFromImage(image, index, levelVk, gl::kOffsetZero, + stageSubresourceUpdateFromImage(image, index, levelVk, 0, gl::kOffsetZero, image->get().getLevelExtents(levelVk), - image->get().getType()); + image->get().getType(), image->get().getType()); } } @@ -9824,15 +9835,7 @@ for (LevelIndex levelVk(0); levelVk < LevelIndex(levelCount); ++levelVk) { gl::LevelIndex levelGL = toGLLevel(levelVk); - if (!skipLevelsAllFaces.test(levelGL.get())) - { - const gl::ImageIndex index = - gl::ImageIndex::Make2DArrayRange(levelGL.get(), 0, mLayerCount); - - stageSubresourceUpdateFromImage(prevImage.get(), index, levelVk, gl::kOffsetZero, - getLevelExtents(levelVk), mImageType); - } - else if (textureType == gl::TextureType::CubeMap) + if (textureType == gl::TextureType::CubeMap) { for (uint32_t face = 0; face < gl::kCubeFaceCount; ++face) { @@ -9841,12 +9844,20 @@ const gl::ImageIndex index = gl::ImageIndex::Make2DArrayRange(levelGL.get(), face, 1); - stageSubresourceUpdateFromImage(prevImage.get(), index, levelVk, + stageSubresourceUpdateFromImage(prevImage.get(), index, levelVk, face, gl::kOffsetZero, getLevelExtents(levelVk), - mImageType); + mImageType, mImageType); } } } + else if (!skipLevelsAllFaces.test(levelGL.get())) + { + const gl::ImageIndex index = + gl::ImageIndex::Make2DArrayRange(levelGL.get(), 0, mLayerCount); + + stageSubresourceUpdateFromImage(prevImage.get(), index, levelVk, 0, gl::kOffsetZero, + getLevelExtents(levelVk), mImageType, mImageType); + }
Regression Test / PoC
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 9d18f5e..d84daf1 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -2375,7 +2375,7 @@
EXPECT_PIXEL_COLOR_EQ(0, 0, kMipColors[0][0]);
}
-// Test drawing with two texture types, to trigger an ANGLE bug in validation
+// Test drawing with two texture types, regression test for an old bug in validation.
TEST_P(TextureCubeTest, CubeMapBug)
{
glActiveTexture(GL_TEXTURE0);
@@ -2391,7 +2391,8 @@
EXPECT_GL_NO_ERROR();
}
-// Duplicate of CubeMapBug test and change texture bind unit to trigger an ANGLE bug in validation
+// Duplicate of CubeMapBug test and change texture bind unit, regression test for an old bug in
+// validation.
TEST_P(TextureCubeTest, CubeMapBug2)
{
const char *vertexShaderSource = getVertexShaderSource();
@@ -2560,8 +2561,8 @@
EXPECT_PIXEL_COLOR_EQ(px, py, GLColor::green);
}
-// Test creating a FBO with a cube map render target, to test an ANGLE bug
-// https://code.google.com/p/angleproject/issues/detail?id=849
+// Test creating a FBO with a cube map render target, regression test for
+// http://anglebug.com/42266912
TEST_P(TextureCubeTest, CubeMapFBO)
{
// http://anglebug.com/42261821
@@ -2645,6 +2646,237 @@
ASSERT_GL_NO_ERROR();
}
+// Test modifying level 0 while BASE_LEVEL is not 0. Level 0 is allocated, so staged updated when
+// the base level is changed is from the old image allocation.
+TEST_P(TextureCubeTestES3, UpdateLevelZeroWhileBaseLevelIsOne)
+{
+ constexpr uint32_t kSize = 128;
+
+ const std::vector<GLColor> kCubeData(kSize * kSize, GLColor::red);
+ const std::vector<GLColor> kCubeData2(kSize * kSize, GLColor::green);
+ const std::vector<GLColor> kCubeData3(kSize * kSize / 4, GLColor::blue);
+
+ // Create a mutable cube texture
+ GLTexture cube;
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_CUBE_MAP, cube);
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, kSize, kSize, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, kCubeData.data());
+ }
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ // Draw to flush staged updates
+ glUseProgram(mProgram);
+ glUniform1i(mTextureCubeUniformLocation, 0);
+ glUniform1i(mTexture2DUniformLocation, 1);
+
+ const int w = getWindowWidth();
+ const int h = getWindowHeight();
+
+ for (uint32_t i = 0; i < 6; ++i)
+ {
+ glUniform1i(mTextureCubeFaceUniformLocation, i);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(mProgram, "position", 0.5f);
+
+ EXPECT_PIXEL_COLOR_EQ(w / 2, h / 2, GLColor::red) << i;
+ }
+
+ // Set its base level to 1, so any updates to level 0 looks like it's outside the range of the
+ // texture
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_BASE_LEVEL, 1);
+
+ // Populate level 1 so it can be drawn with.
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 1, GL_RGBA, kSize, kSize, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, kCubeData2.data());
+ }
+
+ // Again, sync the texture
+ for (uint32_t i = 0; i < 6; ++i)
+ {
+ glUniform1i(mTextureCubeFaceUniformLocation, i);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(mProgram, "position", 0.5f);
+
+ EXPECT_PIXEL_COLOR_EQ(w / 2, h / 2, GLColor::green) << i;
+ }
+
+ // Redefine level 0 to a smaller size. Regression test for a bug where the data from the old /
+ // larger definition is applied to the new dimensions.
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, kSize / 2, kSize / 2, 0,
+ GL_RGBA, GL_UNSIGNED_BYTE, kCubeData3.data());
+ }
+
+ // Reset base level back to 0 and draw.
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_BASE_LEVEL, 0);
+
+ for (uint32_t i = 0; i < 6; ++i)
+ {
+ glUniform1i(mTextureCubeFaceUniformLocation, i);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(mProgram, "position", 0.5f);
+
+ EXPECT_PIXEL_COLOR_EQ(w / 2, h / 2, GLColor::blue) << i;
+ }
+}
+
+// Test modifying level 0 while BASE_LEVEL is not 0. Level 0 has pending data uploads.
+TEST_P(TextureCubeTestES3, UpdateLevelZeroWhileBaseLevelIsOneWithPendingUploads)
+{
+ constexpr uint32_t kSize = 128;
+
+ const std::vector<GLColor> kCubeData(kSize * kSize, GLColor::red);
+ const std::vector<GLColor> kCubeData2(kSize * kSize, GLColor::green);
+ const std::vector<GLColor> kCubeData3(kSize * kSize / 4, GLColor::blue);
+
+ // Create a mutable cube texture
+ GLTexture cube;
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_CUBE_MAP, cube);
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, kSize, kSize, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, kCubeData.data());
+ }
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ // Keep the updates unflushed.
+ // Set its base level to 1, so any updates to level 0 looks like it's outside the range of the
+ // texture
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_BASE_LEVEL, 1);
+
+ // Populate level 1 so it can be drawn with.
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 1, GL_RGBA, kSize, kSize, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, kCubeData2.data());
+ }
+
+ // Draw to flush staged updates to level 1
+ glUseProgram(mProgram);
+ glUniform1i(mTextureCubeUniformLocation, 0);
+ glUniform1i(mTexture2DUniformLocation, 1);
+
+ const int w = getWindowWidth();
+ const int h = getWindowHeight();
+
+ for (uint32_t i = 0; i < 6; ++i)
+ {
+ glUniform1i(mTextureCubeFaceUniformLocation, i);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(mProgram, "position", 0.5f);
+
+ EXPECT_PIXEL_COLOR_EQ(w / 2, h / 2, GLColor::green) << i;
+ }
+
+ // Redefine level 0 to a smaller size.
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, kSize / 2, kSize / 2, 0,
+ GL_RGBA, GL_UNSIGNED_BYTE, kCubeData3.data());
+ }
+
+ // Reset base level back to 0 and draw.
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_BASE_LEVEL, 0);
+
+ for (uint32_t i = 0; i < 6; ++i)
+ {
+ glUniform1i(mTextureCubeFaceUniformLocation, i);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(mProgram, "position", 0.5f);
+
+ EXPECT_PIXEL_COLOR_EQ(w / 2, h / 2, GLColor::blue) << i;
+ }
+}
+
+// Test modifying level 0 while BASE_LEVEL is not 0. Level 0 has pending clears.
+TEST_P(TextureCubeTestES3, UpdateLevelZeroWhileBaseLevelIsOneWithPendingClears)
+{
+ constexpr uint32_t kSize = 128;
+
+ const std::vector<GLColor> kCubeData(kSize * kSize, GLColor::green);
+ const std::vector<GLColor> kCubeData2(kSize * kSize / 4, GLColor::blue);
+
+ // Create a mutable cube texture
+ GLTexture cube;
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_CUBE_MAP, cube);
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, kSize, kSize, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ }
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ GLFramebuffer clearFbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, clearFbo);
+ glClearColor(1, 0, 0, 1);
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
+ GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, cube, 0);
+ glClear(GL_COLOR_BUFFER_BIT);
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+
+ // Keep the updates unflushed.
+ // Set its base level to 1, so any updates to level 0 looks like it's outside the range of the
+ // texture
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_BASE_LEVEL, 1);
+
+ // Populate level 1 so it can be drawn with.
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 1, GL_RGBA, kSize, kSize, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, kCubeData.data());
+ }
+
+ // Draw to flush staged updates to level 1
+ glUseProgram(mProgram);
+ glUniform1i(mTextureCubeUniformLocation, 0);
+ glUniform1i(mTexture2DUniformLocation, 1);
+
+ const int w = getWindowWidth();
+ const int h = getWindowHeight();
+
+ for (uint32_t i = 0; i < 6; ++i)
+ {
+ glUniform1i(mTextureCubeFaceUniformLocation, i);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(mProgram, "position", 0.5f);
+
+ EXPECT_PIXEL_COLOR_EQ(w / 2, h / 2, GLColor::green) << i;
+ }
+
+ // Redefine level 0 to a smaller size.
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, kSize / 2, kSize / 2, 0,
+ GL_RGBA, GL_UNSIGNED_BYTE, kCubeData2.data());
+ }
+
+ // Reset base level back to 0 and draw.
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_BASE_LEVEL, 0);
+
+ for (uint32_t i = 0; i < 6; ++i)
+ {
+ glUniform1i(mTextureCubeFaceUniformLocation, i);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(mProgram, "position", 0.5f);
+
+ EXPECT_PIXEL_COLOR_EQ(w / 2, h / 2, GLColor::blue) << i;
+ }
+}
+
// Test that glTexSubImage2D works properly when glTexStorage2DEXT has initialized the image with a
// default color.
TEST_P(Texture2DTest, TexStorage)
@@ -14400,6 +14632,61 @@
}
}
+// Test incompatible redefinition of all the faces of a cubemap after the cubemap is allocated.
+TEST_P(TextureCubeTestES3, EntirelyRedefine)
+{
+ constexpr uint32_t kSize = 128;
+
+ const std::vector<GLColor> kCubeData(kSize * kSize, GLColor::red);
+ const std::vector<GLColor> kCubeData2(kSize * kSize / 4, GLColor::blue);
+
+ // Create a mutable cube texture
+ GLTexture cube;
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_CUBE_MAP, cube);
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, kSize, kSize, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, kCubeData.data());
+ }
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ // Draw to flush staged updates
+ glUseProgram(mProgram);
+ glUniform1i(mTextureCubeUniformLocation, 0);
+ glUniform1i(mTexture2DUniformLocation, 1);
... (truncated)
Original Bug Report
Potential Heap-buffer-overflow in the GPU process via stale ANGLE Vulkan staged updates
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 Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A potential heap-buffer-overflow exists in the GPU process when using the ANGLE Vulkan backend due to incorrect pruning of staged updates. Redefining a single cubemap face fails to remove an existing 6-face staged update, which can cause a massive out-of-bounds write when flushed to a smaller destination image.
Affected files:
third_party/swiftshader/src/Vulkan/VkImage.cppthird_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cppthird_party/angle/src/libANGLE/renderer/vulkan/TextureVk.cpp
Estimated timestamp from git blame: 2025-09-08
Overview
There is a potential heap buffer overflow vulnerability in the GPU process when utilizing ANGLE’s Vulkan backend. The issue occurs when redefining the size of a cubemap texture after a staged update has been created for all 6 faces. A flaw in the update pruning logic causes the original, larger staged update to remain active. When flushed, this emits an invalid vkCmdCopyImage command that attempts to copy the large staged extent into a newly allocated, smaller destination image.
Root Cause Analysis
- Incorrect Update Pruning: When a cubemap face is redefined via
TextureVk::redefineLevel, ANGLE attempts to remove existing staged updates for that face by callingmImage->removeSingleSubresourceStagedUpdateswith alayerCountof 1. - Strict Layer Matching: Inside
removeSingleSubresourceStagedUpdates, the functionmatchesLayerRange(layerIndex, layerCount)is used to find matching updates. However,matchesLayerRangestrictly requires an exact match onlayerCount. If an existing staged update spans all 6 layers (e.g., generated by a state sync from togglingTEXTURE_BASE_LEVEL), it will not match the 1-layer redefinition. Consequently, the 6-layer staged update is not pruned. - Missed Redefinition Masking: If the level being redefined is temporarily considered
OutsideAllocatedImage(e.g., becauseTEXTURE_BASE_LEVELwas increased),TextureRedefineLevelskips updating themRedefinedLevelsbitmask. - Unclamped Copy Extent: When the base level is restored and a draw call forces a flush,
flushStagedUpdatesImplprocesses the pending updates. SincemRedefinedLevelswas not set, the flush is not skipped. The stale, un-pruned 6-layer update is processed, causing ANGLE to emit avkCmdCopyImagecommand using the large dimensions of the old staged update against the new, smaller destination image allocation.
Impact
This results in a severe Vulkan Valid Usage violation.
- On SwiftShader (software Vulkan), the oversized extent passed to
Image::copySingleAspectToresults in a linear out-of-boundsmemcpyon the CPU heap. - On hardware Vulkan drivers (e.g., Android, ChromeOS), this translates to an out-of-bounds DMA write by the GPU.
Because the GPU process is explicitly unsandboxed on Android, successfully exploiting this out-of-bounds write could provide an attacker with a direct path to full device compromise (Remote Code Execution and Sandbox Escape) from a zero-click WebGL2 context.
Suggested Attacker Steps
(Note: These are potential steps as our tooling agent cannot execute code to verify the PoC directly.)
- Using WebGL2, initialize a large cubemap (e.g., 2048x2048) on all 6 faces at mip level 0.
- Toggle
TEXTURE_BASE_LEVELto 1. This triggersTextureVk::syncState->respecifyImageStorageIfNecessary, which stages a 6-layer update of the 2048x2048 image to preserve its contents. Level 0 is now consideredOutsideAllocatedImage. - Redefine the cubemap faces at mip level 0 to a smaller size (e.g., 600x600) using
texImage2D. The 6-layer staged update fails to be pruned due to thelayerCountmismatch inmatchesLayerRange.mRedefinedLevelsis not updated because level 0 is outside the allocated image. - Toggle
TEXTURE_BASE_LEVELback to 0. - Issue a draw call that samples from the cubemap. This forces a flush, causing the Vulkan driver to attempt to copy the 2048x2048 staged image into the new 600x600 destination buffer.
Suggested Fix
The staged update tracking mechanism needs to handle overlapping layer ranges correctly.
- Modify
matchesLayerRangeor the logic insideremoveSingleSubresourceStagedUpdatesto correctly identify and prune overlapping updates, perhaps by splitting the existing multi-layer update into smaller updates if only a subset of layers is being redefined. - Ensure that
mRedefinedLevelsis correctly tracked and evaluated even when a level is temporarily outside the allocated image range, preventing stale updates from being flushed inappropriately.
Evaluated with Chrome root at commit: c0eb5541aebfa4ea08806eaf6e94bcc69f87ab2f
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.