High chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized resource in Skia
DescriptionUninitialized resource in Skia
ComponentSkia
Sub-componentGanesh
Bug ClassUninitialized Memory
Tracker561987051
Fix commit409081ee128b (skia) +26/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
src/gpu/ganesh/GrDrawingManager.cpp
modified

Files Changed

  • tests/GrThreadSafeCacheTest.cpp
  • src/gpu/ganesh/GrDrawingManager.cpp
From 409081ee128bce31d6b7cfa1dadab4a0750cdcc0 Mon Sep 17 00:00:00 2001
From: Michael Ludwig <michaelludwig@google.com>
Date: Tue, 22 Sep 2026 14:51:59 -0400
Subject: [PATCH] [ganesh] Drop unique refs in GrThreadSafeCache when flush tasks fail

If any render task fails to execute (or instantiation/preFlush fails),
GrThreadSafeCache entries published ahead of rendering during the flush
(such as lazy-view blur masks) may point to textures that were never
initialized. Drop all uniquely-held entries in the thread-safe cache on
failing flushes so subsequent draws do not consume uninitialized
textures.

Bug: 561987051
Fixed: 561987051
Change-Id: Idff93c3c7ab6480c56f727831d04f7202204c0fd
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1368937
Reviewed-by: Robert Phillips <robertphillips@google.com>
Commit-Queue: Michael Ludwig <michaelludwig@google.com>
---
diff --git a/src/gpu/ganesh/GrDrawingManager.cpp b/src/gpu/ganesh/GrDrawingManager.cpp
index 4de8af0..348e071 100644
--- a/src/gpu/ganesh/GrDrawingManager.cpp
+++ b/src/gpu/ganesh/GrDrawingManager.cpp
@@ -46,6 +46,7 @@
 #include "src/gpu/ganesh/GrTextureProxy.h"
 #include "src/gpu/ganesh/GrTextureResolveManager.h"
 #include "src/gpu/ganesh/GrTextureResolveRenderTask.h"
+#include "src/gpu/ganesh/GrThreadSafeCache.h"
 #include "src/gpu/ganesh/GrTracing.h"
 #include "src/gpu/ganesh/GrTransferFromRenderTask.h"
 #include "src/gpu/ganesh/GrWaitRenderTask.h"
@@ -214,6 +215,18 @@
     }
     this->removeRenderTasks();
 
+    // If any render task failed to execute (or instantiation/preFlush failed), GrThreadSafeCache
+    // entries that were published ahead of rendering during this flush (e.g. a blur mask) may now
+    // point at textures that will never be initialized. Without clearing the cache here, subsequent
+    // draws using the same key will see the unitialized texture and assume it's valid. This only
+    // runs on failing flushes, which are already catastrophic for rendering, so re-creating a few
+    // cached resources afterwards is an acceptable cost.
+    if (!executionResult.fAllTasksSuccessful) {
+        // NOTE: passing a null resource cache means to drop all unique refs, not just enough to
+        // get under a cache's budget.
+        fContext->priv().threadSafeCache()->dropUniqueRefs(/*resourceCache=*/nullptr);
+    }
+
     GrDirectContext::FlushResult flushResult = gpu->executeFlushInfo(proxies, access, info,
                                                                      std::move(timerQuery),
                                                                      newState);
