High chrome UAF 🔧 Commit mapped

Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Bug ClassUAF
Tracker541715128
Fix commita871bdb05932 (angle/angle) +59/-6
CISA KEVNot listed
Creditedweihengqiuu
Disclosed2026-09-08

Background

ANGLE
Google’s translation layer that implements OpenGL ES on top of native GPU APIs such as Direct3D 11, used as Chrome’s GPU-process WebGL backend.
`gl::ImageIndex`
A value identifying a specific texture image (mip level, layer, and layer count) targeted by a texture operation.
`mAssociatedImages`
A map in TextureStorage11_2DArray keyed by LevelLayerRangeKey that records which ImageD3D objects point back into a given texture storage so they can be disassociated when the storage is freed.
Off-chain mip level
A mip level (e.g. level 10) specified independently of level 0’s derived mip chain, so it can exist outside the range [0, getLevelCount()) of the completed storage.

Root Cause Analysis

When redefining a 2D array texture, TextureD3D committed image data into texture storage and recorded image-to-storage back-references in mAssociatedImages, but guarded these operations only with isImageComplete(index) rather than first checking that the index actually falls within the allocated storage via isValidIndex(index). An “off-chain” mip level such as level 10 could pass isImageComplete while lying outside the completed storage’s getLevelCount() range, so TextureStorage11_2DArray::associateImage wrote its entry into mAssociatedImages under a level < getLevelCount() guard that silently dropped the association for the out-of-range level. Because the association was never recorded, when the storage was later released (by redefining level 0 with new dimensions) the off-chain image’s back-pointer into that storage was never cleared, leaving a dangling pointer that was dereferenced on the next redefinition of the off-chain level.

The fix adds an explicit isValidIndex(index) precondition to shouldUseSetData, commitRegion, and setImageImpl so out-of-range indices never touch storage, and widens the bookkeeping guard in associateImage to gl::IMPLEMENTATION_MAX_TEXTURE_LEVELS so every associated image is tracked and thus properly disassociated when storage is freed.

Key insight
The core mistake was conflating “image is complete” with “index is valid for this storage,” letting off-chain mip levels commit into and reference storage whose bookkeeping map never tracked them; the fix decouples the two by requiring isValidIndex(index) before any storage commit and by tracking associations for all representable levels so no back-pointer is left dangling on storage release.

Attack Path

  1. Create an off-chain level Allocate a 2D array texture at level 0, then specify an independent out-of-range level (e.g. level 10) via glTexImage3D, and populate it with glTexSubImage3D to create a staging image.
  2. Force complete storage Sample or draw with the texture so ANGLE allocates completed texture storage sized to level 0’s mip chain, which does not include the off-chain level.
  3. Record a dangling association Writing again to the off-chain level associates its ImageD3D with the storage, but the level < getLevelCount() guard drops the entry, so the back-pointer is never registered for cleanup.
  4. Release the storage Redefine level 0 with new dimensions, freeing the old texture storage while the off-chain image still points into it.
  5. Trigger the use-after-free Redefine or write the off-chain level again, dereferencing the stale storage pointer and reading or writing freed memory in the GPU process.

Impact Assessment

An attacker who runs attacker-controlled WebGL (script in a renderer driving the GPU process) gains a use-after-free on freed Direct3D 11 texture-storage memory, which can corrupt GPU-process heap state and is a stepping stone toward memory disclosure or code execution in that process. It manifests in ANGLE’s D3D11 backend inside Chrome’s GPU process. Preconditions are the Windows/D3D11 backend and the ability to redefine an off-chain 2D array mip level after its backing storage has been released.

Files Changed

  • src/libANGLE/renderer/d3d/TextureD3D.cpp
  • src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
  • src/tests/gl_tests/TextureTest.cpp

Audit Directions

  • Completeness vs. validity conflation
    Audit texture and buffer paths that gate storage commits on an isImageComplete/“ready” style check without a separate bounds/isValidIndex check, since a resource can be complete yet still index outside the current backing allocation.
  • Asymmetric association bookkeeping
    Flag any place where a back-pointer is registered under a narrower bound (e.g. getLevelCount()) than the range over which that pointer can later be dereferenced or cleared, because dropped registrations become dangling pointers at release time.
  • Off-chain / out-of-range mip and layer handling
    Review redefinition and glTexImage/glTexSubImage flows for levels or layers specified outside a completed mip chain, which are a recurring source of storage-lifetime mismatches in the D3D11 backend.
