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 (plausible) — 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 AIPlausible
Reviewer notes: The visible hunk sets mFormat from the image desc immediately before createNativeStorage, so allocation there used a fresh format; the stale-format mismatch more likely hit later getFormat() consumers. Diff is truncated and Metal-backend only; the overflow site is not shown.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker493256564
Fix commit6d8b704e2a18 (angle/angle) +141/-79
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedcinzinga
Reported2026-03-16 (per vendor advisory)
Fix landed2026-03-24 (public git)
Disclosed2026-04-07
Patch gap14 days — fix public in source before the advisory

Background

`TextureMtl`
ANGLE’s Metal backend implementation of a GL texture object, managing native MTLTexture storage and per-level image data.
`mtl::Format`
a wrapper describing a texture’s pixel format, including bits-per-pixel and depth/stencil layout used when sizing and allocating native storage.
`createNativeStorage`
the TextureMtl method that allocates the backing Metal texture (Make2DTexture, etc.) for a given type, mip count, and extent, using a format to determine allocation size and view support.
`NativeTextureWrapper`
a helper wrapping a mtl::TextureRef plus its base GL level, extended so per-storage state (now including the format) travels with the native texture.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: The visible hunk sets mFormat from the image desc immediately before createNativeStorage, so allocation there used a fresh format; the stale-format mismatch more likely hit later getFormat() consumers. Diff is truncated and Metal-backend only; the overflow site is not shown.

TextureMtl kept a single mFormat member that was intended to describe the native storage’s format, but it was overwritten by the last format set on any mip level via setImageImpl/redefineImage, even for levels not present in the native storage. Because createNativeStorage and the allocation helpers (Make2DTexture and needsFormatViewForPixelLocalStorage) read this stale/mismatched mFormat to compute allocation size and view options, the native MTLTexture could be created sized for a different pixel format than the data later written into it, producing a heap buffer overflow.

The fix removes mFormat entirely and threads the correct mtl::Format explicitly: createNativeStorage now takes const mtl::Format &format, and the format is stored immutably inside NativeTextureWrapper::mFormat so it always matches the storage it describes. This works because format is now sourced from the specific image desc at allocation time and bound to the native storage, eliminating the class of mismatches where a per-level format update silently diverged from the allocated storage.

Key insight
The single core mistake was using one mutable, per-texture mFormat field to represent the native storage’s format while letting any mip-level update mutate it, so allocation and data could disagree; the fix removes the field and carries the format alongside the native storage (and per image desc), keeping the allocation format and the data format in lockstep.

Attack Path

  1. Create a texture and define mismatched mip levels Through WebGL/GLES, define image data on multiple mip levels of a TextureMtl with differing internal formats so the mutable mFormat ends up holding a format other than that of the level driving native storage.
  2. Trigger native storage allocation Cause createNativeStorage to run (e.g. via ensureNativeStorageCreated/mip completeness), where Make2DTexture and needsFormatViewForPixelLocalStorage size the backing MTLTexture from the stale mFormat.
  3. Provoke the size mismatch The native storage is allocated for a format whose bytes-per-pixel differ from the format of the data actually transferred into it, so subsequent copies write past the allocation. (inferred)
  4. Achieve heap buffer overflow Data transfer into the undersized native texture overflows the heap allocation, as stated by the advisory. (inferred)

Impact Assessment

An attacker who can run crafted GL/WebGL commands gains a heap buffer overflow in ANGLE, which runs in Chrome’s GPU process. Preconditions are the ability to create a texture and define mip levels with formats chosen so the native storage is allocated with a format different from the data written to it. Any further escalation to a controlled read/write primitive, sandbox escape, or code execution beyond the advisory’s stated heap buffer overflow is (inferred); the advisory does not state exploited-in-the-wild status here.

Changed Functions

FunctionChangeNotes
NativeTextureWrapper
src/libANGLE/renderer/metal/TextureMtl.h
modified
NativeTextureWrapperWithViewSupport
src/libANGLE/renderer/metal/TextureMtl.h
modified
TextureMtl
src/libANGLE/renderer/metal/TextureMtl.mm
modified

Files Changed

  • src/libANGLE/renderer/metal/TextureMtl.h
  • src/libANGLE/renderer/metal/TextureMtl.mm

Audit Directions

  • Mutable cached descriptors
    Look for backend objects that cache a format, size, or layout in a single mutable member while allowing per-level or per-subresource updates to mutate it, since allocation code reading the cached value can diverge from the actual data.
  • Allocation-vs-data format agreement
    Audit paths where native storage is sized from one format source but data is transferred using another (mip levels, cube faces, views), especially where bytes-per-pixel or depth/stencil layout differs.
  • Format threading through view creation
    Review needsFormatViewForPixelLocalStorage and other view/allocation helpers that historically pulled format from shared state to confirm they now receive the format explicitly and consistently.
