← WebKit Silent-Fix Report — 2026-W22

fd143209a4  [Site Isolation] Restoring a page with a cross-site iframe from the back/forward cache terminates the iframe process due to a stale first party for cookies

severity medium class CrossOrigin confidence 0.60 WebKit Site Isolation / bfcache exploitable-grade
Basuke Suzuki Fri May 29 19:23:51 2026 -0700 full: fd143209a425a1b3dafaca50b2f6ff9a7fa153bc bug report ↗ view on GitHub ↗
Primitive: wrong first-party-for-cookies on cross-site iframe restore
Triage note: Fix sends authoritative main-frame URL+origin so cross-site iframe cookie resolution is correct rather than resolved to the wrong site (and no longer terminates the process).
Contents

The bug at a glance

OBSERVED: under Site Isolation with MultiProcessBackForwardCacheEnabled, restoring a page with a cross-site iframe from BFCache after a cross-site navigation caused the iframe’s first party for cookies to resolve to the wrong site (b.com instead of the a.com main frame); the NetworkProcess then denied the access and terminated the iframe process. INFERRED severity: the observable failure is an iframe-process termination (denial of service / broken restore), but the same stale-top-URL condition is a cross-origin cookie-context confusion, which is a privacy/security-relevant class. Rated medium: the shipped effect is a process kill and a mis-resolved (over-restrictive) first party rather than a demonstrated cross-origin cookie disclosure, but it touches the first-party-for-cookies boundary in a multi-process trust context.

During a cross-site main-frame navigation (a.com -> b.com), the new top document’s DocumentSyncData (b.com URL) is broadcast to every web-content process, including the about-to-be-suspended frame.com iframe process. On goBack, the iframe restore reads the page’s main-frame URL to compute the first party for cookies, but that URL is still the stale b.com value because the iframe restore runs before the main frame re-broadcasts a.com’s sync data.

Root cause

Under Site Isolation, a cross-site iframe (frame.com inside a.com) lives in its own web-content process. With MultiProcessBackForwardCacheEnabled, when the top frame navigates cross-site (a.com -> b.com), the a.com page and its frame.com iframe are placed in the back/forward cache in their respective processes, and the iframe process is suspended. Restoring later goes through SuspendedPageProxy::unsuspend() (Source/WebKit/UIProcess/SuspendedPageProxy.cpp), which dispatches Messages::WebPage::RestoreWithFrameItem to each cached iframe process so it can restore its own CachedPage.

OBSERVED root cause (from the commit message): the cross-site navigation broadcasts the new top document’s DocumentSyncData (the b.com URL) to every web-content process, including the to-be-suspended frame.com process, overwriting that process’s top-document sync state. On goBack, the iframe restore path CachedPage::restore -> FrameLoader::open -> updateFirstPartyForCookies reads the page’s main-frame URL, which is still the stale b.com value. So the first party for cookies resolves to b.com instead of the a.com main frame. The NetworkProcess enforces that the first party matches an allowed value for the a.com page; b.com is not allowed, the access is denied, and the iframe process is terminated. Critically, this early first-party read happens before the main frame’s own restore re-broadcasts a.com’s sync data, so the iframe never sees the correction in time — an ordering bug, not just a missing update.

OBSERVED fix: RestoreWithFrameItem is extended to carry an optional std::pair<URL, WebCore::SecurityOriginData> mainFrameURLAndOrigin. In SuspendedPageProxy::unsuspend (the cross-site sender) the UIProcess supplies it authoritatively from the committed suspended main frame: Ref mainFrameOrigin = m_mainFrame->securityOrigin(); and { m_mainFrame->url(), mainFrameOrigin->data() }. The origin is taken from the committed main frame rather than derived from the URL, which the comment notes preserves sandbox/opaque-origin cases. In the iframe WebProcess, WebPage::restoreWithFrameItem calls page->setMainFrameURLAndOrigin(url, origin) before cachedPage->restore(*page), re-establishing the authoritative main-frame URL/origin so the first-party read resolves to a.com. The remaining top-document sync state is still fixed up later by the main frame’s own restore re-broadcast.