From a871bdb05932b9bbaf511e531a8497cf1a6e1865 Mon Sep 17 00:00:00 2001
From: wangra <wangra@google.com>
Date: Sat, 15 Aug 2026 07:56:49 -0400
Subject: [PATCH] D3D11: Fix use-after-free when redefining 2D array mip levels

Validate image index bounds before committing texture storage regions
and track all associated images in TextureStorage11_2DArray to prevent
dangling pointers upon storage release.

Test: angle_end2end_tests --gtest_filter="*RedefineOffChainMipLevelAfterStorageRelease*"
Bug: b/541715128
Change-Id: I35bcce19336025db1d7921baced563b165dcf045
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8263907
Reviewed-by: Yuxin Hu <yuxinhu@google.com>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Ran Wang <wangra@google.com>
---

diff --git a/src/libANGLE/renderer/d3d/TextureD3D.cpp b/src/libANGLE/renderer/d3d/TextureD3D.cpp
index 53dd6a8..ffc3a84 100644
--- a/src/libANGLE/renderer/d3d/TextureD3D.cpp
+++ b/src/libANGLE/renderer/d3d/TextureD3D.cpp
@@ -283,7 +283,7 @@
 
 bool TextureD3D::shouldUseSetData(const gl::ImageIndex &index, const ImageD3D *image) const
 {
-    if (!isImageComplete(index))
+    if (!isValidIndex(index) || !isImageComplete(index))
     {
         return false;
     }
@@ -730,9 +730,8 @@
                                        const gl::ImageIndex &index,
                                        const gl::Box &region)
 {
-    if (mTexStorage && isImageComplete(index))
+    if (isValidIndex(index) && isImageComplete(index))
     {
-        ASSERT(isValidIndex(index));
         ImageD3D *image = getImage(index);
         ANGLE_TRY(image->copyToStorage(context, mTexStorage, index, region));
         image->markClean();
@@ -958,7 +957,7 @@
     // Slow path: non-renderable texture, incomplete level, or texture storage doesn't exist.
     ANGLE_TRY(image->initializeContents(context));
 
-    if (mTexStorage && isImageComplete(index))
+    if (isValidIndex(index) && isImageComplete(index))
     {
         gl::Box fullImageArea(0, 0, 0, image->getWidth(), image->getHeight(), image->getDepth());
         ANGLE_TRY(commitRegion(context, index, fullImageArea));
diff --git a/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp b/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
index 8b86f1c..eea8b27 100644
--- a/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
+++ b/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
@@ -2841,9 +2841,9 @@
     const GLint layerTarget = index.getLayerIndex();
     const GLint numLayers   = index.getLayerCount();
 
-    ASSERT(0 <= level && level < getLevelCount());
+    ASSERT(0 <= level && level < gl::IMPLEMENTATION_MAX_TEXTURE_LEVELS);
 
-    if (0 <= level && level < getLevelCount())
+    if (0 <= level && level < gl::IMPLEMENTATION_MAX_TEXTURE_LEVELS)
     {
         LevelLayerRangeKey key(level, layerTarget, numLayers);
         mAssociatedImages[key] = image;
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 70ae2ba..3e6bc69 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -9112,6 +9112,60 @@
     EXPECT_PIXEL_COLOR_EQ(px, py, GLColor::green);
 }
 
+// Test that redefining an out-of-range mip level after texture storage is released does not cause a
+// use-after-free.
+TEST_P(Texture2DArrayTestES3, RedefineOffChainMipLevelAfterStorageRelease)
+{
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D_ARRAY, tex);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Level 0 = 64x64x1 (complete storage would have 7 mip levels: 0..6)
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, 64, 64, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                 nullptr);
+
+    // Specify off-chain level 10 (independent of level 0 mip chain)
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 10, GL_RGBA8, 4, 4, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+
+    // Write data to level 10 before storage exists to create a staging texture
+    std::vector<GLColor> px(4 * 4, GLColor::red);
+    glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 10, 0, 0, 0, 4, 4, 1, GL_RGBA, GL_UNSIGNED_BYTE,
+                    px.data());
+
+    // Sample the texture to force complete storage allocation (with 7 levels)
+    glUseProgram(mProgram);
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    glFinish();
+
+    // Write to level 10 again (should not associate with 7-level storage or result in dangling
+    // pointers)
+    glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 10, 0, 0, 0, 4, 4, 1, GL_RGBA, GL_UNSIGNED_BYTE,
+                    px.data());
+
+    // Free the storage by redefining level 0 with new dimensions
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, 128, 128, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                 nullptr);
+
+    // Redefining level 10 should not cause UAF / crash
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 10, GL_RGBA8, 8, 8, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    EXPECT_GL_NO_ERROR();
+
+    // Also verify writing subimage to level 10 works without error
+    std::vector<GLColor> px8(8 * 8, GLColor::blue);
+    glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 10, 0, 0, 0, 8, 8, 1, GL_RGBA, GL_UNSIGNED_BYTE,
+                    px8.data());
+    EXPECT_GL_NO_ERROR();
+
+    // Verify drawing with level 10 samples the correct color
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 10);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 10);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_GL_NO_ERROR();
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+}
+
 // Verify that redefining a 2D array level's layer count to 1 and then respecifying the image
 // doesn't cause an out-of-bounds write during the self-copy.
 TEST_P(Texture2DArrayTestES3, RedefineLayerCountTo1AndRespecify)
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 70ae2ba..3e6bc69 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -9112,6 +9112,60 @@
     EXPECT_PIXEL_COLOR_EQ(px, py, GLColor::green);
 }
 