diff --git a/tests/GrThreadSafeCacheTest.cpp b/tests/GrThreadSafeCacheTest.cpp
index 880a286..c580bcf 100644
--- a/tests/GrThreadSafeCacheTest.cpp
+++ b/tests/GrThreadSafeCacheTest.cpp
@@ -1005,7 +1005,8 @@
 //            its lazy proxy, life goes on
 static void test_4_75(GrDirectContext* dContext, skiatest::Reporter* reporter,
                       TestHelper::addAccessFP addAccess,
-                      TestHelper::checkFP check) {
+                      TestHelper::checkFP check,
+                      bool failureClearsProxyCache) {
 
     TestHelper helper(dContext);
 
@@ -1022,10 +1023,14 @@
     dContext->flush();
     dContext->submit(GrSyncCpu::kYes);
 
-    REPORTER_ASSERT(reporter, (helper.*check)(helper.liveCanvas(), kImageWH,
-                                              /*hits*/ 0, /*misses*/ 1, /*refs*/ 0, kNoID));
+    int expectedCacheEntries = failureClearsProxyCache ? 0 : 1;
+    // The flush will fail, which will empty the thread safe cache (b/561987051). The Stats
+    // aren't reset, but we shouldn't find an entry anymore so expect check() to fail.
+    REPORTER_ASSERT(reporter, failureClearsProxyCache !=
+            (helper.*check)(helper.liveCanvas(), kImageWH,
+                            /*hits*/ 0, /*misses*/ 1, /*refs*/ 0, kNoID));
+    REPORTER_ASSERT(reporter, helper.numCacheEntries() == expectedCacheEntries);
 
-    REPORTER_ASSERT(reporter, helper.numCacheEntries() == 1);
     REPORTER_ASSERT(reporter, helper.stats()->fNumLazyCreations == 1);
     REPORTER_ASSERT(reporter, helper.stats()->fNumHWCreations == 0);
     REPORTER_ASSERT(reporter, helper.stats()->fNumSWCreations == 0);
@@ -1036,7 +1041,8 @@
                                        ctxInfo,
                                        CtsEnforcement::kNever) {
     test_4_75(ctxInfo.directContext(), reporter,
-              &TestHelper::addViewAccess, &TestHelper::checkView);
+              &TestHelper::addViewAccess, &TestHelper::checkView,
+              /*failureClearsProxyCache=*/true);
 }
 
 DEF_GANESH_TEST_FOR_RENDERING_CONTEXTS(GrThreadSafeCache4_75Verts,
@@ -1044,7 +1050,8 @@
                                        ctxInfo,
                                        CtsEnforcement::kNever) {
     test_4_75(ctxInfo.directContext(), reporter,
-              &TestHelper::addVertAccess, &TestHelper::checkVert);
+              &TestHelper::addVertAccess, &TestHelper::checkVert,
+              /*failureClearsProxyCache=*/false);
 }
 
 // Case 5: ensure that expanding the map works (esp. wrt custom data)
Loading diff…

Original Bug Report

reported by vm...@google.com

Cross-origin GPU memory leak via GrBlurUtils blur-mask trampolines in GrThreadSafeCache

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: Skia Ganesh’s two GPU blur-mask producers in third_party/skia/src/gpu/ganesh/GrBlurUtils.cpp publish a lazy-view “trampoline” entry into the context-wide (cross-origin) GrThreadSafeCache before the mask content is rendered, and unpublish it only on record-time failure. If the recorded mask render task fails at execute time — stencil-attachment or framebuffer/render-pass allocation failure under GPU memory pressure — the failure is swallowed at every layer and the entry stays cached permanently, backed by a recycled scratch texture whose clear (a render-pass load-op) never ran. Ordinary web content can induce this once via canvas-2D shadow blur, then read another origin’s stale GPU memory through getImageData() on every subsequent frame; each cache hit refreshes the entry’s LRU timestamp, so idle cleanup never evicts it. The full chain was demonstrated dynamically: a cross-origin victim’s canvas content was recovered pixel-identical across 60+ frames and across full browser restarts.

Affected files:

Estimated timestamp from git blame: 2020-09-30

Summary

Both find_or_create_rrect_blur_mask_fp and hw_create_filtered_mask insert a lazy-view entry into GrThreadSafeCache before recording the mask draw, and remove it only when recording fails. The recorded content’s fullscreen clear is not an independent operation — it becomes the OpsTask’s color load-op, which executes only when the render pass actually begins. Two execute-time failure modes (OpsTask::onExecute stencil-attach failure and null render pass, both graceful driver-OOM symptoms) abort before the pass begins, so the clear never runs, and no layer between the render task and Chrome’s raster decoder removes or invalidates the cache entry.

