Medium watch CVSS 4.3 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Skia
DescriptionInformation leak in Skia
ComponentSkia
Sub-componentSecurity
Bug ClassInfo Disclosure
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.
Tracker501759192
Fix commit013ad6e70972 (skia) +32/-5
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-11 (per vendor advisory)
Fix landed2026-07-17 (public git)
Disclosed2026-08-25
Patch gap39 days — fix public in source before the advisory

Background

`SmallPathAtlasMgr`
Ganesh GPU backend manager that caches rendered small-path masks as SmallPathShapeData entries keyed by SmallPathShapeDataKey in fShapeCache.
`SmallPathShapeDataKey`
the cache key for a path entry, whose Hash (via SkChecksum::Hash32) for small, complex geometry effectively depends only on the path’s generation ID, so distinct paths sharing a reused gen ID collide.
`SkIDChangeListener`
a Skia callback interface whose changed() fires when the associated path’s contents or generation ID are modified or discarded, letting caches invalidate stale entries.
`GrStyledShape`
the styled-path object passed into findOrCreate; via addGenIDChangeListener it can register a listener that is notified when the underlying path changes.

Root Cause Analysis

In the vulnerable code, SmallPathAtlasMgr::findOrCreate(const SmallPathShapeDataKey&) looked up fShapeCache purely by key and returned any matching SmallPathShapeData without validating that the cached entry still corresponded to the caller’s live path. Because the SmallPathShapeDataKey::Hash of small, complex paths relies essentially on the path’s generation ID, an attacker who created and discarded many such paths could get a gen ID recycled onto a new path, producing a key that collides with a stale entry left in the cache. The invariant that “a cache hit describes the requested path” was therefore violated: a lookup could return a previously cached victim path’s rendered data instead of the attacker’s, i.e. duplicate/aliased entries.

The fix attaches a SmallPathIDChangeListener to every SmallPathShapeData and registers it on the shape via shape.addGenIDChangeListener, so when the original path is modified or discarded the listener’s changed() sets fHasChanged. findOrCreate now checks shapeData->fIDChangeListener->hasChanged() on a hit and calls deleteCacheEntry to purge the stale entry before recreating it, guaranteeing a returned entry belongs to the current path and eliminating the aliasing.

Key insight
The single core mistake was trusting a key-only cache lookup whose hash collapses to a reusable generation ID, allowing a stale entry to be served for a different path; the fix subscribes each entry to path-change notifications and evicts it the moment its source path changes, so a gen-ID collision can no longer surface a victim’s data.

Attack Path

  1. Force gen-ID-only keys Create small paths with complex geometry so that SmallPathShapeDataKey::Hash depends only on the generation ID, making key collisions cheap to engineer.
  2. Churn the cache Repeatedly create and discard such paths to overwhelm SmallPathAtlasMgr and cause a discarded path’s generation ID to be recycled onto a new path.
  3. Trigger the aliased hit Submit a render request whose recycled gen ID produces a SmallPathShapeDataKey matching a stale SmallPathShapeData in fShapeCache, so findOrCreate returns the previous (victim) entry instead of building a fresh one.
  4. Observe leaked path data Receive the victim’s cached path/mask rather than the requested one, disclosing information belonging to another path/context (inferred) as to the exact rendered bytes exposed, since the diff shows the aliasing but not a concrete read sink.

Impact Assessment

An attacker can cause SmallPathAtlasMgr to return a different (victim) path’s cached rendering, yielding an information leak in Skia’s Ganesh GPU rendering path as described by the advisory. Preconditions are the ability to create and discard many small, complex paths to recycle a generation ID and force a colliding SmallPathShapeDataKey. The advisory classifies this as a LogicError information leak and states no exploited-in-the-wild status; any escalation beyond disclosure of aliased cached path data is (inferred).

Changed Functions

FunctionChangeNotes
if
src/gpu/ganesh/ops/SmallPathAtlasMgr.cpp
modified
SmallPathIDChangeListener
src/gpu/ganesh/ops/SmallPathShapeData.h
modified
SmallPathShapeDataKey
src/gpu/ganesh/ops/SmallPathShapeData.h
modified
SmallPathShapeData
src/gpu/ganesh/ops/SmallPathShapeData.h
modified

