Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in ANGLE
DescriptionHeap buffer overflow in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker499006005
Fix commitcf8ec6403ff8 (angle/angle) +69/-6
CISA KEVNot listed
CreditedThomas Guillem <[email protected]>
Disclosed2026-06-30

Files Changed

  • src/libANGLE/renderer/metal/ContextMtl.mm
  • src/libANGLE/renderer/metal/TextureMtl.h
  • src/libANGLE/renderer/metal/TextureMtl.mm
  • src/tests/gl_tests/TextureTest.cpp
From cf8ec6403ff88f7ff8e544d59b9c405452279cc1 Mon Sep 17 00:00:00 2001
From: Geoff Lang <[email protected]>
Date: Fri, 01 May 2026 13:20:55 -0400
Subject: [PATCH] Metal: Treat glGenerateMipmap as an image redefinition.

When TextureMtl::generateMipmap creates a native storage, it doesn't
treat it as a texture redefinition and clear out old images. This leaves
images with formats and sizes that do not match the storage.

Fixed: chromium:499006005
Change-Id: I78cdbf77ccb75469fba3ca4654ea9118aa80edd5
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7806315
Commit-Queue: Geoff Lang <[email protected]>
Reviewed-by: Shahbaz Youssefi <[email protected]>
Reviewed-by: Kenneth Russell <[email protected]>
---

diff --git a/src/libANGLE/renderer/metal/ContextMtl.mm b/src/libANGLE/renderer/metal/ContextMtl.mm
index 88efc23..c5645c3 100644
--- a/src/libANGLE/renderer/metal/ContextMtl.mm
+++ b/src/libANGLE/renderer/metal/ContextMtl.mm
@@ -2717,7 +2717,7 @@
         TextureMtl *textureMtl = mtl::GetImpl(texture);
 
         // Make sure texture's image definitions will be transferred to GPU.
-        ANGLE_TRY(textureMtl->ensureNativeStorageCreated(context));
+        ANGLE_TRY(textureMtl->ensureNativeStorageCreated(context, true));
 
         // The binding of this texture will be done by ProgramMtl.
         return angle::Result::Continue;
diff --git a/src/libANGLE/renderer/metal/TextureMtl.h b/src/libANGLE/renderer/metal/TextureMtl.h
index 2dfd58b..46d29c7 100644
--- a/src/libANGLE/renderer/metal/TextureMtl.h
+++ b/src/libANGLE/renderer/metal/TextureMtl.h
@@ -165,7 +165,7 @@
     // of images through glTexImage*/glCopyTex* calls. During draw calls, the caller must make sure
     // the actual texture is created by calling this method to transfer the stored images data
     // to the actual texture.
-    angle::Result ensureNativeStorageCreated(const gl::Context *context);
+    angle::Result ensureNativeStorageCreated(const gl::Context *context, bool keepImages);
 
     angle::Result bindToShader(const gl::Context *context,
                                mtl::RenderCommandEncoder *cmdEncoder,
diff --git a/src/libANGLE/renderer/metal/TextureMtl.mm b/src/libANGLE/renderer/metal/TextureMtl.mm
index 803f46d..a61b624 100644
--- a/src/libANGLE/renderer/metal/TextureMtl.mm
+++ b/src/libANGLE/renderer/metal/TextureMtl.mm
@@ -959,10 +959,30 @@
     }
 }
 
-angle::Result TextureMtl::ensureNativeStorageCreated(const gl::Context *context)
+angle::Result TextureMtl::ensureNativeStorageCreated(const gl::Context *context, bool keepImages)
 {
+    auto clearImagesAssociatedWithStorage = [&]() {
+        ASSERT(mNativeTextureStorage);
+        GLuint mips      = mState.getMipmapMaxLevel() - mState.getEffectiveBaseLevel() + 1;
+        int numCubeFaces = static_cast<int>(mNativeTextureStorage->cubeFaces());
+        for (int face = 0; face < numCubeFaces; ++face)
+        {
+            for (mtl::MipmapNativeLevel actualMip = mtl::kZeroNativeMipLevel;
+                 actualMip.get() < mips; ++actualMip)
+            {
+                GLuint imageMipLevel = mNativeTextureStorage->getGLLevel(actualMip);
+                mTexImageDefs[face][imageMipLevel].image = nullptr;
+            }
+        }
+    };
+
     if (mNativeTextureStorage)
     {
+        // Storage exists, deallocate images associated with the storage.
+        if (!keepImages)
+        {
+            clearImagesAssociatedWithStorage();
+        }
         return angle::Result::Continue;
     }
 
@@ -1015,6 +1035,11 @@
         }
     }
 
