High CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in ANGLE
DescriptionHeap buffer overflow in ANGLE
ComponentANGLE
Sub-componentVulkan
Bug ClassOOB
Tracker502738003
Fix commit4c1ee4df925d (angle/angle) +353/-39
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-15 (per vendor advisory)
Fix landed2026-05-06 (public git)
Disclosed2026-05-27
Patch gap21 days — fix public in source before the advisory

Background

ANGLE Vulkan backend
Translates WebGL/GLES into Vulkan in the GPU process. Texture mutations are recorded as staged subresource updates against a vk::ImageHelper and flushed later via vkCmdCopyImage/buffer copies.
`matchesLayerRange` / `intersectsLayerRange`
Layer-range predicates. Pre-fix matching required an exact layerCount match, so a 6-layer (whole-cubemap) staged update would not be matched/removed by a 1-layer (single-face) redefinition.
`mRedefinedLevels` / `OutsideAllocatedImage`
A per-level/per-face bitmask tracking redefined subresources; when a level is outside the currently-allocated image (e.g. after raising TEXTURE_BASE_LEVEL), redefinition can skip updating this mask, so the stale staged update is not later skipped at flush.
`shouldReleaseImage` (in `renderer_utils.cpp`)
Decides whether the old single-level image may be released on redefinition; for cubemaps this must account for all six faces.

Root Cause Analysis

The invariant is that a staged update must either be fully consumed by the new image allocation or be pruned when its subresource is redefined; a staged update must never outlive a redefinition that shrinks its destination. The vulnerable code staged whole-cubemap preservation updates as a single 6-layer (Make2DArrayRange(level, 0, mLayerCount)) update, but allowed cube faces to be redefined one at a time. When a single face was redefined, removeSingleSubresourceStagedUpdates called matchesLayerRange with layerCount == 1, which (requiring an exact layer-count match) failed to match the 6-layer update, so it was not pruned. Combined with mRedefinedLevels not being set when the level was OutsideAllocatedImage (so the flush was not skipped), a later draw-forced flushStagedUpdatesImpl processed the stale 6-layer update and emitted a vkCmdCopyImage copying the old large extent into the new small destination image — a massive out-of-bounds copy (CPU memcpy overflow on SwiftShader; OOB DMA write on hardware drivers).

The patch makes cubemap staged updates always face-by-face so single-face removal always matches. In stageSelfAsSubresourceUpdates (vk_helpers.cpp), the CubeMap branch now unconditionally loops for (face 0..kCubeFaceCount) staging Make2DArrayRange(level, face, 1) per face, and the old “stage all mLayerCount at once unless skipped” path is relegated to the non-cubemap else. stageSubresourceUpdateFromImage gains an explicit srcLayerIndex parameter and separate srcImageType/dstImageType, setting copyToImage.srcSubresource.baseArrayLayer = srcLayerIndex and correct per-face layerCount, so each face copies exactly one layer. renderer_utils.cpp’s shouldReleaseImage now only releases a cubemap’s single-level image if all six faces have been redefined (looping (*redefinedLevels)[face][levelIndexGL]), instead of the old !isCubeMap blanket exclusion. Finally, a defensive ASSERT(!update->intersectsLayerRange(layerIndex, layerCount, mLayerCount)) is added in removeSingleSubresourceStagedUpdates to catch any retained update that still overlaps the range being dropped. The advisory’s CWE-122 heap-buffer-overflow is consistent with the diff’s copy-extent/staging-granularity fix.

Key insight
Whole-cubemap content was staged as one 6-layer update while faces could be redefined individually, and single-face pruning required an exact layer-count match — so a stale 6-layer update survived a face redefinition and was flushed as an oversized vkCmdCopyImage into a smaller image.

Attack Path

  1. Allocate a large cubemap Via WebGL2, create a cubemap with all 6 faces at a large size (e.g. 2048x2048) at mip level 0. (inferred reachability from WebGL2; diff proves the staging/pruning defect.)
  2. Stage whole-cubemap preservation Raise TEXTURE_BASE_LEVEL to 1, triggering syncState→respecifyImageStorageIfNecessary, which stages a 6-layer update of the large image; level 0 becomes OutsideAllocatedImage.
  3. Redefine one/some faces small texImage2D faces at level 0 to a smaller size (e.g. 600x600). The 6-layer staged update is not pruned (layer-count mismatch), and mRedefinedLevels is not updated because level 0 is outside the allocated image.
  4. Restore base level Set TEXTURE_BASE_LEVEL back to 0 so the (now smaller) level 0 is in-range again and the flush is not skipped.
  5. Force flush Issue a draw sampling the cubemap; flushStagedUpdatesImpl processes the stale 6-layer update and emits a vkCmdCopyImage of the 2048x2048 extent into the 600x600 destination.
  6. OOB write primitive The oversized copy overflows the destination allocation: a linear heap OOB memcpy on SwiftShader, or an OOB GPU DMA write on hardware (last step / primitive).