INFERRED scope of the nullopt path: WebPageProxy::goToBackForwardItem (the same-site in-process restore path) is updated to pass std::nullopt. Per the commit message this path does not exhibit the bug because the top URL stays on the a.com site and it has no authoritative restore-target origin, so restoreWithFrameItem simply skips the setMainFrameURLAndOrigin call. The security-relevant correction is confined to the cross-site SuspendedPageProxy sender.

Key code

UIProcess supplies the authoritative main-frame URL+origin into the cross-site restore (SuspendedPageProxy::unsuspend)

    // The cross-site navigation left each iframe process's top-document URL stale, so the restore would
    // resolve the first party for cookies to the wrong site and terminate the iframe. Send the authoritative
    // URL+origin from the committed main frame (origin not derived from the URL, so sandbox/opaque cases survive).
    Ref mainFrameOrigin = m_mainFrame->securityOrigin();
    std::optional<std::pair<URL, WebCore::SecurityOriginData>> mainFrameURLAndOrigin { { m_mainFrame->url(), mainFrameOrigin->data() } };

    m_browsingContextGroup->forEachRemotePage(*page, [suspendedPage = Ref { *this }, &aggregator, mainFrameItemID, mainFrameURLAndOrigin = WTF::move(mainFrameURLAndOrigin)](auto& remotePage) {
        Ref process = remotePage.siteIsolatedProcess();
        if (!suspendedPage->hasSubframeInProcess(process->coreProcessIdentifier()))
            return;
        process->sendWithAsyncReply(Messages::WebPage::RestoreWithFrameItem(mainFrameItemID, mainFrameURLAndOrigin), aggregator->chain(), remotePage.identifierInSiteIsolatedProcess());
    });

Patch walkthrough

  • Source/WebKit/UIProcess/SuspendedPageProxy.cpp — In unsuspend() (the cross-site restore sender), builds an authoritative mainFrameURLAndOrigin from the committed suspended main frame — m_mainFrame->url() and m_mainFrame->securityOrigin()->data() (origin taken from the frame, not derived from the URL, to preserve sandbox/opaque cases) — and passes it into each RestoreWithFrameItem sent to the cached iframe processes.
  • Source/WebKit/UIProcess/WebPageProxy.cpp — In goToBackForwardItem() (the same-site in-process restore path), passes std::nullopt for the new parameter. This path keeps the top URL on the correct site and has no authoritative restore-target origin, so it intentionally opts out of the override.
  • Source/WebKit/WebProcess/WebPage/WebPage.cpp — restoreWithFrameItem() gains the std::optional<std::pair<URL, SecurityOriginData>> parameter; before cachedPage->restore(*page) it calls page->setMainFrameURLAndOrigin(url, origin) when the optional is set, re-establishing the authoritative main-frame URL/origin so updateFirstPartyForCookies resolves to a.com instead of the stale b.com. The comment stresses the value must come from the UIProcess because the local top URL is still the stale cross-site value at this point.
  • Source/WebKit/WebProcess/WebPage/WebPage.h — Updates the restoreWithFrameItem declaration to add the std::optional<std::pair<URL, WebCore::SecurityOriginData>>&& parameter before the completion handler.
  • Source/WebKit/WebProcess/WebPage/WebPage.messages.in — Extends the RestoreWithFrameItem IPC message signature to carry std::optional<std::pair<URL, WebCore::SecurityOriginData>> mainFrameURLAndOrigin alongside the BackForwardFrameItemIdentifier.
  • Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SiteIsolation.mm — Adds TEST(SiteIsolation, GoBackToPageWithIframeBFCache): loads a.com with a cross-site frame.com iframe under MultiProcessBackForwardCacheEnabled, marks both the main and iframe documents, navigates cross-site to b.com, goes back, and asserts the BFCache markers survive (proving restore not reload) and that reading document.cookie in the iframe scope returns non-nil — exercising the first-party path that previously terminated the iframe process.

