CVE-2026-91747
Overview
Background
- Glyph atlas
- A shared GPU texture into which rasterized glyph images are packed for text rendering; entries can be evicted and overwritten when space is needed for new glyphs.
- `fAtlasGeneration`
- A per-
GlyphData/subrun token cached to detect whether the atlas has changed since the subrun’s glyphs were last placed; when it matches the atlas’ current generation, a fast path skips re-adding glyphs and reuses previously computed atlas coordinates. - Subrun split across a flush (`begin > 0`)
- When a subrun’s glyphs cannot all be processed in a single flush, later calls resume at an offset
begin, meaning glyphs in[0, begin)were handled in an earlier flush and glyphs in[begin, end]are handled now. - `fBulkUseUpdater`
- The accumulator that batches glyph use/token updates for the current pass and is reset at the start of a regeneration.
Root Cause Analysis
In both the Ganesh and Graphite text paths (src/gpu/ganesh/text/GlyphData.cpp and src/gpu/graphite/text/GlyphData.cpp), after placing glyphs into the atlas the code updated fAtlasGeneration whenever success && begin + glyphsPlacedInAtlas == glyphVector.glyphCount(), i.e. whenever processing reached the end of the glyph vector. This wrongly assumed that reaching the end implied all of the subrun’s glyphs were currently resident in the atlas with a consistent use/flush token. When begin > 0, the subrun had been split across flushes: glyphs in [0, begin) were consumed in a prior flush and are now evictable, and placing the remaining [begin, end] glyphs may have evicted/overwritten those earlier entries. Recording fAtlasGeneration in that case falsely validates the fast path, so a later reuse skips re-adding glyphs and trusts stale atlas coordinates that no longer point at live atlas data.
The fix requires begin == 0 before caching fAtlasGeneration (success && begin == 0 && glyphsPlacedInAtlas == glyphVector.glyphCount()), so the fast path is only taken when the entire subrun was placed in a single pass and genuinely shares one token, and it clarifies that fBulkUseUpdater.reset() must run so a subsequent addGlyphToBulkAndSetUseToken() re-registers first use under the new token.
begin + glyphsPlacedInAtlas == glyphCount()) as equivalent to “the whole subrun is atlas-resident under one token,” when a partial (begin > 0) update leaves earlier glyphs potentially evicted; the fix adds the begin == 0 guard so fAtlasGeneration is only cached after a complete single-pass placement.Attack Path
- Force a large text subrun
An attacker renders attacker-controlled text (e.g. via a web page, canvas, or CSS) that produces a
GlyphData/subrun with more glyphs than can be flushed at once. - Trigger a partial update
The subrun is split across flushes so that a later regeneration call runs with
begin > 0, processing only[begin, end]while[0, begin)glyphs were placed earlier and are now evictable. - Cause eviction under the buggy guard
Placing the
[begin, end]glyphs evicts/overwrites the earlier[0, begin)entries, yet the old condition still recordsfAtlasGenerationbecause processing reachedglyphCount(). - Reuse the stale fast path
On a subsequent draw the cached
fAtlasGenerationmatches, so the code skips re-adding glyphs and uses atlas coordinates that now reference freed/overwritten atlas texture memory, yielding a use-after-free. - Escalate from the UAF
Reading through the stale atlas locator gives an out-of-bounds/freed-memory access that could be shaped toward information disclosure or memory corruption in the GPU/render context, and any further primitive or code execution beyond this is
(inferred).
Impact Assessment
begin > 0. Escalation to a read/write primitive, sandbox escape, or code execution is (inferred) and not demonstrated by the diff. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/ganesh/text/GlyphData.cpp |
modified | |
ifsrc/gpu/graphite/text/GlyphData.cpp |
modified |
Files Changed
src/gpu/ganesh/text/GlyphData.cppsrc/gpu/graphite/text/GlyphData.cpp
Audit Directions
- Completion implies validityLook for atlas/cache-generation or “still resident” fast paths that conclude from
offset + count == total(reaching the end) that all elements are current, ignoring items processed in earlier partial passes. - Split-across-flush stateAudit code paths keyed on a
begin/offset that resumes work across flushes, checking whether per-element tokens or generation counters are cached as if the whole range shared one flush token. - Eviction after use-token cachingReview any place that records a generation/version after operations (like atlas insertion) that can evict prior entries, ensuring the cached token is only set when no earlier still-referenced entry could have been evicted.
Patch
From 9b1e5fd08d2cbc9c8933c3538716905c4cf19c5a Mon Sep 17 00:00:00 2001
From: Michael Ludwig <michaelludwig@google.com>
Date: Fri, 04 Sep 2026 09:38:19 -0400
Subject: [PATCH] [gpu] Don't mark GlyphData's atlas generation on a partial update
If a GlyphData/subrun is split across a flush (i.e. begin > 0 for a
second call to the same GlyphData), we don't want to mark its atlas
generation as if it had successfully pushed all of its glyphs into the
atlas.
The previous assumption was that if we reached the end, then all the
glyphs were still in the atlas. However, if begin > 0, then the [0,
begin) glyphs were already used and consumed from a previous flush and
then are seen as evictable. It is plausible then that the remaining
[begin, end] glyphs had to overwrite earlier ones in order to fit.
This guards the atlas generation fast path for the scenario when all [0,
glyphCount] glyphs were added in one go, so they really are guaranteed
to have the same use/flush token.
Bug: 540016074
Change-Id: Ibefee9d541cf149c2ce5640ea304aa801c590cab
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1350696
Reviewed-by: Thomas Smith <thomsmit@google.com>
Commit-Queue: Michael Ludwig <michaelludwig@google.com>
---
diff --git a/src/gpu/ganesh/text/GlyphData.cpp b/src/gpu/ganesh/text/GlyphData.cpp
index f4a2e8a..d5b16d9 100644
--- a/src/gpu/ganesh/text/GlyphData.cpp
+++ b/src/gpu/ganesh/text/GlyphData.cpp
@@ -198,7 +198,14 @@
if (fAtlasGeneration != currentAtlasGen) {
SkSpan<const Glyph> glyphSpan = glyphVector.accessBackendGlyphs<Glyph>();
// Calculate the texture coordinates for the vertexes during first use (fAtlasGeneration
- // is set to kInvalidAtlasGeneration) or the atlas has changed in subsequent calls.
+ // is set to kInvalidAtlasGeneration) or the atlas has changed in subsequent calls. Always
+ // reset the update, even when begin > 0. When begin > 0, this subrun was split across
+ // flushes and previously used glyphs in [0, begin) have an older token. If those glyphs are
+ // not used in [begin, end], they shouldn't be part of the next bulk update (and because
+ // we won't have updated the whole subrun in one go, we won't set `fAtlasGeneration` to take
+ // the fast path on reuse). If we do reuse the glyphs, we need the bulk update to have been
+ // reset so that the call to addGlyphToBulkAndSetUseToken() sees the first use with the new
+ // token and updates the atlas locator.
fBulkUseUpdater.reset();
SkBulkGlyphMetricsAndImages metricsAndImages{fTextStrike->strikeSpec()};
@@ -228,8 +235,11 @@
glyphsPlacedInAtlas++;
}
- // Update atlas generation if there are no more glyphs to put in the atlas.
- if (success && begin + glyphsPlacedInAtlas == glyphVector.glyphCount()) {
+ // Update atlas generation if there are no more glyphs to put in the atlas. We can only do
+ // this if we successfully checked/added the entire glyph vector in one pass. Otherwise, on
+ // a partial update, some of the previous glyphs of the subrun that were already drawn could
+ // have been evicted to make room for these remaining glyphs.
+ if (success && begin == 0 && glyphsPlacedInAtlas == glyphVector.glyphCount()) {
// Need to get the freshest value of the atlas' generation because
// updateTextureCoordinates may have changed it.
fAtlasGeneration = atlasManager->atlasGeneration(fResolvedMaskFormat);
diff --git a/src/gpu/graphite/text/GlyphData.cpp b/src/gpu/graphite/text/GlyphData.cpp
index bbbf808..10be6dc 100644
--- a/src/gpu/graphite/text/GlyphData.cpp
+++ b/src/gpu/graphite/text/GlyphData.cpp
@@ -65,7 +65,14 @@
if (fAtlasGeneration != currentAtlasGen) {
// Calculate the texture coordinates for the vertexes during first use (fAtlasGeneration
- // is set to kInvalidAtlasGeneration) or the atlas has changed in subsequent calls.
+ // is set to kInvalidAtlasGeneration) or the atlas has changed in subsequent calls. Always
+ // reset the update, even when begin > 0. When begin > 0, this subrun was split across
+ // flushes and previously used glyphs in [0, begin) have an older token. If those glyphs are
+ // not used in [begin, end], they shouldn't be part of the next bulk update (and because
+ // we won't have updated the whole subrun in one go, we won't set `fAtlasGeneration` to take
+ // the fast path on reuse). If we do reuse the glyphs, we need the bulk update to have been
+ // reset so that the call to addGlyphToBulkAndSetUseToken() sees the first use with the new
+ // token and updates the atlas locator.
fBulkUseUpdater.reset();
SkBulkGlyphMetricsAndImages metricsAndImages{fTextStrike->strikeSpec()};
@@ -93,8 +100,11 @@
glyphsPlacedInAtlas++;
}
- // Update atlas generation if there are no more glyphs to put in the atlas.
- if (success && begin + glyphsPlacedInAtlas == glyphVector.glyphCount()) {
+ // Update atlas generation if there are no more glyphs to put in the atlas. We can only do
+ // this if we successfully checked/added the entire glyph vector in one pass. Otherwise, on
+ // a partial update, some of the previous glyphs of the subrun that were already drawn could
+ // have been evicted to make room for these remaining glyphs.
+ if (success && begin == 0 && glyphsPlacedInAtlas == glyphVector.glyphCount()) {
// Need to get the freshest value of the atlas' generation because
// updateTextureCoordinates may have changed it.
fAtlasGeneration = atlasManager->atlasGeneration(fRenderData.maskFormat);