High watch CVSS 8.1 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in ANGLE
DescriptionOut of bounds read in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker512962749
Fix commita1d8610fd2b4 (angle/angle) +32/-4
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-13 (per vendor advisory)
Fix landed2026-05-20 (public git)
Disclosed2026-06-30
Patch gap41 days — fix public in source before the advisory

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_buffer extension.
Base/max level
the GL_TEXTURE_BASE_LEVEL and GL_TEXTURE_MAX_LEVEL texture 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.

Key insight
The single mistake was clamping only the max level while trusting the attacker-controlled base level verbatim; the fix clamps the base level to getMaxSupportedGLLevel() before it is used to index and size the mip view.

Attack Path

  1. Bind an IOSurface Using the IOSurface client-buffer EGL extension, create a 1x1 single-plane IOSurface and bind it to a GL_TEXTURE_2D texture that only backs mip level 0.
  2. Set an out-of-range base level Call glTexParameteri(target, GL_TEXTURE_BASE_LEVEL, 3), pushing getEffectiveBaseLevel() past the storage’s getMaxSupportedGLLevel().
  3. Trigger view creation Cause TextureMtl::createViewFromBaseToMaxLevel to run, where the unclamped base level is passed to createMipsView, requesting a mip level the storage does not contain.
  4. 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

An attacker running attacker-controlled GL commands (e.g. WebGL content reaching the Metal backend) can drive an out-of-bounds read in ANGLE, which on Chrome runs in the GPU process. The precondition is the IOSurface client-buffer extension and an ES3 context allowing a non-zero 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.mm
  • src/tests/egl_tests/EGLIOSurfaceClientBufferTest.cpp

Audit Directions

  • Asymmetric clamping
    Look 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 storage
    Audit other TextureMtl and native-storage paths where mState.getEffective*Level() or similar client-controlled state indexes storage that may support fewer levels (IOSurface, EGLImage, external textures).
  • Range length computation
    Review maxLevel - baseLevel + 1 style span calculations for unsigned underflow or over-length views when the endpoints are not both validated against getMaxSupportedGLLevel().
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(),
Loading diff…

Regression Test / PoC

shipped with the fix
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(),
Loading diff…

Original Bug Report

reported by [email protected]

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.mm
  • third_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.

  1. From a compromised macOS renderer process, use the CreateSharedImage IPC to allocate a SharedImage backed by an IOSurface, requesting SHARED_IMAGE_USAGE_GLES2_READ usage.
  2. Issue a CreateAndTexStorage2DSharedImageINTERNAL command to map the SharedImage to a GL_TEXTURE_2D texture ID.
  3. Issue a BeginSharedImageAccessDirectCHROMIUM command on this texture ID. This binds the IOSurface to the GL texture in ANGLE, setting mBoundSurface.
  4. Issue a TexParameteri command to set GL_TEXTURE_BASE_LEVEL to 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)).
  5. Bind the texture to a texture unit and compile a WebGL fragment shader that samples from it using sampler2D.
  6. 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.
  7. 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.

View on issue tracker