From 6d8b704e2a185c82430a339d70508742887a962f Mon Sep 17 00:00:00 2001
From: Geoff Lang <geofflang@chromium.org>
Date: Thu, 19 Mar 2026 15:17:08 -0400
Subject: [PATCH] Metal: Remove TextureMtl::mFormat

TextureMtl::mFormat is supposed to represent the format of the native
storage but it was updated to the last format set on any mip level, even
if it is not in the native storage. This is extremely error prone, a lot
of the Metal texturing code relied on it being properly set.

Remove mFormat and query it from the native storage or the specific
image desc.

Bug: chromium:493256564
Change-Id: I629c009b34c7ef7ca5fa7a97f5845accf22b13b8
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7684363
Commit-Queue: Geoff Lang <geofflang@chromium.org>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
---

diff --git a/src/libANGLE/renderer/metal/TextureMtl.h b/src/libANGLE/renderer/metal/TextureMtl.h
index 65283cd..2dfd58b 100644
--- a/src/libANGLE/renderer/metal/TextureMtl.h
+++ b/src/libANGLE/renderer/metal/TextureMtl.h
@@ -182,15 +182,14 @@
                                     int layer,
                                     GLenum format);
 
-    const mtl::Format &getFormat() const { return mFormat; }
-
   private:
     void deallocateNativeStorage(bool keepImages, bool keepSamplerStateAndFormat = false);
     angle::Result createNativeStorage(const gl::Context *context,
                                       gl::TextureType type,
                                       GLuint mips,
                                       GLuint samples,
-                                      const gl::Extents &size);
+                                      const gl::Extents &size,
+                                      const mtl::Format &format);
     angle::Result onBaseMaxLevelsChanged(const gl::Context *context);
     angle::Result ensureSamplerStateCreated(const gl::Context *context);
     // Ensure image at given index is created:
@@ -328,10 +327,10 @@
 
     angle::Result generateMipmapCPU(const gl::Context *context);
 
-    bool needsFormatViewForPixelLocalStorage(const ShPixelLocalStorageOptions &) const;
+    bool needsFormatViewForPixelLocalStorage(const ShPixelLocalStorageOptions &,
+                                             const mtl::Format &format) const;
     bool isImmutableOrPBuffer() const;
 
-    mtl::Format mFormat;
     egl::Surface *mBoundSurface = nullptr;
     class NativeTextureWrapper;
     class NativeTextureWrapperWithViewSupport;
diff --git a/src/libANGLE/renderer/metal/TextureMtl.mm b/src/libANGLE/renderer/metal/TextureMtl.mm
index 9301862..0dcbddd 100644
--- a/src/libANGLE/renderer/metal/TextureMtl.mm
+++ b/src/libANGLE/renderer/metal/TextureMtl.mm
@@ -777,8 +777,8 @@
 class TextureMtl::NativeTextureWrapper : angle::NonCopyable
 {
   public:
-    NativeTextureWrapper(mtl::TextureRef texture, GLuint baseGLLevel)
-        : mNativeTexture(std::move(texture)), mBaseGLLevel(baseGLLevel)
+    NativeTextureWrapper(mtl::TextureRef texture, GLuint baseGLLevel, const mtl::Format &format)
+        : mNativeTexture(std::move(texture)), mBaseGLLevel(baseGLLevel), mFormat(format)
     {
         ASSERT(mNativeTexture && mNativeTexture->valid());
     }
@@ -810,6 +810,7 @@
                                  getNativeLevel(glLevel), slice, dataOut);
     }
 
+    const mtl::Format &getFormat() const { return mFormat; }
     GLuint getBaseGLLevel() const { return mBaseGLLevel; }
     // Get max addressable GL level that this texture supports.
     GLuint getMaxSupportedGLLevel() const { return mBaseGLLevel + mipmapLevels() - 1; }
@@ -855,14 +856,17 @@
   protected:
     mtl::TextureRef mNativeTexture;
     const GLuint mBaseGLLevel;
+    const mtl::Format mFormat;
 };
 
 // This class extends NativeTextureWrapper with support for view creation
 class TextureMtl::NativeTextureWrapperWithViewSupport : public NativeTextureWrapper
 {
   public:
-    NativeTextureWrapperWithViewSupport(mtl::TextureRef texture, GLuint baseGLLevel)
-        : NativeTextureWrapper(std::move(texture), baseGLLevel)
+    NativeTextureWrapperWithViewSupport(mtl::TextureRef texture,
+                                        GLuint baseGLLevel,
+                                        const mtl::Format &format)
+        : NativeTextureWrapper(std::move(texture), baseGLLevel, format)
     {}
 
     // Create a view of one slice at a level.
@@ -952,7 +956,6 @@
     if (!keepSamplerStateAndFormat)
     {
         mMetalSamplerState = nil;
-        mFormat            = mtl::Format();
     }
 }
 