Background

Site Isolation + multi-process BFCache — A cross-site iframe runs in its own web-content process; with MultiProcessBackForwardCacheEnabled the iframe’s page is cached and its process suspended across a cross-site top navigation, then restored via RestoreWithFrameItem.

DocumentSyncData broadcast — On a top navigation the new top document’s sync data (including its URL) is pushed to all web-content processes, overwriting the suspended iframe process’s cached top-document URL with the cross-site value.

First party for cookies — Computed in FrameLoader from the page’s main-frame URL (updateFirstPartyForCookies). For a subframe it must reflect the top document; a stale top URL yields the wrong first party.

NetworkProcess first-party enforcement — The NetworkProcess validates that a page’s first party is an allowed value; when the iframe presented b.com as first party for an a.com page, the access was denied and the iframe process terminated.

SecurityOriginData from the committed frame — The fix carries the origin from m_mainFrame->securityOrigin()->data() rather than deriving it from the URL, so sandboxed/opaque main-frame origins are preserved across the restore.

Restore ordering — The iframe restore’s first-party read runs before the main frame re-broadcasts its correct sync data, so an after-the-fact update cannot fix it — the authoritative value must arrive with the restore message.

Vulnerability window

  1. Initial load — a.com loads with a cross-site frame.com iframe; the iframe runs in its own process under Site Isolation.
  2. Cross-site navigation — Top navigates a.com -> b.com; b.com’s DocumentSyncData (URL) is broadcast to all processes including the suspended frame.com process, overwriting its top-document URL.
  3. Suspension into BFCache — The a.com page and its iframe are cached; the iframe process is suspended with a now-stale top URL of b.com.
  4. goBack / bug — SuspendedPageProxy::unsuspend dispatches RestoreWithFrameItem; the iframe restore (CachedPage::restore -> FrameLoader::open -> updateFirstPartyForCookies) reads the stale b.com top URL, resolving the first party to b.com.
  5. Termination — The NetworkProcess denies the b.com first party for the a.com page and terminates the iframe process, breaking the restore.
  6. Fix (314199@main) — unsuspend sends the authoritative main-frame URL+origin; the iframe WebProcess calls setMainFrameURLAndOrigin before restore so the first party resolves to a.com; the same-site path passes nullopt.
  7. Verification — GoBackToPageWithIframeBFCache confirms BFCache markers survive and iframe document.cookie reads non-nil (first-party access succeeds, process survives).

Proof of concept

VERBATIM (lightly trimmed of the explanatory comment blocks) from the added TestWebKitAPI test SiteIsolation.mm. It loads a.com containing a cross-site frame.com iframe with MultiProcessBackForwardCacheEnabled, marks both documents, navigates cross-site to b.com, goes back, and then asserts the BFCache markers survive (restore, not reload) and that reading document.cookie in the iframe scope returns a non-nil value. On an unpatched build the iframe’s first party resolves to the stale b.com, the NetworkProcess denies the access, and the iframe process is terminated — failing the marker/cookie assertions.