Impact Assessment

The overflow is in the GPU process, reachable zero-click from a WebGL2 context (CVSS AC:L, no special privilege). On SwiftShader the result is a CPU-side linear heap buffer overflow; on hardware Vulkan drivers (Android/ChromeOS) it is an out-of-bounds GPU DMA write. Because the GPU process is unsandboxed on Android, a controlled OOB write there is a plausible path to device compromise/sandbox escape and RCE — but that escalation is inferred; the diff substantiates the oversized-copy / OOB write, not a working RCE. The raw copy is not MiraclePtr-protected.

Files Changed

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

Audit Directions

  • Exact-match layer/level predicates
    Audit all matchesLayerRange/range-match logic used for pruning staged updates to ensure overlapping (not just exactly-equal) ranges are handled; the new intersectsLayerRange assert marks the contract.
  • Per-face vs whole-array staging
    Review every stageSelfAsSubresourceUpdates/stageSubresourceUpdateFromImage call for array textures and 3D images where granularity of redefinition can differ from granularity of staging.
  • `mRedefinedLevels` + `OutsideAllocatedImage`
    Check other redefinition paths that skip updating the redefined-levels bitmask when a level is outside the allocated image, allowing stale flushes.
  • Copy-extent vs destination-size validation
    Look for other vkCmdCopyImage/vkCmdCopyBuffer emitters in the Vulkan backend that trust staged extents without clamping to the current destination allocation.
From 4c1ee4df925d3d8edccd8e7dad7d05f9b807b3a4 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
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 <syoussefi@chromium.org>
Reviewed-by: Charlie Lao <cclao@google.com>
---

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, &copyToImage.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);
+        }
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by rj...@google.com

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

  1. Incorrect Update Pruning: When a cubemap face is redefined via TextureVk::redefineLevel, ANGLE attempts to remove existing staged updates for that face by calling mImage->removeSingleSubresourceStagedUpdates with a layerCount of 1.
  2. Strict Layer Matching: Inside removeSingleSubresourceStagedUpdates, the function matchesLayerRange(layerIndex, layerCount) is used to find matching updates. However, matchesLayerRange strictly requires an exact match on layerCount. If an existing staged update spans all 6 layers (e.g., generated by a state sync from toggling TEXTURE_BASE_LEVEL), it will not match the 1-layer redefinition. Consequently, the 6-layer staged update is not pruned.
  3. Missed Redefinition Masking: If the level being redefined is temporarily considered OutsideAllocatedImage (e.g., because TEXTURE_BASE_LEVEL was increased), TextureRedefineLevel skips updating the mRedefinedLevels bitmask.
  4. Unclamped Copy Extent: When the base level is restored and a draw call forces a flush, flushStagedUpdatesImpl processes the pending updates. Since mRedefinedLevels was not set, the flush is not skipped. The stale, un-pruned 6-layer update is processed, causing ANGLE to emit a vkCmdCopyImage command 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::copySingleAspectTo results in a linear out-of-bounds memcpy on 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.)

  1. Using WebGL2, initialize a large cubemap (e.g., 2048x2048) on all 6 faces at mip level 0.
  2. Toggle TEXTURE_BASE_LEVEL to 1. This triggers TextureVk::syncState -> respecifyImageStorageIfNecessary, which stages a 6-layer update of the 2048x2048 image to preserve its contents. Level 0 is now considered OutsideAllocatedImage.
  3. 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 the layerCount mismatch in matchesLayerRange. mRedefinedLevels is not updated because level 0 is outside the allocated image.
  4. Toggle TEXTURE_BASE_LEVEL back to 0.
  5. 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.

  1. Modify matchesLayerRange or the logic inside removeSingleSubresourceStagedUpdates to 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.
  2. Ensure that mRedefinedLevels is 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.

View on issue tracker