The entry’s backing texture was instantiated earlier in the flush by GrResourceAllocator from the recycled scratch pool — deliberately without zero-initialization, because the (never-executed) load-op clear was expected to cover it. After the poisoned flush:

  • every later frame drawing the same shadowed shape takes the cache-hit path and re-serves the never-written texture as the blur mask — no re-render is ever recorded;
  • each hit calls makeExistingEntryMRU, defeating Chrome’s idle performDeferredCleanup eviction;
  • the cache’s reference keeps the texture out of the scratch pool, freezing the captured victim bytes.

The attacker composites the mask as white-on-black coverage and reads it with getImageData() — one stale VRAM byte per pixel, re-readable indefinitely with all memory pressure released. GrThreadSafeCache and the scratch pool are shared by all origins rasterized on the GPU process’s shared GrDirectContext, so the recycled texture contains other origins’ raster intermediates. Ganesh is the shipping raster backend on Windows, Linux, Android and ChromeOS (gpu/config/gpu_finch_features.cc:378-384); on Android the GPU process is unsandboxed.

The one link not demonstrated on real hardware is the environmental precondition itself: a graceful execute-time driver allocation failure (fresh stencil attachment, GrResourceProvider.cpp:666-716, or Vulkan framebuffer, vk/GrVkGpu.cpp:339-356) while pool-recycled instantiation succeeds. Assuming web-driven GPU memory pressure can produce that window — the same class of precondition behind shipped Skia uninitialized-use CVEs (CVE-2026-11159, CVE-2026-11039) — it is retryable, race-free, and needed only once. A secondary corollary: if instantiation (rather than execution) fails, the surviving never-instantiated entry causes a persistent per-key rendering failure (functional DoS flavor, same root cause).