@@ -976,9 +979,9 @@
     ANGLE_CHECK(contextMtl, desc.format.valid(), gl::err::kInternalError, GL_INVALID_OPERATION);
     angle::FormatID angleFormatId =
         angle::Format::InternalFormatToID(desc.format.info->sizedInternalFormat);
-    mFormat = contextMtl->getPixelFormat(angleFormatId);
+    mtl::Format format = contextMtl->getPixelFormat(angleFormatId);
 
-    ANGLE_TRY(createNativeStorage(context, mState.getType(), mips, 0, desc.size));
+    ANGLE_TRY(createNativeStorage(context, mState.getType(), mips, 0, desc.size, format));
 
     // Transfer data from defined images to actual texture object
     int numCubeFaces = static_cast<int>(mNativeTextureStorage->cubeFaces());
@@ -1019,44 +1022,46 @@
                                               gl::TextureType type,
                                               GLuint mips,
                                               GLuint samples,
-                                              const gl::Extents &size)
+                                              const gl::Extents &size,
+                                              const mtl::Format &format)
 {
     ASSERT(samples == 0 || mips == 0);
     ContextMtl *contextMtl = mtl::GetImpl(context);
 
     // Create actual texture object:
     mSlices              = 1;
-    bool allowFormatView = mFormat.hasDepthAndStencilBits() ||
-                           needsFormatViewForPixelLocalStorage(
-                               contextMtl->getDisplay()->getNativePixelLocalStorageOptions());
+    bool allowFormatView =
+        format.hasDepthAndStencilBits() ||
+        needsFormatViewForPixelLocalStorage(
+            contextMtl->getDisplay()->getNativePixelLocalStorageOptions(), format);
     mtl::TextureRef nativeTextureStorage;
     switch (type)
     {
         case gl::TextureType::_2D:
-            ANGLE_TRY(mtl::Texture::Make2DTexture(
-                contextMtl, mFormat, size.width, size.height, mips,
-                /** renderTargetOnly */ false, allowFormatView, &nativeTextureStorage));
+            ANGLE_TRY(mtl::Texture::Make2DTexture(contextMtl, format, size.width, size.height, mips,
+                                                  /** renderTargetOnly */ false, allowFormatView,
+                                                  &nativeTextureStorage));
             break;
         case gl::TextureType::CubeMap:
             mSlices = 6;
-            ANGLE_TRY(mtl::Texture::MakeCubeTexture(contextMtl, mFormat, size.width, mips,
+            ANGLE_TRY(mtl::Texture::MakeCubeTexture(contextMtl, format, size.width, mips,
                                                     /** renderTargetOnly */ false, allowFormatView,
                                                     &nativeTextureStorage));
             break;
         case gl::TextureType::_3D:
             ANGLE_TRY(mtl::Texture::Make3DTexture(
-                contextMtl, mFormat, size.width, size.height, size.depth, mips,
+                contextMtl, format, size.width, size.height, size.depth, mips,
                 /** renderTargetOnly */ false, allowFormatView, &nativeTextureStorage));
             break;
         case gl::TextureType::_2DArray:
             mSlices = size.depth;
             ANGLE_TRY(mtl::Texture::Make2DArrayTexture(
-                contextMtl, mFormat, size.width, size.height, mips, mSlices,
+                contextMtl, format, size.width, size.height, mips, mSlices,
                 /** renderTargetOnly */ false, allowFormatView, &nativeTextureStorage));
             break;
         case gl::TextureType::_2DMultisample:
             ANGLE_TRY(mtl::Texture::Make2DMSTexture(
-                contextMtl, mFormat, size.width, size.height, samples,
+                contextMtl, format, size.width, size.height, samples,
                 /** renderTargetOnly */ false, allowFormatView, &nativeTextureStorage));
             break;
         default:
@@ -1066,15 +1071,16 @@
     if (mState.getImmutableFormat())
     {
         mNativeTextureStorage = std::make_unique<NativeTextureWrapperWithViewSupport>(
-            std::move(nativeTextureStorage), /*baseGLLevel=*/0);
+            std::move(nativeTextureStorage), /*baseGLLevel=*/0, format);
     }
     else
     {
         mNativeTextureStorage = std::make_unique<NativeTextureWrapperWithViewSupport>(
-            std::move(nativeTextureStorage), /*baseGLLevel=*/mState.getEffectiveBaseLevel());
+            std::move(nativeTextureStorage), /*baseGLLevel=*/mState.getEffectiveBaseLevel(),
+            format);
     }
 
-    ANGLE_TRY(checkForEmulatedChannels(context, mFormat, *mNativeTextureStorage));
+    ANGLE_TRY(checkForEmulatedChannels(context, format, *mNativeTextureStorage));
 
     ANGLE_TRY(createViewFromBaseToMaxLevel());
 
@@ -1091,11 +1097,14 @@
         return angle::Result::Continue;
     }
 
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 acef103..7137425 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -2864,6 +2864,39 @@
     EXPECT_EQ(expected, actual);
 }
 
