Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized Use in ANGLE
DescriptionUninitialized Use in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassUninitialized Memory
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker513920082
Fix commit4ed073fe732f (angle/angle) +94/-5
CWECWE-457 (Use of uninitialized)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-17 (per vendor advisory)
Fix landed2026-05-19 (public git)
Disclosed2026-06-30
Patch gap42 days — fix public in source before the advisory

Background

Image definition
ANGLE’s per-level record (ImageDefinitionMtl, holding image and formatID) describing the format, type, and size of one mip level of a GL texture.
Native texture storage
the backing mNativeTextureStorage Metal 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 glTexImage2D for 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 formatID no longer matches the native storage’s intendedFormatId, 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.

Key insight
The core mistake was encoding a fragile assumption as a debug-only 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

  1. Allocate a texture with a mismatched level Create a 2D texture, define level 0 as GL_RGBA8 256x256, and define level 1 as GL_RGBA16F 128x128 so the level-1 image definition records a format differing from what the native storage will use.
  2. Force native storage allocation Attach level 0 to a framebuffer and clear it, causing the backend to allocate mNativeTextureStorage sized to accommodate the levels.
  3. Redefine the level to desynchronize Reissue glTexImage2D on level 1 as GL_RGBA8 128x128, a size that stays imageWithinNativeStorageLevels but whose formatID no longer matches the stale imageDef.image, hitting the branch that previously only ASSERTed.
  4. 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)
  5. Observe the leaked/incorrect bytes Read back the rendered result; controlled uninitialized GPU memory contents become observable to the application. (inferred)

Impact Assessment

An attacker running attacker-controlled GLSL/WebGL against the Metal backend can read uninitialized or misinterpreted GPU texture-storage memory by desynchronizing a level’s format from its native storage, an information-disclosure primitive within the GPU process on Apple platforms. The precondition is the ability to issue the specific 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.mm
  • src/tests/gl_tests/TextureTest.cpp

Audit Directions

  • ASSERT-guarded invariants
    Search Metal backend code for ASSERT statements that validate cache/storage consistency instead of handling the mismatch, since these become silent no-ops in release builds.
  • Cached per-level views after redefinition
    Review every place imageDef.image (or similar aliased storage views) is reused based on size checks alone, without re-validating formatID, textureType, and dimensions against mNativeTextureStorage.
  • Format/type mutation without recreation
    Audit glTexImage2D/glTexSubImage2D redefinition 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.
From 4ed073fe732f72277a2ad3f9696c49447c7a4c36 Mon Sep 17 00:00:00 2001
From: Le Hoang Quyen <lehoangquyen@chromium.org>
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 <geofflang@chromium.org>
Commit-Queue: Quyen Le <lehoangquyen@google.com>
---

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)
 {
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 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)
 {
Loading diff…

Original Bug Report

reported by vm...@google.com

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.mm
  • third_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mm
  • third_party/angle/src/libANGLE/renderer/metal/mtl_resources.mm
  • third_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)

  1. Create a texture with TEXTURE_MAX_LEVEL set to 1.
  2. Define Level 0 as RGBA8 and Level 1 as RGBA16F using glTexImage2D with no data.
  3. 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.
  4. Redefine Level 1 to RGBA8 and upload data. Due to the logic error, the data is uploaded to a detached texture, while the native storage remains uninitialized.
  5. 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.

View on issue tracker
Uploaded PoC files