← WebKit — 2026-W41

fc5ea9bf36dbf8572e118e5dd572aff39b8f5c74  [Site Isolation] Crash in WebPageProxy::continueNavigationInNewProcess when the page closes while a navigate event is pending

severity medium class LogicError confidence 0.6 signal score 6.05 Source/WebKit/UIProcess patch-now AI review CONFIRMED
Primitive: stale/closed page or swapped main frame processed after async navigation-action policy decision
Triage note: receivedNavigationActionPolicyDecision adds a guard that cancels when the page isClosed(), m_mainFrame is cleared, the navigation no longer exists, or mainFrameIDBeforeNavigationActionDecision != the current main frame ID. Fixes acting on stale navigation state (null main frame / main-frame swap during the async reply), a navigation state-confusion bug.
Structural signals: uaf_lifetime (+4.75), surgical (+1.3)

The bug at a glance

WebPageProxy::receivedNavigationActionPolicyDecision applies an asynchronously-delivered navigation-action policy decision, but did not revalidate that the page and its main frame were still the same (or still existed) when the reply arrived; the patch adds a guard that cancels if the page isClosed(), m_mainFrame is null/cleared, the navigation no longer exists, or the main frame was swapped during the async window. The proven defect is UIProcess navigation state-confusion / use of stale state (including a potential null m_mainFrame dereference) after an async hop; triage LogicError/medium is appropriate. A crash or acting on the wrong main frame is demonstrable; any UAF/RCE escalation is not provable from this diff and is marked (inferred).

The decision handler is an async completion handler captured over protectedThis, so the WebPageProxy survives, but everything it points at (m_mainFrame, the navigation, the main-frame identity) can change underneath it during the embedder’s policy decision — and the pre-patch code trusted all of it.

Root cause

The violated invariant is that a navigation-action policy decision must be applied only to the same live navigation and main frame it was requested for. receivedNavigationActionPolicyDecision() requests a policy decision from the client and resumes in an async completion handler. During that window the page can be closed, m_mainFrame can be reset to null, the navigation can be removed from m_navigationState, or (notably under site isolation / process swap) the main frame can be replaced by a different one.

Pre-patch, the handler proceeded to receivedPolicyDecision() using the captured navigation and the current m_mainFrame without checking any of this, so it could: dereference a now-null m_mainFrame, apply a decision to a navigation that no longer exists, or commit a decision against a main frame that was swapped in after the request. The patch snapshots the current main frame id (currentMainFrameID = protectedThis->m_mainFrame ? m_mainFrame->frameID() : nullopt) and sets cancelled = true if protectedThis->isClosed(), or !protectedThis->m_mainFrame, or !m_navigationState->hasNavigation(navigation->navigationID()), or navigationAction->mainFrameIDBeforeNavigationActionDecision() is set and differs from currentMainFrameID. On cancel it funnels to receivedPolicyDecision(PolicyAction::Ignore, …).

Lifetime note: protectedThis is a Ref/RefPtr keeping the WebPageProxy itself alive, so this is not a UAF of the proxy; the defect is logical use of stale/standin state (and a null-deref of the main frame pointer) rather than a freed-object access. The guard relies on mainFrameIDBeforeNavigationActionDecision() having been captured before the async request as the identity baseline for detecting a main-frame swap.

Key code

Revalidating page/navigation/main-frame identity before applying the async decision

// The page may have been closed or reset (e.g. m_mainFrame cleared) while waiting for the reply.
auto currentMainFrameID = protectedThis->m_mainFrame ? std::optional<WebCore::FrameIdentifier> { protectedThis->m_mainFrame->frameID() } : std::nullopt;
if (protectedThis->isClosed()
    || !protectedThis->m_mainFrame
    || !protectedThis->m_navigationState->hasNavigation(navigation->navigationID())
    || (navigationAction->mainFrameIDBeforeNavigationActionDecision() && navigationAction->mainFrameIDBeforeNavigationActionDecision() != currentMainFrameID))
    cancelled = true;

if (cancelled) {
    protectedThis->receivedPolicyDecision(PolicyAction::Ignore, navigation.ptr(), std::nullopt, WTF::move(navigationAction), WillContinueLoadInNewProcess::No, std::nullopt, WTF::move(message), WTF::move(completionHandler));
    return;
}

Fix approach

The patch adds a liveness-and-identity revalidation at the top of the async policy-decision continuation. Before committing, it checks that the page is not closed, that m_mainFrame still exists, that the navigation is still tracked by m_navigationState (hasNavigation(navigationID)), and that the main frame captured before the request (mainFrameIDBeforeNavigationActionDecision()) still matches the current m_mainFrame->frameID(); any mismatch forces cancelled = true and routes to receivedPolicyDecision(PolicyAction::Ignore, …), safely dropping the stale decision. This closes the state-confusion window without touching the proxy’s own lifetime (protectedThis already keeps it alive); the net effect is that a decision computed for a now-gone or swapped main frame/navigation is ignored instead of applied.

