CVE-2026-9916
Overview
Files Changed
extensions/ANGLE_shader_pixel_local_storage.txtsrc/libANGLE/PixelLocalStorage.hsrc/libANGLE/validationESEXT.cpp
Patch
From d4364d6ab8ad0aaed843e4aee307cc7056f8f2b8 Mon Sep 17 00:00:00 2001
From: Geoff Lang <geofflang@chromium.org>
Date: Thu, 07 May 2026 15:01:15 -0400
Subject: [PATCH] PLS: Validate global state when resuming pixel local storage.
This protects against getting into state that is invalid for PLS while
interrupted (such as modifying the framebuffer size) and then resuming
PLS and using the invalid state.
Fixed: chromium:500080303
Change-Id: I609b05ffc483b9929958e685423b064e1106da10
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7828809
Reviewed-by: Alexey Knyazev <lexa.knyazev@gmail.com>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Geoff Lang <geofflang@chromium.org>
---
diff --git a/extensions/ANGLE_shader_pixel_local_storage.txt b/extensions/ANGLE_shader_pixel_local_storage.txt
index 2d44f24..7c582c0 100644
--- a/extensions/ANGLE_shader_pixel_local_storage.txt
+++ b/extensions/ANGLE_shader_pixel_local_storage.txt
@@ -623,11 +623,12 @@
Decrements the pixel local storage interrupt counter on the current draw
framebuffer. When the counter decrements from 1 to 0, and
PIXEL_LOCAL_STORAGE_ACTIVE_PLANES_ANGLE was nonzero at the time this
- framebuffer entered an interrupted state, the command also begins pixel
- local storage using LOAD_OP_LOAD_ANGLE for texture backed planes and
+ framebuffer entered an interrupted state, BeginPixelLocalStorageANGLE is
+ implicitly called using LOAD_OP_LOAD_ANGLE for texture backed planes and
DONT_CARE for memoryless planes. The number of planes activated is equal to
the value of PIXEL_LOCAL_STORAGE_ACTIVE_PLANES_ANGLE at the time this
- framebuffer entered an interrupted state. This command is ignored when the
+ framebuffer entered an interrupted state. Validation associated with
+ BeingPixelLocalStorageANGLE is also run. This command is ignored when the
default framebuffer object name 0 is bound.
Modify Section 3.8.10.3 "Rendering Feedback Loops"
diff --git a/src/libANGLE/PixelLocalStorage.h b/src/libANGLE/PixelLocalStorage.h
index 10a71fb..ec4827a 100644
--- a/src/libANGLE/PixelLocalStorage.h
+++ b/src/libANGLE/PixelLocalStorage.h
@@ -159,6 +159,7 @@
const PixelLocalStoragePlaneVector &getPlanes() { return mPlanes; }
size_t interruptCount() const { return mInterruptCount; }
+ GLsizei activePlanesAtInterrupt() const { return mActivePlanesAtInterrupt; }
// ANGLE_shader_pixel_local_storage API.
void deinitialize(Context *context, GLint plane) { mPlanes[plane].deinitialize(context); }
diff --git a/src/libANGLE/validationESEXT.cpp b/src/libANGLE/validationESEXT.cpp
index db50a36..33b02a2 100644
--- a/src/libANGLE/validationESEXT.cpp
+++ b/src/libANGLE/validationESEXT.cpp
@@ -1722,9 +1722,17 @@
Any
};
+enum class PLSInterruptedStatus
+{
+ Interrupted,
+ Resumed,
+ Any,
+};
+
bool ValidatePLSCommon(const Context *context,
angle::EntryPoint entryPoint,
- PLSExpectedStatus expectedStatus)
+ PLSExpectedStatus expectedStatus,
+ PLSInterruptedStatus expectedInterruptedStatus)
{
Framebuffer *framebuffer = context->getState().getDrawFramebuffer();
if (expectedStatus != PLSExpectedStatus::Active)
@@ -1738,11 +1746,19 @@
}
}
- // INVALID_FRAMEBUFFER_OPERATION is generated if pixel local storage on the draw framebuffer is
- // in an interrupted state.
const PixelLocalStorage *pls = framebuffer->peekPixelLocalStorage();
- if (pls != nullptr && pls->interruptCount() != 0)
+ const bool plsInterrupted = pls != nullptr && pls->interruptCount() != 0;
+ if (expectedInterruptedStatus == PLSInterruptedStatus::Interrupted && !plsInterrupted)
{
+ // INVALID_FRAMEBUFFER_OPERATION is generated if pixel local storage on the draw framebuffer
+ // is not in an interrupted state.
+ ANGLE_VALIDATION_ERROR(GL_INVALID_FRAMEBUFFER_OPERATION, kPLSNotInterrupted);
+ return false;
+ }
+ else if (expectedInterruptedStatus == PLSInterruptedStatus::Resumed && plsInterrupted)
+ {
+ // INVALID_FRAMEBUFFER_OPERATION is generated if pixel local storage on the draw framebuffer
+ // is in an interrupted state.
ANGLE_VALIDATION_ERROR(GL_INVALID_FRAMEBUFFER_OPERATION, kPLSInterrupted);
return false;
}
@@ -1772,9 +1788,10 @@
bool ValidatePLSCommon(const Context *context,
angle::EntryPoint entryPoint,
GLint plane,
- PLSExpectedStatus expectedStatus)
+ PLSExpectedStatus expectedStatus,
+ PLSInterruptedStatus expectedInterruptedStatus)
{
- if (!ValidatePLSCommon(context, entryPoint, expectedStatus))
+ if (!ValidatePLSCommon(context, entryPoint, expectedStatus, expectedInterruptedStatus))
{
return false;
}
@@ -2005,178 +2022,26 @@
return false;
}
}
-} // namespace
-bool ValidateFramebufferMemorylessPixelLocalStorageANGLE(const Context *context,
+bool ValidateBeginAndRestorePixelLocalStorageGlobalState(const Context *context,
angle::EntryPoint entryPoint,
- GLint plane,
- GLenum internalformat,
- GLbitfield usage)
+ GLsizei n)
{
- if (!ValidatePLSCommon(context, entryPoint, plane, PLSExpectedStatus::Any))
- {
- return false;
- }
-
- // INVALID_ENUM is generated if <internalformat> is not one of the acceptable values in Table
- // X.2, or NONE.
- if (internalformat != GL_NONE)
- {
- if (!ValidatePLSInternalformat(context, entryPoint, internalformat))
- {
- return false;
- }
-
- // INVALID_OPERATION is generated if <internalformat> is not NONE and not color-renderable.
- if (!context->getTextureCaps().get(internalformat).textureAttachment)
- {
- ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kFormatNotRenderable);
- return false;
- }
- }
-
- if ((usage & kValidPLSUsages) != usage)
- {
- ANGLE_VALIDATION_ERROR(GL_INVALID_VALUE, kPLSInvalidUsage);
- return false;
- }
-
- return true;
-}
-
-bool ValidateFramebufferTexturePixelLocalStorageANGLE(const Context *context,
- angle::EntryPoint entryPoint,
- GLint plane,
- TextureID backingtexture,
- GLint level,
- GLint layer,
- GLbitfield usage)
-{
- if (!ValidatePLSCommon(context, entryPoint, plane, PLSExpectedStatus::Any))
- {
- return false;
- }
-
- if (backingtexture.value != 0)
- {
- Texture *tex = context->getTexture(backingtexture);
-
- // INVALID_OPERATION is generated if <backingtexture> is not the name of an existing
- // immutable texture object, or zero.
- if (!tex)
- {
- ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kInvalidTextureName);
- return false;
- }
- if (!tex->getImmutableFormat())
- {
- ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kTextureIsNotImmutable);
- return false;
- }
-
- // INVALID_OPERATION is generated if <backingtexture> is nonzero
- // and not of type GL_TEXTURE_2D, GL_TEXTURE_2D_ARRAY, GL_TEXTURE_CUBE_MAP,
- // or GL_TEXTURE_CUBE_MAP_ARRAY.
- size_t textureDepth;
- if (!ValidatePLSTextureType(context, entryPoint, tex, &textureDepth))
- {
- return false;
- }
-
- // INVALID_VALUE is generated if <backingtexture> is nonzero and <level> < 0.
- if (level < 0)
- {
- ANGLE_VALIDATION_ERROR(GL_INVALID_VALUE, kNegativeLevel);
- return false;
- }
-
- // INVALID_VALUE is generated if <backingtexture> is nonzero and <level> >= the
Regression Test / PoC
diff --git a/src/tests/gl_tests/PixelLocalStorageTest.cpp b/src/tests/gl_tests/PixelLocalStorageTest.cpp
index a4da689..44fd114 100644
--- a/src/tests/gl_tests/PixelLocalStorageTest.cpp
+++ b/src/tests/gl_tests/PixelLocalStorageTest.cpp
@@ -6256,6 +6256,11 @@
ASSERT_GL_INTEGER(GL_PIXEL_LOCAL_STORAGE_ACTIVE_PLANES_ANGLE, 0);
}
+ GLTexture tex;
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 100, 100);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
+
// INVALID_OPERATION is generated if <loadops>[0..<n>-1] is LOAD_OP_LOAD_ANGLE and
// the pixel local storage plane at that same index is memoryless.
glFramebufferMemorylessPixelLocalStorageANGLE(0, GL_RGBA8, GL_NONE);
@@ -6463,6 +6468,98 @@
ASSERT_GL_INTEGER(GL_PIXEL_LOCAL_STORAGE_ACTIVE_PLANES_ANGLE, 0);
}
+// Test that operations performed during interrupted PLS which make PLS invalid are validated on
+// resume.
+TEST_P(PixelLocalStorageValidationTest, ResumeValidatesFramebufferState)
+{
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_ANGLE_shader_pixel_local_storage"));
+
+ // Small PLS backing texture (4x4) and large color attachment (WxH = 128x128).
+ PLSTestTexture smallPLS(GL_RGBA8, 4, 4);
+ PLSTestTexture largeAttachment(GL_RGBA8, W, H);
+
+ std::vector<GLubyte> zeros(W * H * 4, 0);
+ glBindTexture(GL_TEXTURE_2D, largeAttachment);
+ glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, W, H, GL_RGBA, GL_UNSIGNED_BYTE, zeros.data());
+ ASSERT_GL_NO_ERROR();
+
+ // FBO with NO attachments, only a PLS plane.
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexturePixelLocalStorageANGLE(0, smallPLS, 0, 0, GL_NONE);
+ ASSERT_GL_NO_ERROR();
+
+ // Shader that writes to both color attachment 0 and PLS plane 0.
+ PLSProgram p;
+ p.compile(R"(
+ layout(binding=0, rgba8) uniform highp pixelLocalANGLE pls;
+ layout(location=0) out vec4 fragColor;
+ void main()
+ {
+ pixelLocalStoreANGLE(pls, vec4(0, 1, 0, 1));
+ fragColor = vec4(1, 0, 0, 1);
+ })");
+
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glViewport(0, 0, W, H);
+
+ glBeginPixelLocalStorageANGLE(1, GLenumArray({GL_LOAD_OP_ZERO_ANGLE}));
+ ASSERT_GL_NO_ERROR();
+ ASSERT_GL_INTEGER(GL_PIXEL_LOCAL_STORAGE_ACTIVE_PLANES_ANGLE, 1);
+
+ glFramebufferPixelLocalStorageInterruptANGLE();
+ ASSERT_GL_NO_ERROR();
+ ASSERT_GL_INTEGER(GL_PIXEL_LOCAL_STORAGE_ACTIVE_PLANES_ANGLE, 0);
+
+ glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+ largeAttachment, 0);
+ ASSERT_GL_NO_ERROR();
+
+ // Invalid because fbo attachment and PLS plane now have different sizes.
+ glFramebufferPixelLocalStorageRestoreANGLE();
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+}
+
+// Test that state changes which are invalid for PLS are validated on restore
+TEST_P(PixelLocalStorageValidationTest, RestoreValidatesSameStatesAsBegin)
+{
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ ASSERT_GL_NO_ERROR();
+
+ PLSTestTexture pls0(GL_RGBA8, 256, 256, 9);
+ glFramebufferTexturePixelLocalStorageANGLE(0, pls0, 2, 0, GL_NONE);
+
+ GLenum dontCare[1] = {GL_DONT_CARE};
+ glBeginPixelLocalStorageANGLE(1, dontCare);
+ ASSERT_GL_NO_ERROR();
+
+ glFramebufferPixelLocalStorageInterruptANGLE();
+ EXPECT_GL_NO_ERROR();
+
+ glEnable(GL_DITHER);
+ EXPECT_GL_NO_ERROR();
+
+ glFramebufferPixelLocalStorageRestoreANGLE();
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+ glDisable(GL_DITHER);
+ glFramebufferPixelLocalStorageRestoreANGLE();
+ glEndPixelLocalStorageANGLE(1, dontCare);
+ EXPECT_GL_NO_ERROR();
+}
+
+// Restoring PLS is always valid if begin has not been called.
+TEST_P(PixelLocalStorageValidationTest, RestoreAlwaysValidIfNotBegun)
+{
+ glFramebufferPixelLocalStorageInterruptANGLE();
+ EXPECT_GL_NO_ERROR();
+
+ glEnable(GL_DITHER);
+ glFramebufferPixelLocalStorageRestoreANGLE();
+ EXPECT_GL_NO_ERROR();
+}
+
// Check that glBeginPixelLocalStorageANGLE validates feedback loops with GL_TEXTURE_2D as
// specified:
//
Original Bug Report
Potential GPU Process OOB Write via Pixel Local Storage Dimension Validation Bypass
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: A logic flaw in ANGLE’s Pixel Local Storage (PLS) implementation allows a compromised renderer to bypass dimension validation during PLS restoration. By injecting an oversized framebuffer attachment while PLS is interrupted, an attacker can cause out-of-bounds imageStore operations in the GPU process. This can lead to memory corruption and potential arbitrary code execution within the privileged GPU process.
Affected files:
third_party/angle/src/libANGLE/PixelLocalStorage.cppthird_party/angle/src/compiler/translator/tree_ops/RewritePixelLocalStorage.cppthird_party/angle/src/libANGLE/validationESEXT.cppthird_party/angle/src/libANGLE/renderer/vulkan/vk_caps_utils.cpp
Estimated timestamp from git blame: 2023-07-13
Vulnerability Mechanism
A vulnerability exists in ANGLE’s implementation of the ANGLE_shader_pixel_local_storage extension where a logic flaw allows an attacker to bypass critical dimension validation during a Pixel Local Storage (PLS) restore operation.
The core issue is that restoring a suspended PLS session via glFramebufferPixelLocalStorageRestoreANGLE invokes PixelLocalStorage::restore(), which directly calls the internal Context::beginPixelLocalStorage() method. This completely bypasses the standard validation path (ValidateBeginPixelLocalStorageANGLE), which is responsible for ensuring that the PLS backing texture dimensions exactly match the dimensions of the framebuffer’s rendering area.
Potential Exploit Steps
Note: These are potential steps as our tooling agent has not executed a live proof of concept.
An attacker with a compromised renderer process could trigger this out-of-bounds write via raw GLES command buffer operations by following these steps:
- Initialize PLS with a small texture: Create a Framebuffer Object (FBO) and attach a small texture (e.g., 16x16) to a PLS plane using
glFramebufferTexturePixelLocalStorageANGLE. Ensure there are no standard color/depth attachments. - Begin PLS: Call
glBeginPixelLocalStorageANGLE. Because there are no standard attachments,ValidateBeginPixelLocalStorageANGLEskips thekPLSDimensionsDontMatchRenderingAreadimension check. The PLS session starts with an active plane dimension of 16x16. - Interrupt PLS: Call
glFramebufferPixelLocalStorageInterruptANGLE. This safely suspends the PLS session and zeros out the active plane count in the context state, while saving the fact that 1 plane was active. - Inject an oversized attachment: Call
glFramebufferTexture2Dto attach a large texture (e.g., 4096x4096) toGL_COLOR_ATTACHMENT0. Because the active PLS plane count was zeroed during the interrupt, the implicit PLS-end guard in the entry point is bypassed, and the standard attachment succeeds. - Restore PLS: Call
glFramebufferPixelLocalStorageRestoreANGLE. The validation for this command only verifies that an interrupt is pending. The restoration process directly invokes the internal PLS start sequence, bypassingValidateBeginPixelLocalStorageANGLE. The system fails to realize the 16x16 PLS texture no longer matches the new 4096x4096 rendering area. - Draw with PLS: Execute a draw call (e.g.,
glDrawArrays) using a shader that utilizespixelLocalStoreANGLEand a viewport of 4096x4096.
Impact
During shader compilation, the ANGLE shader translator (RewritePixelLocalStorage.cpp) rewrites PLS store operations to native imageStore instructions using gl_FragCoord.xy as the coordinate, without performing any bounds clamping.
Because the rendering area is 4096x4096, fragments are generated with coordinates up to (4095, 4095). The shader executes imageStore using these coordinates into the PLS image unit, which is backed by the 16x16 texture.
ANGLE’s Vulkan renderer does not enable VK_EXT_image_robustness or the robustImageAccess feature for storage images. Consequently, these operations result in an unchecked out-of-bounds write to GPU memory. This memory corruption can be leveraged by the attacker to overwrite critical data structures, hijack control flow, and achieve Remote Code Execution (RCE) and Sandbox Escape within the highly privileged GPU process.
Suggested Fix
The PLS implementation must ensure that the framebuffer attachments are still compatible with the PLS planes upon restoration.
One approach is to have Context::framebufferPixelLocalStorageRestore (or ValidateFramebufferPixelLocalStorageRestoreANGLE) perform the same dimension checks found in ValidateBeginPixelLocalStorageANGLE. If the dimensions of the texture-backed PLS planes no longer match the rendering area (the intersection of all attachment extents), the restore operation should fail and return a GL_INVALID_OPERATION error.
Alternatively, modifying the framebuffer attachments (e.g., via glFramebufferTexture2D) while PLS is interrupted could be explicitly forbidden, or forced to clear the pending PLS interrupt state.
Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad
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.