Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Bug ClassUAF
Tracker498371085
Fix commit7757353a17f7 (angle/angle) +100/-32
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • src/libANGLE/renderer/vulkan/ContextVk.cpp
  • src/libANGLE/renderer/vulkan/FramebufferVk.cpp
  • src/libANGLE/renderer/vulkan/RenderbufferVk.cpp
  • src/libANGLE/renderer/vulkan/ShareGroupVk.cpp
  • src/libANGLE/renderer/vulkan/ShareGroupVk.h
  • src/libANGLE/renderer/vulkan/TextureVk.cpp
  • src/libANGLE/renderer/vulkan/vk_helpers.cpp
From 7757353a17f7b8fee0e766a3069455ecb735599d Mon Sep 17 00:00:00 2001
From: Charlie Lao <cclao@google.com>
Date: Wed, 01 Apr 2026 14:57:33 -0700
Subject: [PATCH] Vulkan: Remove image from mImagesWithTileMemory when fallback

When we fallback from tile memory, make sure we remove it from
mImagesWithTileMemory. Otherwise, when image is released, it walks
mImagesWithTileMemory and check mUsesTileMemory and is false, it will
keep it in the list even though image is released. There is no actual
bug exist yet, due to almost all cases when we fallback, we will always
end up with submitCommands which will make mImagesWithTileMemory empty.
But this change will make it more robust.

Bug: b/498371085
Change-Id: I6f620e46ba2cddba06004539e640cc8626c76e91
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7723223
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Yuxin Hu <yuxinhu@google.com>
Commit-Queue: Charlie Lao <cclao@google.com>
---

diff --git a/src/libANGLE/renderer/vulkan/ContextVk.cpp b/src/libANGLE/renderer/vulkan/ContextVk.cpp
index 93a9617..8fad6f7 100644
--- a/src/libANGLE/renderer/vulkan/ContextVk.cpp
+++ b/src/libANGLE/renderer/vulkan/ContextVk.cpp
@@ -9034,7 +9034,6 @@
 
 bool ContextVk::isImageWithTileMemoryFinalized(const vk::ImageHelper *image) const
 {
-    ASSERT(image->useTileMemory());
     return std::find(mImagesWithTileMemory.begin(), mImagesWithTileMemory.end(), image) ==
            mImagesWithTileMemory.end();
 }
@@ -9042,32 +9041,30 @@
 angle::Result ContextVk::finalizeImagesWithTileMemory()
 {
     ASSERT(!mImagesWithTileMemory.empty());
+    std::vector<vk::ImageHelper *> imagesToClearForSimulation;
 
     // Check all images with tile memory to see if they have valid content or not. tile memory are
     // transient, we must reallocate to keep data valid across command buffer boundary.
-    for (auto iter = mImagesWithTileMemory.begin(); iter != mImagesWithTileMemory.end();)
+    while (!mImagesWithTileMemory.empty())
     {
-        vk::ImageHelper *image = *iter;
+        vk::ImageHelper *image = mImagesWithTileMemory.back();
+        ASSERT(image->useTileMemory());
+        mImagesWithTileMemory.pop_back();
+
         // Other context may have submitted command buffer and causes it fallback already, so check
         // again.
-        if (image->isVkImageContentDefined() && image->useTileMemory())
+        if (image->isVkImageContentDefined())
         {
             ANGLE_TRY(image->fallbackFromTileMemory(this));
             ASSERT(!image->useTileMemory());
-            iter = mImagesWithTileMemory.erase(iter);
         }
-        else
+        else if (!getFeatures().supportsTileMemoryHeap.enabled)
         {
-            ++iter;
+            imagesToClearForSimulation.push_back(image);
         }
     }
 
-    if (getFeatures().supportsTileMemoryHeap.enabled)
-    {
-        // We dont explicitly unbind tileMemory here. They occur implicitly at endCommandBiuffer
-        // time
-    }
-    else
+    if (!imagesToClearForSimulation.empty())
     {
         ASSERT(getFeatures().simulateTileMemoryForTesting.enabled);
 
@@ -9077,7 +9074,7 @@
         params.layer                           = 0;
         params.clearValue                      = {};
         params.clearArea                       = gl::Box(0, 0, 0, 0, 0, 1);
-        for (vk::ImageHelper *image : mImagesWithTileMemory)
+        for (vk::ImageHelper *image : imagesToClearForSimulation)
         {
             // Other context may have triggered fallback already, so check
             // again.
@@ -9100,10 +9097,13 @@
                 image->invalidateEntireLevelStencilContent(this, gl::LevelIndex(0));
             }
         }
+
+        imagesToClearForSimulation.clear();
+        // clearTextureNoFlush will end up add it back to mImagesWithTileMemory.
+        mImagesWithTileMemory.clear();
     }
 
