CVE-2026-13819
Overview
Background
- ANGLE
- Chrome’s translation layer that implements OpenGL ES on top of native GPU backends such as Apple’s Metal.
- IOSurface
- a shareable macOS GPU buffer that ANGLE can bind to a GL texture through the
EGL_ANGLE_iosurface_client_bufferextension. - Base/max level
- the
GL_TEXTURE_BASE_LEVELandGL_TEXTURE_MAX_LEVELtexture parameters that select which mip levels are used; on an IOSurface-backed texture only level 0 actually exists. - Texture view
- a Metal texture object that aliases a sub-range of an existing texture’s mip levels, created here by
mtl::TextureRef::createMipsView.
Root Cause Analysis
TextureMtl::createViewFromBaseToMaxLevel builds a Metal view spanning the effective base level through the effective max level of the GL texture. The vulnerable path clamped maxLevel to the storage’s getMaxSupportedGLLevel but used the raw mState.getEffectiveBaseLevel() unclamped, both when computing baseToMaxLevels and when calling createMipsView. For an IOSurface-backed texture that only supports level 0, an application could set GL_TEXTURE_BASE_LEVEL to a value like 3, so the base level exceeded the storage’s supported range and the view was requested from a mip level that does not exist in the underlying storage.
The fix introduces a clamped baseLevel = std::min(getMaxSupportedGLLevel(), getEffectiveBaseLevel()) and uses it everywhere the raw base level was previously used, guaranteeing the view’s starting level and length stay within the storage’s real mip range.
getMaxSupportedGLLevel() before it is used to index and size the mip view.Attack Path
- Bind an IOSurface
Using the IOSurface client-buffer EGL extension, create a 1x1 single-plane IOSurface and bind it to a
GL_TEXTURE_2Dtexture that only backs mip level 0. - Set an out-of-range base level
Call
glTexParameteri(target, GL_TEXTURE_BASE_LEVEL, 3), pushinggetEffectiveBaseLevel()past the storage’sgetMaxSupportedGLLevel(). - Trigger view creation
Cause
TextureMtl::createViewFromBaseToMaxLevelto run, where the unclamped base level is passed tocreateMipsView, requesting a mip level the storage does not contain. - Out-of-bounds read The view is created over a level range outside the texture storage, and subsequent use reads mip metadata/texels beyond the valid range, matching the advisory’s out-of-bounds read (inferred as the concrete sink; the patch shows only the invalid view creation).
Impact Assessment
GL_TEXTURE_BASE_LEVEL, and the observed effect in the advisory is a crash/OOB read; any escalation to information disclosure or further memory corruption is (inferred) and not demonstrated by the patch. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.1.Files Changed
src/libANGLE/renderer/metal/TextureMtl.mmsrc/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp
Audit Directions
- Asymmetric clampingLook for functions that clamp one bound (max) of a range but pass the other bound (base/min) unclamped into indexing or size arithmetic.
- State-derived indices into fixed storageAudit other
TextureMtland native-storage paths wheremState.getEffective*Level()or similar client-controlled state indexes storage that may support fewer levels (IOSurface, EGLImage, external textures). - Range length computationReview
maxLevel - baseLevel + 1style span calculations for unsigned underflow or over-length views when the endpoints are not both validated againstgetMaxSupportedGLLevel().
Patch
From a1d8610fd2b4de7607d4e805b38bbedcd2f06ac3 Mon Sep 17 00:00:00 2001 From: Le Hoang Quyen <[email protected]> Date: Wed, 20 May 2026 22:47:54 +0800 Subject: [PATCH] Metal: Fix crash when setting non-zero base level on IOSurface. - Clamped baseLevel and maxLevel to supported range in `createViewFromBaseToMaxLevel` to avoid creating invalid views. - Added `SetNonZeroBaseLevel` test in `EGLIOSurfaceClientBufferTest.cpp`. Bug: chromium:512962749 Change-Id: I0faff0414aa0230918ed7bce663f4a87d7bf376e Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7862465 Reviewed-by: Geoff Lang <[email protected]> Commit-Queue: Quyen Le <[email protected]> --- diff --git a/src/libANGLE/renderer/metal/TextureMtl.mm b/src/libANGLE/renderer/metal/TextureMtl.mm index 7c73e45..4c84667 100644 --- a/src/libANGLE/renderer/metal/TextureMtl.mm +++ b/src/libANGLE/renderer/metal/TextureMtl.mm @@ -1112,15 +1112,17 @@ angle::Result TextureMtl::createViewFromBaseToMaxLevel() { ASSERT(mNativeTextureStorage); + uint32_t baseLevel = + std::min(mNativeTextureStorage->getMaxSupportedGLLevel(), mState.getEffectiveBaseLevel()); uint32_t maxLevel = std::min(mNativeTextureStorage->getMaxSupportedGLLevel(), mState.getEffectiveMaxLevel()); // In edge case where base level > max level, clamp up to base level. - maxLevel = std::max(maxLevel, mState.getEffectiveBaseLevel()); + maxLevel = std::max(maxLevel, baseLevel); mtl::TextureRef nativeViewFromBaseToMaxLevelRef; if (maxLevel == mNativeTextureStorage->getMaxSupportedGLLevel() && - mState.getEffectiveBaseLevel() == mNativeTextureStorage->getBaseGLLevel()) + baseLevel == mNativeTextureStorage->getBaseGLLevel()) { // If base & max level are the same in mNativeTextureStorage, we don't need // a dedicated view. Furthermore, Intel driver has some bugs when sampling a view @@ -1129,9 +1131,9 @@ } else { - uint32_t baseToMaxLevels = maxLevel - mState.getEffectiveBaseLevel() + 1; + uint32_t baseToMaxLevels = maxLevel - baseLevel + 1; nativeViewFromBaseToMaxLevelRef = - mNativeTextureStorage->createMipsView(mState.getEffectiveBaseLevel(), baseToMaxLevels); + mNativeTextureStorage->createMipsView(baseLevel, baseToMaxLevels); } mViewFromBaseToMaxLevel = std::make_unique<NativeTextureWrapper>( diff --git a/src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp b/src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp index 0979256..309bbe5 100644 --- a/src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp +++ b/src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp @@ -1415,6 +1415,32 @@ // TODO([email protected]): Test setting width and height to less than the IOSurface's work as // expected. +// Test that binding an IOSurface and setting base level to 3 does not cause an error. +TEST_P(IOSurfaceClientBufferTest, SetNonZeroBaseLevel) +{ + ANGLE_SKIP_TEST_IF(!hasIOSurfaceExt()); + ANGLE_SKIP_TEST_IF(getGLTextureTarget() != GL_TEXTURE_2D); + ANGLE_SKIP_TEST_IF(getClientMajorVersion() < 3); + + // Create a 1x1 IOSurface + ScopedIOSurfaceRef ioSurface = CreateSinglePlaneIOSurface(1, 1, 'BGRA', 4); + + // Bind it to a texture + EGLSurface pbuffer; + GLTexture texture; + bindIOSurfaceToTexture(ioSurface, 1, 1, 0, GL_BGRA_EXT, GL_UNSIGNED_BYTE, &pbuffer, &texture); + + // Set base level to 3 + glTexParameteri(getGLTextureTarget(), GL_TEXTURE_BASE_LEVEL, 3); + EXPECT_GL_NO_ERROR(); + + // Clean up + EGLBoolean result = eglReleaseTexImage(mDisplay, pbuffer, EGL_BACK_BUFFER); + EXPECT_EGL_TRUE(result); + result = eglDestroySurface(mDisplay, pbuffer); + EXPECT_EGL_TRUE(result); +} + ANGLE_INSTANTIATE_TEST(IOSurfaceClientBufferTest, ES2_OPENGL(), ES3_OPENGL(),
Regression Test / PoC
diff --git a/src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp b/src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp index 0979256..309bbe5 100644 --- a/src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp +++ b/src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp @@ -1415,6 +1415,32 @@ // TODO([email protected]): Test setting width and height to less than the IOSurface's work as // expected. +// Test that binding an IOSurface and setting base level to 3 does not cause an error. +TEST_P(IOSurfaceClientBufferTest, SetNonZeroBaseLevel) +{ + ANGLE_SKIP_TEST_IF(!hasIOSurfaceExt()); + ANGLE_SKIP_TEST_IF(getGLTextureTarget() != GL_TEXTURE_2D); + ANGLE_SKIP_TEST_IF(getClientMajorVersion() < 3); + + // Create a 1x1 IOSurface + ScopedIOSurfaceRef ioSurface = CreateSinglePlaneIOSurface(1, 1, 'BGRA', 4); + + // Bind it to a texture + EGLSurface pbuffer; + GLTexture texture; + bindIOSurfaceToTexture(ioSurface, 1, 1, 0, GL_BGRA_EXT, GL_UNSIGNED_BYTE, &pbuffer, &texture); + + // Set base level to 3 + glTexParameteri(getGLTextureTarget(), GL_TEXTURE_BASE_LEVEL, 3); + EXPECT_GL_NO_ERROR(); + + // Clean up + EGLBoolean result = eglReleaseTexImage(mDisplay, pbuffer, EGL_BACK_BUFFER); + EXPECT_EGL_TRUE(result); + result = eglDestroySurface(mDisplay, pbuffer); + EXPECT_EGL_TRUE(result); +} + ANGLE_INSTANTIATE_TEST(IOSurfaceClientBufferTest, ES2_OPENGL(), ES3_OPENGL(),
Original Bug Report
Potential out-of-bounds Metal texture view via GL_TEXTURE_BASE_LEVEL
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 without the Chrome Security team. 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 compromised renderer to create a Metal texture view with an out-of-bounds mipmap range. By setting a non-zero GL_TEXTURE_BASE_LEVEL on an IOSurface-backed texture, an attacker can potentially trigger an out-of-bounds read in the GPU process. This could lead to a cross-origin or cross-process information leak on macOS.
Affected files:
third_party/angle/src/libANGLE/renderer/metal/TextureMtl.mmthird_party/angle/src/libANGLE/renderer/metal/mtl_resources.mm
Estimated timestamp from git blame: Unknown (Google3 checkout)
Root Cause Analysis
A vulnerability exists in ANGLE’s Metal backend due to insufficient validation of the mipmap level range when creating texture views. Specifically, in TextureMtl::createViewFromBaseToMaxLevel(), the code attempts to compute a valid maxLevel but fails to account for cases where the effective base level exceeds the native storage’s maximum supported level.
In third_party/angle/src/libANGLE/renderer/metal/TextureMtl.mm:
angle::Result TextureMtl::createViewFromBaseToMaxLevel()
{
ASSERT(mNativeTextureStorage);
uint32_t maxLevel =
std::min(mNativeTextureStorage->getMaxSupportedGLLevel(), mState.getEffectiveMaxLevel());
// In edge case where base level > max level, clamp up to base level.
maxLevel = std::max(maxLevel, mState.getEffectiveBaseLevel());
...
else
{
uint32_t baseToMaxLevels = maxLevel - mState.getEffectiveBaseLevel() + 1;
nativeViewFromBaseToMaxLevelRef =
mNativeTextureStorage->createMipsView(mState.getEffectiveBaseLevel(), baseToMaxLevels);
}
}
When a texture is backed by an egl::Surface (such as an IOSurface SharedImage), TextureMtl::bindTexImage() sets mBoundSurface. This causes TextureMtl::isImmutableOrPBuffer() to return true. When a user changes the texture’s parameters, TextureMtl::onBaseMaxLevelsChanged() avoids reallocating the underlying native texture and directly calls createViewFromBaseToMaxLevel().
If the attacker sets GL_TEXTURE_BASE_LEVEL to a value N that is greater than the IOSurface’s getMaxSupportedGLLevel() (which is 0, since IOSurfaces only have 1 mip level), maxLevel is improperly clamped up to N. This results in baseToMaxLevels being calculated as 1, and a call to createMipsView(N, 1) is issued.
This call propagates to mtl::Texture::createMipsView, which creates an Objective-C NSRange using the out-of-bounds base level: NSMakeRange(N, 1). The resulting range is passed to the Apple Metal API -[MTLTexture newTextureViewWithPixelFormat:textureType:levels:slices:]. Apple’s documentation requires that levels.location + levels.length <= parent.mipmapLevelCount. Violating this precondition results in undefined behavior at the driver level, often returning an aliased texture view pointing to adjacent, unowned GPU memory.
Potential Exploitation Steps
Note: These are suggested theoretical steps, as our tooling agent does not currently have the ability to execute code or build a working proof-of-concept.
- From a compromised macOS renderer process, use the
CreateSharedImageIPC to allocate aSharedImagebacked by an IOSurface, requestingSHARED_IMAGE_USAGE_GLES2_READusage. - Issue a
CreateAndTexStorage2DSharedImageINTERNALcommand to map theSharedImageto aGL_TEXTURE_2Dtexture ID. - Issue a
BeginSharedImageAccessDirectCHROMIUMcommand on this texture ID. This binds the IOSurface to the GL texture in ANGLE, settingmBoundSurface. - Issue a
TexParametericommand to setGL_TEXTURE_BASE_LEVELto a non-zero value (e.g., 5). Because the surface is bound, ANGLE takes the immutable path and creates an out-of-bounds Metal texture view (NSMakeRange(5, 1)). - Bind the texture to a texture unit and compile a WebGL fragment shader that samples from it using
sampler2D. - Issue a draw call to a WebGL framebuffer. The GPU fragment shader samples from the out-of-bounds texture view, reading adjacent GPU heap memory.
- Call
glReadPixels()on the framebuffer to read the leaked GPU memory back into the compromised renderer process, completing the cross-origin information leak.
Impact
This vulnerability allows a compromised renderer to trigger an out-of-bounds read in the GPU process. Depending on the macOS Metal driver’s handling of the invalid NSRange, the driver may return a texture view that aliases adjacent GPU heap memory. Sampling from this view allows an attacker to read cross-origin or cross-process memory, resulting in a high-severity Information Leak.
Suggested Fix
In TextureMtl::createViewFromBaseToMaxLevel(), validate that the effective base level does not exceed the native storage’s maximum supported level before attempting to create the view. If mState.getEffectiveBaseLevel() > mNativeTextureStorage->getMaxSupportedGLLevel(), the code should safely clamp the effective base level to the maximum supported level, or safely fallback to a 1x1 default texture if the base level is completely out of bounds for the current storage.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
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.