CVE-2026-43804
Overview
Impact:
Description: Visiting a website may lead to an app denial-of-service
Researchers: Heiko Kiesel of SEEMOO, TU Darmstadt
Key insight. Programmatic focus of another window via a named-target window.open was gated on page visibility but not on consuming a transient user activation, letting a page refocus its opener (a popunder) with no fresh gesture.
Patch Details
diff --git a/Source/WebCore/loader/FrameLoader.cpp b/Source/WebCore/loader/FrameLoader.cpp
index b8b82805eb5b..0191bc145bab 100644
--- a/Source/WebCore/loader/FrameLoader.cpp
+++ b/Source/WebCore/loader/FrameLoader.cpp
@@ -4872,7 +4872,8 @@ std::pair<RefPtr<Frame>, CreatedNewPage> createWindow(LocalFrame& openerFrame, F
if (!request.frameName().isEmpty() && !isBlankTargetFrameName(request.frameName())) {
if (RefPtr frame = openerFrame.loader().findFrameForNavigation(request.frameName(), openerFrame.protectedDocument().get())) {
if (!isSelfTargetFrameName(request.frameName())) {
- if (RefPtr page = frame->page(); page && isInVisibleAndActivePage(openerFrame))
+ RefPtr openerWindow = openerFrame.window();
+ if (RefPtr page = frame->page(); page && isInVisibleAndActivePage(openerFrame) && openerWindow && openerWindow->consumeTransientActivation())
page->chrome().focus();
}
frame->updateOpener(openerFrame);
diff --git a/Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm b/Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm
index bfdac0f91dfd..70993eff08d7 100644
--- a/Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm
@@ -269,6 +269,70 @@ HTTPServer server({
// during popup creation.
EXPECT_FALSE(focusCalled);
}
+
+TEST(VerifyUserGesture, PopunderPreventedViaDualEventListeners)
+{
+ auto openerHTML = "<script>"
+ "window.name = 'opener';"
+ "let popup;"
+ "let opened = false;"
+ "addEventListener('pointerdown', () => {"
+ " if (!opened) {"
+ " opened = true;"
+ " popup = window.open('https://domain2.com/popup');"
+ " }"
+ "});"
+ "addEventListener('click', () => {"
+ " if (popup && popup.refocusOpener) popup.refocusOpener();"
+ "});"
+ "</script>"_s;
+ auto popupHTML = "<script>"
+ "function refocusOpener() { window.open('', 'opener'); }"
+ "</script>"_s;
+ HTTPServer server({
+ { "/opener"_s, { openerHTML } },
+ { "/popup"_s, { popupHTML } }
+ }, HTTPServer::Protocol::HttpsProxy);
+
+ RetainPtr configuration = server.httpsProxyConfiguration();
+ RetainPtr navigationDelegate = adoptNS([TestNavigationDelegate new]);
+ [navigationDelegate allowAnyTLSCertificate];
+ RetainPtr uiDelegate = adoptNS([TestUIDelegate new]);
+ RetainPtr openerWebView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ [openerWebView setNavigationDelegate:navigationDelegate.get()];
+ [openerWebView setUIDelegate:uiDelegate.get()];
+ [openerWebView configuration].preferences.javaScriptCanOpenWindowsAutomatically = YES;
+
+ __block RetainPtr<TestWKWebView> popupWebView;
+ __block RetainPtr<TestNavigationDelegate> popupNavigationDelegate;
+ uiDelegate.get().createWebViewWithConfiguration = ^(WKWebViewConfiguration *configuration, WKNavigationAction *navigationAction, WKWindowFeatures *) {
+ [navigationAction._userInitiatedAction consume];
+ popupNavigationDelegate = adoptNS([TestNavigationDelegate new]);
+ [popupNavigationDelegate allowAnyTLSCertificate];
+ popupWebView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration]);
+ [popupWebView setNavigationDelegate:popupNavigationDelegate.get()];
+ return popupWebView.get();
+ };
+
+ __block bool focusCalled = false;
+ uiDelegate.get().focusWebView = ^(WKWebView *) {
+ focusCalled = true;
+ };
+
+ [openerWebView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://domain1.com/opener"]]];
+ [navigationDelegate waitForDidFinishNavigation];
+
+ [openerWebView mouseDownAtPoint:CGPointMake(50, 50) simulatePressure:NO];
+ [openerWebView mouseUpAtPoint:CGPointMake(50, 50)];
+ [openerWebView waitForPendingMouseEvents];
+ while (!popupWebView)
+ Util::spinRunLoop();
+ [popupNavigationDelegate waitForDidFinishNavigation];
+
+ Util::spinRunLoop(10);
+
+ EXPECT_FALSE(focusCalled);
+}
#endif
}
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-43804 |
| Bugzilla | 316816 |
| Component | WebCore |
| Bug Class | Bypass |
| Severity | medium |
| Commit | 2057f457fb75d4e5… |
| Advisory | Apple Advisory |
Terminology
- Transient activation — A short-lived ’the user just interacted’ state that gates privileged actions like opening or focusing windows; it can be consumed so a single gesture is not reused.
- Named-target window.open — window.open(url, name) reuses an existing browsing context whose window.name matches, rather than creating a new one — the path createWindow handles here.
- Popunder — An abuse where a page opens a popup and then refocuses itself, leaving the popup hidden beneath the main window.
- consumeTransientActivation() — Checks that a valid transient activation exists and clears it, so the gesture cannot be spent twice.
Root Cause Analysis
createWindow in FrameLoader.cpp implements window.open(url, name) when the name targets an already-existing frame. When a matching frame is found and the name is not _self, the code raised/focused the target’s chrome with page->chrome().focus(), gated only on isInVisibleAndActivePage(openerFrame). It never required — or consumed — a transient user activation. As a result script could programmatically pull focus to an existing window (typically its opener) with no genuine user gesture, defeating the popup/popunder protections that are supposed to require a real activation: the opener spawns a popup and then, later, calls window.open('', 'opener') to yank focus back to itself, leaving the popup underneath (a ‘popunder’). The fix captures RefPtr openerWindow = openerFrame.window() and adds && openerWindow && openerWindow->consumeTransientActivation() to the focus condition. consumeTransientActivation() both verifies a live transient activation exists and spends it, so (a) script with no activation cannot focus another window at all, and (b) one user gesture can no longer be replayed to steal focus repeatedly. The added API test PopunderPreventedViaDualEventListeners reproduces the bypass: a pointerdown listener opens the popup and a separate click listener calls back into the opener via window.open('', 'opener'), a dual-listener split that previously slipped past the single-activation check. The restored invariant is that programmatic cross-window focus through a named-target window.open requires and consumes a fresh transient user activation. This is a logic / UI-integrity defect, not a memory-safety bug.
Attack Path
1. Register split listeners
Opener page adds a pointerdown listener and a click listener; browsers deliver pointerdown before click for one physical press.
2. Open the popup on pointerdown
On the first pointerdown the page calls window.open(‘https://victim/popup’) while it still holds activation.
3. Refocus the opener on click
On the subsequent click the page calls popup.refocusOpener(), which runs window.open(’’, ‘opener’) in the popup targeting the opener by name.
4. Steal focus without a fresh gesture
Pre-patch createWindow focuses the opener’s chrome gated only on visibility, so focus returns to the opener and the popup is left underneath — a popunder achieved without consuming an activation.
Impact Assessment
No memory-safety primitive is involved. The impact is UI-integrity abuse: a page can steal window focus / create popunders without a real user gesture, which is disruptive (the advisory frames it as an app denial-of-service) and can assist clickjacking or phishing by controlling which window is frontmost. It is confined to WebContent window-management behavior and does not lead to code execution. Rated medium (CVSS 6.5).
Audit Directions
Other script-reachable focus paths
grep loader/ and page/ for chrome().focus( and ->focus( reached from script without a nearby consumeTransientActivation() check.
Reusable-gesture checks
Find places that call hasTransientActivation()/isProcessingUserGesture() but never consume it — grep hasTransientActivation versus consumeTransientActivation — since those allow one gesture to authorize repeated privileged actions.
Other named-target behaviors
Audit updateOpener/setOpener and other createWindow branches gated on isInVisibleAndActivePage rather than on activation for the same missing-gesture gap.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
createWindow |
Source/WebCore/loader/FrameLoader.cpp |
modified | When targeting an existing named (non-self) frame, now also requires openerFrame.window()->consumeTransientActivation() before calling page->chrome().focus(), so focus needs and spends a real user gesture. |
VerifyUserGesture.PopunderPreventedViaDualEventListeners |
Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm |
added | Regression test asserting focusWebView is not called when the opener uses the pointerdown/click dual-listener trick to refocus itself. |
Files Changed
Source Files
Source/WebCore/loader/FrameLoader.cpp
Test Files
Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm b/Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm
index bfdac0f91dfd..70993eff08d7 100644
--- a/Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm
@@ -269,6 +269,70 @@ TEST(VerifyUserGesture, PopunderPreventedByConsumedAction)
// during popup creation.
EXPECT_FALSE(focusCalled);
}
+
+TEST(VerifyUserGesture, PopunderPreventedViaDualEventListeners)
+{
+ auto openerHTML = "<script>"
+ "window.name = 'opener';"
+ "let popup;"
+ "let opened = false;"
+ "addEventListener('pointerdown', () => {"
+ " if (!opened) {"
+ " opened = true;"
+ " popup = window.open('https://domain2.com/popup');"
+ " }"
+ "});"
+ "addEventListener('click', () => {"
+ " if (popup && popup.refocusOpener) popup.refocusOpener();"
+ "});"
+ "</script>"_s;
+ auto popupHTML = "<script>"
+ "function refocusOpener() { window.open('', 'opener'); }"
+ "</script>"_s;
+ HTTPServer server({
+ { "/opener"_s, { openerHTML } },
+ { "/popup"_s, { popupHTML } }
+ }, HTTPServer::Protocol::HttpsProxy);
+
+ RetainPtr configuration = server.httpsProxyConfiguration();
+ RetainPtr navigationDelegate = adoptNS([TestNavigationDelegate new]);
+ [navigationDelegate allowAnyTLSCertificate];
+ RetainPtr uiDelegate = adoptNS([TestUIDelegate new]);
+ RetainPtr openerWebView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ [openerWebView setNavigationDelegate:navigationDelegate.get()];
+ [openerWebView setUIDelegate:uiDelegate.get()];
+ [openerWebView configuration].preferences.javaScriptCanOpenWindowsAutomatically = YES;
+
+ __block RetainPtr<TestWKWebView> popupWebView;
+ __block RetainPtr<TestNavigationDelegate> popupNavigationDelegate;
+ uiDelegate.get().createWebViewWithConfiguration = ^(WKWebViewConfiguration *configuration, WKNavigationAction *navigationAction, WKWindowFeatures *) {
+ [navigationAction._userInitiatedAction consume];
+ popupNavigationDelegate = adoptNS([TestNavigationDelegate new]);
+ [popupNavigationDelegate allowAnyTLSCertificate];
+ popupWebView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration]);
+ [popupWebView setNavigationDelegate:popupNavigationDelegate.get()];
+ return popupWebView.get();
+ };
+
+ __block bool focusCalled = false;
+ uiDelegate.get().focusWebView = ^(WKWebView *) {
+ focusCalled = true;
+ };
+
+ [openerWebView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://domain1.com/opener"]]];
+ [navigationDelegate waitForDidFinishNavigation];
+
+ [openerWebView mouseDownAtPoint:CGPointMake(50, 50) simulatePressure:NO];
+ [openerWebView mouseUpAtPoint:CGPointMake(50, 50)];
+ [openerWebView waitForPendingMouseEvents];
+ while (!popupWebView)
+ Util::spinRunLoop();
+ [popupNavigationDelegate waitForDidFinishNavigation];
+
+ Util::spinRunLoop(10);
+
+ EXPECT_FALSE(focusCalled);
+}
#endif
}