-    mImagesWithTileMemory.clear();
-
+    ASSERT(mImagesWithTileMemory.empty());
     return angle::Result::Continue;
 }
 
diff --git a/src/libANGLE/renderer/vulkan/FramebufferVk.cpp b/src/libANGLE/renderer/vulkan/FramebufferVk.cpp
index 4577593..274530b 100644
--- a/src/libANGLE/renderer/vulkan/FramebufferVk.cpp
+++ b/src/libANGLE/renderer/vulkan/FramebufferVk.cpp
@@ -1289,14 +1289,12 @@
         if (!readImage.canTransferFrom())
         {
             ASSERT(readImage.useTileMemory());
-            readImage.finalizeImageLayoutInShareContexts(renderer, contextVk, {});
             ANGLE_TRY(readImage.fallbackFromTileMemory(contextVk));
         }
 
         if (!drawImage.canTransferTo())
         {
             ASSERT(drawImage.useTileMemory());
-            drawImage.finalizeImageLayoutInShareContexts(renderer, contextVk, {});
             ANGLE_TRY(drawImage.fallbackFromTileMemory(contextVk));
         }
     }
diff --git a/src/libANGLE/renderer/vulkan/RenderbufferVk.cpp b/src/libANGLE/renderer/vulkan/RenderbufferVk.cpp
index 8140f9f..920dde1 100644
--- a/src/libANGLE/renderer/vulkan/RenderbufferVk.cpp
+++ b/src/libANGLE/renderer/vulkan/RenderbufferVk.cpp
@@ -332,7 +332,7 @@
     {
         if (mImage)
         {
-            mImage->finalizeImageLayoutInShareContexts(renderer, contextVk, mImageSiblingSerial);
+            mImage->finalizeImageLayoutInShareContexts(contextVk, mImageSiblingSerial);
         }
         mImage = nullptr;
         mImageObserverBinding.bind(nullptr);
diff --git a/src/libANGLE/renderer/vulkan/ShareGroupVk.cpp b/src/libANGLE/renderer/vulkan/ShareGroupVk.cpp
index 1ae69af..418b968 100644
--- a/src/libANGLE/renderer/vulkan/ShareGroupVk.cpp
+++ b/src/libANGLE/renderer/vulkan/ShareGroupVk.cpp
@@ -393,4 +393,15 @@
         }
     }
 }
+
+void ShareGroupVk::imageWillFallbackFromTileMemory(vk::ImageHelper *image)
+{
+    ASSERT(image->useTileMemory());
+    for (auto context : mState.getContexts())
+    {
+        ContextVk *contextVk = vk::GetImpl(context.second);
+        contextVk->finalizeImageLayout(image, {});
+        contextVk->removeImageWithTileMemory(image);
+    }
+}
 }  // namespace rx
diff --git a/src/libANGLE/renderer/vulkan/ShareGroupVk.h b/src/libANGLE/renderer/vulkan/ShareGroupVk.h
index 7692628..5954559 100644
--- a/src/libANGLE/renderer/vulkan/ShareGroupVk.h
+++ b/src/libANGLE/renderer/vulkan/ShareGroupVk.h
@@ -106,6 +106,8 @@
     void onFrameBoundary();
     uint32_t getCurrentFrameCount() const { return mCurrentFrameCount; }
 
+    void imageWillFallbackFromTileMemory(vk::ImageHelper *image);
+
   private:
     angle::Result updateContextsPriority(ContextVk *contextVk, egl::ContextPriority newPriority);
 
diff --git a/src/libANGLE/renderer/vulkan/TextureVk.cpp b/src/libANGLE/renderer/vulkan/TextureVk.cpp
index 3de8b87..948fc8e 100644
--- a/src/libANGLE/renderer/vulkan/TextureVk.cpp
+++ b/src/libANGLE/renderer/vulkan/TextureVk.cpp
@@ -4380,7 +4380,7 @@
         }
         else
         {
-            mImage->finalizeImageLayoutInShareContexts(renderer, contextVk, mImageSiblingSerial);
+            mImage->finalizeImageLayoutInShareContexts(contextVk, mImageSiblingSerial);
             mImageObserverBinding.bind(nullptr);
             mImage = nullptr;
         }
diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.cpp b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
index 2d012de..a7abed6 100644
--- a/src/libANGLE/renderer/vulkan/vk_helpers.cpp
+++ b/src/libANGLE/renderer/vulkan/vk_helpers.cpp
@@ -6122,26 +6122,29 @@
                                                 ContextVk *contextVk,
                                                 UniqueSerial imageSiblingSerial)
 {
-    finalizeImageLayoutInShareContexts(renderer, contextVk, imageSiblingSerial);
+    finalizeImageLayoutInShareContexts(contextVk, imageSiblingSerial);
     contextVk->addToPendingImageGarbage(mUse, mAllocationSize);
     releaseImage(renderer);
 }
 
-void ImageHelper::finalizeImageLayoutInShareContexts(Renderer *renderer,
-                                                     ContextVk *contextVk,
+void ImageHelper::finalizeImageLayoutInShareContexts(ContextVk *contextVk,
                                                      UniqueSerial imageSiblingSerial)
 {
     if (contextVk && mImageSerial.valid())
     {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp b/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp
index 2ccf441..03223a3 100644
--- a/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp
+++ b/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp
@@ -9636,6 +9636,60 @@
     EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::green);
 }
 
+// Regression test for UAF in finalizeImagesWithTileMemory after readPixels triggered
+// fallbackFromTileMemory.
+TEST_P(VulkanPerformanceCounterTest_TileMemory, DepthBufferPBOReadThenDelete)
+{
+    ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled(kPerfMonitorExtensionName));
+    ANGLE_SKIP_TEST_IF(!isFeatureEnabled(Feature::SimulateTileMemoryForTesting) &&
+                       !isFeatureEnabled(Feature::SupportsTileMemoryHeap));
+    ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_NV_read_depth_stencil"));
+
+    setupPrograms();
+
+    GLenum depthStencilFormat = GL_DEPTH24_STENCIL8;
+    constexpr GLsizei kWidth  = 256;
+    constexpr GLsizei kHeight = 256;
+
+    uint64_t tileMemoryImageCountBefore = getPerfCounters().tileMemoryImages;
+
+    GLTexture colorTexture;
+    GLRenderbuffer depthStencil;
+    setupColorTextureAndDepthBuffer(colorTexture, depthStencil, depthStencilFormat, kWidth,
+                                    kHeight);
+    GLFramebuffer fbo;
+    setupFBO(colorTexture, depthStencil, depthStencil, fbo, kWidth, kHeight);
+
+    GLfloat depthValue = 0.0f;
+    // Clear color/depth/stencil buffers
+    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+    glClearDepthf(depthValue * 0.5f + 0.5f);
+    glClearStencil(0x55);
+    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+    glEnable(GL_DEPTH_TEST);
+    drawQuadToVerifyDepthValue(drawGreen, drawRed, depthValue);
+    EXPECT_EQ(1u, getPerfCounters().tileMemoryImages - tileMemoryImageCountBefore);
+
+    // PBO read
+    GLBuffer pbo;
+    glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
+    glBufferData(GL_PIXEL_PACK_BUFFER, kWidth * kHeight * sizeof(uint32_t), nullptr,
+                 GL_DYNAMIC_READ);
+    glReadPixels(0, 0, kWidth, kHeight, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, 0);
+    glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
+    EXPECT_GL_NO_ERROR();
+
+    // Delete depthBuffer
+    depthStencil.reset();
+
+    std::array<GLenum, 2> attachments = {GL_DEPTH_ATTACHMENT, GL_STENCIL_ATTACHMENT};
+    glInvalidateFramebuffer(GL_FRAMEBUFFER, attachments.size(), attachments.data());
+
+    // For completeness, verify rendering results.
+    EXPECT_PIXEL_RECT_EQ(0, 0, kWidth, kHeight, GLColor::green);
+    EXPECT_GL_NO_ERROR();
+}
+
 class VulkanPerformanceCounterTest_Dither : public VulkanPerformanceCounterTest
 {};
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Use-After-Free in ANGLE Vulkan ContextVk::mImagesWithTileMemory

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential Use-After-Free vulnerability exists in ANGLE’s Vulkan backend on devices supporting tile memory (e.g., Qualcomm Adreno). An image falling back from tile memory clears its tracking flag without being removed from the context’s tracking list, causing it to be skipped during destruction cleanup. Subsequent command submissions dereference the freed pointer, which could allow a compromised renderer to execute arbitrary code in the GPU process.