Patch walkthrough

  • Source/WebKit/UIProcess/WebPageProxy.cpp — In receivedNavigationActionPolicyDecision’s async completion handler, adds the isClosed()/null-m_mainFrame/missing-navigation/main-frame-swap guard that sets cancelled before the existing cancelled branch, so a stale decision becomes PolicyAction::Ignore instead of being applied to invalid state.

Background

Navigation-action policy decision — WebPageProxy asks the embedder/client whether a navigation may proceed; the answer arrives asynchronously via a completion handler, opening a window for state to change.

WebPageProxy / m_mainFrame — The UIProcess object for a web page and its current main frame; m_mainFrame can be cleared on page close or replaced on a process swap.

Process swap / main-frame swap — Under site isolation a cross-site navigation can swap the main frame to a different process/frame object; mainFrameIDBeforeNavigationActionDecision() records the pre-decision identity to detect this.

m_navigationState / NavigationID — Tracks in-flight navigations; hasNavigation(navigationID) tells whether the navigation the decision was computed for still exists.

protectedThis pattern — The handler captures a Ref/RefPtr to WebPageProxy so the object outlives the async hop; this keeps the proxy alive but not the validity of what it references.

receivedPolicyDecision / PolicyAction::Ignore — The sink that applies or rejects a decision; the guard reroutes stale decisions to Ignore to drop them safely.

Vulnerability window

  1. Setup — Attacker page initiates a main-frame navigation whose policy decision the embedder resolves asynchronously (or that is subject to a process swap).
  2. Window — While the decision is pending, the page is closed, m_mainFrame is reset, the navigation is withdrawn, or the main frame is swapped.
  3. Trigger — The async completion handler fires and, pre-patch, proceeds to apply the decision using the stale navigation/main frame.
  4. Confusion — The decision is applied to a null/replaced main frame or a non-existent navigation, causing a null-deref or action on the wrong frame.
  5. Fix — The guard detects closed page / null main frame / missing navigation / main-frame swap and ignores the decision.

Proof of concept

No test ships in the diff. Conceptual trigger (web path inferred): start a main-frame navigation while an embedder policy delegate delays the decision (slow decidePolicyForNavigationAction), then during that window close the page/web view or trigger a cross-site navigation that swaps the main frame. When the delayed decision arrives it targets a cleared or replaced main frame; pre-patch it is applied (null-deref / wrong-frame action), post-patch it is ignored.

Exploitation

  1. Reach — Reachable where an attacker can start a main-frame navigation and influence timing so the async policy decision overlaps a page close or main-frame swap (easier with site isolation/process swaps).
  2. Trigger — Resolve the policy decision after m_mainFrame has been cleared or replaced, or after the navigation has been removed.
  3. Primitive — Primary: UIProcess navigation state confusion; secondary: a null m_mainFrame dereference (crash) or a policy decision applied to the wrong/swapped main frame.
  4. Escalation (inferred) — Open questions: whether applying a stale decision to a swapped-in main frame can be steered into a cross-origin commit or a controllable use of freed navigation/frame state; this small diff only establishes the state-confusion/null-deref, not a memory-safety primitive.

Detection & hunting

For defenders and detection engineers:

  • Policy decision after teardown — Log when receivedNavigationActionPolicyDecision resumes with isClosed(), null m_mainFrame, a missing navigation, or a changed main-frame id; pre-patch these were applied.
  • Main-frame id mismatch — Compare mainFrameIDBeforeNavigationActionDecision() against the current m_mainFrame->frameID() at decision time to catch swaps during the async window.
  • Null-frame crashes in policy path — Triage UIProcess crashes dereferencing m_mainFrame around navigation policy completion handlers.

Remaining risk in the patch

  • Swap check is conditional — The main-frame-swap comparison is gated by ’navigationAction->mainFrameIDBeforeNavigationActionDecision() &&’, so a navigation whose pre-decision main-frame id was never recorded skips the swap detection entirely.
  • Main frame only — The guard revalidates page/navigation/main-frame identity but does not check the specific target subframe; a subframe swap or removal during the async window is not covered by this check.
  • Unverified sink — receivedPolicyDecision() and the navigation/navigationAction object lifetimes are not in the supplied diff, so whether Ignoring the decision fully and safely unwinds all captured state is unverified from the patch alone.

Audit directions

  • Other async navigation handlers — Audit receivedNavigationResponsePolicyDecision and similar completion handlers that capture navigation/frame state for the same missing liveness/identity revalidation.
  • Subframe-targeted decisions — The guard only revalidates the main frame; check that decisions targeting subframes also verify the target frame still exists and was not swapped.
  • Navigation withdrawal races — Review all sites that rely on m_navigationState->hasNavigation to ensure navigations removed mid-flight cannot be acted upon.
  • mainFrameIDBeforeNavigationActionDecision capture — Verify this id is always captured before the async request so the swap check is not silently skipped when it is unset.

Before / after

Loading diff…