← All reports

Popunder bypass via overlappping transient activations

LowWebCore FrameLoader —LogicError

CVE: CVE-2026-43804 · Safari 26.6 · Released July 27, 2026 Impact: Visiting a website may lead to an app denial-of-service Apple's description: This issue was addressed through improved state management. Credit: Heiko Kiesel of SEEMOO, TU Darmstadt

Severity: Low | Component: WebCore FrameLoader — createWindow() | a37a650 | Bugzilla 316816

Low — no memory corruption, no cross-origin read, nothing crossing a sandbox boundary. What it does hand web content is an unconditional raise-that-window primitive, because the guard in front of it asked whether the caller's page was on screen rather than whether a user had just touched it.

Every browser draws a line between things a page may do on its own and things it may only do because a user just asked for it. Window management sits firmly on the second side of that line: moving, resizing, closing, and raising top-level windows are all supposed to be downstream of a real interaction, enforced through the HTML user-activation model. WebCore::createWindow() is where window.open() lands in the WebContent process, and it has two very different exits — create a brand-new browsing context, or find an existing one by name and reuse it. The invariant at stake is that both exits are equally gated, because both are equally user-visible.

The angle: A page and a popup it opened could reorder themselves in front of each other at any moment with no user interaction at all — enough to bury a window behind the one the user is reading, or to thrash focus between the two.

Source/WebCore/loader/FrameLoader.cpp

if (!request.frameName().isEmpty() && !isBlankTargetFrameName(request.frameName())) {
if (RefPtr frame = openerFrame.loader().findFrameForNavigation(request.frameName(), protect(openerFrame.document()).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->hasTransientActivation())
page->chrome().focus();
}
if (!features.wantsNoOpener())

Tools/TestWebKitAPI/Tests/WebKit/WKWebView/VerifyUserGestureFromUIProcess.mm

+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;
...
+ __block bool focusCalled = false;
+ uiDelegate.get().focusWebView = ^(WKWebView *) {
+ focusCalled = true;
+ };
+
+ [openerWebView mouseDownAtPoint:CGPointMake(50, 50) simulatePressure:NO];
+ [openerWebView mouseUpAtPoint:CGPointMake(50, 50)];
+ [openerWebView waitForPendingMouseEvents];
...
+ EXPECT_FALSE(focusCalled);
+}

The functional change is one line. In the named-target branch of WebCore::createWindow() — the branch taken when request.frameName() is non-empty, is not _blank, and resolves through openerFrame.loader().findFrameForNavigation() to a frame that already exists — the guard in front of page->chrome().focus() gains two new conjuncts. The patch hoists RefPtr openerWindow = openerFrame.window(); out of the if-initializer and extends the condition to page && isInVisibleAndActivePage(openerFrame) && openerWindow && openerWindow->hasTransientActivation(). Raising the target window now requires that the window which called window.open() currently holds a live transient activation.

Note what the patch does not do: it requires the activation without consuming it. The commit message is explicit that this is a deliberate divergence from the version that shipped on the release branch (305413.1061@safari-7624.5-branch), which did consume. On trunk, consuming here would regress imported/w3c/web-platform-tests/html/browsers/windows/consume-user-activation/window-open.html, because 311026@main had already narrowed window.open()'s consumption to the case where it actually creates a new browsing context, per the spec's "rules for choosing a navigable". The named-reuse path creates nothing, so it must not spend the activation.

The remainder of the diff is a regression test, TEST(VerifyUserGesture, PopunderPreventedViaDualEventListeners), which reproduces the reported bypass shape end to end: a real mouseDown/mouseUp pair over an opener page at domain1.com that names itself opener and opens a popup at domain2.com from its pointerdown handler, then — from its click handler, i.e. a different event born of the same physical click — calls into the popup to run window.open('', 'opener'). The assertion is that the opener's focusWebView UI-delegate callback is never invoked.

The user-activation model. Browsers distinguish script that runs on its own initiative from script running as a direct consequence of user input. Interactions such as a click or a key press set a short-lived per-window flag — a transient activation — that gated APIs consult before doing something the user would notice. LocalDOMWindow::hasTransientActivation() reports whether that flag is currently live; consumeTransientActivation() spends it, so that a single interaction authorizes exactly one gated action.

Where activation travels. When a document receives an activation, the HTML model propagates it to ancestor frames and to same-origin descendants within the same frame tree. It does not cross into other top-level browsing contexts. A newly opened popup therefore starts life with no activation of its own and acquires one only when the user interacts with the popup directly.

Named window targeting. window.open(url, name) does not unconditionally create a window. It first looks for an existing browsing context whose name matches; FrameLoader::findFrameForNavigation() performs that lookup, restricted to the frames the caller is permitted to reach. The _blank and _self keywords are recognized separately by isBlankTargetFrameName() and isSelfTargetFrameName() and handled on their own paths.

