CVE-2026-11043
Overview
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 asrenderTargetWidth/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.
Attack Path
- Establish oversized PLS state
Create a framebuffer, attach a large PLS texture (e.g.
8192x8192) viaglFramebufferTexturePixelLocalStorageANGLE, and run a PLS pass, causing ANGLE to setdefaultWidth/defaultHeightandrenderTargetWidth/renderTargetHeightto the large size. - Switch to a small non-PLS target
Bind a small attachment (e.g. a
4x4GL_RGBA8texture) either to a new FBO or by reusing the same FBO after clearing PLS, then issue a normal draw. - 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. - 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.
- 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
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
| Function | Change | Notes |
|---|---|---|
DrawCommandValidationTestsrc/tests/gl_tests/PixelLocalStorageTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/metal/FrameBufferMtl.mmsrc/libANGLE/renderer/metal/mtl_state_cache.mmsrc/tests/gl_tests/PixelLocalStorageTest.cpp
Audit Directions
- Conditional-only state assignmentLook for descriptor or cache fields written inside an
ifbranch (PLS, MSAA, layered rendering) but lacking a matchingelsereset, since such fields retain values across incompatible configurations. - Default render dimensions vs attachment sizeAudit every path that sets
renderTargetWidth/renderTargetHeight/defaultWidth/defaultHeightto confirm they are cleared whenever real attachments are present, preventing render areas that exceed bound resources. - Cross-FBO and reused-FBO state carryoverCheck 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.
Patch
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 {
Regression Test / PoC
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
{
Original Bug Report
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.mmthird_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:
- Missing State Reset in
RenderPassDesc::convertToMetalDesc: Inthird_party/angle/src/libANGLE/renderer/metal/mtl_state_cache.mm, theconvertToMetalDescmethod updates therenderTargetWidthandrenderTargetHeightproperties of theMTLRenderPassDescriptorobject (mCachedRenderPassDescObjC) ifdefaultWidthordefaultHeightis non-zero. However, it lacks anelsebranch to reset these properties back to 0 (or to the dimensions of the current attachments) when they are not explicitly specified. BecausemCachedRenderPassDescObjCis allocated once perRenderCommandEncoderand reused, these properties retain their values from prior render passes. - Stale State in
FramebufferMtl::prepareRenderPass: Inthird_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mm, theprepareRenderPassmethod populates the C++mtl::RenderPassDescstructure. It only sets thedefaultWidthanddefaultHeightmembers 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, thedefaultWidthanddefaultHeightmembers in the incoming descriptor are 0, which triggers the flaw inconvertToMetalDesc.
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):
- Request the
GL_ANGLE_shader_pixel_local_storageextension. - Create a Framebuffer Object (FBO A) with no attachments.
- Bind a large texture (e.g., 16384x16384) as a PLS plane for FBO A and call
glBeginPixelLocalStorageANGLE. This triggers an internal call tocontext->framebufferParameteri, setting the FBO’sdefaultWidthto 16384. - Execute a draw call on FBO A.
prepareRenderPasssetsdesc.defaultWidth = 16384, andconvertToMetalDescwrites this into the cachedmCachedRenderPassDescObjC.renderTargetWidth. - Create a second Framebuffer Object (FBO B) and attach a very small texture (e.g., 16x16) as a color attachment.
- Execute a draw call on FBO B. Because FBO B has attachments,
prepareRenderPassleavesdesc.defaultWidthas 0. Consequently,convertToMetalDescskips updating therenderTargetWidthin the Objective-C descriptor. - The Metal driver receives the
MTLRenderPassDescriptorwhererenderTargetWidthis 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.