+    if (!keepImages)
+    {
+        clearImagesAssociatedWithStorage();
+    }
+
     return angle::Result::Continue;
 }
 
@@ -1686,7 +1711,7 @@
 
 angle::Result TextureMtl::generateMipmap(const gl::Context *context)
 {
-    ANGLE_TRY(ensureNativeStorageCreated(context));
+    ANGLE_TRY(ensureNativeStorageCreated(context, false));
 
     ContextMtl *contextMtl = mtl::GetImpl(context);
     if (!mViewFromBaseToMaxLevel)
@@ -1859,7 +1884,7 @@
                                                     GLsizei samples,
                                                     FramebufferAttachmentRenderTarget **rtOut)
 {
-    ANGLE_TRY(ensureNativeStorageCreated(context));
+    ANGLE_TRY(ensureNativeStorageCreated(context, true));
 
     ContextMtl *contextMtl = mtl::GetImpl(context);
     ANGLE_CHECK(contextMtl, mNativeTextureStorage, gl::err::kInternalError, GL_INVALID_OPERATION);
@@ -1920,7 +1945,7 @@
         }
     }
 
-    ANGLE_TRY(ensureNativeStorageCreated(context));
+    ANGLE_TRY(ensureNativeStorageCreated(context, true));
     ANGLE_TRY(ensureSamplerStateCreated(context));
 
     return angle::Result::Continue;
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index d0ad952..4894a4d 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -19306,6 +19306,44 @@
     swapBuffers();
 }
 
+// Call glGenerateMipmap multiple times with different formats. Covers issues with texture
+// redefinition.
+TEST_P(Texture2DTestES3, MultipleGenerateMipmapCalls)
+{
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+
+    // Full mip chain of GL_RGB
+    const GLsizei originalW = 128, originalH = 128;
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, originalW, originalH, 0, GL_RGB, GL_UNSIGNED_BYTE,
+                 nullptr);
+    glGenerateMipmap(GL_TEXTURE_2D);
+    ASSERT_GL_NO_ERROR();
+
+    // Full mip chain of R8
+    const GLsizei redefineW = 64, redefineH = 64;
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, redefineW, redefineH, 0, GL_RED, GL_UNSIGNED_BYTE,
+                 nullptr);
+    glGenerateMipmap(GL_TEXTURE_2D);
+    ASSERT_GL_NO_ERROR();
+
+    // Update mip 1 which should be R8
+    const GLsizei w = redefineW / 2;
+    const GLsizei h = redefineH / 2;
+    std::vector<GLubyte> data(w * h, 0xFF);
+    glTexSubImage2D(GL_TEXTURE_2D, 1, 0, 0, w, h, GL_RED, GL_UNSIGNED_BYTE, data.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Verify data in mip 1
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 1);
+    EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    EXPECT_PIXEL_RECT_EQ(0, 0, w, h, GLColor::red);
+    ASSERT_GL_NO_ERROR();
+}
+
 // Verify that image uniforms can link in separable programs
 TEST_P(TextureTestES31, LinkedImageUniforms)
 {
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 d0ad952..4894a4d 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -19306,6 +19306,44 @@
     swapBuffers();
 }
 
