CVE-2026-13833
Overview
Background
- Image definition
- ANGLE’s per-level record (
ImageDefinitionMtl, holdingimageandformatID) describing the format, type, and size of one mip level of a GL texture. - Native texture storage
- the backing
mNativeTextureStorageMetal object that ANGLE allocates for a texture; per-level views (imageDef.image) are aliased into it when a level fits the storage’s dimensions. - Texture redefinition
- reissuing
glTexImage2Dfor an existing level with a new internal format, type, or size, which may invalidate the previously recorded image definition. - Per-level format mismatch
- a state where a mip level’s recorded
formatIDno longer matches the native storage’sintendedFormatId, so the cached view is stale even though the storage size is unchanged.
Root Cause Analysis
In TextureMtl::redefineImage (the TextureMtl.mm code path around mNativeTextureStorage), the code checked mNativeTextureStorage && imageDef.image && imageWithinNativeStorageLevels and then merely ASSERTed that the existing imageDef matched the new type, formatID, and size. The invariant “an existing image definition for an in-bounds level always agrees with the current native storage format” does not hold when a level is first defined with one format (e.g. GL_RGBA16F) and later redefined with a format that matches the native storage’s size but contradicts the old definition, so in release builds the ASSERT is compiled out and the stale imageDef.image view is silently reused. Because the reused view describes the wrong format/interpretation of the underlying storage, subsequent sampling reads the level’s bytes under an inconsistent format and can surface uninitialized or misinterpreted memory.
The fix drops imageDef.image from the guard condition and replaces the assertion with real control flow: if the definition still matches it is kept as a no-op, otherwise imageDef.image is set to nullptr and imageDef = getImageDefinition(index) lazily recreates a correct view of the native storage. This makes the desynchronized case a recoverable recreation instead of undefined behavior.
ASSERT instead of handling it — a redefined level whose format desynchronizes from the native storage was assumed impossible, so the stale view was reused in release builds; the fix turns that assumption into an explicit branch that lazily rebuilds the view via getImageDefinition.Attack Path
- Allocate a texture with a mismatched level
Create a 2D texture, define level 0 as
GL_RGBA8256x256, and define level 1 asGL_RGBA16F128x128 so the level-1 image definition records a format differing from what the native storage will use. - Force native storage allocation
Attach level 0 to a framebuffer and clear it, causing the backend to allocate
mNativeTextureStoragesized to accommodate the levels. - Redefine the level to desynchronize
Reissue
glTexImage2Don level 1 asGL_RGBA8128x128, a size that staysimageWithinNativeStorageLevelsbut whoseformatIDno longer matches the staleimageDef.image, hitting the branch that previously onlyASSERTed. - Sample the stale view
Draw a quad sampling level 1 via
textureLod, so the reused, format-mismatched view is read and returns uninitialized or misinterpreted storage bytes instead of the written data.(inferred) - Observe the leaked/incorrect bytes
Read back the rendered result; controlled uninitialized GPU memory contents become observable to the application.
(inferred)
Impact Assessment
glTexImage2D redefinition sequence and then sample the affected level, which is reachable from ordinary WebGL/GLES content. The advisory classifies this as Uninitialized Use in ANGLE and states no exploited-in-the-wild status; any escalation from information disclosure to a stronger primitive is not demonstrated by the patch (inferred).Files Changed
src/libANGLE/renderer/metal/TextureMtl.mmsrc/tests/gl_tests/TextureTest.cpp
Audit Directions
- ASSERT-guarded invariantsSearch Metal backend code for
ASSERTstatements that validate cache/storage consistency instead of handling the mismatch, since these become silent no-ops in release builds. - Cached per-level views after redefinitionReview every place
imageDef.image(or similar aliased storage views) is reused based on size checks alone, without re-validatingformatID,textureType, and dimensions againstmNativeTextureStorage. - Format/type mutation without recreationAudit
glTexImage2D/glTexSubImage2Dredefinition paths across backends for cases where changing a level’s format keeps prior storage without lazily rebuilding the view, which can expose uninitialized or misinterpreted memory.
Patch
From 4ed073fe732f72277a2ad3f9696c49447c7a4c36 Mon Sep 17 00:00:00 2001 From: Le Hoang Quyen <[email protected]> Date: Tue, 19 May 2026 14:39:28 +0800 Subject: [PATCH] Metal: Fix texture storage desynchronization bug Fix texture redefinition when the new format matches native storage but contradicts the old image definition. Instead of asserting, lazily recreate the view of the native storage. Added a regression test PerLevelFormatMismatchRedefine. Bug: chromium:513920082 Change-Id: I48a930256b6ff545fb5fa7b54b0198be99bc34c6 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7859329 Reviewed-by: Geoff Lang <[email protected]> Commit-Queue: Quyen Le <[email protected]> --- diff --git a/src/libANGLE/renderer/metal/TextureMtl.mm b/src/libANGLE/renderer/metal/TextureMtl.mm index 2a39b56..4b3f069 100644 --- a/src/libANGLE/renderer/metal/TextureMtl.mm +++ b/src/libANGLE/renderer/metal/TextureMtl.mm @@ -1345,6 +1345,7 @@ return imageDef; } + angle::Result TextureMtl::getRenderTarget(ContextMtl *context, const gl::ImageIndex &imageIndex, GLsizei implicitSamples, @@ -2058,12 +2059,22 @@ // If native texture still exists, it means the size hasn't been changed, no need to create new // image - if (mNativeTextureStorage && imageDef.image && imageWithinNativeStorageLevels) + if (mNativeTextureStorage && imageWithinNativeStorageLevels) { - ASSERT(imageDef.image->textureType() == - mtl::GetTextureType(GetTextureImageType(index.getType())) && - imageDef.formatID == mNativeTextureStorage->getFormat().intendedFormatId && - imageDef.image->sizeAt0() == size); + if (imageDef.image && + imageDef.image->textureType() == + mtl::GetTextureType(GetTextureImageType(index.getType())) && + imageDef.formatID == mNativeTextureStorage->getFormat().intendedFormatId && + imageDef.image->sizeAt0() == size) + { + // Keep it! (No-op) + } + else + { + // Recreate the view at this index. + imageDef.image = nullptr; + imageDef = getImageDefinition(index); + } } else { diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp index 7e30108..6ec348a 100644 --- a/src/tests/gl_tests/TextureTest.cpp +++ b/src/tests/gl_tests/TextureTest.cpp @@ -3134,6 +3134,84 @@ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red); } +// Test case to verify that per-level format mismatch followed by redefinition +// works correctly and does not lead to stale image definitions or uninitialized sampling. +TEST_P(Texture2DTestES3, PerLevelFormatMismatchRedefine) +{ + // We need ES3 for textureLod. + + GLTexture tex; + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1); + + // Level 0: RGBA8, 256x256 + std::vector<GLColor> dataA(256 * 256, GLColor::red); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 256, 256, 0, GL_RGBA, GL_UNSIGNED_BYTE, dataA.data()); + + // Level 1: RGBA16F, 128x128 (Per-level format mismatch) + glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA16F, 128, 128, 0, GL_RGBA, GL_HALF_FLOAT, nullptr); + ASSERT_GL_NO_ERROR(); + + // Create framebuffer and attach level 0 + GLFramebuffer fbo; + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0); + ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER); + + // Clear level 0 to force allocation of texture storage in some backends. + glClearColor(1.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + // Level 1: Redefine as RGBA8, 128x128 with sentinel value 0x42 + std::vector<GLColor> sentinelData(128 * 128, GLColor(0x42, 0x42, 0x42, 0x42)); + glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 128, 128, 0, GL_RGBA, GL_UNSIGNED_BYTE, + sentinelData.data()); + ASSERT_GL_NO_ERROR(); + + // Set filters to enable mipmapping + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + + // Draw fullscreen quad sampling from mip 1 using textureLod + constexpr char kVS[] = R"(#version 300 es + in vec4 position; + out vec2 texcoord; + void main() { + gl_Position = position; + texcoord = position.xy * 0.5 + 0.5; + })"; + + constexpr char kFS[] = R"(#version 300 es + precision mediump float; + uniform highp sampler2D tex; + in vec2 texcoord; + out vec4 fragColor; + void main() { + fragColor = textureLod(tex, texcoord, 1.0); + })"; + + ANGLE_GL_PROGRAM(program, kVS, kFS); + glUseProgram(program); + + GLTexture readbackTex; + glBindTexture(GL_TEXTURE_2D, readbackTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, getWindowWidth(), getWindowHeight(), 0, GL_RGBA, + GL_UNSIGNED_BYTE, nullptr); + + GLFramebuffer readbackFbo; + glBindFramebuffer(GL_FRAMEBUFFER, readbackFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, readbackTex, 0); + ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, tex); + glUniform1i(glGetUniformLocation(program, "tex"), 0); + + drawQuad(program, "position", 0.5f); + + EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(0x42, 0x42, 0x42, 0x42)); +} + // Almost mirrors UnitTest_DMSAA_dst_read test from Android skqp test suite TEST_P(Texture2DTestES3, UnitTest_DMSAA_dst_read) {
Regression Test / PoC
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 7e30108..6ec348a 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -3134,6 +3134,84 @@
EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
}
+// Test case to verify that per-level format mismatch followed by redefinition
+// works correctly and does not lead to stale image definitions or uninitialized sampling.
+TEST_P(Texture2DTestES3, PerLevelFormatMismatchRedefine)
+{
+ // We need ES3 for textureLod.
+
+ GLTexture tex;
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
+
+ // Level 0: RGBA8, 256x256
+ std::vector<GLColor> dataA(256 * 256, GLColor::red);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 256, 256, 0, GL_RGBA, GL_UNSIGNED_BYTE, dataA.data());
+
+ // Level 1: RGBA16F, 128x128 (Per-level format mismatch)
+ glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA16F, 128, 128, 0, GL_RGBA, GL_HALF_FLOAT, nullptr);
+ ASSERT_GL_NO_ERROR();
+
+ // Create framebuffer and attach level 0
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
+ ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ // Clear level 0 to force allocation of texture storage in some backends.
+ glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ // Level 1: Redefine as RGBA8, 128x128 with sentinel value 0x42
+ std::vector<GLColor> sentinelData(128 * 128, GLColor(0x42, 0x42, 0x42, 0x42));
+ glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 128, 128, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ sentinelData.data());
+ ASSERT_GL_NO_ERROR();
+
+ // Set filters to enable mipmapping
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+ // Draw fullscreen quad sampling from mip 1 using textureLod
+ constexpr char kVS[] = R"(#version 300 es
+ in vec4 position;
+ out vec2 texcoord;
+ void main() {
+ gl_Position = position;
+ texcoord = position.xy * 0.5 + 0.5;
+ })";
+
+ constexpr char kFS[] = R"(#version 300 es
+ precision mediump float;
+ uniform highp sampler2D tex;
+ in vec2 texcoord;
+ out vec4 fragColor;
+ void main() {
+ fragColor = textureLod(tex, texcoord, 1.0);
+ })";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ glUseProgram(program);
+
+ GLTexture readbackTex;
+ glBindTexture(GL_TEXTURE_2D, readbackTex);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, getWindowWidth(), getWindowHeight(), 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+
+ GLFramebuffer readbackFbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, readbackFbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, readbackTex, 0);
+ ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glUniform1i(glGetUniformLocation(program, "tex"), 0);
+
+ drawQuad(program, "position", 0.5f);
+
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(0x42, 0x42, 0x42, 0x42));
+}
+
// Almost mirrors UnitTest_DMSAA_dst_read test from Android skqp test suite
TEST_P(Texture2DTestES3, UnitTest_DMSAA_dst_read)
{
Original Bug Report
ANGLE Metal: Potential cross-origin GPU memory leak via texture storage desynchronization
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 Metal backend allows a texture’s native storage to become desynchronized from its per-level image definitions. This can lead to shaders sampling from uninitialized GPU memory, potentially leaking cross-origin data such as decoded video frames or other web content.
Affected files:
third_party/angle/src/libANGLE/renderer/metal/TextureMtl.mmthird_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mmthird_party/angle/src/libANGLE/renderer/metal/mtl_resources.mmthird_party/angle/src/libANGLE/Texture.cpp
Estimated timestamp from git blame: 2024-05-14
Summary
A vulnerability in ANGLE’s Metal backend (used in Chrome on macOS and iOS) can potentially lead to cross-origin GPU memory disclosure. The issue stems from a desynchronization between per-level ImageDefinitionMtl objects (which hold standalone texture data) and mNativeTextureStorage (the actual MTLTexture used by shaders). This allows an attacker to sample from uninitialized GPU memory that may contain sensitive data from other origins.
Root Cause Analysis
The vulnerability is located in third_party/angle/src/libANGLE/renderer/metal/TextureMtl.mm and involves two primary sites:
1. Native Storage Creation (Site A)
When ensureNativeStorageCreated is called, it allocates a new native texture storage and attempts to transfer data from previously defined standalone images (mTexImageDefs) into this storage. However, the loop at line 971 silently skips any level where the standalone image’s format does not match the native storage’s format. In such cases, the native storage’s mip level remains uninitialized, while the ImageDefinitionMtl for that level remains non-null, pointing to the old standalone texture.
2. Image Redefinition (Site B)
When a mip level is later redefined (via glTexImage2D) to a format that does match the existing native storage, redefineImage checks for compatibility at line 2042. If the format and size match the storage, it avoids deallocating the storage. It then enters a conditional block at line 2061:
if (mNativeTextureStorage && imageDef.image && imageWithinNativeStorageLevels)
{
ASSERT(imageDef.image->textureType() == ... &&
imageDef.formatID == mNativeTextureStorage->getFormat().intendedFormatId &&
imageDef.image->sizeAt0() == size);
}
In release builds, this block is a no-op because it only contains an ASSERT. If the level was previously skipped at Site A, imageDef.image still points to the stale, detached texture of the old format. The backend fails to update or clear this stale reference, leading to a desynchronization.
Impact
Subsequent pixel uploads are routed to the detached standalone texture because getImageDefinition prefers an existing imageDef.image. However, when the texture is bound for sampling in a draw call, the backend binds the mNativeTextureStorage. Consequently, the shader samples from the uninitialized native storage (VRAM) instead of the uploaded data. Since the frontend marks the level as Initialized upon upload, ANGLE’s robustness checks (which would otherwise zero-fill the storage) are bypassed.
Potential Reproduction Steps (Not verified by execution)
- Create a texture with
TEXTURE_MAX_LEVELset to 1. - Define Level 0 as
RGBA8and Level 1 asRGBA16FusingglTexImage2Dwith no data. - Force native storage creation by binding the texture to a framebuffer and performing a clear. The native storage will be
RGBA8. Level 1 will be skipped during synchronization because of the format mismatch, leaving its storage uninitialized. - Redefine Level 1 to
RGBA8and upload data. Due to the logic error, the data is uploaded to a detached texture, while the native storage remains uninitialized. - Draw using the texture and sample from mip level 1 in a shader. The resulting colors will contain leaked GPU memory contents.
Suggested Fix
In TextureMtl::redefineImage, the condition at line 2061 should be modified to ensure the existing imageDef.image is actually synchronized with the native storage and matches the intended format. For example, include the formatID and size checks as part of the if condition rather than solely within an ASSERT block. This ensures that a new, correct image definition (or view) is created when a desynchronization is detected in release builds.
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.