← All issues

[4] Use-after-free in CSSFontFace::setStatus via CSSFontFace::load

The observer loop that kept every client alive — but not registered

Severity: High | Component: WebCore CSS font loading | 5aedb82

Rated High because the diff adds a membership recheck guarding an observer callback that a re-entrant style recalc can detach mid-loop, producing a web-reachable use-after-free; escalation to a read/write primitive requires reclaiming the freed collateral, which the diff does not establish.

Fixed a use-after-free in CSS font-load client iteration. The iterateClients helper snapshotted the weak client set into a Vector<Ref<CSSFontFaceClient>> and unconditionally invoked each callback; the patch wraps the callback in a membership recheck.

Source/WebCore/css/CSSFontFace.cpp

static void iterateClients(WeakHashSet<CSSFontFaceClient>& clients, NOESCAPE const Function<void(CSSFontFaceClient&)>& callback)
{
- for (auto& client : copyToVectorOf<Ref<CSSFontFaceClient>>(clients))
- callback(client);
+ for (auto& client : copyToVectorOf<Ref<CSSFontFaceClient>>(clients)) {
+ if (clients.contains(client))
+ callback(client);
+ }
}

LayoutTests/fonts/font-face-load-crash.html

+Object.defineProperty(FontFace.prototype, 'then', { get() {
+ document.getElementById('v').remove();
+ document.body.offsetHeight;
+}, configurable: true });
+face.load();

The patch modifies the file-static iterateClients. Previously it snapshotted the weak client set into a Vector<Ref<CSSFontFaceClient>> via copyToVectorOf and unconditionally invoked callback(client) for every entry. The patch wraps the callback in if (clients.contains(client)) callback(client);. Every mutator that notifies observers routes through this helper (setFamily, setWeight, and per the commit title the setStatus/load path), so the guard applies to all client-notification loops.

Failure to re-validate observer-set membership across a JavaScript re-entrancy boundary in an iterate-then-callback loop.

CSSFontFaceClient is an observer interface implemented by objects (CSS font selectors, FontFace wrappers) that want to be told when a CSSFontFace changes family/weight/loading status. WeakHashSet<CSSFontFaceClient> is a set of weak references to those clients, so entries drop automatically when a client is destroyed. copyToVectorOf<Ref<CSSFontFaceClient>>(clients) snapshots the weak set into strong references so the loop has a stable list and each referenced client stays alive while iterated. Re-entrancy here is a point where native C++ calls into JavaScript — promise resolution consulting FontFace.prototype.then — which can synchronously run script and mutate C++ state before returning. Style recalculation, forced by reading layout-dependent properties such as offsetHeight, can rebuild the font face set and register/unregister clients. The normal flow: FontFace.load()CSSFontFace::load()setStatus()iterateClients() walks the snapshot and calls each observer to report the loading-state change.

This is a use-after-free via stale-observer invocation driven by JavaScript re-entrancy during observer iteration. Before the fix, iterateClients copied the weak client set into a vector of strong Ref<CSSFontFaceClient> and invoked each client's callback without re-checking that the client was still a registered member. The Ref snapshot keeps each client object's memory alive for the loop, so this is not a raw dangling-pointer-to-client bug — the copy deliberately protects against the client object itself being freed. The problem is that a callback earlier in the loop can re-enter script (the load path resolves a promise, and the attacker's FontFace.prototype.then getter runs arbitrary JS), and that JS forces a style recalculation that unregisters a client and tears down the state its notification callback depends on.

The test poisons FontFace.prototype.then with a getter that removes a <style> element and reads document.body.offsetHeight to force synchronous style recalculation, then calls face.load(). When the loop reaches the now-stale but still Ref-alive client and invokes its callback, that callback operates on a client that has been logically detached — dereferencing associated objects (the CSSFontFace/font-selector/wrapper linkage) that the re-entrant recalc has already destroyed. The missing invariant is that a client snapshotted at loop entry is still a live registered observer at the moment its callback fires.

This is a web-reachable UAF in font-load client iteration, reliably a renderer crash. If the freed associated state is reclaimed by attacker-controlled heap content before the callback dereferences it, this would evolve into a controlled UAF primitive; realizing that best case would require heap grooming to place attacker data in the freed slot and a callback path that reads or writes through the dangling linkage.

This vulnerability weakens memory safety inside the WebContent process. The security model assumes observer callbacks in CSSFontFace::iterateClients are delivered only to clients that remain registered and whose backing state is live for the notification loop; before the fix this was violated whenever a callback re-entered script and mutated the font/style graph. An attacker running only ordinary web content could drive the UAF; a stronger primitive would still require a separate sandbox escape to leave the renderer.

This is the canonical WebKit re-entrancy pattern: a snapshot-then-iterate loop over an observer set where the snapshot protects object lifetime (via Ref) but not logical membership. Copying to Ref defends against the object being freed, which lulls reviewers into thinking the loop is safe — but the real hazard is that a re-entrant callback detaches an observer and tears down its dependencies. The fix (recheck contains before each callback) is the correct minimal pattern and is worth propagating to every iterateClients-style loop in WebCore.

Note: The routing of the load/setStatus path through iterateClients, the exact freed object and dangling linkage, and the escalation to a controlled primitive are inferred from the commit title and surrounding code rather than directly shown in the diff. The re-entrancy trigger and the membership-recheck fix are directly supported by the patch and test.