Overview

Medium
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker498721316
Fix commitddfcefdf93c6 (angle/angle) +98/-0
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-01 (per vendor advisory)
Fix landed2026-04-23 (public git)
Disclosed2026-06-02
Patch gap40 days — fix public in source before the advisory

Background

Pixel Local Storage (PLS)
an ANGLE extension (GL_ANGLE_shader_pixel_local_storage) that lets a fragment shader read and write per-pixel storage attached to a framebuffer, implemented on Metal via a render pass that has no color attachments and instead relies on default render-target dimensions.
`MTLRenderPassDescriptor`
the Metal object describing a render pass, whose defaultWidth/defaultHeight (exposed in ANGLE as renderTargetWidth/renderTargetHeight) define the render area when the pass has no attachments to derive size from.
Metal debug device
a validation layer that flags illegal GPU state, here catching a default render size larger than an actual attachment.

Root Cause Analysis

When ANGLE’s Metal backend set up a PLS render pass it assigned desc.defaultWidth/desc.defaultHeight in FrameBufferMtl and objCDesc.renderTargetWidth/objCDesc.renderTargetHeight in mtl_state_cache from the framebuffer’s default size, but these members were only ever written in the PLS branch and never cleared in the non-PLS branch. Consequently, after a large PLS framebuffer was used, the stale large dimensions leaked into a subsequent non-PLS render pass that bound a small attachment, violating the invariant that the default render size must not exceed the bound attachment’s size. The Metal debug device reports this mismatch, and because the render area is derived from the oversized default dimensions rather than the small attachment, rendering can write outside the intended attachment bounds.

The fix adds else branches that explicitly reset both dimension pairs to 0 when PLS is not in use, so the render size is derived from the actual attachment. This restores the invariant that default dimensions apply only when the descriptor legitimately lacks attachments.

Key insight
The single mistake is treating the render-pass default width/height as write-once PLS state instead of state that must be reset on every configuration; the fix zeroes both fields in the non-PLS path so stale oversized dimensions can never carry over.

Attack Path

  1. Establish oversized PLS state Create a framebuffer, attach a large PLS texture (e.g. 8192x8192) via glFramebufferTexturePixelLocalStorageANGLE, and run a PLS pass, causing ANGLE to set defaultWidth/defaultHeight and renderTargetWidth/renderTargetHeight to the large size.
  2. Switch to a small non-PLS target Bind a small attachment (e.g. a 4x4 GL_RGBA8 texture) either to a new FBO or by reusing the same FBO after clearing PLS, then issue a normal draw.
  3. Leak stale dimensions into the pass Because the non-PLS branch never reset the default size, the render pass keeps the large defaultWidth/defaultHeight, so the effective render area exceeds the small attachment.
  4. Trigger the out-of-bounds write (inferred) Rendering against a default size larger than the actual attachment drives writes past the attachment’s true bounds, the out-of-bounds write the advisory describes.
  5. Escalate from the OOB write (inferred) A controlled GPU-side out-of-bounds write could corrupt adjacent memory to build a stronger primitive, though the patch itself demonstrates only the size-mismatch state leak.

Impact Assessment

An attacker running WebGL/GPU commands gains an out-of-bounds write in ANGLE, reached from the GPU process where ANGLE translates rendering for the Metal backend on Apple hardware. The precondition is the GL_ANGLE_shader_pixel_local_storage extension being available and a PLS render pass being used before a smaller non-PLS target within the same context. The advisory classifies this as an out-of-bounds write in ANGLE and does not state exploited-in-the-wild status; any escalation beyond the demonstrated dimension leak is (inferred).

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

Audit Directions

  • Conditional-only state assignment
    Look for descriptor or cache fields written inside an if branch (PLS, MSAA, layered rendering) but lacking a matching else reset, since such fields retain values across incompatible configurations.
  • Default render dimensions vs attachment size
    Audit every path that sets renderTargetWidth/renderTargetHeight/defaultWidth/defaultHeight to confirm they are cleared whenever real attachments are present, preventing render areas that exceed bound resources.
  • Cross-FBO and reused-FBO state carryover
    Check backend state that persists between framebuffer bindings or reuse of the same FBO, where transitioning between PLS and non-PLS or between differently sized targets can leak stale sizing state.
From ddfcefdf93c6cbd46bc2d2761d2902ca5938a13a Mon Sep 17 00:00:00 2001
From: Corentin Wallez <[email protected]>
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 <[email protected]>
Reviewed-by: Geoff Lang <[email protected]>
Auto-Submit: Corentin Wallez <[email protected]>
Commit-Queue: Corentin Wallez <[email protected]>
---

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 [email protected]

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