High CVSS 9.6 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Sub-componentVulkan
Bug ClassUAF
Tracker519259107
Fix commitac3ce93e2f4a (angle/angle) +143/-1
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-02 (per vendor advisory)
Fix landed2026-06-03 (public git)
Disclosed2026-07-29
Patch gap56 days — fix public in source before the advisory

Background

TextureVk::releaseImage
ANGLE Vulkan backend path that tears down a texture’s image and its image views.
finalizeImageLayoutInAllSharedContexts
flushes/finalizes image layout across every context in the share group; it can re-entrantly call ContextVk::flushAndSubmitCommands() → submitCommands().
mSingleLayerRenderTargets
per-texture vectors of RenderTargetVk; a FramebufferVk’s mRenderTargetCache.mDepthStencilRenderTarget can point into them.

Root Cause Analysis

In TextureVk::releaseImage, releaseImageViews() (which frees the texture’s mSingleLayerRenderTargets vectors) was called before finalizeImageLayoutInAllSharedContexts(mImage). But finalize can re-entrantly trigger submitCommands(), and that submit dereferences FramebufferVk::mRenderTargetCache.mDepthStencilRenderTarget, which points into exactly the render-target vectors releaseImageViews() just freed — a use-after-free.

The fix moves finalizeImageLayoutInAllSharedContexts(mImage) to run before releaseImageViews(), so the re-entrant submit sees live render targets; the commit message states this mechanism explicitly and a multi-context regression test is added.

Key insight
A re-entrant submitCommands() inside image finalization used render targets that releaseImageViews() had already freed; reordering finalize before the view release closes the window.

Attack Path

  1. ENTRY From WebGL/web content, set up two share-group contexts each with an open render pass using a shared depth texture as the depth attachment.
  2. TRIGGER Redefine/release the shared depth texture so TextureVk::releaseImage runs.
  3. MISSING CHECK releaseImageViews() frees mSingleLayerRenderTargets before the shared-context finalize runs.
  4. PRIMITIVE The finalize re-entrantly calls submitCommands(), which dereferences a depth-stencil RenderTargetVk pointing into the freed vectors — use-after-free.
  5. IMPACT Memory corruption in the GPU process, reachable from web content. (Exploit beyond the UAF is inferred; the ordering bug and freed object are shown by the patch + message.)

Impact Assessment

A content-reachable use-after-free in ANGLE’s Vulkan backend (GPU process), a strong corruption primitive; reliable triggering needs the multi-context/open-render-pass setup the regression test models, so difficulty is moderate. Advisory severity high; no ITW stated.

Files Changed

  • src/libANGLE/renderer/vulkan/TextureVk.cpp
  • src/tests/gl_tests/VulkanPerformanceCounterTest.cpp

Audit Directions

  • Release ordering vs re-entrant submit
    Audit teardown paths that free render targets/views for re-entrant flushAndSubmitCommands/submitCommands that may still reference them; finalize/flush before freeing.
  • Share-group finalize
    Review other finalize*InAllSharedContexts callers for freed-state assumptions.
From ac3ce93e2f4a26108bbaaef70812ce16a219e86a Mon Sep 17 00:00:00 2001
From: Zhenyao Mo <zmo@chromium.org>
Date: Tue, 02 Jun 2026 15:11:29 -0700
Subject: [PATCH] Vulkan: Fix Use-After-Free in TextureVk::releaseImage

finalizeImageLayoutInAllSharedContexts() may re-entrantly invoke
ContextVk::flushAndSubmitCommands() -> submitCommands(), which can
dereference FramebufferVk::mRenderTargetCache.mDepthStencilRenderTarget
pointing into the texture's mSingleLayerRenderTargets vectors.

This CL moves finalizeImageLayoutInAllSharedContexts() prior to
releaseImageViews() in TextureVk::releaseImage(), ensuring the
render targets are not freed when the re-entrant submit occurs.

A regression test is included to verify this behavior.

Bug: chromium:519259107
Change-Id: I69f90e3688cd02933cb93c5adc3ae56421944db4
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7897336
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Charlie Lao <cclao@google.com>
Auto-Submit: Zhenyao Mo <zmo@chromium.org>
Commit-Queue: Zhenyao Mo <zmo@chromium.org>
---

