Overview

High
Severity
β€”
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ANGLE
DescriptionInappropriate implementation in ANGLE
ComponentANGLE
Bug ClassLogic Error
Tracker492131521
Fix commit2c0fcdce8c91 (angle/angle) +86/-12
CISA KEVNot listed
Creditedsweetchip
Disclosed2026-03-31

Files Changed

  • src/libANGLE/Framebuffer.cpp
  • src/libANGLE/Framebuffer.h
  • src/libANGLE/FramebufferAttachment.cpp
  • src/libANGLE/FramebufferAttachment.h
  • src/tests/gl_tests/RobustResourceInitTest.cpp
From 2c0fcdce8c915937764f57c70a30c5e07432645a Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <[email protected]>
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 <[email protected]>
Reviewed-by: Geoff Lang <[email protected]>
---

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);
+
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

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, memcpy to JavaScript ArrayBuffer) 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

  1. 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’s initState is MayNeedInit. This forces ensureDrawAttachmentsInitialized to zero all sibling layers before markAttachmentsInitialized marks the entire mip as initialized.

  2. 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 via level * 6 + faceIndex. This requires resizing mImageDescs to 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
  1. Create TEXTURE_2D_ARRAY texture A (2x1, 4 layers), clear layers 1-2 with magenta [255,0,255,255]
  2. Delete texture A
  3. Create TEXTURE_2D_ARRAY texture B (same dimensions)
  4. Clear only layer 1 of B with green
  5. readPixels on layer 2 of B
**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:

  1. Open victim_layer_visual.html β€” sprays 100 TEXTURE_2D_ARRAY textures (256x256x4 layers, ~100 MB) with a distinctive blue+red cross pattern, then deletes all textures
  2. Open poc_attacker_layer_visual.html in a separate tab β€” probes every 1 second for stale victim data via the layer leak bug
  3. 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.
  4. After victim navigation/close, the attacker’s new TEXTURE_2D_ARRAY allocations reuse the same VMA blocks β†’ readPixels on 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

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

View on issue tracker