Affected files:

  • third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.h
  • third_party/angle/src/libANGLE/renderer/vulkan/RenderbufferVk.cpp

Estimated timestamp from git blame: 2026-01-08

Vulnerability Details

In ANGLE’s Vulkan backend, ContextVk maintains a list of images currently utilizing transient tile memory via std::vector<vk::ImageHelper *> mImagesWithTileMemory.

The vulnerability stems from a state desynchronization when an image is forced out of tile memory before its destruction:

  1. When glReadPixels is called on an active tile memory attachment, Vulkan transfer limitations force ANGLE to fall back to regular memory.
  2. vk::ImageHelper::readPixelsImpl calls fallbackFromTileMemory (vk_helpers.cpp), which copies the image state and explicitly sets mUseTileMemory = false on the original ImageHelper object.
  3. Crucially, fallbackFromTileMemory does not remove the ImageHelper from ContextVk::mImagesWithTileMemory.
  4. When the attacker subsequently deletes the renderbuffer, RenderbufferVk::onDestroy initiates image cleanup.
  5. The cleanup routine calls ImageHelper::finalizeImageLayoutInShareContexts. This function dictates list removal using the condition if (mUseTileMemory) { ... removeImageWithTileMemory(this); }.
  6. Because mUseTileMemory was previously cleared, the removal loop is bypassed. The ImageHelper object is then freed via SafeDelete, leaving a dangling raw pointer inside ContextVk::mImagesWithTileMemory.

When a context containing the dangling pointer flushes its command buffer (e.g., via glFlush), ContextVk::finalizeImagesWithTileMemory iterates over mImagesWithTileMemory, dereferencing the freed pointer and invoking methods on it.

Potential Impact

This is a potential Renderer-to-GPU sandbox escape. ImageHelper inherits from angle::Subject, which contains an inline FastVector of observer pointers. By grooming the heap, an attacker can reclaim the freed ImageHelper memory and populate the observer list with a fake ObserverBindingBase pointer.

When ContextVk::finalizeImagesWithTileMemory calls fallbackFromTileMemory on the attacker-controlled object, it eventually triggers onStateChange(). This iterates over the attacker’s fake observers and executes a virtual function call (binding->getObserver()->onSubjectStateChange()), granting Remote Code Execution (RCE) in the GPU process. MiraclePtr (BRP) does not mitigate this, as the vector stores raw pointers.

Suggested Reproduction Steps

Note: These are theoretical steps, as our AI tooling does not currently possess the ability to run and verify proof-of-concept code.

  1. Prerequisite: Run Chrome on a device with a Vulkan driver supporting VK_QCOM_tile_memory_heap.
  2. In WebGL, create a GL_DEPTH_COMPONENT24 renderbuffer, attach it to an FBO, and issue a draw call to place the attachment into the context’s mImagesWithTileMemory list.
  3. Bind a PBO and call glReadPixels on the depth attachment. This triggers fallbackFromTileMemory, setting mUseTileMemory = false.
  4. Call glDeleteRenderbuffers on the renderbuffer. The ImageHelper is deleted, but the pointer remains in the context’s tracking list.
  5. Allocate similarly sized WebGL objects to reclaim the freed memory chunk, injecting a fake angle::Subject state and vtable pointers.
  6. Call glFlush() to trigger ContextVk::submitCommands, which loops over the dangling pointer and executes the hijacked virtual call.

Proposed Fix

There are two primary ways to fix this:

  1. Update Tracking in Fallback: Ensure that ImageHelper::fallbackFromTileMemory explicitly calls removeImageWithTileMemory for all contexts in the share group before (or concurrently with) setting mUseTileMemory to false.
  2. Unconditional Removal: Modify ImageHelper::finalizeImageLayoutInShareContexts to unconditionally check if the image is in the mImagesWithTileMemory list, rather than gating the removal loop behind the if (mUseTileMemory) check.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results from 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