Medium CVSS 6.5 webkit Bypass 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionVisiting a website may lead to an app denial-of-service
ComponentWebCore Loader
Bug ClassBypass
Tracker316816
Fix commit2057f457fb75 (WebKit/WebKit) +66/-1
CWECWE-284
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedHeiko Kiesel of SEEMOO, TU Darmstadt
Disclosed2026-07-27

Background

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.

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.

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).

Changed Functions

FunctionChangeNotes
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/WebCore/loader/FrameLoader.cpp
  • Tools/TestWebKitAPI/Tests/WebKitCocoa/VerifyUserGestureFromUIProcess.mm

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.
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
 
 }
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.