Root Cause Analysis

  1. Publish before render, remove only on record failure: third_party/skia/src/gpu/ganesh/GrBlurUtils.cpp:866-883 — the entry becomes visible to all consumers before any GPU work executes:

    // [`third_party/skia/src/gpu/ganesh/GrBlurUtils.cpp:866-882`](https://source.chromium.org/chromium/chromium/src/+/main:third_party/skia/src/gpu/ganesh/GrBlurUtils.cpp;l=866)
    view = threadSafeCache->findOrAdd(key, lazyView);          // <-- published, unrendered
    if (view != lazyView) {
        return GrTextureEffect::Make(std::move(view), ...);    // <-- cache hit: never re-rendered
    }
    if (!fillin_view_on_gpu(dContext, lazyView, trampoline.get(), ...)) {
        threadSafeCache->remove(key);                          // <-- RECORD-time failure only
        return nullptr;
    }
    

    The path variant (hw_create_filtered_mask, :1326-1388) is identical. fillin_view_on_gpu (:751-811) and create_mask_GPU (:257-305) only record clear + draw + GaussianBlur into deferred contexts; returning true means “recorded”, not “rendered”.

  2. The clear is only a render-pass load-op: third_party/skia/src/gpu/ganesh/SurfaceFillContext.cpp:236-241 — a fullscreen clear on a fresh surface becomes opsTask->setColorLoadOp(GrLoadOp::kClear, color), executed only when the pass begins.

  3. Execute-time failures return before the pass begins: third_party/skia/src/gpu/ganesh/ops/OpsTask.cpp:580-584 (stencil attachment fails — a fresh driver allocation keyed by render-target dimensions, GrResourceProvider.cpp:666-716) and :706-708 (create_render_pass returns null — e.g. GrVkGpu::onGetOpsRenderPass when framebuffer creation fails with VK_ERROR_OUT_OF_*_MEMORY, vk/GrVkGpu.cpp:339-356). Both return ExecutionResult::RanButFailed(); the load-op clear never runs. Neither abandons the context.

  4. The backing texture is recycled, uncleared scratch: proxies are instantiated before task execution (GrDrawingManager.cpp:208 assign vs :212 execute), via GrResourceProvider::createTexture → getExactScratch (GrResourceProvider.cpp:255-267), which returns pooled textures with previous contents; the comment at :363-365 confirms scratch reuse deliberately skips initial clears.

  5. The failure is swallowed at every layer: GrDrawingManager.cpp:292-306 accumulates RanButFailed and keeps executing (the consumer draw still samples the mask); no failure path touches GrThreadSafeCache (exhaustive check of all removal/purge callers). On the Chrome side, SharedContextState::FlushWriteAccess calls skgpu::ganesh::Flush(surface) and discards its result (gpu/command_buffer/service/shared_context_state.cc:887-895), so RasterDecoderImpl::DoEndRasterCHROMIUM still marks the raster successful.

  6. Persistence: the trampoline’s lazy callback checks only isInstantiated(), never rendered-ness (GrThreadSafeCache.cpp:360-371). Every cache hit refreshes the LRU timestamp (:121-138), so dropUniqueRefsOlderThan (idle cleanup) never fires for it, and over-budget eviction walks LRU→MRU and stops before hot entries. The cache’s ref freezes the stale texture out of the scratch pool.

  7. Web reachability: canvas-2D shadowBlur builds a DrawLooper (third_party/blink/renderer/platform/graphics/draw_looper_builder.cc:54-74); OOP-R deserializes loopers in the GPU process by default (cc/paint/paint_op_reader.cc:367-376) and playback applies SkMaskFilter::MakeBlur (cc/paint/draw_looper.cc:28-31). Blur mask filters are never FP-handled (GrFragmentProcessors.cpp:146-150), so Ganesh routes them to DrawShapeWithMaskFilter → GrBlurUtils.cpp producers (rrect :1121, path :1451). No permissions or user interaction involved.

Reproduction

  1. Attacker page inflates GPU memory from JS (WebGL textures / large OffscreenCanvases) until fresh driver allocations are near failure, while pool-recycled allocations still succeed.
  2. Draw ONE blur-shadowed shape on an accelerated 2D canvas: ctx.shadowColor='white'; ctx.shadowBlur=2*sigma; then ctx.fill(path2d) (reuse the same Path2D so the GPU-side paint cache keeps the SkPath genID and cache key stable) or roundRect+fill.
  3. During flush, the mask/blur target instantiates from recycled scratch, but the mask render pass fails at execute (fresh stencil attachment or Vulkan framebuffer allocation).
  4. Release all memory pressure.
  5. Each subsequent frame: redraw the same shadowed shape and call getImageData() over the shadow rect.
  6. Expected: a mask render task that failed to execute unpublishes its cache entry (as record-time failure already does), or the unrendered entry is never served to consumers.

Observed (dynamic validation on an instrumented ASAN build, two genuinely distinct web origins): the entry was published before rendering, its 512×512 A8 intermediate instantiated from recycled scratch holding a cross-origin victim canvas’s content, and after a single injected execute-time render-pass failure the attacker’s getImageData() returned the victim’s rendered secret as the shadow pixels. Sixty consecutive frames produced cache hits with zero re-renders and hash-identical leaked pixels, stable across full browser restarts; a control run without the injected failure produced a normal blurred shadow. The only simulated element was the driver allocation failure itself (fault-injected at the documented failure site, byte-identical to GrVkGpu framebuffer-OOM behavior), because the software-GL test environment aborts under real GPU memory exhaustion instead of returning the graceful out-of-memory errors real drivers produce; inducing that window from web content on real hardware was not re-demonstrated here.

Severity

