← WebKit — 2026-W35

dcb90d33071047bdce6165da4246389355e97a31  Use-after-free in CSSSegmentedFontFace::fontRanges

severity high class UAF confidence 0.85 signal score 8.3 Source/WebCore/css patch-now AI review CONFIRMED
Primitive: Dangling reference into m_cache HashMap bucket rehashed by reentrant author script in CSSSegmentedFontFace::fontRanges
Triage note: The fixed (#else) branch builds FontRanges in a local instead of holding a reference (auto& ranges) into the m_cache bucket array; the inline comment states the loop calls CSSFontFace::font() which for local() sources resolves a FontFace loaded promise synchronously, running author script that re-enters fontRanges() and rehashes the table, dangling the reference. Classic container-reference UAF, web-content reachable.
Structural signals: msg_strong (+3.0), msg_medium (+2.0), area:Source/WebCore/css (+2.0), surgical (+1.3)

The bug at a glance

This is a use-after-free in WebCore font handling: CSSSegmentedFontFace::fontRanges held a C++ reference (auto& ranges) into a value stored inside the m_cache HashMap bucket array while the loop body could synchronously run author script that re-enters fontRanges() and inserts into m_cache, rehashing the table and freeing/moving the bucket storage the reference still points at. Subsequent writes through that reference (appendFont, ranges.shrinkToFit()) then touch freed HashMap storage. It is reachable from untrusted web content via CSS @font-face with a local() source plus a FontFace loaded promise handler, and the write-through-dangling-reference shape is a real container-invalidation UAF, so high severity is appropriate; escalation to full RCE would require heap-grooming the freed bucket, which this diff does not demonstrate (inferred).

The hook is a re-entrancy trap hidden behind a font cache: taking auto& ranges into the hash bucket looks harmless, but one of the FontAccessor calls in the loop can complete a local() font load synchronously, resolve a JS promise, and let author script mutate the very cache whose bucket you are still writing into.

Root cause

The violated invariant is that a reference into a WTF HashMap’s value storage is only valid while the map is not structurally mutated; any insertion that triggers a rehash reallocates the bucket array and invalidates every outstanding reference/iterator. In Source/WebCore/css/CSSSegmentedFontFace.cpp the vulnerable (RC_REMOVE_SEC_ACCEL_3) path takes such a reference and then calls out to code that can re-enter the same function. The pre-patch sequence is: 1. auto addResult = m_cache.add(std::make_tuple(FontDescriptionKey(fontDescription), fontPaletteValues), FontRanges()) inserts (or finds) the cache entry. 2. auto& ranges = addResult.iterator->value binds a long-lived reference directly into the bucket’s FontRanges value. 3. If !addResult.isNewEntry it returns ranges; otherwise it proceeds to populate it. 4. The for (auto& face : m_fontFaces) loop calls into each face (CSSFontFace::font(), per the patch comment), which for a local() source completes the load synchronously and notifies clients, which can resolve a FontFace loaded promise and run author JavaScript. 5. That author script calls back into CSSSegmentedFontFace::fontRanges() (directly or via style resolution), and a fresh m_cache.add(...) with a new key grows the table past its load factor and rehashes, reallocating the bucket array and destroying the old storage. 6. Control returns to the outer frame, which keeps using the now-dangling ranges reference — appendFont(ranges, …) appends a range and ranges.shrinkToFit() writes into freed/moved memory, and the final return ranges reads it.

The lifetime facts matter: m_cache is a plain WTF HashMap keyed by (FontDescriptionKey, FontPaletteValues) whose value is a FontRanges held inline in the bucket, so there is no Ref/RefPtr keeping the value alive across a rehash — the reference is raw aliasing of container storage, exactly the pattern that dangles on reallocation. FontRanges itself owns a vector of ranges, so the dangling writes are into a heap object inside freed bucket memory.

This matches the triage bugclass (UAF) and the diff proves the defect: the fixed (#else) branch builds the result in a stack local FontRanges ranges; and only inserts into m_cache after the loop finishes (m_cache.add(WTF::move(key), ranges)), with a plain m_cache.find(key) fast path up front; no live reference into the bucket survives across the re-entrant call. The free itself (bucket reallocation on rehash) is standard HashMap behavior; its exploitability as more than a UAF write is (inferred).

Key code

Vulnerable: reference into m_cache bucket held across a re-entrant loop; fix builds a local and inserts last

// vulnerable (RC_REMOVE_SEC_ACCEL_3):
auto addResult = m_cache.add(std::make_tuple(FontDescriptionKey(fontDescription), fontPaletteValues), FontRanges());
auto& ranges = addResult.iterator->value;   // alias into bucket array
if (!addResult.isNewEntry)
    return ranges;
// fix (#else): build in a local, insert after the loop
FontRanges ranges;
... /* loop may re-enter fontRanges() and rehash m_cache */
ranges.shrinkToFit();
m_cache.add(WTF::move(key), ranges);

Fix approach

The patch stops holding a reference into m_cache across the re-entrant loop. It replaces the add()-then-alias pattern with an up-front read-only m_cache.find(key) fast path that returns entry->value by copy when present, builds the new result entirely in a stack-local FontRanges ranges, runs the populating loop (which may re-enter and rehash m_cache harmlessly, since nothing aliases the bucket), and only then performs m_cache.add(WTF::move(key), ranges) to publish the finished value. The guard/holder here is ’no live reference into the container during a callout that can mutate it’; the semantic change is that the cache is populated after the work completes rather than incrementally, and a concurrent re-entrant call now recomputes rather than seeing a half-built entry. The hot path collapses to find-or-build-then-insert with no aliasing window.

Patch walkthrough

  • Source/WebCore/css/CSSSegmentedFontFace.cpp — fontRanges() no longer binds auto& ranges into the m_cache bucket. It does a lookup-only m_cache.find(key) and returns a copy on hit, otherwise builds a local FontRanges ranges, runs the loop that may synchronously resolve a FontFace promise and re-enter/rehash the cache, and inserts the finished ranges with m_cache.add(WTF::move(key), ranges) only at the end. An inline comment documents the re-entrancy via CSSFontFace::font() on local() sources.

Background

CSSSegmentedFontFace — WebCore object representing the stack of @font-face sources for a family/range; fontRanges() resolves and caches the concrete FontRanges used for text layout.

WTF HashMap rehash invalidation — WTF::HashMap stores values inline in an open-addressed bucket array; an add() that crosses the load factor reallocates and rehashes the array, invalidating all outstanding references and iterators into it.

local() @font-face source — A font source that names a locally installed font; unlike a network url() source it can be resolved synchronously, so CSSFontFace::font() may complete the load and notify clients inline.

FontFace loaded promise — The CSS Font Loading API exposes a per-FontFace loaded JS promise; resolving it runs author microtasks/handlers that can execute arbitrary script, including script that triggers style resolution and re-enters fontRanges().

FontRanges / appendFont — FontRanges holds the ordered set of (FontAccessor, range) entries for a segmented face; appendFont() pushes entries into its internal vector, i.e. it writes through the ranges reference.

Vulnerability window

  1. Setup — Author CSS declares a segmented @font-face family including a local() source, and registers a FontFace whose loaded promise handler runs script.
  2. Trigger — Styling text with that family calls CSSSegmentedFontFace::fontRanges(), which inserts a new m_cache entry and takes auto& ranges into the bucket.
  3. Re-entrancy — The loop calls CSSFontFace::font() on the local() source, synchronously resolving the FontFace loaded promise and running the author handler.
  4. Rehash / free — The handler forces another fontRanges() (new key) whose m_cache.add() rehashes the table, reallocating the bucket array and freeing the storage ranges points at.
  5. UAF write — Back in the outer call, appendFont(ranges, …) and ranges.shrinkToFit() write into freed bucket memory; return ranges reads it.

Proof of concept

No regression test ships in the diff. Conceptual trigger: define a segmented @font-face family with a local() source and an unloaded entry; obtain its FontFace and attach a .loaded.then(...) handler that, when run, adds many new @font-face rules / queries a different segmented family so the re-entrant fontRanges() insertions rehash m_cache; then style text that forces the first fontRanges() to populate a new entry. The exact rehash-forcing count and style-invalidation path are (inferred).

Exploitation

  1. Reach — Untrusted web content in the renderer: CSS @font-face + CSS Font Loading API are default-available to any page.
  2. Trigger — Drive a synchronous local() load whose promise resolution re-enters fontRanges() and inserts enough entries to rehash m_cache during the outer call’s loop.
  3. Primitive — Primary: write-through-dangling-reference into freed HashMap bucket storage (appendFont/shrinkToFit on ranges). Secondary: the stale returned FontRanges may be consumed by layout, reading freed memory.
  4. Open questions — Escalation past a UAF crash depends on reclaiming the freed bucket array with attacker-controlled bytes (heap grooming between the free and the appendFont write) and on whether the written FontRanges fields land on a corruptible object; not shown by the diff.

Detection & hunting

For defenders and detection engineers:

  • UAF/crash in CSSSegmentedFontFace::fontRanges or FontRanges::appendFont/shrinkToFit — ASan/heap reports pointing into a reallocated HashMap bucket during font resolution, especially with local() sources.
  • Re-entrant fontRanges() under a FontFace loaded handler — Instrument fontRanges() to flag re-entry while an outer invocation holds a bucket reference.
  • Builds defining RC_REMOVE_SEC_ACCEL_3 — The vulnerable aliasing path is selected by that macro; flag configs enabling it.

Remaining risk in the patch

  • find() + add() double lookup — The fix does a find() and a later add() rather than a single add-with-reservation; a re-entrant call that inserts the same key first means the outer call rebuilds and overwrites via add(), which is correct but does extra work — not a safety bug, just a note that the key path is traversed twice.
  • Other callers of CSSFontFace::font() — The same synchronous local()-load-resolves-promise re-entrancy could affect other sites that hold container references across font resolution; only fontRanges() is fixed here.

Audit directions

  • References into WTF HashMap values across callouts — Grep WebCore for addResult.iterator->value / auto& x = ...find(...)->value held across any call that can run script or mutate the same map.
  • Synchronous font load re-entrancy — Trace all paths from CSSFontFace::font()/load notification that can resolve a FontFace promise and re-enter style/font code.
  • Other font caches — Review FontCache, CSSFontSelector and FontFaceSet for cache entries aliased across loads.

Before / after

Loading diff…