CVE-2026-79285
Overview
Background
- ANGLE D3D11 texture storage
- ANGLE backs a GL texture with a backend
mTexStorage; when the storage is torn down, per-image data can be copied back into stagingImageD3Dobjects so it survives. - Cube map redefinition
- Calling
glTexImage2Don one cube face with a mismatched size/format forcesTextureD3D_Cube::redefineImageto discard the existing storage, which requires preserving the five sibling faces at that level. - `gl::TexLevelMask`
- A per-mip-level bitmask telling
releaseTexStoragewhich levels’ data must be copied from storage back into staging images before the storage is destroyed. - `gl::CubeFaceArray<gl::TexLevelMask>`
- The new per-face array of level masks introduced by the patch, giving one independent copy mask for each of the six cube faces.
Root Cause Analysis
TextureD3D_Cube::redefineImage (the function edited in the second hunk; the exact name is not in the hunk header) built a single gl::TexLevelMask copyImageMask that set every level and then cleared the redefined level with copyImageMask.set(level, false), and passed it to releaseTexStorage. Inside releaseTexStorage, the mask was consulted per level as copyStorageToImagesMask[index.getLevelIndex()], so clearing the level excluded that level for all six faces at once — not just the face being redefined. The result was that the other five sibling faces at that level were never copied back into staging images even though the front-end state still considered them initialized, so when the cube map was completed and read again it returned uninitialized VRAM.
The fix adds a releaseTexStorage overload taking a gl::CubeFaceArray<gl::TexLevelMask> and, for gl::TextureType::CubeMap, selects the copy decision per face via copyStorageToImagesMask[index.cubeMapFaceIndex()][index.getLevelIndex()]; redefineImage now fills all faces’ masks and clears only copyImageMasks[faceIndex].set(level, false), so sibling faces are preserved before the storage is destroyed.
gl::CubeFaceArray<gl::TexLevelMask>) so only the redefined face is skipped and sibling face data is copied back before storage teardown.Attack Path
- Create and initialize a cube map A WebGL/GLES page defines all six faces of a cube map at a level with real (or default-initialized) contents so the front end marks them initialized.
- Redefine one face with a mismatch
The page calls
glTexImage2Don a single face (e.g.GL_TEXTURE_CUBE_MAP_POSITIVE_X) with a mismatched size, forcingredefineImageto discardmTexStorage. - Lose sibling data
The single per-level
copyImageMaskcleared the level for all faces, soreleaseTexStorageskipped copying the five sibling faces back into staging images while the front end still deemed them initialized. - Read back uninitialized VRAM
The page restores the face to make the cube complete again and samples/reads the sibling faces, which now return stale, uninitialized GPU memory rather than their original contents (as the
RedefineSiblingFacetest demonstrates).
Impact Assessment
(inferred).Files Changed
src/tests/gl_tests/RobustResourceInitTest.cppsrc/tests/angle_end2end_tests_expectations.txtsrc/libANGLE/renderer/d3d/TextureD3D.cppsrc/libANGLE/renderer/d3d/TextureD3D.h
Audit Directions
- Per-level vs per-face masksSearch other cube-map or array-texture teardown paths where a per-level mask governs a per-face/per-layer operation; a granularity mismatch can drop sibling layers’ data.
- State-vs-storage divergenceLook for code that discards backend storage while the front end still marks images initialized; any copy-back skip there risks exposing uninitialized memory on later reads.
- Robust-resource-init gapsAudit texture redefinition/resize paths against
RobustResourceInitguarantees to ensure previously-initialized data is either preserved or re-cleared, not left stale in reused GPU allocations.
Patch
From ff68f46b370cccfff2410dd10428f7e57e386332 Mon Sep 17 00:00:00 2001 From: Tzarial <[email protected]> Date: Tue, 02 Jun 2026 19:21:31 +0000 Subject: [PATCH] Fix VRAM leak in D3D11 cube map redefinition When a cube map face is redefined with a size or format mismatch, TextureD3D_Cube::redefineImage discards the existing texture storage. Previously, it used a per-level TexLevelMask that excluded the entire level from being copied back to staging textures, causing the data for the other 5 sibling faces at that level to be lost. Since the front-end state still considered them initialized, this led to uninitialized VRAM exposure when the cube map was completed and read from again. This adds a granular releaseTexStorage overload that takes an array of TexLevelMasks (one for each face). redefineImage now specifically excludes only the face being redefined, ensuring the data for sibling faces is preserved in staging textures before the backend storage is destroyed. Bug: b/513918923 Test: angle_end2end_tests --gtest_filter=CubeMapTextureTest.RedefineSiblingFace* Change-Id: I3a4f9ff69eec00cd7b5b2510f472814b19ce09b9 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7893496 Commit-Queue: Tzarial <[email protected]> Reviewed-by: Geoff Lang <[email protected]> --- diff --git a/src/libANGLE/renderer/d3d/TextureD3D.cpp b/src/libANGLE/renderer/d3d/TextureD3D.cpp index 2fcb093..83b4ecf 100644 --- a/src/libANGLE/renderer/d3d/TextureD3D.cpp +++ b/src/libANGLE/renderer/d3d/TextureD3D.cpp @@ -820,6 +820,15 @@ angle::Result TextureD3D::releaseTexStorage(const gl::Context *context, const gl::TexLevelMask ©StorageToImagesMask) { + gl::CubeFaceArray<gl::TexLevelMask> copyMasks; + copyMasks.fill(copyStorageToImagesMask); + return releaseTexStorage(context, copyMasks); +} + +angle::Result TextureD3D::releaseTexStorage( + const gl::Context *context, + const gl::CubeFaceArray<gl::TexLevelMask> ©StorageToImagesMask) +{ if (!mTexStorage) { return angle::Result::Continue; @@ -837,11 +846,22 @@ ImageD3D *image = getImage(index); const int storageWidth = std::max(1, getLevelZeroWidth() >> index.getLevelIndex()); const int storageHeight = std::max(1, getLevelZeroHeight() >> index.getLevelIndex()); + + bool copyImage = false; + if (mState.getType() == gl::TextureType::CubeMap) + { + copyImage = + copyStorageToImagesMask[index.cubeMapFaceIndex()][index.getLevelIndex()]; + } + else + { + copyImage = copyStorageToImagesMask[0][index.getLevelIndex()]; + } + if (image && isImageComplete(index) && image->getWidth() == storageWidth && image->getHeight() == storageHeight && image->getInternalFormat() == storageFormat && - index.getLevelIndex() < static_cast<int>(storageLevels) && - copyStorageToImagesMask[index.getLevelIndex()]) + index.getLevelIndex() < static_cast<int>(storageLevels) && copyImage) { ANGLE_TRY(image->copyFromTexStorage(context, index, mTexStorage)); } @@ -2531,11 +2551,11 @@ { markAllImagesDirty(); - gl::TexLevelMask copyImageMask; - copyImageMask.set(); - copyImageMask.set(level, false); + gl::CubeFaceArray<gl::TexLevelMask> copyImageMasks; + copyImageMasks.fill(gl::TexLevelMask().set()); + copyImageMasks[faceIndex].set(level, false); - ANGLE_TRY(releaseTexStorage(context, copyImageMask)); + ANGLE_TRY(releaseTexStorage(context, copyImageMasks)); } } diff --git a/src/libANGLE/renderer/d3d/TextureD3D.h b/src/libANGLE/renderer/d3d/TextureD3D.h index 9b81f30..ee2dbe6 100644 --- a/src/libANGLE/renderer/d3d/TextureD3D.h +++ b/src/libANGLE/renderer/d3d/TextureD3D.h @@ -204,6 +204,9 @@ angle::Result releaseTexStorage(const gl::Context *context, const gl::TexLevelMask ©StorageToImagesMask); + angle::Result releaseTexStorage( + const gl::Context *context, + const gl::CubeFaceArray<gl::TexLevelMask> ©StorageToImagesMask); GLuint getBaseLevel() const { return mBaseLevel; } diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt index 5ba8173..80e2980 100644 --- a/src/tests/angle_end2end_tests_expectations.txt +++ b/src/tests/angle_end2end_tests_expectations.txt @@ -2633,6 +2633,10 @@ // WGPU y-flip transforms have a bug when run on NVIDIA GPUs on Linux or Windows. 468025322 NVIDIA WGPU : CopyTextureVariationsTest.Copy*Texture/ES2_WebGPU__*YFlip* = SKIP +// WGPU does not support resizing textures or preserving sibling face data upon redefinition yet. +513918923 WGPU : RobustResourceInitTest.RedefineSiblingFace/* = SKIP +513918923 WIN OPENGL : RobustResourceInitTest.RedefineSiblingFace/* = SKIP + // SPIR-V compilation error 384593678 VULKAN : SimpleUniformTestES3.CopyOfUniformsWithArrays/* = SKIP diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp index afab8d1..39fd638 100644 --- a/src/tests/gl_tests/RobustResourceInitTest.cpp +++ b/src/tests/gl_tests/RobustResourceInitTest.cpp @@ -4163,6 +4163,70 @@ EXPECT_GL_NO_ERROR(); } +// Tests that redefining a single face of a cube map does not discard the +// data of other faces at the same level. +TEST_P(RobustResourceInitTest, RedefineSiblingFace) +{ + ANGLE_SKIP_TEST_IF(!hasGLExtension()); + + const GLint N = 4; + + GLTexture tex; + glBindTexture(GL_TEXTURE_CUBE_MAP, tex); + + for (GLenum face = 0; face < 6; face++) + { + glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, N, N, 0, GL_RGBA, + GL_UNSIGNED_BYTE, nullptr); + } + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + EXPECT_GL_NO_ERROR(); + + GLFramebuffer fbo; + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_NEGATIVE_X, + tex, 0); + EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER); + + glViewport(0, 0, N, N); + glClearColor(0.0f, 1.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green); + EXPECT_GL_NO_ERROR(); + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + + // Redefine POSITIVE_X with a mismatched size + glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X, 0, GL_RGBA, 2 * N, 2 * N, 0, GL_RGBA, + GL_UNSIGNED_BYTE, nullptr); + EXPECT_GL_NO_ERROR(); + + // Redefine POSITIVE_X back to normal size to make it cube complete again + glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X, 0, GL_RGBA, N, N, 0, GL_RGBA, GL_UNSIGNED_BYTE, + nullptr); + EXPECT_GL_NO_ERROR(); + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER); + + for (GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X; face <= GL_TEXTURE_CUBE_MAP_NEGATIVE_Z; + face++) + { + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, face, tex, 0); + if (face == GL_TEXTURE_CUBE_MAP_NEGATIVE_X) + { + // Previously set to green with glClear and not redefined. + EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green); + } + else + { + EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::transparentBlack); + } + } + EXPECT_GL_NO_ERROR(); +} + ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND( RobustResourceInitTest, ES3_METAL().enable(Feature::EmulateDontCareLoadWithRandomClear),
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 5ba8173..80e2980 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2633,6 +2633,10 @@
// WGPU y-flip transforms have a bug when run on NVIDIA GPUs on Linux or Windows.
468025322 NVIDIA WGPU : CopyTextureVariationsTest.Copy*Texture/ES2_WebGPU__*YFlip* = SKIP
+// WGPU does not support resizing textures or preserving sibling face data upon redefinition yet.
+513918923 WGPU : RobustResourceInitTest.RedefineSiblingFace/* = SKIP
+513918923 WIN OPENGL : RobustResourceInitTest.RedefineSiblingFace/* = SKIP
+
// SPIR-V compilation error
384593678 VULKAN : SimpleUniformTestES3.CopyOfUniformsWithArrays/* = SKIP
diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp
index afab8d1..39fd638 100644
--- a/src/tests/gl_tests/RobustResourceInitTest.cpp
+++ b/src/tests/gl_tests/RobustResourceInitTest.cpp
@@ -4163,6 +4163,70 @@
EXPECT_GL_NO_ERROR();
}
+// Tests that redefining a single face of a cube map does not discard the
+// data of other faces at the same level.
+TEST_P(RobustResourceInitTest, RedefineSiblingFace)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ const GLint N = 4;
+
+ GLTexture tex;
+ glBindTexture(GL_TEXTURE_CUBE_MAP, tex);
+
+ for (GLenum face = 0; face < 6; face++)
+ {
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_RGBA, N, N, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ }
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ EXPECT_GL_NO_ERROR();
+
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_NEGATIVE_X,
+ tex, 0);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ glViewport(0, 0, N, N);
+ glClearColor(0.0f, 1.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+ EXPECT_GL_NO_ERROR();
+
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+
+ // Redefine POSITIVE_X with a mismatched size
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X, 0, GL_RGBA, 2 * N, 2 * N, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ EXPECT_GL_NO_ERROR();
+
+ // Redefine POSITIVE_X back to normal size to make it cube complete again
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X, 0, GL_RGBA, N, N, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ nullptr);
+ EXPECT_GL_NO_ERROR();
+
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ for (GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X; face <= GL_TEXTURE_CUBE_MAP_NEGATIVE_Z;
+ face++)
+ {
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, face, tex, 0);
+ if (face == GL_TEXTURE_CUBE_MAP_NEGATIVE_X)
+ {
+ // Previously set to green with glClear and not redefined.
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+ }
+ else
+ {
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::transparentBlack);
+ }
+ }
+ EXPECT_GL_NO_ERROR();
+}
+
ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND(
RobustResourceInitTest,
ES3_METAL().enable(Feature::EmulateDontCareLoadWithRandomClear),
Original Bug Report
Potential VRAM leak in ANGLE D3D11 via cube map sibling face redefinition
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 logic error in ANGLE’s D3D11 backend allows sibling faces of a cube map to lose their initialization state and data when another face at the same level is redefined. This leads to a desync between the front-end and backend, potentially allowing a malicious website to read uninitialized GPU memory via glReadPixels.
Affected files:
third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cppthird_party/angle/src/libANGLE/renderer/d3d/d3d11/Image11.cppthird_party/angle/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cppthird_party/angle/src/libANGLE/Texture.cppthird_party/angle/src/libANGLE/Framebuffer.cpp
Estimated timestamp from git blame: Unknown (Google3 checkout)
Root Cause Analysis
A vulnerability exists in ANGLE’s D3D11 backend due to insufficient granularity in the storage-preservation logic during cube map redefinition. When a cube map face is redefined with a mismatched size or format, TextureD3D_Cube::redefineImage triggers a release of the underlying texture storage.
In third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp:
ANGLE_TRY(releaseTexStorage(context, copyImageMask));
The copyImageMask is a gl::TexLevelMask, which tracks initialization status on a per-level basis. However, a cube map contains six faces per level. When redefineImage unsets the bit for a level to exclude a redefined face from data preservation, it inadvertently instructs releaseTexStorage to skip the storage-to-image copy-back for all six faces at that level.
Exploitation Mechanism
The vulnerability is reachable because multiple layers of state management fail to maintain consistency:
- Association Failure: While ANGLE normally recovers associated images during storage destruction in
TextureStorage11_Cube::onDestroy, an image may lose its association ifmRecoveredFromStorageCountreaches its limit (currently 2). In this state, a face modified only via a RenderTarget (e.g.,glClear) exists only in GPU storage and is not associated with anImage11object. - Data Loss: During redefinition of a sibling face, the mask-based bypass in
releaseTexStorageskips preservation, and the lack of association skips recovery inonDestroy. The GPU storage is deleted, losing the cleared data. - Backend Desync: The
Image11::isDirty()check (Image11.cpp:135) incorrectly returnsfalseif an image is dirty but lacks both a staging texture and a storage association. This prevents the lost data from being re-uploaded to new storage. - Information Leak: When new storage is allocated, it remains uninitialized. Because the front-end still believes the face is
Initialized, it bypasses robust resource initialization, allowing the attacker to read VRAM residue viaglReadPixels.
Potential Reproduction Steps
- Create a 4x4 RGBA8 cube map and initialize all faces.
- Redefine a sibling face multiple times to increment the
mRecoveredFromStorageCountfor Face A until association is disabled. - Attach Face A to an FBO and clear it (data is now in GPU-only storage).
- Redefine a different face (Face B) with a mismatched size (8x8). This triggers the buggy release logic.
- Redefine Face B back to 4x4 to make the cube complete.
- Bind Face A and perform
glReadPixels. The operation may return uninitialized VRAM residue instead of the cleared color.
Note: These steps are based on static code analysis; actual behavior may vary depending on D3D11 driver specifics and ANGLE configuration.
Suggested Fix
The gl::TexLevelMask should be replaced or supplemented with a mechanism that tracks preservation requirements on a per-face basis for cube maps, ensuring that sibling faces are not excluded from releaseTexStorage preservation logic when only one face is being redefined.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.