high — Uninitialized/recycled-memory information leak in the GPU process, whose scratch pool and GrThreadSafeCache are shared across all origins, so leaked bytes are cross-origin. Reachable from ordinary web content (canvas-2D shadows) with no permissions or unusual interaction; the single precondition (an induced execute-time allocation failure under GPU memory pressure) is retryable, race-free, and needed only once, after which the leak is deterministic per frame with pressure released. Prior shipped CVEs cover this exact class (CVE-2026-11159, CVE-2026-11039 — uninitialized use in Skia leaking cross-origin data via crafted HTML; distinct, since-fixed root causes and only transient leaks, whereas this entry is persistent and immortal under normal eviction). Not critical: an information leak, no memory corruption.

Suggested Fix Direction

Propagate execute-time render-task failure to GrThreadSafeCache: when a flush’s tasks did not all execute successfully, drop uniquely-held (trampoline-backed) entries whose content was recorded in that flush — a mitigation of exactly this shape was built and verified to eliminate the persistence, leaving only the known transient same-flush window. Alternatively (or additionally), have the lazy-view trampoline record rendered-ness so unrendered entries are treated as cache misses and re-recorded.


POC: Ganesh GrBlurUtils blur-mask trampolines — GrThreadSafeCache entry never unpublished on execute-time render-task failure — one failure window permanently caches an uncleared scratch texture served as the blur mask to all origins (persistent cross-origin GPU info leak)

Strategy: web-content + code-injection (the entire trigger/exfil chain is ordinary web content; a 25-line env-gated fault-injection patch simulates the one environmental precondition — a driver render-pass allocation failure under GPU memory pressure) Bug Type: CWE-908 — use of uninitialized resource (recycled, never-cleared GPU scratch texture served cross-origin), via cache poisoning in third_party/skia/src/gpu/ganesh/GrBlurUtils.cpp

What the POC demonstrates

Both GPU blur-mask producers in GrBlurUtils.cpp publish a lazy-view “trampoline” entry into the context-wide (cross-origin) GrThreadSafeCache before the mask content renders, and unpublish it only on record-time failure (find_or_create_rrect_blur_mask_fp: publish at :866, record-only removal at :881; hw_create_filtered_mask: :1337 / :1357 / :1373). The mask’s fullscreen clear is just the render pass’ color load-op (SurfaceFillContext.cpp:236-241), so when the recorded render task fails at execute time (OpsTask::onExecute returns RanButFailed() on stencil-attach failure :580-584 or null render pass :706-708 — both driver-OOM symptoms), the failure is swallowed at every level (GrDrawingManager.cpp:292-306, shared_context_state.cc discards the flush result) and nothing removes the cache entry. The entry remains backed by the scratch texture that GrResourceAllocator instantiated from the recycled scratch pool with its previous contents intact — another origin’s data — and every later frame that draws the same shadow takes the cache-hit path and re-serves those bytes, forever (each hit refreshes the entry’s LRU timestamp).

