High watch CVSS 6.5 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ANGLE
DescriptionInappropriate implementation in ANGLE
ComponentANGLE
Bug ClassLogic Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker492131521
Fix commit2c0fcdce8c91 (angle/angle) +86/-12
CWECWE-285, CWE-346, CWE-352 (Origin validation error, CSRF)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
Creditedsweetchip
Reported2026-03-12 (per vendor advisory)
Fix landed2026-03-20 (public git)
Disclosed2026-03-31
Patch gap11 days — fix public in source before the advisory

Background

ANGLE robust resource init
Chrome’s defense (enabled via isRobustResourceInitEnabled) that lazily zero-initializes GPU resources before first use so programs cannot read back uninitialized driver memory.
`mResourceNeedsInit`
a per-framebuffer bitmask of attachments still needing initialization; once an attachment is initialized its bit is cleared.
Arrayed / layered attachment
a single mip level of a 2D-array (or 3D) texture bound to the framebuffer at one layer via glFramebufferTextureLayer; a clear touches only that layer, not the whole mip.
`partialClearNeedsInit`
the predicate deciding whether a glClear is “partial” (covers less than the full resource) and therefore must first run full initialization instead of marking the resource initialized.

Root Cause Analysis

partialClearNeedsInit previously took a plain bool color and judged a clear partial only when color channels were masked, stencil was masked, or the scissor partially overlapped the framebuffer. It had no notion of layered attachments, so clearing one layer of a 2D-array texture (bound via glFramebufferTextureLayer) was treated as a full clear: the clear wrote only the selected layer yet the code marked the entire mip level as initialized in mResourceNeedsInit. The other, still-uninitialized layers then counted as initialized and could be read back, defeating robust resource init (inferred disclosure of uninitialized GPU memory).

The fix changes the color parameter to a DrawBufferMask, adds explicit checks that return true (partial) when the depth, stencil, or any color attachment reports hasLayer(), and introduces FramebufferAttachment::hasLayer(); it also threads the correct color draw-buffer mask through partialBufferClearNeedsInit (new DrawBufferMask drawBuffers parameter) and builds colorAttachmentsNeedingInit from mResourceNeedsInit so a layered clear now forces full initialization rather than falsely marking the whole mip clean.

Key insight
The partial-clear test ignored that an attachment can be a single layer of an arrayed texture, so a one-layer clear wrongly marked the entire mip initialized; the fix treats any layered attachment as a partial clear (via the new FramebufferAttachment::hasLayer()), forcing proper initialization of the untouched layers.

Attack Path

  1. Create an array texture Web content (WebGL2 / ES3) allocates a GL_TEXTURE_2D_ARRAY with multiple layers and leaves it uninitialized.
  2. Bind and clear one layer It attaches a single layer with glFramebufferTextureLayer and issues glClear, which the pre-fix partialClearNeedsInit classifies as a full clear.
  3. Mark whole mip initialized ANGLE clears only that layer but clears the resource’s mResourceNeedsInit bit for the entire mip, so the other layers are now considered initialized.
  4. Read back stale layers The attacker samples or reads back the untouched layers and obtains uninitialized GPU memory contents (inferred information disclosure); the shipped Texture2DArrayPartiallyCleared test asserts those layers must read as transparent black.

Impact Assessment

An attacker using ordinary web content with WebGL2 in a stock build can reach this, since robust resource initialization is enabled by default and array textures are a standard ES3 feature; no special flag, extension, or policy is required, and the flaw lives in ANGLE in the GPU process. The vendor advisory rates it high and labels it “inappropriate implementation in ANGLE”; the concrete consequence is reading uninitialized GPU memory, and any sensitive content actually recovered is (inferred) rather than demonstrated by the diff.

Files Changed

  • src/libANGLE/Framebuffer.cpp
  • src/libANGLE/Framebuffer.h
  • src/libANGLE/FramebufferAttachment.cpp
  • src/libANGLE/FramebufferAttachment.h
  • src/tests/gl_tests/RobustResourceInitTest.cpp

Audit Directions

  • Full-vs-partial resource coverage
    review robust-init bookkeeping wherever an operation touches a sub-range (layer, face, slice, region) but clears initialization state for the whole resource/mip.
  • Layered/3D attachments
    audit other framebuffer and clear/invalidate paths for assumptions that an attachment covers an entire texture level rather than a single layer.
  • Boolean-to-mask refactors
    look for predicates that collapse per-draw-buffer or per-attachment state into a single bool, losing the per-index detail needed to decide partiality correctly.
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);
+
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 sw...@gmail.com

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