CVE-2026-9916
Overview
Background
- Pixel Local Storage (PLS)
- An ANGLE extension (
ANGLE_shader_pixel_local_storage) that lets a fragment shader read/write per-pixel “planes” backed by textures or memoryless storage. Planes are bound to a draw framebuffer and activated betweenglBeginPixelLocalStorageANGLEandglEndPixelLocalStorageANGLE. - PLS interrupt/restore
glFramebufferPixelLocalStorageInterruptANGLEsuspends an active PLS session (saving how many planes were active,mActivePlanesAtInterrupt);glFramebufferPixelLocalStorageRestoreANGLEresumes it, implicitly re-running the begin sequence for the saved plane count.- Rendering-area/plane dimension invariant
- Begin validation requires the PLS backing-texture dimensions to match the framebuffer’s rendering area; a mismatch is
kPLSDimensionsDontMatchRenderingArea. The GPU-sideimageStoreemitted by the translator usesgl_FragCoordwithout clamping, so this check is the only bound guarantee.
Root Cause Analysis
The restore path resumed a suspended PLS session by implicitly re-invoking the begin sequence, but it never re-ran the global-state validation that glBeginPixelLocalStorageANGLE performs. While a session was interrupted the framebuffer could legally be mutated (for example, a large color attachment added, changing the rendering area), and on restore ANGLE would reactivate the saved planes against the now-incompatible framebuffer. The backing texture (e.g. 16x16) no longer matched the new rendering area (e.g. 4096x4096), so fragments at coordinates well outside the plane texture would be generated and the unclamped imageStore would write out of bounds of the plane’s backing storage in the GPU process.
The patch (“Validate global state when resuming pixel local storage”) closes this by re-validating on resume. ValidatePLSCommon gains a new PLSInterruptedStatus parameter (Interrupted/Resumed/Any) so callers can require a specific interrupt state and emit kPLSNotInterrupted/kPLSInterrupted accordingly, and a new ValidateBeginAndRestorePixelLocalStorageGlobalState(context, entryPoint, n) factors out the global-state checks so both begin and restore run them. A activePlanesAtInterrupt() accessor is added to PixelLocalStorage.h so restore knows how many planes it is about to reactivate. In effect, restore now performs the same dimension/global-state validation as begin, and a restore against an incompatible framebuffer fails with a GL error instead of proceeding to an out-of-bounds draw. Note the supplied diff is truncated and shows the validation-helper refactor; the out-of-bounds imageStore sink itself is described by the advisory and is not contained in this diff (inferred).
Attack Path
- Compromised renderer Attacker runs raw GLES command-buffer operations against the GPU process (inferred: requires an already-compromised renderer).
- Small-plane begin
Create an FBO, attach a small (16x16) immutable texture as a PLS plane with no standard color attachment, and
glBeginPixelLocalStorageANGLE; with no standard attachment the rendering-area dimension check is skipped at begin. - Interrupt
Call
glFramebufferPixelLocalStorageInterruptANGLE, which suspends PLS and records one active plane while zeroing the live active-plane count. - Enlarge rendering area
Attach a large (4096x4096) texture to
GL_COLOR_ATTACHMENT0while interrupted; the implicit end-guard is bypassed because the live active-plane count was zeroed. - Restore without re-validation
Call
glFramebufferPixelLocalStorageRestoreANGLE; pre-patch, restore only checked that an interrupt was pending and reactivated the 16x16 plane against the 4096x4096 rendering area. - OOB write
Issue a draw with a PLS-storing shader at the 4096x4096 viewport; unclamped
imageStoreat coordinates up to (4095,4095) writes out of bounds of the 16x16 plane backing storage in the GPU process (inferred sink; not in this diff).
Impact Assessment
imageStore write into GPU memory with partial attacker control over offset (via framebuffer size) and data, which the advisory escalates to code execution/sandbox escape. The OOB write is consistent with the advisory and the validation-gap the diff fixes, but the memory-corruption sink is not shown in this (truncated) diff and is therefore inferred.Files Changed
extensions/ANGLE_shader_pixel_local_storage.txtsrc/libANGLE/PixelLocalStorage.hsrc/libANGLE/validationESEXT.cpp
Audit Directions
- Suspend/resume re-validationAudit every “resume/restore” or “implicit begin” path in ANGLE (and command decoders) that re-enters a validated operation without re-running the entry point’s validation; look for state (sizes, formats, attachments) mutable during the suspended window.
- Unclamped image accessReview
RewritePixelLocalStorage.cppand other translator paths emittingimageStore/imageLoadfromgl_FragCoordfor cases that assume a validated size invariant rather than clamping at the sink. - Interrupt-count guardsCheck other PLS entry points that key behavior off
interruptCount()/active-plane counts for the same “count zeroed while interrupted” bypass used to sneak in the oversized attachment. - Robust image accessConfirm whether the Vulkan backend enables
robustImageAccess/VK_EXT_image_robustnessfor storage images on paths that rely on validation for bounds.
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.