+// Test that redefining an out-of-range mip level after texture storage is released does not cause a
+// use-after-free.
+TEST_P(Texture2DArrayTestES3, RedefineOffChainMipLevelAfterStorageRelease)
+{
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D_ARRAY, tex);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Level 0 = 64x64x1 (complete storage would have 7 mip levels: 0..6)
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, 64, 64, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                 nullptr);
+
+    // Specify off-chain level 10 (independent of level 0 mip chain)
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 10, GL_RGBA8, 4, 4, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+
+    // Write data to level 10 before storage exists to create a staging texture
+    std::vector<GLColor> px(4 * 4, GLColor::red);
+    glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 10, 0, 0, 0, 4, 4, 1, GL_RGBA, GL_UNSIGNED_BYTE,
+                    px.data());
+
+    // Sample the texture to force complete storage allocation (with 7 levels)
+    glUseProgram(mProgram);
+    glUniform1i(mTextureArraySliceUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    glFinish();
+
+    // Write to level 10 again (should not associate with 7-level storage or result in dangling
+    // pointers)
+    glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 10, 0, 0, 0, 4, 4, 1, GL_RGBA, GL_UNSIGNED_BYTE,
+                    px.data());
+
+    // Free the storage by redefining level 0 with new dimensions
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, 128, 128, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                 nullptr);
+
+    // Redefining level 10 should not cause UAF / crash
+    glTexImage3D(GL_TEXTURE_2D_ARRAY, 10, GL_RGBA8, 8, 8, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    EXPECT_GL_NO_ERROR();
+
+    // Also verify writing subimage to level 10 works without error
+    std::vector<GLColor> px8(8 * 8, GLColor::blue);
+    glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 10, 0, 0, 0, 8, 8, 1, GL_RGBA, GL_UNSIGNED_BYTE,
+                    px8.data());
+    EXPECT_GL_NO_ERROR();
+
+    // Verify drawing with level 10 samples the correct color
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BASE_LEVEL, 10);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 10);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_GL_NO_ERROR();
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+}
+
 // Verify that redefining a 2D array level's layer count to 1 and then respecifying the image
 // doesn't cause an out-of-bounds write during the self-copy.
 TEST_P(Texture2DArrayTestES3, RedefineLayerCountTo1AndRespecify)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.