diff --git a/src/libANGLE/renderer/vulkan/TextureVk.cpp b/src/libANGLE/renderer/vulkan/TextureVk.cpp
index fc0284c..5c2673f 100644
--- a/src/libANGLE/renderer/vulkan/TextureVk.cpp
+++ b/src/libANGLE/renderer/vulkan/TextureVk.cpp
@@ -4409,11 +4409,20 @@
 {
     ShareGroupVk *shareGroupVk = contextVk->getShareGroup();
 
+    if (mImage)
+    {
+        // finalizeImageLayoutInAllSharedContexts() may re-entrantly call
+        // ContextVk::flushAndSubmitCommands() -> submitCommands(), which can dereference
+        // FramebufferVk::mRenderTargetCache.mDepthStencilRenderTarget pointing into this
+        // texture's mSingleLayerRenderTargets vectors. Finalize before releaseImageViews()
+        // frees those vectors.
+        shareGroupVk->finalizeImageLayoutInAllSharedContexts(mImage);
+    }
+
     releaseImageViews(contextVk);
 
     if (mImage)
     {
-        shareGroupVk->finalizeImageLayoutInAllSharedContexts(mImage);
         if (mOwnsImage)
         {
             mImage->releaseImage(contextVk);
diff --git a/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp b/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp
index 2244cec..ba3cb1c 100644
--- a/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp
+++ b/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp
@@ -14,6 +14,7 @@
 
 #include "include/platform/Feature.h"
 #include "test_utils/ANGLETest.h"
+#include "test_utils/MultiThreadSteps.h"
 #include "test_utils/angle_test_instantiate.h"
 #include "test_utils/gl_raii.h"
 #include "util/EGLWindow.h"
@@ -10354,6 +10355,138 @@
         .enable(Feature::PreferMonolithicPipelinesOverLibraries)
         .disable(Feature::MergeProgramPipelineCachesToGlobalCache));
 
+// Regression test: redefining a depth texture that is bound as the depth attachment of the current
+// draw FBO in two share-group contexts (each with an open render pass and a surviving
+// mImageWithTileMemory pointer) must not dereference a freed RenderTargetVk via
+// FramebufferVk::getImageWithTileMemory() during the re-entrant submitCommands() inside
+// TextureVk::releaseImage().  See ContextVk::submitCommands' hasAnyDirtyBit() guard.
+TEST_P(VulkanPerformanceCounterTest_TileMemory, RedefineSharedDepthTextureWithOpenRenderPasses)
+{
+    ANGLE_SKIP_TEST_IF(!isFeatureEnabled(Feature::SimulateTileMemoryForTesting) &&
+                       !isFeatureEnabled(Feature::SupportsTileMemoryHeap));
+
+    constexpr GLsizei kSize = 64;
+
+    enum class Step
+    {
+        Start,
+        Thread1CreatedDepthTex,
+        Thread0OpenedRP,
+        Thread1OpenedRP,
+        Thread0Redefined,
+        Finish,
+        Abort,
+    };
+    Step currentStep = Step::Start;
+    std::mutex mutex;
+    std::condition_variable condVar;
+    GLuint sharedDepthTex = 0;
+
+    // Per-context setup:
+    // 1. FBO_A: Color and a depth renderbuffer that is invalidated.  The Vulkan backend may use
+    //    tile memory for this depth renderbuffer
+    // 2. FBO_B: Color and the shared depth texture.  The Vulkan backend does not use tile memory
+    //    for textures.
+    auto setupContextState = [&](GLuint colorTexA, GLuint depthRB, GLuint fboA, GLuint colorTexB,
+                                 GLuint fboB, GLuint depthTex, GLuint program) {
+        glBindTexture(GL_TEXTURE_2D, colorTexA);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize);
+        glBindRenderbuffer(GL_RENDERBUFFER, depthRB);
+        glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, kSize, kSize);
+        glBindFramebuffer(GL_FRAMEBUFFER, fboA);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexA, 0);
+        glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRB);
+        EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+        glViewport(0, 0, kSize, kSize);
+        glEnable(GL_DEPTH_TEST);
+        glDepthMask(GL_TRUE);
+        glDepthFunc(GL_ALWAYS);
+        glClearDepthf(1.0f);
+        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+        drawQuad(program, essl1_shaders::PositionAttrib(), 0.5f);
+        const GLenum kDepthAttachment = GL_DEPTH_ATTACHMENT;
+        glInvalidateFramebuffer(GL_FRAMEBUFFER, 1, &kDepthAttachment);
+        EXPECT_GL_NO_ERROR();
+
+        glBindTexture(GL_TEXTURE_2D, colorTexB);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize);
+        glBindFramebuffer(GL_FRAMEBUFFER, fboB);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexB, 0);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0);
+        EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+        drawQuad(program, essl1_shaders::PositionAttrib(), 0.5f);
+        EXPECT_GL_NO_ERROR();
+    };
+
+    auto thread0 = [&](EGLDisplay dpy, EGLSurface surface, EGLContext context) {
+        ThreadSynchronization<Step> threadSynchronization(&currentStep, &mutex, &condVar);
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, surface, surface, context));
+
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Thread1CreatedDepthTex));
+
+        ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::Red());
+        GLTexture colorTexA, colorTexB;
+        GLRenderbuffer depthRB;
+        GLFramebuffer fboA, fboB;
+        setupContextState(colorTexA, depthRB, fboA, colorTexB, fboB, sharedDepthTex, program);
+
+        threadSynchronization.nextStep(Step::Thread0OpenedRP);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Thread1OpenedRP));
+
+        // Both contexts now have an open render pass with |sharedDepthTex| as depth attachment.
+        // In the Vulkan backend if tile memory is used, a reference to |depthRB| may be kept.
+        // The draw framebuffer is not dirty.
+        //
+        // Redefine level 0 of |sharedDepthTex| to recreate its views.  This should not cause
+        // use-after-free.
+        glBindTexture(GL_TEXTURE_2D, sharedDepthTex);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, kSize * 2, kSize * 2, 0,
+                     GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, nullptr);
+        EXPECT_GL_NO_ERROR();
+
+        glFinish();
+        threadSynchronization.nextStep(Step::Thread0Redefined);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Finish));
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT));
+    };
+
+    auto thread1 = [&](EGLDisplay dpy, EGLSurface surface, EGLContext context) {
+        ThreadSynchronization<Step> threadSynchronization(&currentStep, &mutex, &condVar);
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, surface, surface, context));
+
+        ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::Green());
+
+        // Create the shared depth texture as a mutable texture so glTexImage2D can redefine it.
+        GLuint depthTex;
+        glGenTextures(1, &depthTex);
+        glBindTexture(GL_TEXTURE_2D, depthTex);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, kSize, kSize, 0, GL_DEPTH_COMPONENT,
+                     GL_UNSIGNED_INT, nullptr);
+        EXPECT_GL_NO_ERROR();
+        sharedDepthTex = depthTex;
+
+        threadSynchronization.nextStep(Step::Thread1CreatedDepthTex);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Thread0OpenedRP));
+
+        GLTexture colorTexA, colorTexB;
+        GLRenderbuffer depthRB;
+        GLFramebuffer fboA, fboB;
+        setupContextState(colorTexA, depthRB, fboA, colorTexB, fboB, sharedDepthTex, program);
+
+        threadSynchronization.nextStep(Step::Thread1OpenedRP);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Thread0Redefined));
+
+        glFinish();
+        glDeleteTextures(1, &depthTex);
+        threadSynchronization.nextStep(Step::Finish);
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT));
+    };
+
+    std::array<LockStepThreadFunc, 2> threadFuncs = {std::move(thread0), std::move(thread1)};
+    RunLockStepThreads(getEGLWindow(), threadFuncs.size(), threadFuncs.data());
+    ASSERT_NE(currentStep, Step::Abort);
+}
+
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 2244cec..ba3cb1c 100644
--- a/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp
+++ b/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp
@@ -14,6 +14,7 @@
 
 #include "include/platform/Feature.h"
 #include "test_utils/ANGLETest.h"
