Medium webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to memory corruption
DescriptionA use-after-free issue was addressed with improved memory management.
ComponentWebCore
Bug ClassUAF
Tracker313577
Fix commit5aedb82710ba (webkit) +22/-2
CISA KEVNot listed
CreditedMilad Nasr and Nicholas Carlini with Claude, Anthropic
Disclosed2026-09-14

Background

`CSSFontFace`
WebCore object (Source/WebCore/css/CSSFontFace.cpp) modeling a single @font-face rule / FontFace JS object. It notifies interested parties (CSS font selectors, the FontFace promise machinery) through CSSFontFaceClient observers.
`WeakHashSet<CSSFontFaceClient>`
The set of registered clients. Entries are weak: a client can be removed/destroyed independently while the font face is alive.
`iterateClients()`
Helper that dispatches a callback to every client. It snapshots the set into a Vector<Ref<CSSFontFaceClient>> first, so that mutation of the set during dispatch does not invalidate the iterator.
`copyToVectorOf<Ref<...>>`
Copies the live members into a vector of strong Refs, pinning each client object’s lifetime for the duration of the loop.

Root Cause Analysis

iterateClients() snapshotted the client set into copyToVectorOf<Ref<CSSFontFaceClient>>(clients) and then unconditionally invoked callback(client) for each element. The snapshot solves iterator invalidation and the Ref keeps each client object itself allocated — but it does not account for a client being removed from the set mid-iteration. A callback dispatched to an earlier client can run script (or synchronous layout) that unregisters a later client and tears down the state that client’s CSSFontFaceClient callback depends on. Because the stale Ref still pins the object, the subsequent callback() on the removed client re-enters it in a now-defunct state, dereferencing associated state that has already been destroyed — the use-after-free the advisory reports.

The regression test makes this concrete: it installs a getter on FontFace.prototype.then that, when the font-load promise is touched, removes the <style id="v"> element and forces a synchronous layout (document.body.offsetHeight) in the middle of face.load() dispatch. That re-entrancy mutates the font/client graph while iterateClients() is still walking its snapshot. The patch guards each dispatch with if (clients.contains(client)), re-checking that the client is still a live member of the set at the moment of the callback and skipping any client removed since the snapshot was taken, so no callback is delivered to an unregistered/destroyed client.

Key insight
Snapshotting the clients pinned their object lifetimes but not their membership: a callback could unregister a sibling client mid-iteration, yet the loop still dispatched to it. The fix re-checks clients.contains(client) before each callback.

Attack Path

  1. Register a font face Page adds an @font-face rule / creates a FontFace, registering clients on the CSSFontFace.
  2. Hook promise access Script defines a getter on FontFace.prototype.then so that touching the load promise runs attacker code synchronously.
  3. Trigger iteration face.load() (or layout) causes CSSFontFace to call iterateClients(), which snapshots clients and begins dispatching callbacks.
  4. Mutate mid-iteration The hooked getter removes a DOM node (<style>) and forces layout, unregistering/destroying a client while the snapshot loop is still running.
  5. Use-after-free The loop dispatches to the now-removed client, operating on torn-down associated state → use-after-free / crash (the precise freed object is not shown by the diff; WebKit lifetime here is WeakHashSet + Ref, so the freed memory is inferred to be client-associated state rather than the Ref-pinned client itself).

Impact Assessment

Runs in the WebContent (renderer) process, reachable from ordinary web content via @font-face + the FontFace load API and a prototype-chain hook — no elevated privilege required. The diff substantiates a mid-iteration-removal use-after-free; the advisory escalates it to “memory corruption,” which is consistent, but a controlled read/write primitive is inferred rather than demonstrated by the diff. Gating is light: the attacker only needs to run script during callback dispatch, which the then getter provides.

Files Changed

  • LayoutTests/fonts/font-face-load-crash-expected.txt
  • LayoutTests/fonts/font-face-load-crash.html
  • Source/WebCore/css/CSSFontFace.cpp

