Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker498721316
Fix commitddfcefdf93c6 (angle/angle) +98/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
DrawCommandValidationTest
src/tests/gl_tests/PixelLocalStorageTest.cpp
modified

Files Changed

  • src/libANGLE/renderer/metal/FrameBufferMtl.mm
  • src/libANGLE/renderer/metal/mtl_state_cache.mm
  • src/tests/gl_tests/PixelLocalStorageTest.cpp
From ddfcefdf93c6cbd46bc2d2761d2902ca5938a13a Mon Sep 17 00:00:00 2001
From: Corentin Wallez <cwallez@chromium.org>
Date: Thu, 23 Apr 2026 14:58:49 +0200
Subject: [PATCH] [metal] Reset MTLRPDesc.defaultWidth/Height when needed.

When using PLS these members was set, but was never reset to 0 when
going back to non-PLS. Adds test that fail when running with the Metal
debug device.

Fixed: chromium:498721316
Change-Id: Ib5b21bcf6d13c6fe8e914f31f4d03b0219bb8d73
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7790058
Commit-Queue: Geoff Lang <geofflang@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Auto-Submit: Corentin Wallez <cwallez@chromium.org>
Commit-Queue: Corentin Wallez <cwallez@chromium.org>
---

diff --git a/src/libANGLE/renderer/metal/FrameBufferMtl.mm b/src/libANGLE/renderer/metal/FrameBufferMtl.mm
index 59bf9d4..7a24908 100644
--- a/src/libANGLE/renderer/metal/FrameBufferMtl.mm
+++ b/src/libANGLE/renderer/metal/FrameBufferMtl.mm
@@ -1137,6 +1137,11 @@
         desc.defaultWidth  = mState.getDefaultWidth();
         desc.defaultHeight = mState.getDefaultHeight();
     }
+    else
+    {
+        desc.defaultWidth  = 0;
+        desc.defaultHeight = 0;
+    }
 
     return angle::Result::Continue;
 }
diff --git a/src/libANGLE/renderer/metal/mtl_state_cache.mm b/src/libANGLE/renderer/metal/mtl_state_cache.mm
index b26ddc8..9036e06 100644
--- a/src/libANGLE/renderer/metal/mtl_state_cache.mm
+++ b/src/libANGLE/renderer/metal/mtl_state_cache.mm
@@ -873,6 +873,13 @@
         objCDesc.renderTargetHeight       = defaultHeight;
         objCDesc.defaultRasterSampleCount = 1;
     }
+    else
+    {
+        objCDesc.renderTargetWidth  = 0;
+        objCDesc.renderTargetHeight = 0;
+        // No need to reset defaultRasterSampleCount as it is only applied when no attachments are
+        // provided.
+    }
 }
 
 // StateCache implementation
diff --git a/src/tests/gl_tests/PixelLocalStorageTest.cpp b/src/tests/gl_tests/PixelLocalStorageTest.cpp
index cd44017..bbd48ff 100644
--- a/src/tests/gl_tests/PixelLocalStorageTest.cpp
+++ b/src/tests/gl_tests/PixelLocalStorageTest.cpp
@@ -4382,6 +4382,92 @@
     glEndTilingQCOM(GL_COLOR_BUFFER_BIT0_QCOM);
 }
 
