[5] Complex text path did not retain the system fallbacks it used
The complex text path never learned the rule the simple path follows.
Medium. Web-reachable and attacker-schedulable, but WTF weak pointers zero on destruction, so the confirmed effect is a null-base fault during painting rather than a stale-pointer read. Upgrading past DoS needs a consumer that hoists a raw Font alias out of the buffer before the purge.
Text rendering caches shaped runs so that repeated draws of the same string skip re-shaping, and those cached results reference the fonts they used only weakly to avoid reference cycles. WebKit shapes text through either a fast "simple" path or, for complex scripts and heavy kerning, ComplexTextController, which on Apple platforms hands the job to Core Text. FontCascadeFonts — the object behind a FontCascade — holds both the shaped-text cache and a HashSet<Ref<Font>> whose entire purpose is to keep alive the system fallback faces those cached runs reference. The contract that makes weak font references safe is that whoever writes a Font into a cached glyph buffer must also register a strong owner for it.
The angle: a page rendering complex-script text into a cached run can drive the font cache to reclaim a fallback face that a live cached run still points at, then repaint and fault the renderer at a moment of its choosing.
The commit message walks the asymmetry directly:
A
GlyphBuffercached in aFontCascadeFontsshaped text cache references itsFontsthrough weak pointers (308842@main). The simple text path keeps the system fallbacks it uses alive inFontCascadeFonts::glyphDataForSystemFallback, but the complex text path did not, so a Core Text fallback used only there had a single reference andFontCache::purgeInactiveFontDatacould destroy it while a cached shaped run still referenced it. Painting that run then dereferenced an expired weak pointer inFontCascade::drawGlyphBuffer.
Source/WebCore/platform/graphics/coretext/ComplexTextControllerCoreText.mm
Source/WebCore/platform/graphics/FontCascadeFonts.cpp
Tools/TestWebKitAPI/Tests/WebCore/FontCascade.cpp
Patch Details
FontCascadeFonts.h/.cpp gain a public void FontCascadeFonts::addSystemFallbackFont(Ref<Font>&&) that inserts into the existing HashSet<Ref<Font>> m_systemFallbackFontSet; the pre-existing simple-path call site in FontCascadeFonts::glyphDataForSystemFallback is rewritten from m_systemFallbackFontSet.add(systemFallbackFont.releaseNonNull()) to addSystemFallbackFont(systemFallbackFont.releaseNonNull()) — a pure refactor with the same effect.
The real fix is in ComplexTextController::collectComplexTextRunsForCharacters: where the Core Text run font was previously materialised as a bare pointer off a temporary — runFont = protect(FontCache::forCurrentThread())->fontForPlatformData(runFontPlatformData).ptr() — the patch now binds Ref systemFallbackFont = ...fontForPlatformData(runFontPlatformData), obtains RefPtr fonts = m_fontCascade->fonts(), calls fonts->addSystemFallbackFont(systemFallbackFont.copyRef()) so the FontCascadeFonts object holds a strong reference, and only then sets runFont = systemFallbackFont.ptr(). An API test shapes a run of complex-script code points under a "Times" cascade, collects the fallbacks into a SingleThreadWeakHashSet<const Font>, and asserts EXPECT_FALSE(font.hasOneRef()) for each — i.e. that some owner beyond FontCache's own entry retains each fallback.
Asymmetric lifetime handling between two implementations of one operation — one path retains a resource it publishes into a weak cache, the parallel path does not.
Background
Simple vs. complex text path.
WebKit shapes text through a fast "simple" path (WidthIterator, per-character glyph lookup) or, when the run needs full shaping, through ComplexTextController, which on Apple platforms delegates to Core Text typesetting.
System fallback font.
When the specified font families cannot render a code point, the platform picks a substitute face; on the complex path Core Text performs this cascade itself and hands back a CTFont per run, which WebKit wraps via FontCache::fontForPlatformData(FontPlatformData).
Reference types.
Ref<T>/RefPtr<T> are WebKit's strong reference-counting smart pointers; .ptr() yields the raw pointer without transferring ownership. SingleThreadWeakPtr<T> is a zeroing weak pointer — it does not keep the pointee alive, and reads through it return null once the pointee is destroyed.
GlyphBuffer.
The shaped output structure holding parallel arrays of glyph IDs, advances, origins, string offsets, and per-glyph font references declared as Vector<SingleThreadWeakPtr<const Font>> m_fonts (see GlyphBuffer.h); fontAt(index) returns *m_fonts[index].
FontCascadeFonts and the shaped text cache.
FontCascadeFonts is the RefCounted object behind a FontCascade, holding realized fallback ranges, a glyph-page cache, HashSet<Ref<Font>> m_systemFallbackFontSet ("keep the system fallback fonts we use alive"), a GlyphGeometryCache, and a ShapedTextCache of TextShapingResultAndDisplayList entries. FontCascadeFonts::getOrCreateCachedShapedText() memoizes shaping results keyed on the text run; its tuning constants in ShapedTextCacheDefaults are documented as targeting Canvas fillText/strokeText, with maxTextLength = 128 and maxSize = 3000.
FontCache and purging.
FontCache::forCurrentThread() owns FontDataCache (HashMap<FontPlatformData, Ref<Font>>) plus a m_purgeTimer driving purgeInactiveFontDataIfNeeded; fonts held only by the cache itself are the intended reclamation targets under memory pressure.
FontCascadeCache.
Keyed by font description + selector, it maps to FontCascadeFonts entries and offers clearShapedTextCaches(), pruneUnreferencedEntries() (drops entries where fonts.get().hasOneRef()), and pruneSystemFallbackFonts() (which calls FontCascadeFonts::pruneSystemFallbacks()).
Analysis
The bug is an object-lifetime violation: a cached structure retains a resource by weak reference with no matching strong owner.
Simple path Complex path (pre-fix)
─────────── ──────────────────────
glyphDataForSystemFallback collectComplexTextRunsForCharacters
font = fontForPlatformData(..) fontForPlatformData(..).ptr()
m_systemFallbackFontSet.add ─┐ └─► Ref dropped immediately
│
strong owner ┘ only owner: FontDataCache entry
│
GlyphBuffer.m_fonts[i] (weak) ──────┘
│
purgeInactiveFontData: hasOneRef ► destroy
│
repaint ► fontAt(i) ► *m_fonts[i] ← cleared weak
GlyphBuffer.h declares its font array as Vector<SingleThreadWeakPtr<const Font>> m_fonts — the buffer references its Fonts only weakly (the commit message attributes this design to a prior revision, 308842@main). The invariant this creates is that whoever puts a Font into a cached GlyphBuffer must ensure some strong owner keeps it alive as long as the cache entry survives. The simple text path upholds it: glyphDataForSystemFallback adds every system fallback it resolves into m_systemFallbackFontSet. The complex path did not: it called FontCache::fontForPlatformData(...).ptr(), dropping the returned Ref immediately, so the only strong reference to a Core-Text-selected fallback was FontCache's own FontDataCache entry.
FontCache::purgeInactiveFontData reclaims Fonts whose only remaining reference is the cache's own — the added test encodes exactly that criterion via hasOneRef(), and the new test comment states the failure directly ("a cached shaped run's weak Font reference dangles once FontCache::purgeInactiveFontData reclaims the font"). A fallback used only by the complex path satisfied that condition even while a live FontCascadeFonts still held a shaped run whose GlyphBuffer pointed at it. Once the Font is destroyed, the zeroing weak slots clear, and a later paint reaches GlyphBuffer::fontAt() — ASSERT(m_fonts[index]); return *m_fonts[index];, where the assertion is compiled out in release — producing a dereference of a cleared weak pointer during painting.
One detail the supplied context does not settle: FontCascadeFonts::pruneSystemFallbacks() ends with m_shapedTextCache.clear() and, by its name and the retention comment it pairs with, likely also clears m_systemFallbackFontSet (its full body is beyond the supplied truncation); FontCascadeCache::pruneSystemFallbackFonts() walks m_entries calling it, so the usual purge path also invalidates shaped caches. The window therefore likely requires a FontCascadeFonts that a purge does not reach through m_entries — one still referenced by a live FontCascade after pruneUnreferencedEntries() or the maximumEntries random eviction removed its map entry, or a createForPlatformFont instance never in the map at all — or an ordering inside purgeInactiveFontData where the font is released before the cascade caches are cleared.
A plausible trigger sequence, anchored on the code in the diff:
- Style an element or a canvas context with a font family that cannot cover the target script (the test uses "Times" with U+06D8, U+092D, U+0B40, U+0F96, U+0DBD, U+0EAF, U+A86C, U+0ACF), forcing
collectComplexTextRunsForCharactersdown theeffectiveFont->isSystemFontFallbackPlaceholder()branch where Core Text selects the run font. - Repeat the same short run so
getOrCreateCachedShapedTextmemoizes it — theShapedTextCacheDefaultscomments name CanvasfillText/strokeTextas the tuned workload, andinitialInterval = -3means the entry is cached on first sight; the resultingGlyphBufferrecords the fallback face only weakly. - Drive
FontCache::purgeInactiveFontData— via memory pressure, or by churning many distinct font descriptions to age out the cache — at a moment when the owningFontCascadeFontsis not reached bypruneSystemFallbackFonts()(for example after itsm_entriesslot was dropped bypruneUnreferencedEntries()or themaximumEntriesrandom eviction while a liveFontCascadestill holds theRef); the fallback, whose only strong reference before the fix wasFontDataCache's own, is destroyed. - Repaint the cached run, so the glyph-drawing path reads a
GlyphBufferslot whose weak pointer has been cleared.
Step 3 is the load-bearing and least-verified step — the supplied FontCache.cpp excerpt is truncated before purgeInactiveFontData, so the precise purge/prune ordering that leaves a shaped cache entry alive is inferred rather than read.
On exploitability: this is reachable from ordinary web content with no privileged API involved. The immediate observable effect is a dereference of a cleared SingleThreadWeakPtr<const Font> during painting — fontAt() returns *m_fonts[index] with its assertion compiled out in release, so the subsequent member access faults on a null base, an attacker-schedulable renderer crash rather than a stale-pointer read. Because WTF weak pointers zero on destruction, the classic reclaim-the-freed-slot path does not follow directly from the cached GlyphBuffer itself. A stronger primitive would require a consumer that hoists the const Font& out of the buffer into a raw alias (or a CTFontRef/FontPlatformData copy) before the purge and uses it afterwards; if such an alias exists in the glyph-drawing path or its callees, that path could become a genuine use-after-free over a Font whose platform data feeds Core Graphics glyph drawing, and heap grooming might then matter. The supplied context does not include the body of FontCascade::drawGlyphBuffer, so that escalation remains a projection.
This vulnerability weakens memory safety inside the WebContent process. The security-model assumption at stake is the ownership contract that comes with GlyphBuffer holding weak Font references: every Font reachable from a cached shaped run must have a strong owner outliving that cache entry. Before the fix, a Core-Text-selected system fallback used only by the complex text path had no such owner, so ordinary page content combined with font-cache purging could drive painting into a dereference of a cleared weak pointer — a renderer-side crash that attacker-authored content could steer at will (reliable denial of service, and a useful crash-oracle/heap-state signal).
Insight
The interesting shape here is a mitigation that created an ownership obligation. Making GlyphBuffer::m_fonts weak removed reference cycles and made buffers cheap to cache, but it silently converted "anyone who writes a Font into a GlyphBuffer" into "anyone who writes a Font into a GlyphBuffer must also register a strong owner." Only one of the two shaping paths was taught that rule, and the comment the patch adds ("mirrors glyphDataForSystemFallback()") is an admission that the contract lives in prose rather than in the type system. The structural fix would be for the sink — GlyphBuffer::add or the shaped-cache insertion — to take the retention responsibility, instead of trusting every producer to remember. There is also a layered-defense signal worth reading: the pre-existing test PurgeInactiveFontDataClearsShapedTextCache shows the team already believed purge-clears-cache was sufficient protection; this bug is what happens when a second, weaker guarantee is the one that actually has to hold in the paths the first does not reach.
Audit directions
-
Weak-reference cache with externalized retention duty. A cache stores non-owning references to shared objects, delegating the retention duty to each producer by convention rather than enforcing it at the insertion point. The invariant is if a container holds weak references, the container's insert API — not its callers — must own the strong reference. Narrow: grep
WebCore/platform/graphicsfor every call site that reachesGlyphBuffer::add/makeHoleor populates aTextShapingResult, and check each for a matchingaddSystemFallbackFont/m_systemFallbackFontSetinsertion — start with the non-Core-Text shaping backends (ComplexTextControllerHarfBuzz/Skia and Windows/DirectWrite equivalents), which have the samefontForPlatformData(...).ptr()shape as the fixed line. Wider: audit other WebCore caches whose value types embedWeakPtr/SingleThreadWeakPtr/WeakHashSetmembers —MixedFontGlyphPage::m_fonts,FontCascadeFonts::m_cachedPrimaryFont, display-list recorders holding platform resources — and ask for each whether the cache's lifetime can exceed the weakly-held object's. Widest: this is the general "weak-reference cache with externalized retention duty" class, present in any system with an intern/resource cache plus weak back-references — Chromium'sbase::WeakPtrin Skia typeface caches, Rust'sWeak<T>in memoization tables, Java'sWeakHashMapvalue graphs. Match tell (narrow): a call of the formcache->getOrCreate(...).ptr()or.get()on a temporaryRef/RefPtrwhose result is then stored anywhere that outlives the statement. Match tell (wider): any struct with both a weak member and aclear()/prune method, where some code path can reach the struct without going through the prune entry point. Match tell (widest): if the codebase has an eviction predicate that reads "refcount == 1", ask which containers referencing that object deliberately do not contribute to the count. -
Divergent duplicate implementations of one logical operation. Fast path and slow path, platform A and platform B, where a lifetime or security obligation is implemented in one branch only. The invariant is any obligation attached to an operation must be discharged in every implementation of that operation, ideally at a shared choke point. Narrow: diff
FontCascadeFonts::glyphDataForSystemFallbackagainstComplexTextController::collectComplexTextRunsForCharactersfor remaining behavioural divergence beyond retention — small-caps handling is already flagged with a FIXME in the complex path, and synthetic-oblique/vertical-orientation handling is worth the same comparison. Wider: apply the same paired reading to other simple/complex splits in WebCore text —FontCascade::layoutSimpleTextvslayoutComplexText,widthForSimpleTextvs the complex width path, and the emphasis-mark and text-decoration variants — asking of each obligation found in one "does the twin do this too?". Widest: this is the "parallel implementation drift" class that applies wherever an optimization introduces a second code path over the same data — JIT fast paths vs. interpreter slow paths, SIMD vs. scalar kernels, cached vs. uncached branches in any renderer. Match tell: any comment of the form "mirrors X()" or "like the simple path" — it marks a contract enforced by human memory, and each one is a candidate site for the next drift. -
Two-phase invalidation where the sweep enumerates objects through a registry the objects can outlive. Investigate the ordering and reachability contract between
FontCache::purgeInactiveFontDataand the cascade-level caches it is supposed to invalidate. Narrow: trace whether every liveFontCascadeFontsis reachable fromFontCascadeCache::m_entriesat purge time —pruneUnreferencedEntries()removes entries byhasOneRef(), themaximumEntriesguard callsm_entries.remove(m_entries.random()), andFontCascadeFonts::createForPlatformFontproduces instances that may never be registered; each of these is a way a cascade with a populatedm_shapedTextCachecould misspruneSystemFallbackFonts(). Wider: audit other WebKit sweeps that iterate a registry to invalidate dependents — glyph-page invalidation on font-cache generation bumps,FontSelectorversion invalidation, and display-list resource caches — for the same "registry is not the ownership graph" gap. Widest: the reusable invariant is an invalidation sweep is only sound if its enumeration set is a superset of the set of objects holding the invalidated references; it applies to any GC-adjacent or epoch-based reclamation scheme, including refcount-plus-registry designs in Chromium's resource caches and epoch reclamation in lock-free containers. Match tell: any cleanup loop over a container that also has an eviction policy — if entries can leave the container while the objects they described stay alive, the sweep has a hole. Verifying this one is nontrivial from source alone; a targeted test that evicts a cascade entry while keeping aFontCascadealive, then purges, would settle it empirically. -
Cached rendering artifacts that transitively reference platform-owned handles. Verify that the sink side of the shaped-text cache cannot outlive any resource it references, not just fonts. Narrow: examine
TextShapingResultAndDisplayList, which pairs aTextShapingResultwith aRefPtr<const DisplayList::DisplayList>— enumerate what the display list's recorded items reference (fonts, images, gradients, platform surfaces) and check whether each is strongly held for the entry's lifetime the waym_systemFallbackFontSetnow holds fonts. Wider: the same question applies to every long-lived recorded-drawing cache in WebCore and the GPU process display-list replay path, where a recorded item may name a resource by identifier rather than by reference. Widest: this is the "serialized/recorded reference outliving its referent" class, common to any record-then-replay architecture — command buffers in graphics APIs, deferred rendering queues, and remote-procedure display lists in any multi-process renderer. Match tell: a cached or serialized structure whose fields are handles, identifiers, or weak pointers rather than owning references, combined with any replay entry point that does not re-validate those handles before use.