+#include "test_utils/MultiThreadSteps.h"
 #include "test_utils/angle_test_instantiate.h"
 #include "test_utils/gl_raii.h"
 #include "util/EGLWindow.h"
@@ -10354,6 +10355,138 @@
         .enable(Feature::PreferMonolithicPipelinesOverLibraries)
         .disable(Feature::MergeProgramPipelineCachesToGlobalCache));
 
+// Regression test: redefining a depth texture that is bound as the depth attachment of the current
+// draw FBO in two share-group contexts (each with an open render pass and a surviving
+// mImageWithTileMemory pointer) must not dereference a freed RenderTargetVk via
+// FramebufferVk::getImageWithTileMemory() during the re-entrant submitCommands() inside
+// TextureVk::releaseImage().  See ContextVk::submitCommands' hasAnyDirtyBit() guard.
+TEST_P(VulkanPerformanceCounterTest_TileMemory, RedefineSharedDepthTextureWithOpenRenderPasses)
+{
+    ANGLE_SKIP_TEST_IF(!isFeatureEnabled(Feature::SimulateTileMemoryForTesting) &&
+                       !isFeatureEnabled(Feature::SupportsTileMemoryHeap));
+
+    constexpr GLsizei kSize = 64;
+
+    enum class Step
+    {
+        Start,
+        Thread1CreatedDepthTex,
+        Thread0OpenedRP,
+        Thread1OpenedRP,
+        Thread0Redefined,
+        Finish,
+        Abort,
+    };
+    Step currentStep = Step::Start;
+    std::mutex mutex;
+    std::condition_variable condVar;
+    GLuint sharedDepthTex = 0;
+
+    // Per-context setup:
+    // 1. FBO_A: Color and a depth renderbuffer that is invalidated.  The Vulkan backend may use
+    //    tile memory for this depth renderbuffer
+    // 2. FBO_B: Color and the shared depth texture.  The Vulkan backend does not use tile memory
+    //    for textures.
+    auto setupContextState = [&](GLuint colorTexA, GLuint depthRB, GLuint fboA, GLuint colorTexB,
+                                 GLuint fboB, GLuint depthTex, GLuint program) {
+        glBindTexture(GL_TEXTURE_2D, colorTexA);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize);
+        glBindRenderbuffer(GL_RENDERBUFFER, depthRB);
+        glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, kSize, kSize);
+        glBindFramebuffer(GL_FRAMEBUFFER, fboA);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexA, 0);
+        glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRB);
+        EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+        glViewport(0, 0, kSize, kSize);
+        glEnable(GL_DEPTH_TEST);
+        glDepthMask(GL_TRUE);
+        glDepthFunc(GL_ALWAYS);
+        glClearDepthf(1.0f);
+        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+        drawQuad(program, essl1_shaders::PositionAttrib(), 0.5f);
+        const GLenum kDepthAttachment = GL_DEPTH_ATTACHMENT;
+        glInvalidateFramebuffer(GL_FRAMEBUFFER, 1, &kDepthAttachment);
+        EXPECT_GL_NO_ERROR();
+
+        glBindTexture(GL_TEXTURE_2D, colorTexB);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize);
+        glBindFramebuffer(GL_FRAMEBUFFER, fboB);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexB, 0);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0);
+        EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+        drawQuad(program, essl1_shaders::PositionAttrib(), 0.5f);
+        EXPECT_GL_NO_ERROR();
+    };
+
+    auto thread0 = [&](EGLDisplay dpy, EGLSurface surface, EGLContext context) {
+        ThreadSynchronization<Step> threadSynchronization(&currentStep, &mutex, &condVar);
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, surface, surface, context));
+
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Thread1CreatedDepthTex));
+
+        ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::Red());
+        GLTexture colorTexA, colorTexB;
+        GLRenderbuffer depthRB;
+        GLFramebuffer fboA, fboB;
+        setupContextState(colorTexA, depthRB, fboA, colorTexB, fboB, sharedDepthTex, program);
+
+        threadSynchronization.nextStep(Step::Thread0OpenedRP);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Thread1OpenedRP));
+
+        // Both contexts now have an open render pass with |sharedDepthTex| as depth attachment.
+        // In the Vulkan backend if tile memory is used, a reference to |depthRB| may be kept.
+        // The draw framebuffer is not dirty.
+        //
+        // Redefine level 0 of |sharedDepthTex| to recreate its views.  This should not cause
+        // use-after-free.
+        glBindTexture(GL_TEXTURE_2D, sharedDepthTex);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, kSize * 2, kSize * 2, 0,
+                     GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, nullptr);
+        EXPECT_GL_NO_ERROR();
+
+        glFinish();
+        threadSynchronization.nextStep(Step::Thread0Redefined);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Finish));
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT));
+    };
+
+    auto thread1 = [&](EGLDisplay dpy, EGLSurface surface, EGLContext context) {
+        ThreadSynchronization<Step> threadSynchronization(&currentStep, &mutex, &condVar);
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, surface, surface, context));
+
+        ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::Green());
+
+        // Create the shared depth texture as a mutable texture so glTexImage2D can redefine it.
+        GLuint depthTex;
+        glGenTextures(1, &depthTex);
+        glBindTexture(GL_TEXTURE_2D, depthTex);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, kSize, kSize, 0, GL_DEPTH_COMPONENT,
+                     GL_UNSIGNED_INT, nullptr);
+        EXPECT_GL_NO_ERROR();
+        sharedDepthTex = depthTex;
+
+        threadSynchronization.nextStep(Step::Thread1CreatedDepthTex);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Thread0OpenedRP));
+
+        GLTexture colorTexA, colorTexB;
+        GLRenderbuffer depthRB;
+        GLFramebuffer fboA, fboB;
+        setupContextState(colorTexA, depthRB, fboA, colorTexB, fboB, sharedDepthTex, program);
+
+        threadSynchronization.nextStep(Step::Thread1OpenedRP);
+        ASSERT_TRUE(threadSynchronization.waitForStep(Step::Thread0Redefined));
+
+        glFinish();
+        glDeleteTextures(1, &depthTex);
+        threadSynchronization.nextStep(Step::Finish);
+        EXPECT_EGL_TRUE(eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT));
+    };
+
+    std::array<LockStepThreadFunc, 2> threadFuncs = {std::move(thread0), std::move(thread1)};
+    RunLockStepThreads(getEGLWindow(), threadFuncs.size(), threadFuncs.data());
+    ASSERT_NE(currentStep, Step::Abort);
+}
+
 // Enable SimulateTileMemoryForTesting feature to get some test coverage on bots. Note that if both
 // SimulateTileMemoryForTesting and SupportsTileMemoryHeap are enabled, SupportsTileMemoryHeap will
 // take precedence.
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Vulkan Use-After-Free in TextureVk::releaseImage due to Bypassed Dirty-Bit Guard

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 potential Use-After-Free vulnerability exists in ANGLE’s Vulkan backend because TextureVk::releaseImage frees RenderTargetVk vector elements before performing a re-entrant flush. Since the front-end framebuffers have not yet been notified of the change, the hasAnyDirtyBit() safety check is bypassed, leading to a dangling pointer dereference during submitCommands.

