← WebKit — 2026-W39

14335b961fad08e33275290ab6135c522ff4a89a  Protect FontCascadeFonts across fallback walks that can load a font

severity high class UAF confidence 0.78 signal score 6.7 Source/WebCore/platform patch-now AI review PLAUSIBLE
Primitive: FontRanges reference from fallbackRangesAt() dangles if FontCascadeFonts is replaced during fallback iteration
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.
Structural signals: smartptr_member_added (+3.0), null_guard (+1.2), msg_fuzzer (+2.5)

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_ARG fonts()->glyphDataForCharacter(...) with a held Ref 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-supplied fonts.realizeFallbackRangesAt(...), dropping the internal protect(m_fonts) that only lived for the single call.
  • Source/WebCore/platform/graphics/cairo/FontCairoHarfbuzzNG.cpp — fontForCombiningCharacterSequence adds Ref 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 — Adds Ref 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

  1. Setup — A page lays out text containing a combining-character sequence or a character needing fallback, invoking fontForCombiningCharacterSequence / glyphDataForCharacter.
  2. Trigger — The fallback loop obtains a const FontRanges& from fallbackRangesAt(i) backed by FontCascadeFonts.
  3. Window — A nested call (fontForCharacter, font cache, system fallback) reenters FontCascade and causes m_fonts to be replaced, dropping the last strong ref (inferred).
  4. Use-after-free — Continued loop use or the post-loop fontForFirstRange() reads the now-freed FontRanges/Font memory.
  5. 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

  1. Reach — Reachable from ordinary text layout/measurement of a page containing fallback-requiring glyphs; no special API needed.
  2. Trigger — Induce reentrant FontCascadeFonts replacement during the fallback loop (font loading / style invalidation) so the backing object is freed mid-iteration.
  3. Primitive — Use-after-free read of freed FontRanges/Font memory while selecting a fallback font.
  4. 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.

Before / after

Loading diff…