TEST(SiteIsolation, GoBackToPageWithIframeBFCache)
{
    HTTPServer server({
        { "/a"_s, { "<iframe src='https://frame.com/frame'></iframe>"_s } },
        { "/b"_s, { ""_s } },
        { "/frame"_s, { ""_s } }
    }, HTTPServer::Protocol::HttpsProxy);
    auto *configuration = server.httpsProxyConfiguration();
    enableFeature(configuration, @"MultiProcessBackForwardCacheEnabled");
    auto [webView, navigationDelegate] = siteIsolatedViewAndDelegate(configuration);

    [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://a.com/a"]]];
    [navigationDelegate waitForDidFinishNavigationAndLoadInSubframe];
    [webView objectByEvaluatingJavaScript:@"window.__bfcacheMarker = true"];
    [webView objectByEvaluatingJavaScript:@"window.__iframeBfcacheMarker = true" inFrame:[webView firstChildFrame]];

    [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://b.com/b"]]];
    [navigationDelegate waitForDidFinishNavigation];

    [webView goBack];
    [navigationDelegate waitForDidFinishNavigation];

    Vector<ExpectedFrameTree> expectedAfterGoBack = {
        { "https://a.com"_s, { { RemoteFrame } } },
        { RemoteFrame, { { "https://frame.com"_s } } },
    };
    while (!frameTreesMatch(frameTrees(webView.get()).get(), Vector<ExpectedFrameTree> { expectedAfterGoBack }))
        TestWebKitAPI::Util::spinRunLoop();

    EXPECT_TRUE([[webView objectByEvaluatingJavaScript:@"window.__bfcacheMarker ? true : false"] boolValue]);
    EXPECT_TRUE([[webView objectByEvaluatingJavaScript:@"window.__iframeBfcacheMarker ? true : false" inFrame:[webView firstChildFrame]] boolValue]);
    EXPECT_NOT_NULL([webView objectByEvaluatingJavaScript:@"String(document.cookie)" inFrame:[webView firstChildFrame]]);
    checkFrameTreesInProcesses(webView.get(), WTF::move(expectedAfterGoBack));
}

Exploitation

  1. Setup — A page with a cross-site iframe under Site Isolation and MultiProcessBackForwardCacheEnabled; a cross-site top navigation followed by goBack drives the vulnerable restore path — all reachable from ordinary web content.
  2. Stale-state creation — The cross-site navigation’s DocumentSyncData broadcast overwrites the suspended iframe process’s top URL, guaranteeing the stale value on restore.
  3. Observed effect — The iframe’s first party for cookies mis-resolves to the navigated-to site; the NetworkProcess denies the access and terminates the iframe process (renderer DoS / broken restore).
  4. Escalation assessment — The shipped effect is a wrong (over-restrictive) first party plus a process kill, not a demonstrated cross-origin cookie read/write; the patch is a correctness+robustness fix on the first-party-for-cookies boundary. Any privacy impact of a mis-resolved first party in adjacent code paths would need separate analysis.

Detection & hunting

For defenders and SOC / detection engineers:

  • Iframe process termination on goBack — Look for NetworkProcess first-party-denial terminations of a subframe process immediately after a BFCache restore following a cross-site navigation (RELEASE_LOG ProcessSwapping around RestoreWithFrameItem).
  • Stale top URL at restore — Instrument updateFirstPartyForCookies / FrameLoader::open during CachedPage::restore to flag when the main-frame URL differs from the restore-target site for a subframe.
  • BFCache marker loss for iframes — A cross-site iframe that reloads (loses its JS marker) instead of restoring from BFCache indicates the iframe process was terminated by this bug.

Audit directions

  • Other consumers of broadcast top-document sync state during restore — Audit all restore/unsuspend paths that read the page main-frame URL/origin (updateFirstPartyForCookies and siblings) for reliance on possibly-stale broadcast DocumentSyncData rather than an authoritative UIProcess-supplied value.
  • RestoreWithFrameItem senders — Verify every sender of RestoreWithFrameItem supplies the correct mainFrameURLAndOrigin (or nullopt only where the top URL is provably correct); confirm goToBackForwardItem’s nullopt assumption holds for all same-site nested-iframe restore shapes.
  • setMainFrameURLAndOrigin timing — Review other paths that could observe the main-frame URL/origin between suspension and the main frame’s re-broadcast, ensuring none race ahead of setMainFrameURLAndOrigin the way the first-party read did.
  • Origin-vs-URL derivation — Search Site Isolation restore/navigation code for places that derive a SecurityOrigin from a URL rather than carrying the committed frame’s origin, which would break sandboxed/opaque-origin cases as this fix explicitly guards against.

Before / after

Loading diff…