Affected files:

  • third_party/angle/src/libANGLE/renderer/vulkan/TextureVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/ShareGroupVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/FramebufferVk.cpp
  • third_party/angle/src/libANGLE/renderer/RenderTargetCache.h

Estimated timestamp from git blame: 2026-06-01

Description

A potential Use-After-Free (UAF) vulnerability exists in ANGLE’s Vulkan backend due to an order-of-operations issue in TextureVk::releaseImage. The function frees vector-backed RenderTargetVk elements before performing a re-entrant flush that triggers submitCommands. This bypasses the !drawFramebuffer->hasAnyDirtyBit() safety check because the front-end framebuffers have not yet been notified of the texture change via observer messages.

Root Cause Analysis

In third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp, the safety check designed to prevent accessing stale/dangling RenderTargetVk cached pointers gates the unsafe getImageWithTileMemory() call on !drawFramebuffer->hasAnyDirtyBit():

// ContextVk.cpp
if (mImageWithTileMemory != nullptr)
{
    ...
    const gl::Framebuffer *drawFramebuffer = mState.getDrawFramebuffer();
    const vk::ImageHelper *drawFBOImageWithTileMemory =
        (drawFramebuffer != nullptr && !drawFramebuffer->hasAnyDirtyBit())
            ? getDrawFramebuffer()->getImageWithTileMemory()   // <-- Potential UAF
            : nullptr;

However, in TextureVk::releaseImage (third_party/angle/src/libANGLE/renderer/vulkan/TextureVk.cpp), the by-value elements of mSingleLayerRenderTargets are cleared (deallocated) before the layout is finalized in shared contexts and before observer notifications are sent:

void TextureVk::releaseImage(ContextVk *contextVk)
{
    ShareGroupVk *shareGroupVk = contextVk->getShareGroup();

    releaseImageViews(contextVk);                                       // (a) FREE RenderTargetVk vectors

    if (mImage)
    {
        shareGroupVk->finalizeImageLayoutInAllSharedContexts(mImage);   // (b) RE-ENTRANT FLUSH → submitCommands
        ...
    }
    ...
    onStateChange(angle::SubjectMessage::SubjectChanged);               // FBO-dirty-bit notification is too late
}

Inside releaseImageViews(contextVk), renderTargetLevels.clear() is called, which frees the std::vector<RenderTargetVk> heap storage. Meanwhile, the active FramebufferVk’s mRenderTargetCache.mDepthStencilRenderTarget still holds a bare pointer to one of those elements (&layerRenderTargets[layerIndex]).

When finalizeImageLayoutInAllSharedContexts is called, under specific conditions (e.g., shared contexts with attachment usage or foreign images), it triggers a re-entrant queue submission via flushAndSubmitCommands -> submitCommands. Because onStateChange(SubjectChanged) has not yet run, the front-end gl::Framebuffer dirty bits are empty. Consequently, !drawFramebuffer->hasAnyDirtyBit() evaluates to true, and the code executes getImageWithTileMemory(), causing a UAF read on the freed RenderTargetVk memory.

Potential Trigger Steps

Note: Our tooling does not currently have the capability to run code or execute physical proof-of-concept tests. The following are potential steps that would trigger the path on a target configuration supporting VK_QCOM_tile_memory_heap (e.g., Qualcomm Adreno GPU with driver >= 512.868.1):

  1. Allocate a depth renderbuffer R with tile memory. Render to FBO_A(R), registering ContextVk::mImageWithTileMemory = R.mImage.
  2. Invalidate the depth attachment of FBO_A via glInvalidateFramebuffer(GL_DEPTH_ATTACHMENT), forcing R.mImage->isVkImageContentDefined() = false.
  3. Create a depth texture depthTex that does not use tile memory, and bind it to FBO_B. Issue a draw on FBO_B. This closes the FBO_A render pass. Since the content was undefined, mImageWithTileMemory survives and remains pointing to R.mImage.
  4. During syncState for FBO_B, mRenderTargetCache.mDepthStencilRenderTarget is populated with a bare pointer into depthTex’s single-layer render targets vector.
  5. Trigger a level redefinition on depthTex (for example, via glTexImage2D with incompatible dimensions or via foreign EGLImage target bindings). This invokes redefineLevel -> releaseImage.
  6. releaseImage frees the single-layer render targets vector backing the cached pointer, then executes finalizeImageLayoutInAllSharedContexts on depthTex.mImage.
  7. This triggers a layout finalization flush and re-entrant submission before any dirty bit is propagated. The safety guard evaluates to true, resulting in a UAF read in FramebufferVk::getImageWithTileMemory() on the stale cached pointer.

Suggested Fix

To prevent this vulnerability, ensure that the front-end observers/framebuffers are notified of the state changes, or that the render targets are not prematurely freed before the re-entrant shared-context layout finalization occurs.

One potential solution is to re-order the operations in TextureVk::releaseImage so that releaseImageViews(contextVk) (which frees the render target vectors) is called after layout finalization and after observer notifications, or notify the observers of state changes before performing shared context layout finalization:

void TextureVk::releaseImage(ContextVk *contextVk)
{
    ShareGroupVk *shareGroupVk = contextVk->getShareGroup();

    if (mImage)
    {
        shareGroupVk->finalizeImageLayoutInAllSharedContexts(mImage);
        ...
    }

    // Free the image views and render targets after layout finalization has completed
    releaseImageViews(contextVk);
    ...
    onStateChange(angle::SubjectMessage::SubjectChanged);
}

Evaluated with Chrome root at commit: 87214e6721f6c34afd9181b80769a24c0c601c50


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