+// Regression test for TextureMtl::mFormat becoming mismatched with the native storage format when
+// updating mips outside of the storage.
+TEST_P(Texture2DTestES3, StaleFormatCacheOutOrRangeMip)
+{
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 4);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Create RGBA8 texture with one mip.
+    constexpr GLuint kWidth = 24;
+    std::vector<GLColor> pixels(kWidth, GLColor::red);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kWidth, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                 pixels.data());
+
+    // Force native storage allocation with a draw
+    ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Simple(), essl3_shaders::fs::Red());
+    drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+
+    // Set data on a high mip with a different format causing mFormat to change in TextureMtl
+    glTexImage2D(GL_TEXTURE_2D, 10, GL_R8, 1, 1, 0, GL_RED, GL_UNSIGNED_BYTE, nullptr);
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
+    ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    // Check that ReadPixels reads RGBA data from mip 0 correctly.
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+}
+
 // 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 ci...@gmail.com

ANGLE Metal Stale mFormat Cache causes GPU OOB WRITE


Report description

ANGLE Metal Stale mFormat Cache causes GPU OOB WRITE


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/metal/TextureMtl.mm


The problem

Please describe the technical details of the vulnerability

Heap buffer overflow with attacker-controlled data in the GPU process via stale mFormat cache in ANGLE’s Metal backend. Incomplete fix for chromium:435683799.

TextureMtl::redefineImage (TextureMtl.mm:2067) unconditionally sets mFormat for any texImage2D level, including out-of-range levels that do not trigger storage reallocation. Calling texImage2D at mip level 14 (TEXTURE_MAX_LEVEL=4) with format R8 poisons mFormat to R8 (1 byte/pixel) while the native Metal texture remains RGBA8 (4 bytes/pixel).

readPixelsImpl then sizes readPixelRowBuffer as Wx1 bytes. Metal’s getBytes writes Wx4 bytes of actual RGBA8 data, overflowing 3xW bytes of attacker-controlled pixel data past the buffer.

Steps to Reproduce

Stable (Chrome 145.0.7632.160, macOS x86_64, Intel GPU, Metal backend):

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome file:///path/to/poc_write_crash.html

No flags required. Default configuration. The page auto-reloads and the GPU process crashes within 1-2 page loads.

chrome://crashes IDs:

  • 897c7d4318e7beb6
  • 63aee33eb14f4815

Terminal output from the crash:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSCFNumber storageMode]: unrecognized selector sent to instance 0x4141414141414141'

The 0x4141414141414141 value is attacker-controlled pixel data from the overflow reaching ObjC dispatch. Full symbolized stack trace attached as crash_trace.txt.

Proposed Fix

Guard the mFormat cache update so it only applies to levels within native storage:

    // Cache last defined image format:
-   mFormat                      = mtlFormat;
+   if (imageWithinNativeStorageLevels)
+   {
+       mFormat                  = mtlFormat;
+   }

The variable imageWithinNativeStorageLevels is already computed at line 2043 and gates the storage reallocation at line 2052. It should also gate the format cache update.

Bisect

The unconditional mFormat assignment was introduced in ANGLE commit fe26bae452 (“Metal backend implementation pt 2”, 2019-10-10). Present since the Metal backend was first implemented, approximately Chrome 80.

The incomplete fix was commit 86a8d11c82 (“Metal: Fix potential incorrect format used for texSubImage”, 2025-08-11), which patched setPerSliceSubImage and convertAndSetPerSliceSubImage but left line 2067 unfixed.

Impact analysis

  • Web-accessible via WebGL2 (no compromised renderer required).
  • GPU process heap overflow with attacker-controlled data (pixel values).
  • macOS only (Metal backend, Intel GPUs).
  • Seven affected code paths from the same stale mFormat root cause.

The cause

What version of Chrome have you found the security issue in?

145.0.7632.160 Stable

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a sandboxed process)

How would you like to be publicly acknowledged for your report?

cinzinga

View on issue tracker