CVE-2026-5283
Overview
Files Changed
src/libANGLE/Framebuffer.cppsrc/libANGLE/Framebuffer.hsrc/libANGLE/FramebufferAttachment.cppsrc/libANGLE/FramebufferAttachment.hsrc/tests/gl_tests/RobustResourceInitTest.cpp
Patch
From 2c0fcdce8c915937764f57c70a30c5e07432645a Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Thu, 19 Mar 2026 14:36:55 -0400
Subject: [PATCH] Fix robust init vs arrayed textures vs glClear
Bug: chromium:492131521
Change-Id: I534f34a52574084808ca0ecd024f5034c565f579
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7684418
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
---
diff --git a/src/libANGLE/Framebuffer.cpp b/src/libANGLE/Framebuffer.cpp
index 9031f4b..0672c12 100644
--- a/src/libANGLE/Framebuffer.cpp
+++ b/src/libANGLE/Framebuffer.cpp
@@ -1656,7 +1656,7 @@
}
bool Framebuffer::partialClearNeedsInit(const Context *context,
- bool color,
+ DrawBufferMask color,
bool depth,
bool stencil)
{
@@ -1681,7 +1681,7 @@
// If colors masked, we must clear before we clear. Do a simple check.
// TODO(jmadill): Filter out unused color channels from the test.
- if (color && glState.anyActiveDrawBufferChannelMasked())
+ if (color.any() && glState.anyActiveDrawBufferChannelMasked())
{
return true;
}
@@ -1704,6 +1704,24 @@
}
}
+ // For layered attachments, consider this a partial clear. Otherwise the framebuffer clears
+ // some layers but marks the entire mip as initialized.
+ if (depth && mState.mDepthAttachment.hasLayer())
+ {
+ return true;
+ }
+ if (stencil && mState.mStencilAttachment.hasLayer())
+ {
+ return true;
+ }
+ for (size_t colorIndex : color)
+ {
+ if (mState.mColorAttachments[colorIndex].hasLayer())
+ {
+ return true;
+ }
+ }
+
return false;
}
@@ -2495,7 +2513,13 @@
return angle::Result::Continue;
}
- if (partialClearNeedsInit(context, color, depth, stencil))
+ // Note that mResourceNeedsInit puts the color buffers first, and so the bits for color buffers
+ // match the indices in DrawBufferMask. Additionally, the depth and stencil bits are
+ // automatically dropped as part of the constructor for DrawBufferMask, since they don't fit,
+ // but are explicitly masked out here for clarity.
+ const DrawBufferMask colorAttachmentsNeedingInit(mState.mResourceNeedsInit.bits() &
+ DrawBufferMask().set().bits());
+ if (partialClearNeedsInit(context, colorAttachmentsNeedingInit, depth, stencil))
{
ANGLE_TRY(ensureDrawAttachmentsInitialized(context));
}
@@ -2570,7 +2594,7 @@
break;
}
- if (partialBufferClearNeedsInit(context, buffer) &&
+ if (partialBufferClearNeedsInit(context, buffer, clearColorAttachments) &&
(clearColorAttachments.any() || clearDepth || clearStencil))
{
ANGLE_TRY(mImpl->ensureAttachmentsInitialized(context, clearColorAttachments, clearDepth,
@@ -2760,7 +2784,9 @@
return mState.mFoveationState.getSupportedFoveationFeatures();
}
-bool Framebuffer::partialBufferClearNeedsInit(const Context *context, GLenum bufferType)
+bool Framebuffer::partialBufferClearNeedsInit(const Context *context,
+ GLenum bufferType,
+ DrawBufferMask drawBuffers)
{
if (!context->isRobustResourceInitEnabled() || mState.mResourceNeedsInit.none())
{
@@ -2770,13 +2796,14 @@
switch (bufferType)
{
case GL_COLOR:
- return partialClearNeedsInit(context, true, false, false);
+ ASSERT(drawBuffers.any());
+ return partialClearNeedsInit(context, drawBuffers, false, false);
case GL_DEPTH:
- return partialClearNeedsInit(context, false, true, false);
+ return partialClearNeedsInit(context, {}, true, false);
case GL_STENCIL:
- return partialClearNeedsInit(context, false, false, true);
+ return partialClearNeedsInit(context, {}, false, true);
case GL_DEPTH_STENCIL:
- return partialClearNeedsInit(context, false, true, true);
+ return partialClearNeedsInit(context, {}, true, true);
default:
UNREACHABLE();
return false;
diff --git a/src/libANGLE/Framebuffer.h b/src/libANGLE/Framebuffer.h
index b584b0d..199bf7e 100644
--- a/src/libANGLE/Framebuffer.h
+++ b/src/libANGLE/Framebuffer.h
@@ -536,8 +536,15 @@
// * some color channels are masked out
// * some stencil values are masked out
// * scissor test partially overlaps the framebuffer
- bool partialClearNeedsInit(const Context *context, bool color, bool depth, bool stencil);
- bool partialBufferClearNeedsInit(const Context *context, GLenum bufferType);
+ // * any attachment is an arrayed texture, but the framebuffer attachment doesn't completely
+ // cover it
+ bool partialClearNeedsInit(const Context *context,
+ DrawBufferMask color,
+ bool depth,
+ bool stencil);
+ bool partialBufferClearNeedsInit(const Context *context,
+ GLenum bufferType,
+ DrawBufferMask drawBuffers);
FramebufferAttachment *getAttachmentFromSubjectIndex(angle::SubjectIndex index);
diff --git a/src/libANGLE/FramebufferAttachment.cpp b/src/libANGLE/FramebufferAttachment.cpp
index 11726ee..0f38964 100644
--- a/src/libANGLE/FramebufferAttachment.cpp
+++ b/src/libANGLE/FramebufferAttachment.cpp
@@ -216,6 +216,11 @@
return (index.has3DLayer() ? index.getLayerIndex() : 0);
}
+bool FramebufferAttachment::hasLayer() const
+{
+ return mTarget.textureIndex().hasLayer();
+}
+
bool FramebufferAttachment::isLayered() const
{
return mTarget.textureIndex().isLayered();
diff --git a/src/libANGLE/FramebufferAttachment.h b/src/libANGLE/FramebufferAttachment.h
index 9632c4f..924d9ba 100644
--- a/src/libANGLE/FramebufferAttachment.h
+++ b/src/libANGLE/FramebufferAttachment.h
@@ -110,6 +110,7 @@
TextureTarget cubeMapFace() const;
GLint mipLevel() const;
GLint layer() const;
+ bool hasLayer() const;
bool isLayered() const;
GLsizei getNumViews() const { return mNumViews; }
diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp
index 9ba74e2..6ba10e1 100644
--- a/src/tests/gl_tests/RobustResourceInitTest.cpp
+++ b/src/tests/gl_tests/RobustResourceInitTest.cpp
@@ -609,7 +609,7 @@
glBindFramebuffer(GL_FRAMEBUFFER, fb);
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture->get(), 0,
textureLayer);
- EXPECT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
checkFramebufferNonZeroPixels(skipX, skipY, skipWidth, skipHeight, skip);
}
@@ -1960,6 +1960,40 @@
}
}
+// Test that robust init is done correctly for array textures if a layer is cleared with glClear.
+TEST_P(RobustResourceInitTestES3, Texture2DArrayPartiallyCleared)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ constexpr int kSize = 1024;
+ constexpr int kLayers = 8;
+ constexpr int kClearLayer = 3;
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kSize, kSize, kLayers, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+
+ // Clear one layer, expect the other layers to read back as transparent black.
+ GLFramebuffer framebuffer;
+ glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
+ glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, kClearLayer);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
Regression Test / PoC
diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp
index 9ba74e2..6ba10e1 100644
--- a/src/tests/gl_tests/RobustResourceInitTest.cpp
+++ b/src/tests/gl_tests/RobustResourceInitTest.cpp
@@ -609,7 +609,7 @@
glBindFramebuffer(GL_FRAMEBUFFER, fb);
glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture->get(), 0,
textureLayer);
- EXPECT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
checkFramebufferNonZeroPixels(skipX, skipY, skipWidth, skipHeight, skip);
}
@@ -1960,6 +1960,40 @@
}
}
+// Test that robust init is done correctly for array textures if a layer is cleared with glClear.
+TEST_P(RobustResourceInitTestES3, Texture2DArrayPartiallyCleared)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ constexpr int kSize = 1024;
+ constexpr int kLayers = 8;
+ constexpr int kClearLayer = 3;
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
+ glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kSize, kSize, kLayers, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+
+ // Clear one layer, expect the other layers to read back as transparent black.
+ GLFramebuffer framebuffer;
+ glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
+ glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, 0, kClearLayer);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ glClearColor(0, 1, 0, 1);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ for (int layer = 0; layer < kLayers; ++layer)
+ {
+ if (layer != kClearLayer)
+ {
+ checkNonZeroPixels3D(&texture, 0, 0, 0, 0, layer, GLColor::transparentBlack);
+ }
+ }
+ checkNonZeroPixels3D(&texture, 0, 0, kSize, kSize, kClearLayer, GLColor::green);
+ ASSERT_GL_NO_ERROR();
+}
+
// Test that using TexStorage2D followed by CompressedSubImage works with robust init.
// Taken from WebGL test conformance/extensions/webgl-compressed-texture-s3tc.
TEST_P(RobustResourceInitTestES3, CompressedSubImage)
Original Bug Report
ANGLE WebGL2: Missing Per-Layer Init Tracking in TEXTURE_2D_ARRAY Leaves Layers Uninitialized, Leads to Cross-Origin GPU Texture Data Leak
Report description
ANGLE WebGL2: Missing Per-Layer Init Tracking in TEXTURE_2D_ARRAY Leaves Layers Uninitialized, Leads to Cross-Origin GPU Texture Data Leak
Bug location
Where do you want to report your vulnerability?
Chrome VRP β Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Summary
ANGLE’s robust resource initialization tracks TEXTURE_2D_ARRAY init state per mip-level (mipmap level = resolution step: level 0 is full-size, level 1 is half, etc.), but not per layer (layer = individual 2D slice within an array texture). A TEXTURE_2D_ARRAY with 4 layers at mip 0 has only one initState shared across all 4 layers. Clearing one layer via framebufferTextureLayer + gl.clear marks the entire mip level (all layers) as initialized β but only that single layer actually receives a GPU write. Sibling layers remain filled with stale GPU memory from previously freed textures. readPixels on those uninitialized layers bypasses all init checks and returns the stale data directly to JavaScript.
Tested on
- AWS G4DN instance (Nvidia T4)
- Ubuntu 24.04.4 server + XRDP
- Chromium asan build (vulkan backend)
- For reliable reproduction, I recommend testing under the same or similar environment (NVIDIA GPU + Linux + Vulkan backend). See the Notes section for details on hardware-dependent behavior.
Root Cause
The frontend tracks TEXTURE_2D_ARRAY init state per mip-level (mipmap resolution step) but missing per-layer tracking (individual 2D slice within the array). Note that cube maps already have per-face tracking (level * 6 + faceIndex), but array textures do not β GetImageDescIndex returns only level, collapsing all layers into one ImageDesc.initState. This is a frontend logic bug β all backends (Vulkan, D3D11, Metal) are equally affected because they trust the frontend’s initState verdict.
// src/libANGLE/Texture.cpp:43-47
size_t GetImageDescIndex(TextureTarget target, size_t level)
{
return IsCubeMapFaceTarget(target) ? (level * 6 + CubeMapTextureTargetToFaceIndex(target))
: level; // β BUG: cube maps get per-face tracking,
// but 2D_ARRAY layers all share one ImageDesc per mip
}
All layers of a mip map to a single ImageDesc.initState. When a single layer is cleared:
// src/libANGLE/Framebuffer.cpp:2498-2508
if (partialClearNeedsInit(context, color, depth, stencil))
{
ANGLE_TRY(ensureDrawAttachmentsInitialized(context)); // β SKIPPED for full-viewport clear
}
markAttachmentsInitialized(clearedColorAttachments, depth, stencil); // β marks entire mip Initialized
partialClearNeedsInit returns false for a full-viewport, unmasked clear. The code skips ensureDrawAttachmentsInitialized (which would zero ALL layers) and jumps to markAttachmentsInitialized, setting initState = Initialized for the whole mip β even though only one layer received a GPU write.
After this, ensureReadAttachmentsInitialized (the readPixels init gate) sees mResourceNeedsInit is empty and skips initialization:
// src/libANGLE/Framebuffer.cpp:2623-2630
angle::Result Framebuffer::ensureReadAttachmentsInitialized(const Context *context)
{
ASSERT(context->isRobustResourceInitEnabled());
if (mState.mResourceNeedsInit.none()) // β false because initState was set to Initialized
{
return angle::Result::Continue; // β init SKIPPED, stale GPU memory returned
}
Introduced in commit 05b35b210e (2017-10-03, “D3D11: Lazy robust resource init”) which added ImageDesc.initState, partialClearNeedsInit, markAttachmentsInitialized, and ensureReadAttachmentsInitialized all at once β with per-mip-only granularity from the start.
Execution Flow
texStorage3D(2D_ARRAY, 4 layers) ImageDesc[mip0].initState = MayNeedInit
(all 4 layers share this single ImageDesc)
framebufferTextureLayer(FBO_A, layer=0) FBO_A targets layer 0 only
mResourceNeedsInit.set(0, true) (MayNeedInit)
gl.clear(COLOR_BUFFER_BIT) on FBO_A partialClearNeedsInit() = false (full-viewport, no mask)
ensureDrawAttachmentsInitialized SKIPPED
GPU writes ONLY layer 0
markAttachmentsInitialized:
setInitState(Initialized) on attachment
-> Texture::setInitState -> getImageDesc(target, level)
-> GetImageDescIndex returns level=0
-> ImageDesc[mip0].initState = Initialized <-- BUG
(layers 1-3 still contain stale GPU memory)
framebufferTextureLayer(FBO_B, layer=2) FBO_B attach: attachment->initState() called
-> Texture::initState(imageIndex) -> getImageDesc(level=0)
-> returns Initialized (false: layer 2 was never written)
-> mResourceNeedsInit.set(0, false)
gl.readPixels() on FBO_B ensureReadAttachmentsInitialized:
mResourceNeedsInit.none() == true -> skip init
backend reads layer 2 stale GPU memory -> INFO LEAK
Notes
- ASAN cannot detect this vulnerability. The stale data resides in GPU device memory (VRAM), which is outside ASAN’s instrumentation scope. All CPU-side operations (staging buffer allocation,
memcpyto JavaScriptArrayBuffer) are correctly sized and within bounds. The bug produces wrong data content (stale instead of zero), not an out-of-bounds memory access β so no ASAN signal is expected. - Reproduction depends on GPU hardware and driver. The leaked data comes from VRAM blocks that are freed and reallocated by the GPU driver’s memory allocator (e.g. Vulkan VMA). Whether stale data persists depends on the driver’s allocation behavior:
- NVIDIA Vulkan: does not zero-fill VRAM on allocation β 100% reproducible
- Apple Metal: zero-fills on allocation β not reproducible despite identical frontend bug
- AMD/Intel Vulkan: behavior may vary by driver version
- VRAM reuse also depends on texture size alignment and allocator fragmentation β same-sized textures maximize reuse probability.
Suggested Fix
-
Quick fix: In
partialClearNeedsInit(Framebuffer.cpp), treat a single-layer clear on a multi-layer texture (TEXTURE_2D_ARRAY,TEXTURE_2D_MULTISAMPLE_ARRAY,TEXTURE_CUBE_MAP_ARRAY) as a partial clear when the mip’sinitStateisMayNeedInit. This forcesensureDrawAttachmentsInitializedto zero all sibling layers beforemarkAttachmentsInitializedmarks the entire mip as initialized. -
Thorough fix: Extend
GetImageDescIndex(Texture.cpp) to track init state per-layer for array texture types, similar to how cube maps already track per-face vialevel * 6 + faceIndex. This requires resizingmImageDescsto account for the layer count, which is a larger refactor.
Reproduction Steps
Minimal Reproduction (poc.html)
# Linux with Vulkan GPU (tested on T4 / Ubuntu)
./chrome --headless=new --no-sandbox --disable-gpu-sandbox \
--use-gl=angle --use-angle=vulkan --ignore-gpu-blocklist \
poc.html --dump-dom
- Create
TEXTURE_2D_ARRAYtextureA(2x1, 4 layers), clear layers 1-2 with magenta[255,0,255,255] - Delete texture
A - Create
TEXTURE_2D_ARRAYtextureB(same dimensions) - Clear only layer 1 of
Bwith green readPixelson layer 2 ofB
**Check 'poc_html_result.png'**
Expected: [0,0,0,0, 0,0,0,0] (uninitialized β zeroed by robust init)
Actual: [255,0,255,255, 255,0,255,255] (stale magenta from deleted texture A)
10-Round Spray-and-Leak (poc_v2_enhanced.html)
./chrome --headless=new --no-sandbox --disable-gpu-sandbox \
--use-gl=angle --use-angle=vulkan --ignore-gpu-blocklist \
poc_v2_enhanced.html --dump-dom
Check 'poc_v2_enhanced_html_result.png'
# Output (10 rounds, 256x256x4 RGBA8):
# Round 1 seed=#deadbeef | status=LEAK | stale=100.0% (65536/65536) | err=0
# Round 2 seed=#cafebabe | status=LEAK | stale=100.0% (65536/65536) | err=0
# ...
# Total stale bytes leaked: 2621440
Seeds 10 different patterns (0xDEADBEEF, 0xCAFEBABE, 0x41414141, …) into texture A across rounds, verifies each pattern leaks back through texture B’s uninitialized layer 2. 256x256 RGBA8 = 262,144 bytes leaked per round.
Cross-Origin Visual Reproduction (victim_layer_visual.html + poc_attacker_layer_visual.html)
./chrome --no-sandbox --disable-gpu-sandbox --use-gl=angle --use-angle=vulkan --ignore-gpu-blocklist
Demonstrates real-world cross-origin GPU memory leak between two independent pages:
- Open
victim_layer_visual.htmlβ sprays 100TEXTURE_2D_ARRAYtextures (256x256x4 layers, ~100 MB) with a distinctive blue+red cross pattern, then deletes all textures - Open
poc_attacker_layer_visual.htmlin a separate tab β probes every 1 second for stale victim data via the layer leak bug - Trigger condition: The victim page must navigate away, refresh, or close the tab so that its WebGL textures are freed back to the GPU memory allocator (VMA free list). While the victim’s textures are live, they cannot be reallocated to the attacker.
- After victim navigation/close, the attacker’s new
TEXTURE_2D_ARRAYallocations reuse the same VMA blocks βreadPixelson uninitialized layer 2 returns the victim’s stale cross pattern
This matches the real-world attack scenario: a victim browses a GPU-intensive site (Google Maps satellite tiles, Google Earth 3D terrain, video conferencing frames), then navigates away. The attacker page β open in another tab β continuously probes and recovers the victim’s freed GPU texture data.
Impact analysis
Impact
A WebGL2 page can read GPU pixel data left behind by other websites after they navigate away. An attacker page continuously probes for reused GPU memory blocks; when a victim page (e.g. Google Maps, Google Earth, Google Meet) navigates away and its textures are freed, the attacker recovers the victim’s rendered pixel data β satellite imagery, map tiles, video frames β without any user interaction.
cross_origin_texture_leak_poc_1.png : Google Maps/Earth satellite imagery tiles (which were freed via leaving / navigating to other page) visible in leaked stale texture data on above aws environment. (Please check image attachments)
The cause
What version of Chrome have you found the security issue in?
147.0.7726.0
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Information Leak
How would you like to be publicly acknowledged for your report?
sweetchip