Files Changed

  • src/gpu/ganesh/ops/SmallPathAtlasMgr.h
  • src/gpu/ganesh/ops/SmallPathShapeData.h
  • src/gpu/ganesh/ops/SmallPathAtlasMgr.cpp

Audit Directions

  • Key-only cache lookups over recyclable IDs
    Audit other Ganesh/Skia caches keyed by hashes that collapse to a generation ID (as SmallPathShapeDataKey::Hash does) and confirm each cache hit is validated against the live object, not just matched by key.
  • Missing change-listener registration
    Look for caches storing rendered path/shape data without an SkIDChangeListener, mirroring the pattern already used in SoftwarePathRenderer.cpp, since absence of invalidation permits stale-entry aliasing.
  • Listener lifetime and deregistration
    Review entries that own an SkIDChangeListener to ensure they call markShouldDeregister on destruction and correctly reset state, so listeners are neither leaked nor left signaling freed cache entries.
From 013ad6e7097238654847bfa5a5fb2c6c3823ee41 Mon Sep 17 00:00:00 2001
From: Alexis Cruz-Ayala <alexisdavidc@google.com>
Date: Fri, 17 Jul 2026 12:51:34 -0400
Subject: [PATCH] [Security] Add a SmallPathIDChangeListener to SmallPathAtlasMgr

There was a vulnerability where SkPathID could have duplicate entries if an attacker created and discarded enough Small, Complex Paths to overwhelm the SmallPathAtlasMgr cache. This could lead to the attacker submitting a request to the cache for a path, and receiving a victim's path instead.

The solution involved mirroring what is done in the SoftwarePathRenderer.cpp and using an SkIDChangeListener to listen for when a path is modified or discarded and delete the entry in the cache. This way, duplicate entries are avoided.

A test was added that shows that an ID collision can be forced amongst SmallPaths with complex geometry; any hash of two small, complex paths would rely only on the genID, making it easier to hit a duplicate entry.

Bug: b/501759192
Change-Id: Id80a0167370dfca1b7dac511f1ddcfda16a72fc1
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1294996
Reviewed-by: Florin Malita <fmalita@google.com>
Commit-Queue: Alexis Cruz-Ayala <alexisdavidc@google.com>
---
diff --git a/src/gpu/ganesh/ops/SmallPathAtlasMgr.cpp b/src/gpu/ganesh/ops/SmallPathAtlasMgr.cpp
index 6fa8667..81ca65f 100644
--- a/src/gpu/ganesh/ops/SmallPathAtlasMgr.cpp
+++ b/src/gpu/ganesh/ops/SmallPathAtlasMgr.cpp
@@ -11,10 +11,12 @@
 #include "include/gpu/GpuTypes.h"
 #include "include/gpu/ganesh/GrBackendSurface.h"
 #include "include/gpu/ganesh/GrTypes.h"
+#include "include/private/SkIDChangeListener.h"
 #include "include/private/SkTo.h"
 #include "include/private/gpu/ganesh/GrTypesPriv.h"
 #include "src/gpu/MaskFormat.h"
 #include "src/gpu/ganesh/GrCaps.h"
+#include "src/gpu/ganesh/geometry/GrStyledShape.h"
 #include "src/gpu/ganesh/ops/SmallPathShapeData.h"
 
 #include <cstddef>
@@ -86,11 +88,19 @@
     delete shapeData;
 }
 
