Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in ANGLE
DescriptionHeap buffer overflow in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
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.
Independent review by AIConfirmed
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker499006005
Fix commitcf8ec6403ff8 (angle/angle) +69/-6
CWECWE-122, CWE-787 (Heap buffer overflow, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedThomas Guillem <[email protected]>
Reported2026-04-03 · bounty $10000 (per vendor advisory)
Fix landed2026-05-04 (public git)
Disclosed2026-06-30
Patch gap57 days — fix public in source before the advisory

Background

`TextureMtl`
The Metal-backend implementation of an ANGLE GL texture, which stages per-level image data in mTexImageDefs before transferring it into a native Metal texture object.
`ensureNativeStorageCreated`
The method that lazily allocates the backing native Metal storage (mNativeTextureStorage) so staged image data can be uploaded to the GPU during draws and other operations.
Texture redefinition
The GL behavior where a call such as glTexImage2D changes a level’s format or size, invalidating any previously staged images that no longer match the new storage layout.
`glGenerateMipmap`
A GL entry point that (re)creates a texture’s full mip chain from its base level, which in this backend forces creation of new native storage.

Root Cause Analysis

The vulnerable path was TextureMtl::generateMipmap, which called ensureNativeStorageCreated(context) to allocate a fresh mNativeTextureStorage without treating that allocation as an image redefinition. Because the stale per-level entries in mTexImageDefs were never cleared, images retaining the old format and dimensions remained associated with storage that had been created for a different format and size (for example, an original GL_RGB 128x128 chain replaced by an GL_R8 64x64 chain). Subsequent operations that consulted those stale image definitions could compute buffer sizes or copy extents against mismatched format/size assumptions, producing a heap buffer overflow.

The fix adds a keepImages parameter to ensureNativeStorageCreated and, when keepImages is false, invokes the new clearImagesAssociatedWithStorage lambda to null out mTexImageDefs[face][imageMipLevel].image for every face and mip covered by the storage. generateMipmap now passes false, so creating native storage during mipmap generation correctly discards the stale, mismatched image definitions, restoring the invariant that staged images agree with the storage’s format and size.

Key insight
The single mistake was treating native-storage creation in generateMipmap as a benign lazy allocation rather than a redefinition, leaving stale mTexImageDefs entries whose format and size no longer matched the new storage; the fix explicitly clears those images (via keepImages == false) so no operation acts on mismatched metadata.

Attack Path

  1. Establish an initial mip chain From WebGL/GLES, bind a texture and define a full mip chain with one format and size, e.g. glTexImage2D(GL_RGB, 128, 128) followed by glGenerateMipmap.
  2. Redefine with a smaller/incompatible format Call glTexImage2D(GL_R8, 64, 64) on the base level and glGenerateMipmap again, which in the old code creates new native storage while leaving stale mTexImageDefs entries describing the previous GL_RGB 128x128 images.
  3. Trigger use of the mismatched images Perform a subsequent operation (such as a sub-image update or upload keyed off the stale image definitions) so the backend sizes or copies data using the old format/dimensions against the newly allocated storage.
  4. Heap buffer overflow The size/extent mismatch causes an out-of-bounds heap access in ANGLE’s Metal backend, consistent with the advisory’s “Heap buffer overflow in ANGLE”. (inferred) the exact out-of-bounds sink is not shown in the diff.
  5. Escalation (inferred) a controlled overflow of GPU-process heap memory could be shaped toward a read/write primitive within the GPU process, but the patch demonstrates only the memory-safety violation.

Impact Assessment

An attacker who can run WebGL/GLES commands (e.g. from a web page) can drive a heap buffer overflow in ANGLE’s Metal backend, corrupting heap memory in the process that hosts ANGLE, typically Chrome’s GPU process, on Apple/Metal platforms. The precondition is the ability to issue the texture-redefinition plus glGenerateMipmap sequence, which is reachable from ordinary untrusted web content. (inferred) any escalation to a read/write primitive or code execution beyond the memory-safety violation; the advisory states the class as OOB / heap buffer overflow and does not report exploited-in-the-wild status here.

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

Audit Directions

  • Lazy-allocation as implicit redefinition
    Audit every caller of ensureNativeStorageCreated and similar lazy storage/allocation helpers to confirm the keepImages choice matches whether the format/size may have changed since images were staged.
  • Stale staged metadata after format/size change
    Look for other backends or texture paths (mipmap generation, glTexStorage, respecification) that create native storage without invalidating cached per-level mTexImageDefs-style metadata.
  • Format/size mismatch between images and storage
    Search for size or copy-extent computations in the Metal backend that derive dimensions or bytes-per-pixel from image definitions rather than from the current native storage, which can desynchronize after redefinition.
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