14335b961fad08e33275290ab6135c522ff4a89a Protect FontCascadeFonts across fallback walks that can load a font
Triage note: Under #ifndef RC_REMOVE_SEC_ACCEL_3, glyphDataForCharacter and fontForCombiningCharacterSequence take a Ref<FontCascadeFonts> once and pass it into fallbackRangesAt(fonts, i) instead of re-fetching fonts() each call; keeps the object owning the returned const FontRanges& alive across the loop, fixing a use-after-free. RC_REMOVE_SEC_ACCEL_3 bracketing flags it as a silent security fix; text/font rendering is web-reachable.
The bug at a glance
FontCascade::fallbackRangesAt() returns a const FontRanges& that lives inside the FontCascadeFonts object owned by m_fonts. Pre-patch, glyphDataForCharacter() and the per-platform fontForCombiningCharacterSequence()/ComplexTextController paths re-fetched fonts() and called fallbackRangesAt each iteration while holding only a transient protect(m_fonts) inside that call, so the owning FontCascadeFonts was not pinned across the loop. If m_fonts is replaced during iteration the held FontRanges reference dangles, giving a use-after-free of font data read during text layout/measurement, which is web-reachable from ordinary rendering. The RC_REMOVE_SEC_ACCEL_3 bracketing marks it a silent security fix; UAF/high is plausible, though the escalation beyond a read of freed font memory is (inferred).
A returned-reference lifetime bug hiding behind SUPPRESS_UNCOUNTED_ARG: the hot text path trusted that the object backing a const FontRanges& stayed alive for the whole fallback loop, when only a per-call temporary Ref protected it.
Root cause
The invariant is that any const FontRanges& obtained from fallbackRangesAt(i) must outlive its use, which requires the backing FontCascadeFonts (m_fonts) to stay alive and unreplaced for the duration. In the pre-patch (RC_REMOVE_SEC_ACCEL_3) code, fallbackRangesAt(unsigned) is return protect(m_fonts)->realizeFallbackRangesAt(...) — the protect() Ref is a temporary that is destroyed when fallbackRangesAt returns, so between successive fallbackRangesAt calls nothing but the m_fonts member keeps the object alive. The vulnerable sequence in fontForCombiningCharacterSequence is: 1. loop for (unsigned i = 0; !fallbackRangesAt(i).isNull(); ++i) evaluates fallbackRangesAt(i) returning a reference into FontCascadeFonts; 2. body calls fallbackRangesAt(i).fontForCharacter(character) and inspects the Font; 3. fontForCharacter / canRenderCombiningCharacterSequence / FontCache::systemFallbackForCharacterCluster can run code (font selector / cache work) that reenters FontCascade and reassigns m_fonts (e.g. invalidation / fonts() lazily rebuilding), dropping the last Ref; 4. the next use of the previously returned FontRanges& — or fallbackRangesAt(0).fontForFirstRange() after the loop — now reads freed memory.
The patch takes an explicit owner once per operation and threads it through: glyphDataForCharacter does Ref fonts = *this->fonts(); and calls fonts->glyphDataForCharacter(...); every fontForCombiningCharacterSequence variant (Cairo/HarfBuzz, CoreText, Skia) and the ComplexTextControllerCoreText path add Ref fonts = *this->fonts(); (or *m_fontCascade->fonts()) at entry and call the new fallbackRangesAt(FontCascadeFonts& fonts, unsigned index) overload, which uses the caller-held fonts.realizeFallbackRangesAt(...) instead of its own transient protect(m_fonts). Because the loop now holds a stack Ref for its whole lifetime, the object backing every returned FontRanges& cannot be freed mid-iteration.
The proven defect from the diff is a lifetime error on a returned reference. Calling it a UAF (triage bugclass) is justified because FontCascadeFonts is ref-counted and the only strong owner across the loop was being dropped; however the diff does not itself show the reassignment of m_fonts, so the exact free site is (inferred) and the escalation past an out-of-bounds/freed read is not demonstrated.
Key code
Hold the FontCascadeFonts owner for the whole fallback loop and pass it into fallbackRangesAt
// FontCascadeInlines.h (post-patch overload)
inline const FontRanges& FontCascade::fallbackRangesAt(FontCascadeFonts& fonts, unsigned index) const
{
return fonts.realizeFallbackRangesAt(m_fontDescription, protect(fontSelector()).get(), index);
}
// caller (fontForCombiningCharacterSequence)
Ref fonts = *this->fonts();
for (unsigned fallbackIndex = 0; ; ++fallbackIndex) {
auto& fontRanges = fallbackRangesAt(fonts, fallbackIndex);
if (fontRanges.isNull())
break;
RefPtr fallbackFont = fontRanges.fontForCharacter(character);
...
}
Fix approach
The fix introduces an explicit Ref fonts = *this->fonts(); at the top of each text operation and a new fallbackRangesAt(FontCascadeFonts&, unsigned) overload that realizes ranges off the caller-held reference rather than re-fetching and transiently protecting m_fonts per call. The stack Ref is the new lifetime holder: it pins the FontCascadeFonts for the entire fallback loop and all post-loop fontForFirstRange() uses, so no reassignment of the m_fonts member can free the object underneath a live FontRanges&. Semantically the per-call protect(m_fonts) collapses into a single owning reference per operation; the hot path performs one addRef/deref instead of one per fallback index. glyphDataForCharacter likewise routes through the held fonts->glyphDataForCharacter(...) instead of the SUPPRESS_UNCOUNTED_ARG fonts()->....
Patch walkthrough
Source/WebCore/platform/graphics/FontCascade.cpp— glyphDataForCharacter replaces the SUPPRESS_UNCOUNTED_ARGfonts()->glyphDataForCharacter(...)with a heldRef fonts = *this->fonts(); return fonts->glyphDataForCharacter(...).Source/WebCore/platform/graphics/FontCascade.h— Declares the new fallbackRangesAt(FontCascadeFonts&, unsigned) overload alongside (and behind the ifdef, instead of) the old fallbackRangesAt(unsigned).Source/WebCore/platform/graphics/FontCascadeInlines.h— Implements the new overload using the caller-suppliedfonts.realizeFallbackRangesAt(...), dropping the internalprotect(m_fonts)that only lived for the single call.Source/WebCore/platform/graphics/cairo/FontCairoHarfbuzzNG.cpp— fontForCombiningCharacterSequence addsRef fonts = *this->fonts();and converts the fallback loop plus the post-loop fallbackRangesAt(0).fontForFirstRange() to the fonts-parameter overload.Source/WebCore/platform/graphics/coretext/ComplexTextControllerCoreText.mm— AddsRef fonts = *m_fontCascade->fonts();and routes the system-fallback selection and the runFont matching loop through fallbackRangesAt(fonts, i).Source/WebCore/platform/graphics/coretext/FontCascadeCoreText.cpp— CoreText fontForCombiningCharacterSequence adds the held Ref and the fonts-parameter loop form.Source/WebCore/platform/graphics/skia/FontCascadeSkia.cpp— Skia fontForCombiningCharacterSequence adds the held Ref and routes the fallback loop and systemFallbackForCharacterCluster(…fallbackRangesAt(fonts,0)…) through the new overload.
Background
FontCascade / FontCascadeFonts — FontCascade is the resolved font list for an element’s style; FontCascadeFonts (m_fonts) is the ref-counted cache of realized fallback ranges it owns and that fonts() returns.
FontRanges / realizeFallbackRangesAt — FontRanges maps character ranges to concrete Font objects; realizeFallbackRangesAt lazily builds the Nth fallback entry and returns a reference into storage owned by FontCascadeFonts.
SUPPRESS_UNCOUNTED_ARG — A WebKit annotation that silences the static ref-counting checker for a raw-pointer argument; its presence on the old fonts() call flags exactly the unprotected lifetime the checker would otherwise complain about.
Ref vs transient protect() — Ref fonts = *this->fonts() holds a strong reference for the enclosing scope; protect(m_fonts) inside fallbackRangesAt created only a per-call temporary Ref, insufficient to span a multi-iteration loop.
Combining character sequence fallback — Rendering a grapheme cluster (base + combining marks) walks the fallback font list to find a font that can render the whole sequence, repeatedly calling fallbackRangesAt — the loop whose returned references must stay valid.
Vulnerability window
- Setup — A page lays out text containing a combining-character sequence or a character needing fallback, invoking fontForCombiningCharacterSequence / glyphDataForCharacter.
- Trigger — The fallback loop obtains a const FontRanges& from fallbackRangesAt(i) backed by FontCascadeFonts.
- Window — A nested call (fontForCharacter, font cache, system fallback) reenters FontCascade and causes m_fonts to be replaced, dropping the last strong ref (inferred).
- Use-after-free — Continued loop use or the post-loop fontForFirstRange() reads the now-freed FontRanges/Font memory.
- Post-patch — The stack Ref keeps the object alive across the entire operation, eliminating the dangling reference.
Proof of concept
No regression test ships in the diff. Conceptual trigger (inferred): render text that forces combining-sequence fallback (e.g. a base glyph plus combining marks with no single covering font) while mutating the element’s font stack (style/@font-face/font loading) so FontCascadeFonts is invalidated and rebuilt during the fallback walk; the dangling const FontRanges& is then used. The precise reentrancy that frees m_fonts is not shown in the diff, so the web trigger is (inferred).
Exploitation
- Reach — Reachable from ordinary text layout/measurement of a page containing fallback-requiring glyphs; no special API needed.
- Trigger — Induce reentrant FontCascadeFonts replacement during the fallback loop (font loading / style invalidation) so the backing object is freed mid-iteration.
- Primitive — Use-after-free read of freed FontRanges/Font memory while selecting a fallback font.
- Escalation (inferred) — Open questions gate escalation: whether the window can be widened and the freed FontCascadeFonts bytes reclaimed with attacker-controlled data, and whether a corrupted Font/FontRanges can steer a later virtual dispatch — not demonstrated by the diff.
Detection & hunting
For defenders and detection engineers:
- FontCascadeFonts freed while a FontRanges reference into it is live — ASan/heap-use-after-free inside realizeFallbackRangesAt / fontForCharacter during text layout.
- m_fonts reassignment during a fallbackRangesAt loop — Instrument FontCascade::fonts() / m_fonts setter to flag reentrancy while a fallback iteration is on the stack.
- Static ref-counting checker hits on SUPPRESS_UNCOUNTED_ARG font paths — Audit remaining SUPPRESS_UNCOUNTED_ARG annotations on FontCascade for the same unprotected-owner shape.
Remaining risk in the patch
- Free site not present in the diff — The diff proves the lifetime hole and fixes it, but the code path that actually reassigns/frees m_fonts during iteration is not in the supplied diff, so that leg is (inferred) and should be verified against FontCascade::fonts() internals.
- Platform-conditional coverage — The owner-Ref is added per platform file (Cairo, CoreText, Skia, ComplexText); any additional platform or future fallback caller not updated would reintroduce the same unprotected-reference pattern.
Audit directions
- Other fonts()/fallbackRangesAt callers — Find remaining call sites that call fonts() or fallbackRangesAt(unsigned) without holding a Ref across their use, especially in width/measurement code.
- Returned-reference APIs on ref-counted font objects — Review functions returning const Font&/const FontRanges& from ref-counted holders to ensure callers pin the owner.
- SUPPRESS_UNCOUNTED_ARG annotations in platform/graphics — Each suppression marks a deliberately unchecked raw owner; re-examine whether the lifetime truly spans the use.
- Reentrancy via FontCache / system fallback — Map which font-cache and systemFallbackForCharacterCluster paths can invalidate/rebuild FontCascadeFonts mid-operation.