CVE-2026-43704
Overview
Impact:
Description: A malicious web extension may be able to cause an unexpected process crash
Researchers: dr3dd
Key insight. Extension event dispatch iterated the live listener list by reference while callbacks could add/remove listeners; copying the list and retaining each listener across call() removes the iterator-invalidation UAF.
Patch Details
diff --git a/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIDevToolsExtensionPanelCocoa.mm b/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIDevToolsExtensionPanelCocoa.mm
index b67b1520577e..68d947e9071b 100644
--- a/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIDevToolsExtensionPanelCocoa.mm
+++ b/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIDevToolsExtensionPanelCocoa.mm
@@ -74,7 +74,10 @@ void WebExtensionContextProxy::dispatchDevToolsExtensionPanelShownEvent(Inspecto
if (!extensionPanel)
return;
- for (auto& listener : extensionPanel->onShown().listeners()) {
+ // Copy the listeners since call() can trigger a mutation of the listeners.
+ auto listenersCopy = extensionPanel->onShown().listeners();
+
+ for (RefPtr listener : listenersCopy) {
auto globalContext = listener->globalContext();
listener->call(toWindowObject(globalContext, *frame));
}
diff --git a/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIPortCocoa.mm b/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIPortCocoa.mm
index b5fedd5193ed..e076b223d97b 100644
--- a/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIPortCocoa.mm
+++ b/Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIPortCocoa.mm
@@ -161,7 +161,10 @@ void WebExtensionAPIPort::fireMessageEventIfNeeded(id message, bool userGesture)
RELEASE_LOG_DEBUG(Extensions, "Fired port message event for channel %{public}llu in %{public}@ world", channelIdentifier().toUInt64(), toDebugString(contentWorldType()).createNSString().get());
- for (auto& listener : m_onMessage->listeners()) {
+ // Copy the listeners since call() can trigger a mutation of the listeners.
+ auto listenersCopy = m_onMessage->listeners();
+
+ for (RefPtr listener : listenersCopy) {
auto globalContext = listener->globalContext();
std::optional<WebCore::UserGestureIndicator> gestureIndicator;
@@ -197,7 +200,10 @@ void WebExtensionAPIPort::fireDisconnectEventIfNeeded()
RELEASE_LOG_DEBUG(Extensions, "Fired port disconnect event for channel %{public}llu in %{public}@ world", m_channelIdentifier ? m_channelIdentifier->toUInt64() : 0, toDebugString(contentWorldType()).createNSString().get());
- for (auto& listener : m_onDisconnect->listeners()) {
+ // Copy the listeners since call() can trigger a mutation of the listeners.
+ auto listenersCopy = m_onDisconnect->listeners();
+
+ for (RefPtr listener : listenersCopy) {
auto globalContext = listener->globalContext();
listener->call(toJS(globalContext, this));
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm
index 36df19179b7d..0af1f19888e2 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm
@@ -1724,6 +1724,57 @@ TEST(WKWebExtensionAPIRuntime, ConnectNative)
[manager run];
}
+TEST(WKWebExtensionAPIRuntime, PortMutateListenersDuringDispatch)
+{
+ auto *backgroundScript = Util::constructScript(@[
+ @"const port = browser.runtime?.connectNative('test')",
+
+ @"const initialListenerCount = 64",
+ @"let messageDispatchCount = 0",
+ @"let disconnectDispatchCount = 0",
+
+ @"port?.onMessage.addListener(() => {",
+ @" ++messageDispatchCount",
+ @" for (let i = 0; i < 1024; ++i)",
+ @" port?.onMessage.addListener(() => { })",
+ @"})",
+
+ @"for (let i = 0; i < initialListenerCount; ++i)",
+ @" port?.onMessage.addListener(() => { ++messageDispatchCount })",
+
+ @"port?.onDisconnect.addListener(() => {",
+ @" ++disconnectDispatchCount",
+ @" for (let i = 0; i < 1024; ++i)",
+ @" port?.onDisconnect.addListener(() => { })",
+ @"})",
+
+ @"for (let i = 0; i < initialListenerCount; ++i)",
+ @" port?.onDisconnect.addListener(() => { ++disconnectDispatchCount })",
+
+ @"port?.onDisconnect.addListener(() => {",
+ @" ++disconnectDispatchCount",
+ @" browser.test.assertEq(messageDispatchCount, initialListenerCount + 1, 'Should dispatch to the listeners registered when onMessage fired')",
+ @" browser.test.assertEq(disconnectDispatchCount, initialListenerCount + 2, 'Should dispatch to the listeners registered when onDisconnect fired')",
+ @" browser.test.notifyPass()",
+ @"})",
+
+ @"port?.postMessage('Hello')"
+ ]);
+
+ auto manager = Util::loadExtension(runtimeManifest, @{ @"background.js": backgroundScript });
+
+ [manager.get().context setPermissionStatus:WKWebExtensionContextPermissionStatusGrantedExplicitly forPermission:WKWebExtensionPermissionNativeMessaging];
+
+ manager.get().internalDelegate.connectUsingMessagePort = ^(WKWebExtensionMessagePort *messagePort) {
+ messagePort.messageHandler = ^(id message, NSError *error) {
+ [messagePort sendMessage:@"Received" completionHandler:^(NSError *) { }];
+ [messagePort disconnectWithError:nil];
+ };
+ };
+
+ [manager run];
+}
+
TEST(WKWebExtensionAPIRuntime, ConnectNativeWithInvalidMessage)
{
auto *backgroundScript = Util::constructScript(@[
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-43704 |
| Bugzilla | 314642 |
| Component | WebKit |
| Bug Class | UAF |
| Severity | medium |
| Commit | 1759ab219c63940b… |
| Advisory | Apple Advisory |
Terminology
- WebExtension events — chrome/browser-style extension events (runtime.Port.onMessage/onDisconnect, devtools panel onShown) dispatch to registered JS listeners.
- Iterator invalidation — Mutating a collection while iterating by reference invalidates iterators and can free elements still being visited.
- Snapshot + RefPtr — Copying the list and holding a RefPtr per element makes dispatch resilient to add/remove during callbacks.
Root Cause Analysis
This fixes a use-after-free caused by mutating an event-listener list while iterating it during dispatch in the WebKit web-extension APIs. Three dispatch routines iterated their listener collections by reference and invoked each listener’s JS callback: WebExtensionContextProxy::dispatchDevToolsExtensionPanelShownEvent looped over extensionPanel->onShown().listeners(), and WebExtensionAPIPort::fireMessageEventIfNeeded / fireDisconnectEventIfNeeded looped over m_onMessage->listeners() / m_onDisconnect->listeners(), each calling listener->call(…). Calling into JS can run extension code that adds or removes listeners, mutating the very collection being iterated; the reference-based range-for then walks invalidated iterators / freed listener objects — a use-after-free. The fix takes a copy of the listener list before iterating (auto listenersCopy = …listeners()) and iterates it as for (RefPtr listener : listenersCopy), so iteration proceeds over a stable snapshot and each listener is retained (RefPtr) across its call(). The restored invariant is that dispatch iterates an immutable snapshot and keeps every listener alive across the callback, regardless of listener mutations triggered by the callback. The regression test registers many onMessage/onDisconnect listeners, and inside a firing listener adds 1024 more, asserting the dispatch counts match the listeners present when the event fired (i.e. no crash and snapshot semantics).
Attack Path
1. Register listeners
A malicious/buggy web extension registers listeners on a runtime port (onMessage/onDisconnect) or a DevTools panel onShown event.
2. Mutate during dispatch
When the event fires, a listener callback adds or removes listeners, mutating the collection mid-iteration.
3. Iterate freed entries
The reference-based loop walks invalidated iterators / freed listener objects.
4. Use-after-free
Dispatch dereferences a freed listener in the WebContent (extension world) process.
Impact Assessment
A use-after-free in the WebContent process (extension content world), reachable by a malicious web extension that mutates its own listener set during event dispatch. It requires an installed/malicious extension, narrowing reach, but the UAF is attacker-controlled and can escalate beyond the advisory’s crash with grooming.
Audit Directions
Other extension dispatch loops
Grep the WebExtension API .mm files for for (… : …listeners()) followed by listener->call() without a prior copy; each is a candidate UAF.
Observer callbacks that reenter
Audit event/observer dispatch across WebKit for iteration over live lists whose callbacks can mutate the list.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
WebExtensionContextProxy::dispatchDevToolsExtensionPanelShownEvent |
Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIDevToolsExtensionPanelCocoa.mm |
modified | Copies onShown().listeners() and iterates the snapshot as RefPtr listeners before calling each, so mutations during call() are safe. |
WebExtensionAPIPort::fireMessageEventIfNeeded |
Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIPortCocoa.mm |
modified | Iterates a copied m_onMessage listener list with RefPtr elements to avoid UAF when call() mutates the listeners. |
WebExtensionAPIPort::fireDisconnectEventIfNeeded |
Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIPortCocoa.mm |
modified | Same snapshot-and-retain fix for the onDisconnect listener dispatch. |
Files Changed
Source Files
Source/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIDevToolsExtensionPanelCocoa.mmSource/WebKit/WebProcess/Extensions/API/Cocoa/WebExtensionAPIPortCocoa.mm
Test Files
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm
index 36df19179b7d..0af1f19888e2 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebExtensionAPIRuntime.mm
@@ -1724,6 +1724,57 @@ TEST(WKWebExtensionAPIRuntime, ConnectNative)
[manager run];
}
+TEST(WKWebExtensionAPIRuntime, PortMutateListenersDuringDispatch)
+{
+ auto *backgroundScript = Util::constructScript(@[
+ @"const port = browser.runtime?.connectNative('test')",
+
+ @"const initialListenerCount = 64",
+ @"let messageDispatchCount = 0",
+ @"let disconnectDispatchCount = 0",
+
+ @"port?.onMessage.addListener(() => {",
+ @" ++messageDispatchCount",
+ @" for (let i = 0; i < 1024; ++i)",
+ @" port?.onMessage.addListener(() => { })",
+ @"})",
+
+ @"for (let i = 0; i < initialListenerCount; ++i)",
+ @" port?.onMessage.addListener(() => { ++messageDispatchCount })",
+
+ @"port?.onDisconnect.addListener(() => {",
+ @" ++disconnectDispatchCount",
+ @" for (let i = 0; i < 1024; ++i)",
+ @" port?.onDisconnect.addListener(() => { })",
+ @"})",
+
+ @"for (let i = 0; i < initialListenerCount; ++i)",
+ @" port?.onDisconnect.addListener(() => { ++disconnectDispatchCount })",
+
+ @"port?.onDisconnect.addListener(() => {",
+ @" ++disconnectDispatchCount",
+ @" browser.test.assertEq(messageDispatchCount, initialListenerCount + 1, 'Should dispatch to the listeners registered when onMessage fired')",
+ @" browser.test.assertEq(disconnectDispatchCount, initialListenerCount + 2, 'Should dispatch to the listeners registered when onDisconnect fired')",
+ @" browser.test.notifyPass()",
+ @"})",
+
+ @"port?.postMessage('Hello')"
+ ]);
+
+ auto manager = Util::loadExtension(runtimeManifest, @{ @"background.js": backgroundScript });
+
+ [manager.get().context setPermissionStatus:WKWebExtensionContextPermissionStatusGrantedExplicitly forPermission:WKWebExtensionPermissionNativeMessaging];
+
+ manager.get().internalDelegate.connectUsingMessagePort = ^(WKWebExtensionMessagePort *messagePort) {
+ messagePort.messageHandler = ^(id message, NSError *error) {
+ [messagePort sendMessage:@"Received" completionHandler:^(NSError *) { }];
+ [messagePort disconnectWithError:nil];
+ };
+ };
+
+ [manager run];
+}
+
TEST(WKWebExtensionAPIRuntime, ConnectNativeWithInvalidMessage)
{
auto *backgroundScript = Util::constructScript(@[