← All reports

[5] Complex text path did not retain the system fallbacks it used

MediumWebCore font renderingUAF

The complex text path never learned the rule the simple path follows.

0082e68

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 GlyphBuffer cached in a FontCascadeFonts shaped text cache references its Fonts through weak pointers (308842@main). The simple text path keeps the system fallbacks it uses alive in FontCascadeFonts::glyphDataForSystemFallback, but the complex text path did not, so a Core Text fallback used only there had a single reference and FontCache::purgeInactiveFontData could destroy it while a cached shaped run still referenced it. Painting that run then dereferenced an expired weak pointer in FontCascade::drawGlyphBuffer.

Source/WebCore/platform/graphics/coretext/ComplexTextControllerCoreText.mm

FontPlatformData runFontPlatformData(runCTFont.get(), CTFontGetSize(runCTFont.get()));
- runFont = protect(FontCache::forCurrentThread())->fontForPlatformData(runFontPlatformData).ptr();
+ Ref systemFallbackFont = protect(FontCache::forCurrentThread())->fontForPlatformData(runFontPlatformData);
+ // Keep this system fallback alive. The cached shaped buffer references it only weakly (mirrors glyphDataForSystemFallback()).
+ RefPtr fonts = m_fontCascade->fonts();
+ fonts->addSystemFallbackFont(systemFallbackFont.copyRef());
+ runFont = systemFallbackFont.ptr();

Source/WebCore/platform/graphics/FontCascadeFonts.cpp

// Keep the system fallback fonts we use alive.
if (fallbackGlyphData.isValid())
- m_systemFallbackFontSet.add(systemFallbackFont.releaseNonNull());
+ addSystemFallbackFont(systemFallbackFont.releaseNonNull());
...
+void FontCascadeFonts::addSystemFallbackFont(Ref<Font>&& font)
+{
+ m_systemFallbackFontSet.add(WTF::move(font));
+}

Tools/TestWebKitAPI/Tests/WebCore/FontCascade.cpp

+TEST(FontCascadeTest, ComplexTextRetainsSystemFallbackFonts)
+{
+ FontCascadeDescription description;
+ description.setOneFamily("Times"_s);
+ ...
+ // Complex-script characters Times cannot render, forcing Core Text system fallbacks.
+ static constexpr std::array<char16_t, 8> characters { 0x06D8, 0x092D, 0x0B40, 0x0F96, 0x0DBD, 0x0EAF, 0xA86C, 0x0ACF };
+ ...
+ SingleThreadWeakHashSet<const Font> fallbackFonts;
+ fontCascade.width(run, &fallbackFonts);
+ for (auto& font : fallbackFonts) {
+ hasUsedFallbackFont = true;
+ EXPECT_FALSE(font.hasOneRef());
+ }

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.

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()).

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:

  1. 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 collectComplexTextRunsForCharacters down the effectiveFont->isSystemFontFallbackPlaceholder() branch where Core Text selects the run font.
  2. Repeat the same short run so getOrCreateCachedShapedText memoizes it — the ShapedTextCacheDefaults comments name Canvas fillText/strokeText as the tuned workload, and initialInterval = -3 means the entry is cached on first sight; the resulting GlyphBuffer records the fallback face only weakly.
  3. Drive FontCache::purgeInactiveFontData — via memory pressure, or by churning many distinct font descriptions to age out the cache — at a moment when the owning FontCascadeFonts is not reached by pruneSystemFallbackFonts() (for example after its m_entries slot was dropped by pruneUnreferencedEntries() or the maximumEntries random eviction while a live FontCascade still holds the Ref); the fallback, whose only strong reference before the fix was FontDataCache's own, is destroyed.
  4. Repaint the cached run, so the glyph-drawing path reads a GlyphBuffer slot 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).

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.