CVE-2026-5868
Overview
Background
- `TextureMtl`
- ANGLE’s Metal backend implementation of a GL texture object, managing native
MTLTexturestorage 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
TextureMtlmethod 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::TextureRefplus 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
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.
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
- Create a texture and define mismatched mip levels
Through WebGL/GLES, define image data on multiple mip levels of a
TextureMtlwith differing internal formats so the mutablemFormatends up holding a format other than that of the level driving native storage. - Trigger native storage allocation
Cause
createNativeStorageto run (e.g. viaensureNativeStorageCreated/mip completeness), whereMake2DTextureandneedsFormatViewForPixelLocalStoragesize the backingMTLTexturefrom the stalemFormat. - 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)
- Achieve heap buffer overflow Data transfer into the undersized native texture overflows the heap allocation, as stated by the advisory. (inferred)
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
NativeTextureWrappersrc/libANGLE/renderer/metal/TextureMtl.h |
modified | |
NativeTextureWrapperWithViewSupportsrc/libANGLE/renderer/metal/TextureMtl.h |
modified | |
TextureMtlsrc/libANGLE/renderer/metal/TextureMtl.mm |
modified |
Files Changed
src/libANGLE/renderer/metal/TextureMtl.hsrc/libANGLE/renderer/metal/TextureMtl.mm
Audit Directions
- Mutable cached descriptorsLook 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 agreementAudit 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 creationReview
needsFormatViewForPixelLocalStorageand other view/allocation helpers that historically pulled format from shared state to confirm they now receive the format explicitly and consistently.
Patch
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;
}
Regression Test / PoC
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)
{
Original Bug Report
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?
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
Is the security issue related to a crash?
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