CVE-2026-17667
Overview
Background
- `invalidateFramebuffer`/`discardFramebuffer`
- GL entry points that tell the driver an attachment’s contents are no longer needed, permitting the backend to skip storing or preserving them.
- Robust resource initialization
- an ANGLE security mode (
isRobustResourceInitEnabled) that guarantees every texture and renderbuffer is zero-filled before a client can observe its contents. - `InitState::MayNeedInit`
- the per-attachment flag telling ANGLE the resource’s contents are undefined and must be lazily cleared before the next read.
- Packed depth/stencil
- a single texture holding both depth and stencil aspects, where invalidating one aspect can leave the combined attachment in an undefined state.
Root Cause Analysis
When an application called invalidate, invalidateSub, or discard on a framebuffer, the Metal backend allowed the attachment’s underlying texture contents to become undefined without ever marking that texture as needing re-initialization. Even though robust resource init was enabled, the InitState for the affected attachments was never transitioned back to MayNeedInit, so the invariant that “any client-observable read is preceded by a clear” was silently broken for invalidated attachments. A subsequent readPixels, sampling, or blit could therefore return stale GPU memory that the zero-fill guarantee was supposed to prevent.
The fix adds markAttachmentsUninitialized, which sets the init state to MayNeedInit and flags the framebuffer dirty bits, and calls it from discard, invalidate, and invalidateSub when the new setNeedInitOnInvalidation feature and robust init are both enabled, so ANGLE re-clears the resource before the next access. The overrideInvalidateAttachments helper additionally filters packed depth/stencil aspects so that partial-aspect invalidation does not incorrectly discard the sibling aspect’s contents.
InitState to MayNeedInit, defeating robust resource init; the fix reinstates that flag via markAttachmentsUninitialized on every invalidate/discard path.Attack Path
- Allocate and populate a framebuffer A WebGL/GPU-process client creates a framebuffer whose color or packed depth/stencil attachment is backed by a Metal texture, relying on robust resource init to keep contents defined.
- Invalidate the attachment
The client calls
invalidateFramebuffer/discardFramebuffer, which lets the backend leave the texture’s contents undefined but, pre-patch, never marks the attachmentMayNeedInit. - Read back stale memory
The client reads the attachment (via
readPixels, sampling, or a blit); because the init state was not reset, ANGLE skips the clear and returns uninitialized GPU texture memory. - Disclose adjacent contents (inferred) The recovered bytes may contain leftover data from prior GPU allocations, giving a cross-origin or cross-context information leak, though the patch itself only demonstrates the uninitialized read, not a specific victim buffer.
Impact Assessment
(inferred) and not established by the diff. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5.Files Changed
include/platform/autogen/FeaturesMtl_autogen.hinclude/platform/autogen/FrontendFeatures_autogen.hinclude/platform/frontend_features.jsoninclude/platform/mtl_features.jsonsrc/libANGLE/Framebuffer.cpp
Audit Directions
- Init-state resets on content-dropping opsAudit every path that lets attachment or texture contents become undefined (invalidate, discard, orphaning, aliasing) to confirm each transitions
InitStateback toMayNeedInitunder robust init. - Backend parity for the fixThe change is Metal-only; review the D3D11 and GL backends where the same invalidate/discard paths still lack
markAttachmentsUninitialized. - Packed aspect handlingLook for other places that treat packed depth/stencil attachments per-aspect, where invalidating one aspect can leave the combined resource observable and undefined.
Patch
From 5242f8c4150e57281ea7cfb12c2226a491194cad Mon Sep 17 00:00:00 2001 From: Le Hoang Quyen <[email protected]> Date: Wed, 20 May 2026 00:35:19 +0800 Subject: [PATCH] Metal: Fix uninitialized textures on invalidate. - Added a front-end feature `set_need_init_on_invalidation` to mark attachments as needing initialization on invalidation. - Added `markAttachmentsUninitialized` in `Framebuffer.cpp` to set the init state to `MayNeedInit` and flag dirty bits. - Updated `discard`, `invalidate`, and `invalidateSub` in `Framebuffer.cpp` to use this new method. - Added `overrideInvalidateAttachments` helper in `Framebuffer.cpp` to filter packed depth/stencil attachments when the feature is enabled. - Optimized `invalidateSub` to treat sub-area invalidations as no-ops in robust resource init mode if the feature is enabled. - Enabled `set_need_init_on_invalidation` in Metal backend. - Added a metal feature to force non-zero texture creations for testing purpose. - Added a new test `ClearThenInvalidateThenReadBack` in `RobustResourceInitTest.cpp`. We only apply the fix to the Metal backend for now. The D3D11 and GL backends will be next. Bug: chromium:513043537 Change-Id: I344bff69ec8c5ca48d385b867397ed384031feaf Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7859172 Reviewed-by: Geoff Lang <[email protected]> Reviewed-by: Shahbaz Youssefi <[email protected]> Commit-Queue: Quyen Le <[email protected]> --- diff --git a/include/platform/autogen/FeaturesMtl_autogen.h b/include/platform/autogen/FeaturesMtl_autogen.h index 3efcc45..202f71c 100644 --- a/include/platform/autogen/FeaturesMtl_autogen.h +++ b/include/platform/autogen/FeaturesMtl_autogen.h @@ -338,6 +338,12 @@ &members, }; + FeatureInfo allocateNonZeroTextures = { + "allocateNonZeroTextures", + FeatureCategory::MetalFeatures, + &members, + }; + }; inline FeaturesMtl::FeaturesMtl() = default; diff --git a/include/platform/autogen/FrontendFeatures_autogen.h b/include/platform/autogen/FrontendFeatures_autogen.h index 4431bb7..9faa0a7 100644 --- a/include/platform/autogen/FrontendFeatures_autogen.h +++ b/include/platform/autogen/FrontendFeatures_autogen.h @@ -225,6 +225,12 @@ &members, }; + FeatureInfo setNeedInitOnInvalidation = { + "setNeedInitOnInvalidation", + FeatureCategory::FrontendFeatures, + &members, + }; + }; inline FrontendFeatures::FrontendFeatures() = default; diff --git a/include/platform/frontend_features.json b/include/platform/frontend_features.json index 6f79c21..4b99462 100644 --- a/include/platform/frontend_features.json +++ b/include/platform/frontend_features.json @@ -270,6 +270,13 @@ "description": [ "Allow #extension ... : disable after non preprocessor tokens in WebGL." ] + }, + { + "name": "set_need_init_on_invalidation", + "category": "Features", + "description": [ + "Set Texture's InitState to MayNeedInit inside invalidateFramebuffer or discardFramebuffer." + ] } ] } diff --git a/include/platform/mtl_features.json b/include/platform/mtl_features.json index 386e3a8..78f0cbc 100644 --- a/include/platform/mtl_features.json +++ b/include/platform/mtl_features.json @@ -411,6 +411,13 @@ "Inject statements into loop bodies when needed to ensure infinite loops are optimized out." ], "issue": "http://crbug.com/1513738" + }, + { + "name": "allocate_non_zero_textures", + "category": "Features", + "description": [ + "Clear allocated textures to non-zero values to help detect uninitialized reads." + ] } ] } diff --git a/src/libANGLE/Framebuffer.cpp b/src/libANGLE/Framebuffer.cpp index a8d5c85..f1d13ab 100644 --- a/src/libANGLE/Framebuffer.cpp +++ b/src/libANGLE/Framebuffer.cpp @@ -337,6 +337,22 @@ return !stencilAttachment || stencilAttachment->getStencilSize() == 8; } +angle::Result CheckAttachmentEnclosed(const Context *context, + const FramebufferAttachment &attachment, + const Rectangle &area, + bool *enclosedOut) +{ + *enclosedOut = true; + if (attachment.isAttached()) + { + ANGLE_TRY(attachment.ensureSizeResolved(context)); + + Extents size = attachment.getSize(); + *enclosedOut = area.encloses(Rectangle(0, 0, size.width, size.height)); + } + return angle::Result::Continue; +} + } // anonymous namespace FramebufferStatus FramebufferStatus::Complete() @@ -1606,6 +1622,15 @@ // can be no-ops, so we should probably do that to ensure consistency. // TODO(jmadill): WebGL behaviour, and robust resource init behaviour without WebGL. + if (context->getFrontendFeatures().setNeedInitOnInvalidation.enabled && + context->isRobustResourceInitEnabled()) + { + // We don't need to override the attachments list like in invalidate() because, unlike + // invalidate(), discard() allows a packed depth/stencil attachment's data to become + // undefined even if the attachments list only contains the depth or stencil aspect. + markAttachmentsUninitialized(context, count, attachments); + } + return mImpl->discard(context, count, attachments); } @@ -1617,6 +1642,15 @@ // can be no-ops, so we should probably do that to ensure consistency. // TODO(jmadill): WebGL behaviour, and robust resource init behaviour without WebGL. + if (context->getFrontendFeatures().setNeedInitOnInvalidation.enabled && + context->isRobustResourceInitEnabled()) + { + auto overrideAttachments = overrideInvalidateAttachments(count, attachments); + markAttachmentsUninitialized(context, overrideAttachments.size(), + overrideAttachments.data()); + return mImpl->invalidate(context, overrideAttachments.size(), overrideAttachments.data()); + } + return mImpl->invalidate(context, count, attachments); } @@ -1655,7 +1689,7 @@ { ASSERT(HasSupportedStencilBitCount(glState.getDrawFramebuffer())); - const auto &depthStencil = glState.getDepthStencilState(); + const auto &depthStencil = glState.getDepthStencilState(); // The least significant |stencilBits| of stencil mask state specify a // mask. Check only those bits, ignoring any masked high bits. // Only the stencil write mask can affect which stencil bits are cleared. Clears are always @@ -1697,6 +1731,43 @@ // can be no-ops, so we should probably do that to ensure consistency. // TODO(jmadill): Make a invalidate no-op in WebGL 2.0. + if (context->getFrontendFeatures().setNeedInitOnInvalidation.enabled && + context->isRobustResourceInitEnabled()) + { + DrawBufferMask colorMask; + bool invalidateDepth = false; + bool invalidateStencil = false; + + for (size_t i = 0; i < count; ++i) + { + GLenum attachment = attachments[i]; + if (attachment >= GL_COLOR_ATTACHMENT0 && + attachment < GL_COLOR_ATTACHMENT0 + IMPLEMENTATION_MAX_DRAW_BUFFERS) + { + colorMask.set(attachment - GL_COLOR_ATTACHMENT0); + } + if (attachment == GL_DEPTH_ATTACHMENT || attachment == GL_DEPTH_STENCIL_ATTACHMENT) + { + invalidateDepth = true; + } + if (attachment == GL_STENCIL_ATTACHMENT || attachment == GL_DEPTH_STENCIL_ATTACHMENT) + { + invalidateStencil = true; + } + } + + bool allEnclosed = false;
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 120b907..6f13d2b 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -572,6 +572,7 @@
496259841 WIN D3D11 : RobustResourceInitTestES3.InvalidateThenReadBack/* = SKIP
496259841 WIN D3D11 : RobustResourceInitTestES3.DrawThenInvalidateThenReadBack/* = SKIP
496259841 WIN D3D11 : RobustResourceInitTestES3.DrawThenInvalidateThenVerifyDepthStencil/* = SKIP
+514927166 WIN D3D11 : RobustResourceInitTestES3.ClearThenInvalidateThenReadBack/* = SKIP
512896813 WIN D3D11 : LineLoopTestES3.LineLoopDrawArraysInstancedBaseInstance/* = SKIP
512896813 WIN D3D11 : LineLoopTestES3.LineLoopDrawElementsInstancedBaseVertexBaseInstance/* = SKIP
514615434 D3D11 : DrawBaseVertexBaseInstanceTest.NegativeBaseVertex/* = SKIP
diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp
index 6935603..0c5d42c 100644
--- a/src/tests/gl_tests/RobustResourceInitTest.cpp
+++ b/src/tests/gl_tests/RobustResourceInitTest.cpp
@@ -809,6 +809,55 @@
EXPECT_GL_NO_ERROR();
}
+// Calling invalidate after clear should either read back the result of the clear call (invalidate
+// didn't happen) or transparent black (the init color).
+TEST_P(RobustResourceInitTestES3, ClearThenInvalidateThenReadBack)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ for (int i = 0; i < 10; ++i)
+ {
+ GLTexture tex;
+ GLRenderbuffer rb;
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+
+ if (i % 2 == 0)
+ {
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kWidth, kHeight);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
+ }
+ else
+ {
+ glBindRenderbuffer(GL_RENDERBUFFER, rb);
+ glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kWidth, kHeight);
+ glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, rb);
+ }
+ ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ // Clear to red
+ glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ // Invalidate
+ std::array<GLenum, 1> attachments = {GL_COLOR_ATTACHMENT0};
+ glInvalidateFramebuffer(GL_FRAMEBUFFER, 1, attachments.data());
+
+ const int w = getWindowWidth();
+ const int h = getWindowHeight();
+
+ // Read back, ensure the content is either red or transparent black
+ GLColor invalidated(100, 100, 100, 100);
+ glReadPixels(w / 2, h / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, &invalidated);
+ EXPECT_TRUE(invalidated == GLColor::red || invalidated == GLColor::transparentBlack)
+ << "At iteration " << i << ", color is " << invalidated;
+ EXPECT_GL_NO_ERROR();
+ }
+}
+
// Calling invalidate after draw should either read back the result of the draw call (invalidate
// didn't happen) or the init depth/stencil values.
TEST_P(RobustResourceInitTestES3, DrawThenInvalidateThenReadBackDepthStencil)
@@ -3372,6 +3421,7 @@
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(RobustResourceInitTestES3);
ANGLE_INSTANTIATE_TEST_ES3_AND(RobustResourceInitTestES3,
ES3_METAL().enable(Feature::EmulateDontCareLoadWithRandomClear),
+ ES3_METAL().enable(Feature::AllocateNonZeroTextures),
ES3_VULKAN().enable(Feature::AllocateNonZeroMemory));
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(RobustResourceInitTestES31);
Original Bug Report
ANGLE: glInvalidateFramebuffer bypasses robust resource initialization leading to information leak
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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential vulnerability in ANGLE’s Metal and GL backends allows framebuffer invalidation to bypass robust resource initialization. This can lead to a cross-origin information leak by allowing a WebGL context to read uninitialized GPU memory. The issue affects platforms using tile-based rendering architectures, including macOS (Metal) and Android (GL).
Affected files:
third_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mmthird_party/angle/src/libANGLE/renderer/gl/FramebufferGL.cppthird_party/angle/src/libANGLE/Framebuffer.cpp
Estimated timestamp from git blame: 2017-10-03
Description
A potential vulnerability has been identified in ANGLE’s Metal and GL backends where the implementation of glInvalidateFramebuffer (and glDiscardFramebufferEXT) fails to correctly maintain the robust resource initialization state. Robust resource initialization is a security feature designed to ensure that GPU memory is zero-initialized before it can be read by a WebGL context, preventing the leakage of stale data from other origins or the system.
On tile-based architectures (such as Apple Silicon and many mobile GPUs), this logic gap can be exploited to read uninitialized GPU memory because the backend may discard a pending clear operation while the frontend still considers the resource to be initialized.
Technical Analysis
The issue stems from a logic gap in the ANGLE frontend and its interaction with specific backends:
- Frontend State Tracking: ANGLE uses a bitset
mState.mResourceNeedsInitinthird_party/angle/src/libANGLE/Framebuffer.cppto track which attachments require initialization before being read. When a resource is first created, it is marked as needing initialization. - Clear Operation: When
gl.clear()is called, ANGLE’s frontend marks the affected attachments as initialized by resetting the corresponding bits inmResourceNeedsInit(viaFramebuffer::markAttachmentsInitialized). - Invalidate Logic Gap: When
gl.invalidateFramebuffer()is called, the frontend passes the command to the backend but fails to update themResourceNeedsInitstate. A comment inthird_party/angle/src/libANGLE/Framebuffer.cppat line 1618 (and 1607 fordiscard) explicitly acknowledges this:// TODO(jmadill): WebGL behaviour, and robust resource init behaviour without WebGL. - Backend Optimization: In backends like Metal (
third_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mm), an invalidation sets the store action toMTLStoreActionDontCare. On Tile-Based Deferred Rendering (TBDR) hardware, if aclearand aninvalidateoccur in the same render pass, the GPU may discard the results of the clear in its local tile memory instead of committing the cleared values to the texture’s backing memory in system RAM. - Information Leak: Because the frontend metadata incorrectly indicates the resource is still “initialized,” a subsequent
gl.readPixels()or similar operation bypasses the robust initialization check inFramebuffer::ensureReadAttachmentsInitialized(third_party/angle/src/libANGLE/Framebuffer.cpp:2612). The backend then reads the uninitialized backing memory, which contains stale GPU data.
Potential Steps to Reproduce (Suggested)
- Initialize a WebGL2 context (robust resource initialization is enabled by default in Chrome).
- Create a texture and attach it to a Framebuffer Object (FBO).
- Perform a full
gl.clear(gl.COLOR_BUFFER_BIT). This causes ANGLE to mark the resource as initialized in its internal metadata. - Call
gl.invalidateFramebuffer()for the color attachment. In the Metal or GL backend, this causes the previously requested clear to be discarded from the GPU’s tile buffer. - Call
gl.readPixels(). Because the frontend believes the resource is already initialized, it skips the safety clear. The backend reads and returns the uninitialized stale memory from the texture’s backing store.
Suggested Fix
The Framebuffer::invalidate, Framebuffer::invalidateSub, and Framebuffer::discard methods in third_party/angle/src/libANGLE/Framebuffer.cpp should be updated to mark the invalidated attachments as needing initialization (e.g., by resetting their InitState to MayNeedInit and updating the mResourceNeedsInit bitset) when robust resource initialization is enabled. This ensures that any subsequent read operation will correctly trigger a robust clear if the content has been invalidated.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.