This POC reproduces the full chain end-to-end in one headful Chrome session:

  1. Victim (http://127.0.0.1:8081, cross-origin iframe) draws a secret (block-letter text) with canvas-2D shadowBlur path fills. Its GPU mask chains rasterize the secret’s silhouette into temporary 512x512 A8 render targets that are then freed — contents intact — into the shared scratch pool.
  2. Attacker (http://localhost:8080) rasters a 199x101 sentinel canvas — this arms the simulated-OOM window (stand-in for WebGL memory-pressure inflation on real hardware).
  3. Attacker draws ONE blur-shadowed rounded rect into its canvas (a CSS box-shadowed div — border-radius 110px, blur 8px ⇒ sigma 4 — rendered through an SVG foreignObject image, the html2canvas technique, so a native rounded-rect op with the shadow DrawLooper plays back inside the canvas raster). Ganesh publishes the 269x269 nine-patch mask entry, instantiates the blur chain’s approx-fit 512x512 A8 intermediate from the recycled pool = the victim’s texture, and that render pass fails at execute time (injected getOpsRenderPass() == nullptr, exactly what GrVkGpu::onGetOpsRenderPass returns when framebuffer allocation fails with VK_ERROR_OUT_OF_DEVICE_MEMORY). The victim’s bytes flow unblurred through the rest of the chain into the permanently cached mask.
  4. Attacker reads the shadow pixels with getImageData(): they are the victim’s secret text (see poisoned-shadow-first.png — “SEC…RET / 42S…EC” nine-patch corners are 1:1 victim bytes).
  5. Attacker redraws the same shadow every frame: the poisoned entry is re-served from the cache with no re-render — 60 consecutive cache HIT log lines, and the leaked readback is hash-identical across 4 samples spanning ~60 frames (poisoned-result.json, stable: true). The leak is also byte-identical across full browser restarts (poisoned-shadow-first.png ≡ bandaid-shadow-first.png).

Baseline control (same pages, fault injection disarmed): normal blurred-rrect shadow, different stable hash (baseline-*.png, baseline-result.json).

Prerequisites

  • Chromium ASAN build (out/asan/chrome) with chromium-changes.patch applied (env-gated instrumentation + simulated driver OOM; completely inert unless the SK_POC_LOG / SK_POC_SIM_OOM environment variables are set).
    patch -p1 -d /chromium_src/src < /poc-artifacts/chromium-changes.patch
    autoninja -C /chromium_src/src/out/asan chrome     # (cd /chromium_src/src first)
    
  • X display (headful), python3.

Reproduction

cd /poc-artifacts && ./run-poc.sh

This serves the attacker origin on http://localhost:8080 and the victim origin on http://127.0.0.1:8081, then launches Chrome twice:

  1. baseline — SK_POC_SIM_OOM unset (no fault injection): normal shadow.
  2. poisoned — SK_POC_SIM_OOM=199x101:512x512:1: after the attacker’s 199x101 sentinel raster arms the gate, the next A8 512x512 render pass (the attacker’s blur intermediate) fails exactly once; everything else runs stock.

Outputs land in this directory: {baseline,poisoned}-shadow-{first,last}.png, {baseline,poisoned}-result.json (per-frame leak hashes), {baseline,poisoned}-gpu.log (GPU-process instrumentation; distilled in gpu-log-evidence.txt).

Expected Result

  • poisoned-shadow-first.png / poisoned-shadow-last.png: the victim’s block-letter secret, served as the attacker’s blur mask (identical bytes, first to last frame).
  • poisoned-result.json: stable: true with a leak hash (619feeb7) distinct from baseline (d8714185).
  • poisoned-gpu.log: PUBLISHED lazy view ... BEFORE rendering → reusing scratch texture ... previous contents kept → simulating render-pass allocation failure ... returning null → 60× cache HIT -&gt; reusing cached mask texture, NO re-render.

Output (actual run)

[POC-BLUR] rrect-mask key 29690743: PUBLISHED lazy view into GrThreadSafeCache BEFORE rendering (dims=269x269)
[POC-SCRATCH] reusing scratch texture id=224 512x512 (previous contents kept, label was 'ResourceAllocatorRegister')
[POC-SIM-OOM] armed; A8 pass target 269x269 (stencil=0)
[POC-SIM-OOM] armed; A8 pass target 512x512 (stencil=0)
[POC-SIM-OOM] simulating render-pass allocation failure (512x512 A8, match 1) -> returning null
[POC-BLUR] rrect-mask key 29690743: cache HIT -> reusing cached mask texture, NO re-render (dims=269x269)
   ... (60 identical cache HITs; zero re-renders)

poisoned-result.json:  stable=true  samples: 619feeb7, 619feeb7, 619feeb7, 619feeb7, 619feeb7
baseline-result.json:  stable=true  samples: d8714185, d8714185, d8714185, d8714185, d8714185

What is simulated, and why it is faithful

The only simulated element is the driver allocation failure itself: the injected code makes GrGpu::getOpsRenderPass() return nullptr for one attacker-selected A8 render pass — byte-for-byte the failure mode of GrVkGpu::onGetOpsRenderPass() when GrVkFramebuffer creation fails under VRAM pressure (vk/GrVkGpu.cpp:339-356), and the sibling of the stencil-attach failure (OpsTask.cpp:578-584). All handling of that failure — RanButFailed(), the swallowing in GrDrawingManager, the cache behavior, the scratch recycling, the readback — is stock Chromium.

This environment renders through SwiftShader/llvmpipe, where “GPU memory” is host malloc: exhausting it kills the GPU process (ASAN/PartitionAlloc abort) instead of returning the graceful VK_ERROR_OUT_OF_DEVICE_MEMORY / GL_OUT_OF_MEMORY that real drivers produce under VRAM pressure, and a GPU-process crash destroys the very cache being poisoned. A real-hardware attacker inflates GPU memory from JS (WebGL textures) until a fresh driver allocation fails while pool-recycled allocations keep succeeding — a retryable, race-free window; the attacker needs it to succeed only once. On the same class of window, transient variants of this leak have shipped as CVEs (CVE-2026-11159, CVE-2026-11039: uninitialized use in Skia, cross-origin data leak via crafted HTML). Ganesh is the shipping raster backend on Windows/Linux/Android/ChromeOS (Graphite is default only on Apple).

Notes on fidelity of the rest of the chain:

  • Attacker and victim are true different web origins (localhost vs 127.0.0.1); the secret never crosses the renderer boundary — it travels exclusively through recycled GPU-process scratch memory.
  • willReadFrequently: false (an ordinary author-controlled context attribute) keeps the attacker canvas GPU-accelerated across repeated getImageData() calls; without it Chrome silently demotes the canvas to CPU raster after the second readback.
  • Fresh (uniquified) SVG data-URLs per frame keep Blink replaying the paint record instead of substituting a renderer-side rasterization, so the GPU-side cache is consulted every frame — this is what exposes the 60 consecutive poisoned cache hits.

Bandaid fix (bandaid-fix.patch)

On any flush whose render tasks did not all execute successfully, GrDrawingManager::flush now drops all GrThreadSafeCache entries that are not referenced outside the cache (dropUniqueRefs(nullptr)), unpublishing any trampoline entries whose backing render tasks failed. Verified rebuilt + re-run (bandaid-result.json, bandaid-gpu.log): the poison frame still shows the one-frame transient leak (same-flush consumer; that one-shot window is a separate, previously known issue), but the entry is dropped at the failing flush’s end, the next frame re-publishes and renders a healthy mask, and all subsequent samples return the baseline hash — the persistence primitive is eliminated.

bandaid-result.json: first=619feeb7 (transient), round0..3=d8714185 (= baseline, healthy)
bandaid-gpu.log:     PUBLISH -> injected failure -> [entry dropped] -> PUBLISH (healthy) -> 59 HITs

Files

file purpose
poc.html, victim.html, server.py, run-poc.sh the POC (attacker page, victim page, two-origin server, runner)
chromium-changes.patch env-gated instrumentation + simulated driver-OOM (inert without env vars)
bandaid-fix.patch the fix; apply on top, rebuild, re-run to verify
poisoned-shadow-{first,last}.png leaked victim secret, first & last sampled frame (identical)
baseline-shadow-{first,last}.png control: normal blurred shadow
bandaid-shadow-{first,last}.png with fix: transient first frame, healthy afterwards
{baseline,poisoned,bandaid}-result.json JS-side telemetry: per-sample leak hashes + phase log
{baseline,poisoned,bandaid}-gpu.log full GPU-process logs (instrumentation lines)
gpu-log-evidence.txt distilled log evidence, annotated

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.

View on issue tracker
Links in the report