CVE-2026-113196
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.mm |
modified |
Files Changed
ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mmios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.hios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.mm
Patch
From 499204b4261c93bd042be9e326c529ad6954ff93 Mon Sep 17 00:00:00 2001
From: Olivier Gallant <ogallant@google.com>
Date: Tue, 22 Sep 2026 09:32:25 -0700
Subject: [PATCH] IOS Safe Browsing bypass via history.replaceState during async checks
Note: I was not able to reproduce this issue locally, this fix is based
on the added unit tests.
A page committed while an async Safe Browsing check is still in flight
could evade the interstitial by calling history.replaceState(). The
rewrite changes both the NavigationItem and the WKBackForwardList item
URLs, so when the late unsafe verdict arrived the forced reload targeted
the attacker-chosen URL instead of the flagged one, and the innocent
page was displayed in place of the blocking page.
Reload the flagged URL explicitly:
- ReloadPage() now takes the flagged URL instead of reading it back from
GetLastCommittedItem()->GetURL(), which replaceState() can rewrite.
- reload_page_on_commit_ becomes reload_url_on_commit_ so the deferred
reload also carries the URL across the commit boundary.
Bug: 504231046
Change-Id: If49f505dd51d73e35f01c212fba5f096c5fc4c17
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8407105
Reviewed-by: Daniel White <danieltwhite@google.com>
Commit-Queue: Daniel White <danieltwhite@google.com>
Reviewed-by: jdh <jdh@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1702710}
---
diff --git a/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.h b/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.h
index 8fc053ec..91878c3 100644
--- a/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.h
+++ b/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.h
@@ -101,10 +101,11 @@
// navigations occurred before the URL check has finished.
bool IsQueryStale(const SafeBrowsingQueryManager::Query& query);
- // Decides if a page is reloaded on commit. Used when an async check is
- // completed while an unsafe query is in the
- // `to_be_committed_redirect_chain_`.
- bool ShouldReloadOnCommit();
+ // Decides if a page is reloaded on commit. Returns the URL to reload if an
+ // async check is completed while an unsafe query is in the
+ // `to_be_committed_redirect_chain_`. This also clears
+ // `reload_url_on_commit_`.
+ std::optional<GURL> TakeReloadUrlOnCommit();
// Returns whether a query contained in `query_data` is still relevant. May
// return false if navigations occurred before the URL check has finished.
@@ -117,9 +118,9 @@
// Returns the redirect chain for the committed navigation.
std::vector<GURL> GetRedirectChain() const;
- // Reloads the page. Used when a reload is necessary for triggering an error
- // page.
- void ReloadPage();
+ // Reloads the page with `url`. Used when a reload is necessary for
+ // triggering an error page.
+ void ReloadPage(const GURL& url);
// Returns a policy decision based on query `result`.
web::WebStatePolicyDecider::PolicyDecision CreatePolicyDecision(
@@ -220,6 +221,16 @@
RedirectChain redirect_chain,
const SafeBrowsingQueryManager::QueryData& query_data);
+ // Returns whether the navigation described by `request_info` to
+ // `request_url` is a reload or back/forward load issued while Safe
+ // Browsing still has an undecided unsafe navigation for `request_url`'s
+ // origin. Such a load is the web layer re-loading the flagged document,
+ // whose URL may have been rewritten by a same-document history operation
+ // and therefore cannot be matched against the unsafe resource directly.
+ bool IsReloadWithPendingUnsafeDecision(
+ const web::WebStatePolicyDecider::RequestInfo& request_info,
+ const GURL& request_url) const;
+
// Callback invoked when a main frame query for `url` has finished with
// `decision` after performing a check of type `performed_check`.
void OnMainFrameUrlQueryDecided(
@@ -308,7 +319,7 @@
// A list of queries corresponding to the redirect chain saved after
// DidFinishNavigation() is called.
std::list<MainFrameUrlQuery> committed_redirect_chain_;
- bool reload_page_on_commit_ = false;
+ std::optional<GURL> reload_url_on_commit_;
};
// Helper object that observes results of URL check queries.
diff --git a/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.mm b/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.mm
index f4d59969..f4223ed 100644
--- a/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.mm
+++ b/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.mm
@@ -248,15 +248,11 @@
!GetOldestPendingCommittedQuery(query_data);
}
-bool SafeBrowsingTabHelper::PolicyDecider::ShouldReloadOnCommit() {
- bool should_reload = reload_page_on_commit_;
-
- // Flip bit to signify a reload has been triggered.
- if (reload_page_on_commit_) {
- reload_page_on_commit_ = false;
- }
-
- return should_reload;
+std::optional<GURL>
+SafeBrowsingTabHelper::PolicyDecider::TakeReloadUrlOnCommit() {
+ std::optional<GURL> reload_url = reload_url_on_commit_;
+ reload_url_on_commit_ = std::nullopt;
+ return reload_url;
}
void SafeBrowsingTabHelper::PolicyDecider::SetCommittedRedirectChain() {
@@ -289,31 +285,55 @@
return redirect_chain;
}
-void SafeBrowsingTabHelper::PolicyDecider::ReloadPage() {
+bool SafeBrowsingTabHelper::PolicyDecider::IsReloadWithPendingUnsafeDecision(
+ const web::WebStatePolicyDecider::RequestInfo& request_info,
+ const GURL& request_url) const {
+ // Only reloads and back/forward navigations can be repair loads. Regular
+ // navigations and redirects on an unsafe host must stay allowed here; they
+ // are evaluated in `ShouldAllowResponse()` once their check completes.
+ bool is_reload_or_back_forward =
+ ui::PageTransitionCoreTypeIs(request_info.transition_type,
+ ui::PAGE_TRANSITION_RELOAD) ||
+ (request_info.transition_type & ui::PAGE_TRANSITION_FORWARD_BACK);
+ if (!is_reload_or_back_forward) {
+ return false;
+ }
+
+ // The flagged document cannot be recognized by URL: `history.replaceState()`
+ // rewrites both its NavigationItem and its WKBackForwardList item to an
+ // attacker-chosen URL before the web layer re-loads it. Since
+ // SafeBrowsingUrlAllowList keys decisions per origin, a decision still
+ // pending for `request_url` means this reload targets the origin Safe
+ // Browsing flagged, so it is blocked in favor of the stored resource.
+ SafeBrowsingUrlAllowList* allow_list =
+ SafeBrowsingUrlAllowList::FromWebState(web_state());
+ return allow_list &&
+ allow_list->IsUnsafeNavigationDecisionPending(request_url);
+}
+
+void SafeBrowsingTabHelper::PolicyDecider::ReloadPage(const GURL& url) {
web::NavigationManager* navigation_manager =
web_state()->GetNavigationManager();
navigation_manager->DiscardNonCommittedItems();
- web::NavigationItem* last_committed_item =
- navigation_manager->GetLastCommittedItem();
- if (last_committed_item) {
- // A standard `navigation_manager->Reload()` call does nothing on iOS
- // if the WKWebView is currently displaying a custom HTML string. Since
- // this method is frequently called to swap out a local error page with
- // a Safe Browsing interstitial, we must force a brand new navigation to
- // the original URL instead of relying on the native reload command.
- //
- // The `last_committed_item` is used because the local error page actually
- // commits to the navigation history using the malicious URL as its base.
- web::NavigationManager::WebLoadParams params(last_committed_item->GetURL());
- params.transition_type = ui::PAGE_TRANSITION_RELOAD;
- navigation_manager->LoadURLWithParams(params);
- } else {
- // If there is no committed item, we cannot force a new load via URL.
- // Fall back to the standard reload.
+ if (!navigation_manager->GetLastCommittedItem()) {
+ // Without a committed item there is nothing to swap out, so fall back to
+ // the standard reload.
navigation_manager->Reload(web::ReloadType::NORMAL,
/*check_for_repost=*/false);
+ return;
}
+
+ // A standard `navigation_manager->Reload()` call does nothing on iOS
+ // if the WKWebView is currently displaying a custom HTML string. Since
+ // this method is frequently called to swap out a local error page or a
+ // committed page with a Safe Browsing interstitial, we must force a brand
+ // new navigation to `url` instead of relying on the native reload command
+ // or the last committed item URL (which may have been rewritten by
+ // same-document history operations such as `history.replaceState()`).
+ web::NavigationManager::WebLoadParams params(url);
+ params.transition_type = ui::PAGE_TRANSITION_RELOAD;
+ navigation_manager->LoadURLWithParams(params);
}
web::WebStatePolicyDecider::PolicyDecision
@@ -373,8 +393,8 @@
}
void SafeBrowsingTabHelper::PolicyDecider::UpdateForMainFrameDocumentChange() {
- if (ShouldReloadOnCommit()) {
- ReloadPage();
+ if (std::optional<GURL> reload_url = TakeReloadUrlOnCommit()) {
+ ReloadPage(*reload_url);
} else {
SetCommittedRedirectChain();
}
@@ -425,16 +445,19 @@
pending_main_frame_query_ = MainFrameUrlQuery(
request_url, base::SysNSStringToUTF8([request HTTPMethod]));
- // If there is a pre-existing main frame unsafe resource for `request_url`
- // that haven't yet resulted in an error page, this resource can be used to
- // show the error page for the current load. This can occur in back/forward
- // navigations to safe browsing error pages, where ShouldAllowRequest() is
- // called multiple times consecutively for the same URL.
+ // If there is a pre-existing main frame unsafe resource that hasn't yet
+ // resulted in an error page, this resource can be used to show the error page
+ // for the current load. This can occur in back/forward navigations to safe
+ // browsing error pages, where `ShouldAllowRequest()` is called multiple times
+ // consecutively for the same URL, or when the web layer re-loads a committed
+ // document whose URL was rewritten by history.replaceState().
SafeBrowsingUnsafeResourceContainer* unsafe_resource_container =
SafeBrowsingUnsafeResourceContainer::FromWebState(web_state());
const security_interstitials::UnsafeResource* main_frame_resource =
unsafe_resource_container->GetMainFrameUnsafeResource();
- if (main_frame_resource && main_frame_resource->url == request_url) {
+ if (main_frame_resource &&
+ (main_frame_resource->url == request_url ||
+ IsReloadWithPendingUnsafeDecision(request_info, request_url))) {
return std::move(callback).Run(
CreateSafeBrowsingErrorDecision(*main_frame_resource));
}
@@ -725,7 +748,7 @@
}
if (should_display_error && decision.ShouldDisplayError()) {
- ReloadPage();
+ ReloadPage(url);
}
return;
}
@@ -741,7 +764,7 @@
}
if (should_display_error && decision.ShouldDisplayError()) {
- reload_page_on_commit_ = true;
+ reload_url_on_commit_ = url;
}
return;
}
@@ -759,23 +782,12 @@
std::move(response_callback).Run(decision);
pending_main_frame_redirect_chain_.clear();
} else if (response_callback.is_null() && decision.ShouldDisplayError()) {
- reload_page_on_commit_ = true;
+ reload_url_on_commit_ = url;
}
if (decision.ShouldCancelNavigation()) {
client_->OnMainFrameUrlQueryCancellationDecided(web_state(), url);
}
- } else if (decision.ShouldDisplayError()) {
- // The query may be missing if it was clobbered by a subsequent navigation
- // loop, such as the one used to load a local HTML error page after an early
- // navigation failure.
- // If the WebState is currently trying to display the malicious URL
- // (meaning the user is looking at an error page for that site), we must
- // force a fresh navigation so the Safe Browsing interstitial is displayed.
- if (web_state()->GetVisibleURL() == url ||
- web_state()->GetLastCommittedURL() == url) {
- ReloadPage();
- }
}
}
diff --git a/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mm b/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mm
index 3f4502b..fff484a 100644
--- a/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mm
+++ b/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mm
@@ -1050,6 +1050,184 @@
EXPECT_TRUE(main_frame_reload_request_decision.ShouldDisplayError());
}
+// This is a control case for
+// `PostCommitAsyncVerdictReloadShowsInterstitialWithReplaceState` below.
+//
+// Tests that without a `history.replaceState()` rewrite, the forced reload
+// issued when a late async verdict flags an already-committed page targets
+// the flagged URL itself, and the reload request is immediately cancelled
+// to show the Safe Browsing interstitial.
+TEST_P(SafeBrowsingTabHelperTest,
+ PostCommitAsyncVerdictReloadShowsInterstitialWithoutReplaceState) {
+ GURL phishing_url("http://" + FakeSafeBrowsingService::kAsyncUnsafeHost +
+ "/phishing.html");
+ ASSERT_FALSE(navigation_manager_->LoadURLWithParamsWasCalled());
+
+ // Navigation to the phishing URL starts; the sync (local database) check
+ // finds nothing, so the response is allowed while the async
+ // (hash-real-time / URL-real-time) check is still in flight.
+ EXPECT_TRUE(ShouldAllowRequestUrl(phishing_url).ShouldAllowNavigation());
+ // The sync check callback is queued by the request above; run it from a
+ // posted task so that it completes asynchronously, as in production.
+ ASSERT_FALSE(client_.sync_completion_callbacks_.empty());
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(^() {
+ client_.run_sync_callbacks();
+ }));
+
+ if (SafeBrowsingDecisionArrivesBeforeResponse()) {
+ ASSERT_TRUE(base::test::RunUntil(
+ [&] { return client_.sync_completion_callbacks_.empty(); }));
+ }
+ EXPECT_TRUE(ShouldAllowResponseUrl(phishing_url).ShouldAllowNavigation());
+
+ // The phishing page commits with a last-committed NavigationItem.
+ navigation_manager_->AddItem(phishing_url, ui::PAGE_TRANSITION_LINK);
+ web::NavigationItem* item = navigation_manager_->GetItemAtIndex(
+ navigation_manager_->GetItemCount() - 1);
+ navigation_manager_->SetLastCommittedItem(item);
+ web::FakeNavigationContext commit_context;
+ commit_context.SetUrl(phishing_url);
+ commit_context.SetHasCommitted(true);
+ web_state_.OnNavigationFinished(&commit_context);
+
+ // The async verdict for the phishing URL arrives: unsafe. This triggers
+ // ReloadPage(), which issues a fresh navigation to the last-committed
+ // item's URL — still the phishing URL.
+ client_.run_async_callbacks();
+ ASSERT_TRUE(base::test::RunUntil(
+ [&] { return navigation_manager_->LoadURLWithParamsWasCalled(); }));
+
+ auto last_params = navigation_manager_->GetLastLoadURLWithParams();
+ ASSERT_TRUE(last_params.has_value());
+ EXPECT_EQ(phishing_url, last_params->url);
+
+ // The reload request matches the stored UnsafeResource exactly, so it is
+ // cancelled and the error page (interstitial) is displayed.
+ auto reload_decision =
+ ShouldAllowRequestUrl(phishing_url, /*for_main_frame=*/true,
+ ui::PageTransition::PAGE_TRANSITION_RELOAD);
+ EXPECT_TRUE(reload_decision.ShouldCancelNavigation());
+ EXPECT_TRUE(reload_decision.ShouldDisplayError());
+}
+
+// Tests that `history.replaceState()` cannot bypass a late async Safe Browsing
+// verdict:
+// 1. The page commits while the async check is in flight (sync check clean).
+// 2. The page runs history.replaceState({}, '', '/innocent.html'), rewriting
+// the last-committed NavigationItem's URL.
+// 3. The async verdict arrives (unsafe) and ReloadPage() navigates to the
+// flagged URL rather than the attacker-chosen replaceState URL.
+// 4. The reload request for the flagged URL is cancelled with an error page
+// (interstitial).
+// 5. In the residual weakness scenario where the tab's back/forward history
+// already contains the replaceState target and the web-layer error /
+// back-forward repair machinery tries to reload the current WebKit
+// back-forward item (whose URL was updated by replaceState to innocent_url),
+// ShouldAllowRequest() also cancels and displays the error page because
+// an unsafe navigation decision is pending for the host.
+TEST_P(SafeBrowsingTabHelperTest,
+ PostCommitAsyncVerdictReloadShowsInterstitialWithReplaceState) {
+ GURL phishing_url("http://" + FakeSafeBrowsingService::kAsyncUnsafeHost +
+ "/phishing.html");
+ GURL innocent_url("http://" + FakeSafeBrowsingService::kAsyncUnsafeHost +
+ "/innocent.html");
+ ASSERT_FALSE(navigation_manager_->LoadURLWithParamsWasCalled());
+
+ // Navigation to the phishing URL starts; the sync (local database) check
+ // finds nothing, so the response is allowed while the async
+ // (hash-real-time / URL-real-time) check is still in flight.
+ EXPECT_TRUE(ShouldAllowRequestUrl(phishing_url).ShouldAllowNavigation());
+ // The sync check callback is queued by the request above; run it from a
+ // posted task so that it completes asynchronously, as in production.
+ ASSERT_FALSE(client_.sync_completion_callbacks_.empty());
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(^() {
+ client_.run_sync_callbacks();
+ }));
+
+ if (SafeBrowsingDecisionArrivesBeforeResponse()) {
+ ASSERT_TRUE(base::test::RunUntil(
+ [&] { return client_.sync_completion_callbacks_.empty(); }));
+ }
+ EXPECT_TRUE(ShouldAllowResponseUrl(phishing_url).ShouldAllowNavigation());
+
+ // The phishing page commits with a last-committed NavigationItem.
+ navigation_manager_->AddItem(phishing_url, ui::PAGE_TRANSITION_LINK);
+ web::NavigationItem* item = navigation_manager_->GetItemAtIndex(
+ navigation_manager_->GetItemCount() - 1);
+ navigation_manager_->SetLastCommittedItem(item);
+ web::FakeNavigationContext commit_context;
+ commit_context.SetUrl(phishing_url);
+ commit_context.SetHasCommitted(true);
+ web_state_.OnNavigationFinished(&commit_context);
+
+ // The committed page runs `history.replaceState({}, '', '/innocent.html')`.
+ // `NavigationManagerImpl::UpdateCurrentItemForReplaceState()` rewrites the
+ // last-committed NavigationItem's URL in place, and the resulting
+ // same-document navigation is ignored by DidFinishNavigation(), so the
+ // committed redirect chain (with its pending async query for the phishing
+ // URL) is preserved.
+ item->SetURL(innocent_url);
+ web::FakeNavigationContext replace_state_context;
+ replace_state_context.SetUrl(innocent_url);
+ replace_state_context.SetHasCommitted(true);
+ replace_state_context.SetIsSameDocument(true);
+ web_state_.OnNavigationFinished(&replace_state_context);
+
... (file diff truncated)
Regression Test / PoC
diff --git a/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mm b/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mm
index 3f4502b..fff484a 100644
--- a/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mm
+++ b/ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper_unittest.mm
@@ -1050,6 +1050,184 @@
EXPECT_TRUE(main_frame_reload_request_decision.ShouldDisplayError());
}
+// This is a control case for
+// `PostCommitAsyncVerdictReloadShowsInterstitialWithReplaceState` below.
+//
+// Tests that without a `history.replaceState()` rewrite, the forced reload
+// issued when a late async verdict flags an already-committed page targets
+// the flagged URL itself, and the reload request is immediately cancelled
+// to show the Safe Browsing interstitial.
+TEST_P(SafeBrowsingTabHelperTest,
+ PostCommitAsyncVerdictReloadShowsInterstitialWithoutReplaceState) {
+ GURL phishing_url("http://" + FakeSafeBrowsingService::kAsyncUnsafeHost +
+ "/phishing.html");
+ ASSERT_FALSE(navigation_manager_->LoadURLWithParamsWasCalled());
+
+ // Navigation to the phishing URL starts; the sync (local database) check
+ // finds nothing, so the response is allowed while the async
+ // (hash-real-time / URL-real-time) check is still in flight.
+ EXPECT_TRUE(ShouldAllowRequestUrl(phishing_url).ShouldAllowNavigation());
+ // The sync check callback is queued by the request above; run it from a
+ // posted task so that it completes asynchronously, as in production.
+ ASSERT_FALSE(client_.sync_completion_callbacks_.empty());
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(^() {
+ client_.run_sync_callbacks();
+ }));
+
+ if (SafeBrowsingDecisionArrivesBeforeResponse()) {
+ ASSERT_TRUE(base::test::RunUntil(
+ [&] { return client_.sync_completion_callbacks_.empty(); }));
+ }
+ EXPECT_TRUE(ShouldAllowResponseUrl(phishing_url).ShouldAllowNavigation());
+
+ // The phishing page commits with a last-committed NavigationItem.
+ navigation_manager_->AddItem(phishing_url, ui::PAGE_TRANSITION_LINK);
+ web::NavigationItem* item = navigation_manager_->GetItemAtIndex(
+ navigation_manager_->GetItemCount() - 1);
+ navigation_manager_->SetLastCommittedItem(item);
+ web::FakeNavigationContext commit_context;
+ commit_context.SetUrl(phishing_url);
+ commit_context.SetHasCommitted(true);
+ web_state_.OnNavigationFinished(&commit_context);
+
+ // The async verdict for the phishing URL arrives: unsafe. This triggers
+ // ReloadPage(), which issues a fresh navigation to the last-committed
+ // item's URL — still the phishing URL.
+ client_.run_async_callbacks();
+ ASSERT_TRUE(base::test::RunUntil(
+ [&] { return navigation_manager_->LoadURLWithParamsWasCalled(); }));
+
+ auto last_params = navigation_manager_->GetLastLoadURLWithParams();
+ ASSERT_TRUE(last_params.has_value());
+ EXPECT_EQ(phishing_url, last_params->url);
+
+ // The reload request matches the stored UnsafeResource exactly, so it is
+ // cancelled and the error page (interstitial) is displayed.
+ auto reload_decision =
+ ShouldAllowRequestUrl(phishing_url, /*for_main_frame=*/true,
+ ui::PageTransition::PAGE_TRANSITION_RELOAD);
+ EXPECT_TRUE(reload_decision.ShouldCancelNavigation());
+ EXPECT_TRUE(reload_decision.ShouldDisplayError());
+}
+
+// Tests that `history.replaceState()` cannot bypass a late async Safe Browsing
+// verdict:
+// 1. The page commits while the async check is in flight (sync check clean).
+// 2. The page runs history.replaceState({}, '', '/innocent.html'), rewriting
+// the last-committed NavigationItem's URL.
+// 3. The async verdict arrives (unsafe) and ReloadPage() navigates to the
+// flagged URL rather than the attacker-chosen replaceState URL.
+// 4. The reload request for the flagged URL is cancelled with an error page
+// (interstitial).
+// 5. In the residual weakness scenario where the tab's back/forward history
+// already contains the replaceState target and the web-layer error /
+// back-forward repair machinery tries to reload the current WebKit
+// back-forward item (whose URL was updated by replaceState to innocent_url),
+// ShouldAllowRequest() also cancels and displays the error page because
+// an unsafe navigation decision is pending for the host.
+TEST_P(SafeBrowsingTabHelperTest,
+ PostCommitAsyncVerdictReloadShowsInterstitialWithReplaceState) {
+ GURL phishing_url("http://" + FakeSafeBrowsingService::kAsyncUnsafeHost +
+ "/phishing.html");
+ GURL innocent_url("http://" + FakeSafeBrowsingService::kAsyncUnsafeHost +
+ "/innocent.html");
+ ASSERT_FALSE(navigation_manager_->LoadURLWithParamsWasCalled());
+
+ // Navigation to the phishing URL starts; the sync (local database) check
+ // finds nothing, so the response is allowed while the async
+ // (hash-real-time / URL-real-time) check is still in flight.
+ EXPECT_TRUE(ShouldAllowRequestUrl(phishing_url).ShouldAllowNavigation());
+ // The sync check callback is queued by the request above; run it from a
+ // posted task so that it completes asynchronously, as in production.
+ ASSERT_FALSE(client_.sync_completion_callbacks_.empty());
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(^() {
+ client_.run_sync_callbacks();
+ }));
+
+ if (SafeBrowsingDecisionArrivesBeforeResponse()) {
+ ASSERT_TRUE(base::test::RunUntil(
+ [&] { return client_.sync_completion_callbacks_.empty(); }));
+ }
+ EXPECT_TRUE(ShouldAllowResponseUrl(phishing_url).ShouldAllowNavigation());
+
+ // The phishing page commits with a last-committed NavigationItem.
+ navigation_manager_->AddItem(phishing_url, ui::PAGE_TRANSITION_LINK);
+ web::NavigationItem* item = navigation_manager_->GetItemAtIndex(
+ navigation_manager_->GetItemCount() - 1);
+ navigation_manager_->SetLastCommittedItem(item);
+ web::FakeNavigationContext commit_context;
+ commit_context.SetUrl(phishing_url);
+ commit_context.SetHasCommitted(true);
+ web_state_.OnNavigationFinished(&commit_context);
+
+ // The committed page runs `history.replaceState({}, '', '/innocent.html')`.
+ // `NavigationManagerImpl::UpdateCurrentItemForReplaceState()` rewrites the
+ // last-committed NavigationItem's URL in place, and the resulting
+ // same-document navigation is ignored by DidFinishNavigation(), so the
+ // committed redirect chain (with its pending async query for the phishing
+ // URL) is preserved.
+ item->SetURL(innocent_url);
+ web::FakeNavigationContext replace_state_context;
+ replace_state_context.SetUrl(innocent_url);
+ replace_state_context.SetHasCommitted(true);
+ replace_state_context.SetIsSameDocument(true);
+ web_state_.OnNavigationFinished(&replace_state_context);
+
+ // The async verdict for the phishing URL arrives: unsafe. This triggers
+ // `ReloadPage()`, which issues a fresh navigation to the flagged URL.
+ client_.run_async_callbacks();
+ ASSERT_TRUE(base::test::RunUntil(
+ [&] { return navigation_manager_->LoadURLWithParamsWasCalled(); }));
+
+ // The forced navigation targets the flagged URL, not the attacker-chosen
+ // replaceState URL.
+ auto last_params = navigation_manager_->GetLastLoadURLWithParams();
+ ASSERT_TRUE(last_params.has_value());
+ EXPECT_EQ(phishing_url, last_params->url);
+
+ // The reload request for the flagged URL matches the stored UnsafeResource,
+ // so it is cancelled and the error page (interstitial) is displayed.
+ auto reload_decision =
+ ShouldAllowRequestUrl(phishing_url, /*for_main_frame=*/true,
+ ui::PageTransition::PAGE_TRANSITION_RELOAD);
+ EXPECT_TRUE(reload_decision.ShouldCancelNavigation());
+ EXPECT_TRUE(reload_decision.ShouldDisplayError());
+
+ // When web-layer error / back-forward repair machinery tries to reload the
+ // current WebKit back-forward item (rewritten to innocent_url),
+ // `ShouldAllowRequest()` must also cancel and display the error page because
+ // an unsafe navigation decision is pending for the host.
+ auto repair_decision =
+ ShouldAllowRequestUrl(innocent_url, /*for_main_frame=*/true,
+ ui::PageTransition::PAGE_TRANSITION_RELOAD);
+ EXPECT_TRUE(repair_decision.ShouldCancelNavigation());
+ EXPECT_TRUE(repair_decision.ShouldDisplayError());
+
+ // That blocked repair load commits its own error page, which rebuilds the
+ // decider's committed navigation state. A further repair load must still be
+ // blocked, so the block must not depend on state that this commit replaces.
+ web::FakeNavigationContext error_commit_context;
+ error_commit_context.SetUrl(innocent_url);
+ error_commit_context.SetHasCommitted(true);
+ web_state_.OnNavigationFinished(&error_commit_context);
+ auto second_repair_decision =
+ ShouldAllowRequestUrl(innocent_url, /*for_main_frame=*/true,
+ ui::PageTransition::PAGE_TRANSITION_RELOAD);
+ EXPECT_TRUE(second_repair_decision.ShouldCancelNavigation());
+ EXPECT_TRUE(second_repair_decision.ShouldDisplayError());
+
+ // However, an unrelated clean origin without a pending decision is not
+ // blocked.
+ GURL clean_url("http://safe.chromium.test/clean.html");
+ auto clean_decision =
+ ShouldAllowRequestUrl(clean_url, /*for_main_frame=*/true,
+ ui::PageTransition::PAGE_TRANSITION_LINK);
+ EXPECT_TRUE(clean_decision.ShouldAllowNavigation());
+ EXPECT_FALSE(clean_decision.ShouldDisplayError());
+}
+
// Tests that an early navigation failure (simulating a local error) correctly
// caches the Safe Browsing state for a subsequent back/forward navigation.
TEST_P(SafeBrowsingTabHelperTest,
Original Bug Report
IOS Safe Browsing bypass via history.replaceState during async checks
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A logic flaw in iOS Safe Browsing potentially allows malicious sites to bypass the full-page security interstitial. By using history.replaceState before asynchronous real-time checks complete, an attacker can change the committed URL, causing the browser to reload a safe URL instead of showing the warning.
Affected files:
ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.mmios/web/navigation/navigation_manager_impl.mmios/web/navigation/crw_js_navigation_handler.mmios/web/navigation/crw_web_view_navigation_observer.mmios/chrome/browser/safe_browsing/model/safe_browsing_client_impl.mmios/components/security_interstitials/safe_browsing/safe_browsing_service_impl.mm
Estimated timestamp from git blame: 2026-03-24
Description
On iOS, Safe Browsing performs both synchronous (local database) and asynchronous (network-based real-time) checks for each navigation. The implementation in SafeBrowsingTabHelper allows a page to commit and begin executing JavaScript once the synchronous check passes, even if the asynchronous check is still in flight.
If the asynchronous check later returns an ‘unsafe’ verdict, the browser attempts to reload the page to display a full-page Safe Browsing interstitial. However, a malicious site can bypass this protection by using history.replaceState to change its URL immediately after the initial commit. When the asynchronous verdict arrives, the browser’s reload logic incorrectly uses the newly set, attacker-controlled URL, effectively suppressing the warning.
Technical Details
- Async Check Window: In
ios/components/security_interstitials/safe_browsing/safe_browsing_tab_helper.mm,ShouldAllowResponsechecks if navigation can proceed by filtering forRedirectChainFilter::kSyncQueries. If the local database check is clean, the page commits while the asynchronous network lookup is still in progress. - URL Mutation: Once committed, an attacker-controlled page (e.g.,
/phishing) can execute inline JavaScript:history.replaceState({}, '', '/innocent');. On iOS, this updates the URL of theNavigationManagerImpl’slast_committed_itemto/innocentviaUpdateCurrentItemForReplaceState. - Threat Detected: The asynchronous check for the original
/phishingURL returns a threat verdict.SafeBrowsingTabHelper::PolicyDecider::OnMainFrameUrlAsyncQueryDecidedis triggered. AnUnsafeResourcefor/phishingis stored in theSafeBrowsingUnsafeResourceContainer. - Flawed Reload Logic:
OnMainFrameUrlAsyncQueryDecidedcallsReloadPage()to show the interstitial.ReloadPage()retrieves the URL for the new navigation vianavigation_manager->GetLastCommittedItem()->GetURL(). Due to thereplaceStatecall, this returns/innocentinstead of the original flagged URL. - Interstitial Bypass: A new navigation to
/innocentis initiated. WhenShouldAllowRequestprocesses this new navigation, it checks if the request URL matches the storedUnsafeResource’s URL. Since/innocentdoes not match the stored/phishingURL, the strict equality check fails. The navigation is allowed, and the interstitial is completely suppressed, leaving the user on the malicious page.
Potential Reproduction Steps
Note: These steps are suggested based on code analysis.
- Host a page at
https://attacker.example/phishingthat is not in the local Safe Browsing database but is flagged by real-time checks. - Configure the response for
/phishingto include:<script>history.replaceState({}, '', '/innocent');</script>. - Host a benign page at
https://attacker.example/innocent. - On a Chrome iOS client with default Safe Browsing, navigate to
https://attacker.example/phishing. - The phishing page will load and execute. When the async check returns, the page will reload to
/innocentwithout showing the Safe Browsing interstitial.
Suggested Fix
In SafeBrowsingTabHelper::PolicyDecider::ReloadPage(), instead of relying on navigation_manager->GetLastCommittedItem()->GetURL(), the reload should be explicitly targeted at the URL of the MainFrameUrlQuery that triggered the threat detection, or the URL stored in the SafeBrowsingUnsafeResourceContainer. This ensures the error page always corresponds to the URL that was actually flagged by Safe Browsing, regardless of any subsequent DOM/History API manipulations.
Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.