Released June 29, 2026

CVE-2026-43704

CVSS 5.3 severity medium CWE-416 vector CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:N/A:H

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.mm
  • Source/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(@[