CVE-2026-11043
Overview
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
Patch
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
{
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.