-SmallPathShapeData* SmallPathAtlasMgr::findOrCreate(const SmallPathShapeDataKey& key) {
+SmallPathShapeData* SmallPathAtlasMgr::findOrCreate(const SmallPathShapeDataKey& key,
+                                                    const GrStyledShape& shape) {
     auto shapeData = fShapeCache.find(key);
+
+    if (shapeData && shapeData->fIDChangeListener->hasChanged()) {
+        this->deleteCacheEntry(shapeData);
+        shapeData = nullptr;
+    }
+
     if (!shapeData) {
         // TODO: move the key into the ctor
         shapeData = new SmallPathShapeData(key);
+        shape.addGenIDChangeListener(shapeData->fIDChangeListener);
         fShapeCache.add(shapeData);
         fShapeList.addToTail(shapeData);
 #ifdef DF_PATH_TRACKING
@@ -108,7 +118,7 @@
     SmallPathShapeDataKey key(shape, desiredDimension);
 
     // TODO: move the key into 'findOrCreate'
-    return this->findOrCreate(key);
+    return this->findOrCreate(key, shape);
 }
 
 SmallPathShapeData* SmallPathAtlasMgr::findOrCreate(const GrStyledShape& shape,
@@ -116,7 +126,7 @@
     SmallPathShapeDataKey key(shape, ctm);
 
     // TODO: move the key into 'findOrCreate'
-    return this->findOrCreate(key);
+    return this->findOrCreate(key, shape);
 }
 
 GrDrawOpAtlas::ErrorCode SmallPathAtlasMgr::addToAtlas(GrResourceProvider* resourceProvider,
diff --git a/src/gpu/ganesh/ops/SmallPathAtlasMgr.h b/src/gpu/ganesh/ops/SmallPathAtlasMgr.h
index c72fd42..9e17694 100644
--- a/src/gpu/ganesh/ops/SmallPathAtlasMgr.h
+++ b/src/gpu/ganesh/ops/SmallPathAtlasMgr.h
@@ -93,7 +93,7 @@
     void deleteCacheEntry(SmallPathShapeData*);
 
 private:
-    SmallPathShapeData* findOrCreate(const SmallPathShapeDataKey&);
+    SmallPathShapeData* findOrCreate(const SmallPathShapeDataKey&, const GrStyledShape&);
 
     void evict(GrPlotLocator) override;
 
diff --git a/src/gpu/ganesh/ops/SmallPathShapeData.h b/src/gpu/ganesh/ops/SmallPathShapeData.h
index 2828b0d..908c5ad 100644
--- a/src/gpu/ganesh/ops/SmallPathShapeData.h
+++ b/src/gpu/ganesh/ops/SmallPathShapeData.h
@@ -12,6 +12,7 @@
 #if !defined(SK_ENABLE_OPTIMIZE_SIZE)
 
 #include "include/core/SkRect.h"
+#include "include/private/SkIDChangeListener.h"
 #include "include/private/SkTemplates.h"
 #include "src/core/SkChecksum.h"
 #include "src/core/SkTInternalLList.h"
@@ -25,6 +26,18 @@
 
 namespace skgpu::ganesh {
 
+class SmallPathIDChangeListener : public SkIDChangeListener {
+public:
+    SmallPathIDChangeListener() : fHasChanged(false) {}
+
+    bool hasChanged() const { return fHasChanged.load(std::memory_order_relaxed); }
+
+    void changed() override { fHasChanged.store(true, std::memory_order_relaxed); }
+
+private:
+    std::atomic<bool> fHasChanged;
+};
+
 class SmallPathShapeDataKey {
 public:
     // TODO: add a move variant
@@ -58,7 +71,9 @@
 
 class SmallPathShapeData {
 public:
-    SmallPathShapeData(const SmallPathShapeDataKey& key) : fKey(key) {}
+    SmallPathShapeData(const SmallPathShapeDataKey& key)
+            : fKey(key), fIDChangeListener(sk_make_sp<SmallPathIDChangeListener>()) {}
+    ~SmallPathShapeData() { fIDChangeListener->markShouldDeregister(); }
 
     const SmallPathShapeDataKey fKey;
     SkRect                      fBounds;
@@ -73,6 +88,8 @@
     static inline uint32_t Hash(const SmallPathShapeDataKey& key) {
         return SkChecksum::Hash32(key.data(), sizeof(uint32_t) * key.count32());
     }
+
+    sk_sp<SmallPathIDChangeListener> fIDChangeListener;
 };
 
 }  // namespace skgpu::ganesh
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential cross-origin info leak via 30-bit SkPathData ID wrap in Ganesh

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.

Overview: A potential cross-origin information leak exists in the GPU process when using the Ganesh rendering backend. An attacker could potentially wrap the 30-bit SkPathData generation ID counter to collide with a victim’s cached path in the shared SmallPathAtlasMgr. This collision could allow the attacker to read the alpha mask of the victim’s cross-origin paths.

Affected files:

  • third_party/skia/src/gpu/ganesh/ops/SmallPathAtlasMgr.cpp
  • third_party/skia/src/core/SkPathData.cpp
  • third_party/skia/src/core/SkPath.cpp
  • third_party/skia/src/gpu/ganesh/geometry/GrStyledShape.cpp
  • third_party/skia/src/gpu/ganesh/ops/SmallPathShapeData.cpp
  • third_party/skia/src/gpu/ganesh/ops/SmallPathRenderer.cpp

Estimated timestamp from git blame: 2025-11-03

A potential cross-origin information leak exists in the Ganesh backend’s SmallPathAtlasMgr. Due to a 30-bit wraparound in path generation IDs combined with cache sharing across origins in the GPU process, an attacker could potentially read the alpha masks (silhouettes) of small, complex paths rendered by other origins.

Technical Details

  1. 30-bit Generation ID Wraparound: In third_party/skia/src/core/SkPathData.cpp, the next_pathdata_unique_id() function generates unique IDs for SkPathData objects. It masks the ID to 30 bits (id <<= 2; id >>= 2;) to reserve space for fill type information. This causes the global genID counter to wrap around after approximately 1.07 billion allocations.
  2. Cache Key Truncation: When rendering small paths, SmallPathRenderer generates a cache key via GrStyledShape::writeUnstyledKey. If a path is complex (has more than 10 verbs, per kMaxKeyFromDataVerbCnt), the function drops the raw geometry (points and verbs) from the key. The key relies entirely on the path’s 30-bit genID and state (fill type, winding).
  3. Shared GPU Cache without Eviction: The SmallPathAtlasMgr caches these rendered paths into a GrDrawOpAtlas. Because it is owned by a GrDirectContext shared via SharedContextState in the GPU process, this cache is globally accessible across all renderer processes. Furthermore, unlike other renderers (e.g., SoftwarePathRenderer), SmallPathAtlasMgr does not register an SkIDChangeListener. The cache entry is not evicted when the original path is destroyed, keeping it alive until the atlas plot is randomly recycled.

Suggested Attacker Steps

Note: These are potential steps based on code analysis. Our tooling agent does not execute code to verify a proof-of-concept.

  1. A victim origin renders a small, complex path (e.g., >10 verbs, bounds <73px), such as an SVG icon or custom font glyph. This caches the path’s alpha mask in the GPU process’s shared SmallPathAtlasMgr.
  2. An attacker origin executes a JavaScript loop to repeatedly draw and discard paths to a <canvas> context. By relying on Blink’s ClientPaintCache budget limits and purges, or using volatile paths, the attacker can deserialize millions of paths in the GPU process without causing an Out-Of-Memory (OOM) crash.
  3. After approximately 1.07 billion path creations, the GPU process’s atomic 30-bit genID counter wraps around.
  4. The attacker times or brute-forces a draw call of an exploratory complex path (>10 verbs) using matrix parameters matching the victim’s, aligning its new genID with the victim’s cached genID.
  5. The cache key collides in SmallPathAtlasMgr::findOrCreate. Instead of rasterizing the attacker’s path, SmallPathOp::onPrepareDraws binds the victim’s UV coordinates from the shared atlas.
  6. The victim’s alpha mask is drawn onto the attacker’s canvas. The attacker extracts this cross-origin pixel data using CanvasRenderingContext2D.getImageData().

Suggested Fix

  1. Register an SkIDChangeListener in SmallPathAtlasMgr or SmallPathShapeData (similar to the pattern used in SoftwarePathRenderer and TriangulatingPathRenderer) so that cached atlas entries are explicitly invalidated when the underlying SkPath is modified or destroyed.
  2. Alternatively, prevent cross-origin cache collisions in the GPU process entirely by scoping the cache keys to the renderer process or using a 64-bit generation ID.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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