+// Regression test for the Metal backend not resetting the MTLRenderPassDesc defaultWidth/Height
+// when switching between PLS and no PLS. Checks the state leak that happens when switching FBOs.
+TEST_P(PixelLocalStorageTest, DefaultRPDescSizeLeak_BetweenFBOs)
+{
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_ANGLE_shader_pixel_local_storage"));
+    mProgram.compile(R"(
+        layout(binding=0, rgba8) uniform highp pixelLocalANGLE pls;
+        void main()
+        {
+            pixelLocalStoreANGLE(pls, color + pixelLocalLoadANGLE(pls));
+        })");
+
+    // Render to a large-sized PLS.
+    {
+        GLFramebuffer fbo;
+        glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+
+        PLSTestTexture texture(GL_RGBA8, 8192, 8192);
+        glFramebufferTexturePixelLocalStorageANGLE(0, texture, 0, 0, GL_NONE);
+
+        glBeginPixelLocalStorageANGLE(1, GLenumArray({GL_LOAD_OP_ZERO_ANGLE}));
+        mProgram.drawBoxes({{FULLSCREEN}});
+        glEndPixelLocalStorageANGLE(1, GLenumArray({GL_STORE_OP_STORE_ANGLE}));
+    }
+
+    // Render to a small texture. If the state leaks, the Metal debug device complains that the
+    // defaultWidth/Height is larger than the attachment size.
+    {
+        GLFramebuffer fbo;
+        glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+
+        GLTexture texture;
+        glBindTexture(GL_TEXTURE_2D, texture);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
+        EXPECT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+        ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::Red());
+        drawQuad(program, essl1_shaders::PositionAttrib(), 0.0f);
+    }
+}
+
+// Regression test for the Metal backend not resetting the MTLRenderPassDesc defaultWidth/Height
+// when switching between PLS and no PLS. Checks the state leak that happens when reusing the same
+// FBO.
+TEST_P(PixelLocalStorageTest, DefaultRPDescSizeLeak_SameFBO)
+{
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_ANGLE_shader_pixel_local_storage"));
+
+    mProgram.compile(R"(
+        layout(binding=0, rgba8) uniform highp pixelLocalANGLE pls;
+        void main()
+        {
+            pixelLocalStoreANGLE(pls, color + pixelLocalLoadANGLE(pls));
+        })");
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+
+    // Render to a large-sized PLS.
+    {
+
+        PLSTestTexture texture(GL_RGBA8, 8192, 8192);
+        glFramebufferTexturePixelLocalStorageANGLE(0, texture, 0, 0, GL_NONE);
+
+        glBeginPixelLocalStorageANGLE(1, GLenumArray({GL_LOAD_OP_ZERO_ANGLE}));
+        mProgram.drawBoxes({{FULLSCREEN}});
+        glEndPixelLocalStorageANGLE(1, GLenumArray({GL_STORE_OP_STORE_ANGLE}));
+
+        glFramebufferTexturePixelLocalStorageANGLE(0, 0, 0, 0, GL_NONE);
+    }
+
+    // Render to a small texture. If the state leaks, the Metal debug device complains that the
+    // defaultWidth/Height is larger than the attachment size.
+    {
+        GLTexture texture;
+        glBindTexture(GL_TEXTURE_2D, texture);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
+        EXPECT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+        ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::Red());
+        drawQuad(program, essl1_shaders::PositionAttrib(), 0.0f);
+    }
+}
+
 // Checks that draw commands validate current PLS state against the shader's PLS uniforms.
 class DrawCommandValidationTest
 {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/PixelLocalStorageTest.cpp b/src/tests/gl_tests/PixelLocalStorageTest.cpp
index cd44017..bbd48ff 100644
--- a/src/tests/gl_tests/PixelLocalStorageTest.cpp
+++ b/src/tests/gl_tests/PixelLocalStorageTest.cpp
@@ -4382,6 +4382,92 @@
     glEndTilingQCOM(GL_COLOR_BUFFER_BIT0_QCOM);
 }
 