Audit Directions

  • Snapshot-then-dispatch loops
    Audit other copyToVectorOf<Ref<...>>(someWeakHashSet) / copyToVector dispatch loops in WebCore that invoke reentrant callbacks without re-checking set membership (font, animation, media, and mutation-observer notification paths are prime candidates).
  • Reentrancy via JS getters/promises
    Any observer dispatch reachable while user JS can run (promise then, event listeners) should assume the client set can mutate during the loop.
  • `FontFaceSet`/`CSSFontSelector` clients
    Review sibling notification paths on the font-loading pipeline for the same remove-during-iteration assumption.
diff --git a/Source/WebCore/css/CSSFontFace.cpp b/Source/WebCore/css/CSSFontFace.cpp
index ac6f2b015f8a..6515d3514d9a 100644
--- a/Source/WebCore/css/CSSFontFace.cpp
+++ b/Source/WebCore/css/CSSFontFace.cpp
@@ -62,8 +62,10 @@ DEFINE_ALLOCATOR_WITH_HEAP_IDENTIFIER(CSSFontFace);
 
 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);
+    }
 }
 
 void CSSFontFace::appendSources(CSSFontFace& fontFace, CSSValueList& srcList, ScriptExecutionContext* context, bool isInitiatingElementInUserAgentShadowTree)
diff --git a/LayoutTests/fonts/font-face-load-crash-expected.txt b/LayoutTests/fonts/font-face-load-crash-expected.txt
new file mode 100644
index 000000000000..b0352cadbf49
--- /dev/null
+++ b/LayoutTests/fonts/font-face-load-crash-expected.txt
@@ -0,0 +1 @@
+This test passes if WebKit does not crash.
diff --git a/LayoutTests/fonts/font-face-load-crash.html b/LayoutTests/fonts/font-face-load-crash.html
new file mode 100644
index 000000000000..43472c0fb8fc
--- /dev/null
+++ b/LayoutTests/fonts/font-face-load-crash.html
@@ -0,0 +1,17 @@
+<style id="v">x{}</style>
+<body>
+<script>
+window?.testRunner?.dumpAsText();
+let s = document.createElement('style');
+s.textContent = '@font-face { font-family: t; src: local(Helvetica); }';
+document.head.appendChild(s);
+document.body.offsetHeight;
+let face = [...document.fonts].find(f => f.family.replace(/['"]/g, '') === 't');
+face.loaded;
+Object.defineProperty(FontFace.prototype, 'then', { get() {
+    document.getElementById('v').remove();
+    document.body.offsetHeight;
+}, configurable: true });
+face.load();
+document.write('This test passes if WebKit does not crash.');
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/fonts/font-face-load-crash-expected.txt b/LayoutTests/fonts/font-face-load-crash-expected.txt
new file mode 100644
index 000000000000..b0352cadbf49
--- /dev/null
+++ b/LayoutTests/fonts/font-face-load-crash-expected.txt
@@ -0,0 +1 @@
+This test passes if WebKit does not crash.
diff --git a/LayoutTests/fonts/font-face-load-crash.html b/LayoutTests/fonts/font-face-load-crash.html
new file mode 100644
index 000000000000..43472c0fb8fc
--- /dev/null
+++ b/LayoutTests/fonts/font-face-load-crash.html
@@ -0,0 +1,17 @@
+<style id="v">x{}</style>
+<body>
+<script>
+window?.testRunner?.dumpAsText();
+let s = document.createElement('style');
+s.textContent = '@font-face { font-family: t; src: local(Helvetica); }';
+document.head.appendChild(s);
+document.body.offsetHeight;
+let face = [...document.fonts].find(f => f.family.replace(/['"]/g, '') === 't');
+face.loaded;
+Object.defineProperty(FontFace.prototype, 'then', { get() {
+    document.getElementById('v').remove();
+    document.body.offsetHeight;
+}, configurable: true });
+face.load();
+document.write('This test passes if WebKit does not crash.');
+</script>
Loading diff…