+// Call glGenerateMipmap multiple times with different formats. Covers issues with texture
+// redefinition.
+TEST_P(Texture2DTestES3, MultipleGenerateMipmapCalls)
+{
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+
+    // Full mip chain of GL_RGB
+    const GLsizei originalW = 128, originalH = 128;
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, originalW, originalH, 0, GL_RGB, GL_UNSIGNED_BYTE,
+                 nullptr);
+    glGenerateMipmap(GL_TEXTURE_2D);
+    ASSERT_GL_NO_ERROR();
+
+    // Full mip chain of R8
+    const GLsizei redefineW = 64, redefineH = 64;
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, redefineW, redefineH, 0, GL_RED, GL_UNSIGNED_BYTE,
+                 nullptr);
+    glGenerateMipmap(GL_TEXTURE_2D);
+    ASSERT_GL_NO_ERROR();
+
+    // Update mip 1 which should be R8
+    const GLsizei w = redefineW / 2;
+    const GLsizei h = redefineH / 2;
+    std::vector<GLubyte> data(w * h, 0xFF);
+    glTexSubImage2D(GL_TEXTURE_2D, 1, 0, 0, w, h, GL_RED, GL_UNSIGNED_BYTE, data.data());
+    ASSERT_GL_NO_ERROR();
+
+    // Verify data in mip 1
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 1);
+    EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    EXPECT_PIXEL_RECT_EQ(0, 0, w, h, GLColor::red);
+    ASSERT_GL_NO_ERROR();
+}
+
 // Verify that image uniforms can link in separable programs
 TEST_P(TextureTestES31, LinkedImageUniforms)
 {
Loading diff…

Original Bug Report

reported by [email protected]

Cross-origin information disclosure via stale ANGLE Metal texture views (GPU process heap leak)

VULNERABILITY

Any WebGL2 page on macOS can continuously read GPU process heap memory, leaking cross-origin URLs, pointers, and metadata from other tabs. ~35 MB/s sustained, no user interaction, default Chrome config.

The GPU process composites all tabs. URL strings from resource loading end up on its heap. Stale ANGLE texture views expose this memory to JavaScript through readPixels.

REPRODUCTION (cross-origin URL leak)

  1. Open attached poc-heap-leak.html in official Google Chrome (stable or canary) on macOS
  2. Open other tabs with any websites (e.g. reddit.com)
  3. Watch the PoC page — leaked URL fragments, pointers, and strings appear within seconds

See attached video (poc-heap-leak.mov) and screenshot (poc-heap-leak.png).

Works on both stable (147) and canary (148). The PoC tries multiple exploitation strategies to cover different ANGLE internal layouts.

ROOT CAUSE

TextureMtl::retainImageDefinitions() in TextureMtl.mm. When a texture is redefined (format or size change), redefineImage() calls deallocateNativeStorage(keepImages=true) which saves stale image views on all mip levels. These views reference old Metal texture allocations whose backing memory contains recycled GPU data from other tabs’ compositor textures.

6d8b704e2a (“Remove TextureMtl::mFormat”, cherry-picked to M146/M147) does not fix this. The stale image definitions survive — getImageDefinition() only updates when imageDef.image is null, but retained images are non-null.

OOB READ/WRITE (ASAN)

The same root cause also produces OOB read and write, confirmable with ASAN. The HTML PoC does NOT trigger ASAN (ASAN cannot catch Metal-managed memory issues). To reproduce under ASAN:

  1. Apply attached angle-texturetest.diff, build angle_end2end_tests with ASAN on macOS
  2. OOB read: ANGLE_DEFAULT_PLATFORM=metal ./out/asan/angle_end2end_tests –gtest_filter="StaleFormatIDHeapOverflowRead"
  3. OOB write: MTL_DEBUG_LAYER=1 ANGLE_DEFAULT_PLATFORM=metal ./out/asan/angle_end2end_tests –gtest_filter="StaleImageSizeHeapOverflowWrite"

OOB READ ASAN stack traces attached: StaleFormatIDHeapOverflowRead.txt OOB Write AGX Assert attached: StaleImageSizeHeapOverflowWrite.txt

SUGGESTED FIX

Attached angle-fix-stale-imagedef.diff addresses the root cause.

VERSION Chrome Version: 147.0.7727.50 (stable), 148.0.7769.0 (canary) Operating System: macOS ARM64

ATTACHMENTS

  • poc-heap-leak.html — live cross-origin URL leak PoC
  • poc-heap-leak.mp4 — video demonstration
  • poc-heap-leak.png — screenshot
  • angle-texturetest.diff — ANGLE test for ASAN reproduction
  • StaleFormatIDHeapOverflowRead.txt — ASAN stack trace
  • StaleImageSizeHeapOverflowWrite.txt — ASAN stack trace
  • angle-fix-stale-imagedef.diff — suggested fix

CREDIT INFORMATION Reporter credit: Thomas Guillem <[email protected]>

View on issue tracker