+// Regression test for the Metal backend not resetting the MTLRenderPassDesc defaultWidth/Height
+// when switching between PLS and no PLS. Checks the state leak that happens when switching FBOs.
+TEST_P(PixelLocalStorageTest, DefaultRPDescSizeLeak_BetweenFBOs)
+{
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_ANGLE_shader_pixel_local_storage"));
+    mProgram.compile(R"(
+        layout(binding=0, rgba8) uniform highp pixelLocalANGLE pls;
+        void main()
+        {
+            pixelLocalStoreANGLE(pls, color + pixelLocalLoadANGLE(pls));
+        })");
+
+    // Render to a large-sized PLS.
+    {
+        GLFramebuffer fbo;
+        glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+
+        PLSTestTexture texture(GL_RGBA8, 8192, 8192);
+        glFramebufferTexturePixelLocalStorageANGLE(0, texture, 0, 0, GL_NONE);
+
+        glBeginPixelLocalStorageANGLE(1, GLenumArray({GL_LOAD_OP_ZERO_ANGLE}));
+        mProgram.drawBoxes({{FULLSCREEN}});
+        glEndPixelLocalStorageANGLE(1, GLenumArray({GL_STORE_OP_STORE_ANGLE}));
+    }
+
+    // Render to a small texture. If the state leaks, the Metal debug device complains that the
+    // defaultWidth/Height is larger than the attachment size.
+    {
+        GLFramebuffer fbo;
+        glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+
+        GLTexture texture;
+        glBindTexture(GL_TEXTURE_2D, texture);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
+        EXPECT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+        ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::Red());
+        drawQuad(program, essl1_shaders::PositionAttrib(), 0.0f);
+    }
+}
+
+// Regression test for the Metal backend not resetting the MTLRenderPassDesc defaultWidth/Height
+// when switching between PLS and no PLS. Checks the state leak that happens when reusing the same
+// FBO.
+TEST_P(PixelLocalStorageTest, DefaultRPDescSizeLeak_SameFBO)
+{
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_ANGLE_shader_pixel_local_storage"));
+
+    mProgram.compile(R"(
+        layout(binding=0, rgba8) uniform highp pixelLocalANGLE pls;
+        void main()
+        {
+            pixelLocalStoreANGLE(pls, color + pixelLocalLoadANGLE(pls));
+        })");
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+
+    // Render to a large-sized PLS.
+    {
+
+        PLSTestTexture texture(GL_RGBA8, 8192, 8192);
+        glFramebufferTexturePixelLocalStorageANGLE(0, texture, 0, 0, GL_NONE);
+
+        glBeginPixelLocalStorageANGLE(1, GLenumArray({GL_LOAD_OP_ZERO_ANGLE}));
+        mProgram.drawBoxes({{FULLSCREEN}});
+        glEndPixelLocalStorageANGLE(1, GLenumArray({GL_STORE_OP_STORE_ANGLE}));
+
+        glFramebufferTexturePixelLocalStorageANGLE(0, 0, 0, 0, GL_NONE);
+    }
+
+    // Render to a small texture. If the state leaks, the Metal debug device complains that the
+    // defaultWidth/Height is larger than the attachment size.
+    {
+        GLTexture texture;
+        glBindTexture(GL_TEXTURE_2D, texture);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4);
+        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
+        EXPECT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+        ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::Red());
+        drawQuad(program, essl1_shaders::PositionAttrib(), 0.0f);
+    }
+}
+
 // Checks that draw commands validate current PLS state against the shader's PLS uniforms.
 class DrawCommandValidationTest
 {
Loading diff…

Original Bug Report

reported by vm...@google.com

Stale renderTargetWidth in cached MTLRenderPassDescriptor leads to potential GPU OOB write

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 security team.

Overview: ANGLE’s Metal backend fails to reset the renderTargetWidth and renderTargetHeight properties in its cached MTLRenderPassDescriptor when switching between render passes. A compromised renderer can leverage the Pixel Local Storage (PLS) extension to inject large dimensions into this cached state, which then persist when rendering to a smaller attachment. This violates Apple Metal API invariants and can result in out-of-bounds GPU memory writes.

Affected files:

  • third_party/angle/src/libANGLE/renderer/metal/mtl_state_cache.mm
  • third_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mm

Estimated timestamp from git blame: 2022-11-30

Technical Details

A vulnerability exists in ANGLE’s Metal backend where renderTargetWidth and renderTargetHeight are not properly reset in the persistent MTLRenderPassDescriptor object, leading to a state leak across render passes.

This is caused by two overlapping logic flaws:

  1. Missing State Reset in RenderPassDesc::convertToMetalDesc: In third_party/angle/src/libANGLE/renderer/metal/mtl_state_cache.mm, the convertToMetalDesc method updates the renderTargetWidth and renderTargetHeight properties of the MTLRenderPassDescriptor object (mCachedRenderPassDescObjC) if defaultWidth or defaultHeight is non-zero. However, it lacks an else branch to reset these properties back to 0 (or to the dimensions of the current attachments) when they are not explicitly specified. Because mCachedRenderPassDescObjC is allocated once per RenderCommandEncoder and reused, these properties retain their values from prior render passes.
  2. Stale State in FramebufferMtl::prepareRenderPass: In third_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mm, the prepareRenderPass method populates the C++ mtl::RenderPassDesc structure. It only sets the defaultWidth and defaultHeight members when the framebuffer has no attachments. If a render pass is executed on a framebuffer without attachments, and then a subsequent render pass occurs on a framebuffer with attachments, the defaultWidth and defaultHeight members in the incoming descriptor are 0, which triggers the flaw in convertToMetalDesc.

Potential Attack Chain

To exploit this, an attacker needs a mechanism to set a large defaultWidth on a framebuffer without attachments. While external access to glFramebufferParameteri is restricted, a compromised renderer can trigger it internally via the GL_ANGLE_shader_pixel_local_storage (PLS) extension, which is supported on hardware such as Intel macOS.

Suggested steps to trigger the vulnerability (requires a compromised renderer process):

  1. Request the GL_ANGLE_shader_pixel_local_storage extension.
  2. Create a Framebuffer Object (FBO A) with no attachments.
  3. Bind a large texture (e.g., 16384x16384) as a PLS plane for FBO A and call glBeginPixelLocalStorageANGLE. This triggers an internal call to context->framebufferParameteri, setting the FBO’s defaultWidth to 16384.
  4. Execute a draw call on FBO A. prepareRenderPass sets desc.defaultWidth = 16384, and convertToMetalDesc writes this into the cached mCachedRenderPassDescObjC.renderTargetWidth.
  5. Create a second Framebuffer Object (FBO B) and attach a very small texture (e.g., 16x16) as a color attachment.
  6. Execute a draw call on FBO B. Because FBO B has attachments, prepareRenderPass leaves desc.defaultWidth as 0. Consequently, convertToMetalDesc skips updating the renderTargetWidth in the Objective-C descriptor.
  7. The Metal driver receives the MTLRenderPassDescriptor where renderTargetWidth is still 16384, far exceeding the 16x16 attachment bounds.

Impact

Per Apple Metal documentation, renderTargetWidth “must be set to a value equal to or smaller than the smallest width across all attachments.” Violating this invariant causes undefined behavior. During rasterization, the Metal driver assumes the target memory is 16384 pixels wide, resulting in massive out-of-bounds writes into GPU memory. This memory corruption within the GPU process could potentially be escalated to a sandbox escape.

Suggested Fix

Modify RenderPassDesc::convertToMetalDesc in mtl_state_cache.mm to ensure renderTargetWidth, renderTargetHeight, and defaultRasterSampleCount are always explicitly set or reset.

    if ((defaultWidth | defaultHeight) != 0)
    {
        objCDesc.renderTargetWidth        = defaultWidth;
        objCDesc.renderTargetHeight       = defaultHeight;
        objCDesc.defaultRasterSampleCount = 1;
    }
    else
    {
        // Reset to 0 so Metal derives the size from the attachments
        objCDesc.renderTargetWidth        = 0;
        objCDesc.renderTargetHeight       = 0;
        objCDesc.defaultRasterSampleCount = 0; // Or standard default
    }

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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