createWindow(). This is the WebCore function behind window.open(). It returns a std::pair<RefPtr<Frame>, CreatedNewPage>: when the named target already exists it reuses that frame and reports that no new page was created; otherwise it asks the embedder to create one. The two outcomes take structurally different routes through the function.

Chrome::focus() and the predicate in front of it. Chrome::focus() is WebCore's request that the embedder bring the window hosting a given Page to the front — in the API test it surfaces as the TestUIDelegate's focusWebView block. isInVisibleAndActivePage(frame) is a predicate over page state: whether the frame's page is currently visible and sitting in an active window.

Popunder. A window-ordering technique in which content opens a second window and then arranges for the original window to be raised above it. The new window stays open but sits hidden behind the one the user is actually looking at.

RefPtr. WebKit's reference-counted smart pointer. openerFrame.window() returns the frame's LocalDOMWindow, held here in a local RefPtr for the duration of the check.

This is an ambient-authority check standing in for a capability check.

  Before:                                  After:
  window.open('', 'opener')                window.open('', 'opener')
    └─► findFrameForNavigation()             └─► findFrameForNavigation()
          └─► isInVisibleAndActivePage?            └─► isInVisibleAndActivePage?
                 "am I on screen?"  ── yes ─┐            AND hasTransientActivation?
                                            │               "did a user touch ME?"
                 ┌──────────────────────────┘                    │
                 ▼                                   popup: no ──┴──► blocked
            chrome().focus()  ← no gesture required

In the diagram, the left column is the whole of the pre-fix authorization: isInVisibleAndActivePage(openerFrame) answers "is the calling page currently on screen and in an active window?" — a description of the environment the caller happens to find itself in, not a grant the caller holds. For a popup that an attacker's page just opened and that is sitting frontmost, the answer is trivially yes, permanently. The predicate takes a frame and reads state reachable from it; nothing in it is bound to a specific user interaction. The right column adds the missing question, and the popup's answer to it is no, by construction.

The mechanism follows directly. Because createWindow() short-circuits into the named-target branch whenever the name resolves to an existing frame, none of the popup-blocking or new-window activation checks that guard the create-a-page path are consulted — the call degenerates into a pure "raise that window" primitive available to any frame that can name-resolve a target through findFrameForNavigation(). In practice that means windows within the attacker's own browsing-context group: the opener/openee chains it created itself. window.open('', 'openerName') from the popup, with an empty URL so nothing even navigates, is the whole exploit.

The "overlapping transient activations" in the title name the specific bypass the reporter found against the previous state of this code, and the regression test encodes it precisely. A single physical click is not one activation-bearing event; it is a sequence:

  1. pointerdown fires on the opener. The handler calls window.open('https://domain2.com/popup') — a genuine new-browsing-context creation, which consumes the activation.
  2. The popup loads and runs its script, defining refocusOpener().
  3. click fires on the opener — same physical interaction, but a distinct event that re-mints a transient activation on the opener's window.
  4. The opener's click handler reaches into the popup and invokes popup.refocusOpener(), which runs window.open('', 'opener').
  5. That resolves the existing opener frame by name, skips the creation path entirely, and calls page->chrome().focus() — raising the opener above the popup. Popunder achieved.

A consumption-based mitigation applied at popup-creation time is satisfied and spent by step 1, while step 3 hands the page a fresh token anyway. That is why the fix is placed at the focus site itself rather than upstream, and why requiring is the right verb: the call in step 4 originates in the popup's realm, and the popup's window has never held an activation, because activation propagates only within a frame tree and never across top-level windows. Requiring blocks the popunder exactly as consuming would, without spending a token the spec says this path has no business spending.

The reach of this is bounded. The vulnerable call originates in the WebContent process and travels outward through Chrome::focus() → ChromeClient::focus() to the UI process, which performs the actual raise. No sandbox boundary is crossed in the attacker's favor and no additional capability lands in the WebContent process — the UI process simply carries out a legitimate UI action for a caller that should not have been authorized to ask. What the attacker gets is arbitrary z-order control over their own window set: popunder placement of an ad or phishing surface, focus stealing, or, driven in a loop, the focus thrashing that Apple's advisory classifies as an app denial-of-service. There is no memory-safety, cross-origin data-access, or sandbox consequence.

A privileged cross-window raise was gated on "is my page visible and active" — ambient state a popup satisfies by default — rather than on a user-interaction token the popup can never hold.

This commit is also an instructive case of a fix that was correct on a release branch and subtly wrong when forward-ported. The branch version consumed the activation; trunk had since narrowed window.open()'s consumption semantics to new-browsing-context creation only (311026@main), so consuming on the named-reuse path would have broken a WPT conformance test. The load-bearing argument for the trunk shape is a threat-model observation rather than a policy one — the popup's principal can never obtain the token in the first place, so a require-check is exactly as strong as a consume-check here. That generalizes: consume only where the spec says to, and prefer a bare requirement when the attacker's principal provably cannot acquire the grant.