CVE-2026-95291
Overview
Background
- `window.history.pushState` / `replaceState`
- Web APIs that let a page change the displayed URL and history entry without a full navigation, gated on the new URL being same-origin with the current page.
- `IsHistoryStateChangeValid`
- The iOS
webhelper inios/web/history_state_util.ccthat decides whether a requested history state URL change is permitted from the current URL. - `DeprecatedGetOriginAsURL`
- A
GURLmethod that flattens a URL to an origin-shapedGURL, which collapses non-standard URLs (e.g.blob:,data:) to an equivalent value rather than computing a true security origin. - `url::IsSameOriginWith`
- A
url/origin.hhelper that compares two URLs using realurl::Originsemantics, correctly distinguishing inner origins ofblob:and other non-standard URLs.
Root Cause Analysis
IsHistoryStateChangeValid guarded history state changes by comparing to_url.DeprecatedGetOriginAsURL() against current_url.DeprecatedGetOriginAsURL(). For non-standard URLs such as blob: URLs, DeprecatedGetOriginAsURL does not produce a distinguishing origin, so two blob: URLs with entirely different inner origins (host, scheme, or port) compared as equal. This violated the same-origin invariant the check exists to enforce, allowing a pushState/replaceState from one origin’s blob: URL to a blob: URL naming a different origin such as blob:https://accounts.google.com/signin.
The fix gates the check behind the default-enabled kHistoryStateChangeNonStandardURLFix feature and, when enabled, replaces the flattened-origin comparison with url::IsSameOriginWith(current_url, to_url), which uses true origin semantics. Because url::IsSameOriginWith treats opaque origins (data:, about:blank, blob:null/...) as never equal to one another, those cases are also now correctly rejected.
DeprecatedGetOriginAsURL for the same-origin check, which cannot distinguish the inner origins of non-standard URLs and thus treated all such URLs as equivalent; the fix swaps in url::IsSameOriginWith, which computes real origins and correctly rejects cross-origin blob: and opaque-origin transitions.Attack Path
- Load an attacker page
The victim opens an attacker-controlled page that has (or can create) a
blob:URL under its own origin. - Invoke history state change
The page calls
window.history.pushState()orwindow.history.replaceState()with a cross-originblob:URL such asblob:https://accounts.google.com/signin. - Bypass the origin check
IsHistoryStateChangeValidcomparesDeprecatedGetOriginAsURLvalues, which collapse bothblob:URLs to equivalent non-distinguishing origins, so the cross-origin change is wrongly accepted. - Displayed URL is spoofed The history entry and address bar reflect a URL naming a trusted origin the page does not control, misrepresenting the security context to the user.
Impact Assessment
blob:https://accounts.google.com/signin, without a real navigation. The only precondition is getting the victim to load an attacker-controlled page that issues a pushState/replaceState with a crafted cross-origin non-standard URL, enabling convincing address-bar spoofing on iOS.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fios/web/navigation/history_state_operations_inttest.mm |
modified | |
TEST_Fios/web/history_state_util_unittest.mm |
modified |
Files Changed
ios/web/history_state_util_unittest.mmios/web/navigation/history_state_operations_inttest.mmios/web/history_state_util.cc
Audit Directions
- Pattern: `DeprecatedGetOriginAsURL` for security decisionsSearch for security checks that compare
DeprecatedGetOriginAsURLresults and replace them withurl::IsSameOriginWithorurl::Origincomparisons, since the deprecated helper mishandles non-standard and opaque URLs. - Pattern: non-standard URL origin handlingAudit any same-origin gate that must handle
blob:,filesystem:,data:, andabout:blankURLs to confirm it derives inner/true origins rather than string- or URL-flattened forms. - Pattern: opaque-origin equalityReview comparisons that could treat two opaque origins (
data:,blob:null/...) as equal, ensuring they are always rejected as distinct per web origin semantics.
Patch
From 038c04e0bd5d5b1db4c1e5e01cfe08396494bc8d Mon Sep 17 00:00:00 2001 From: Mike Dougherty <[email protected]> Date: Wed, 26 Aug 2026 14:37:41 -0700 Subject: [PATCH] Ensure blob URLs are not considered equivalent `IsHistoryStateChangeValid` was previously incorrectly associating all "non-standard" URLs as equivalent due to the usage of `DeprecatedGetOriginAsURL`. Fixed: 543471693 Change-Id: I23304ae4f8cdf0238be58404d18f0dc72cea7897 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8291494 Commit-Queue: Mike Dougherty <[email protected]> Reviewed-by: Justin Cohen <[email protected]> Cr-Commit-Position: refs/heads/main@{#1686710} --- diff --git a/ios/web/history_state_util.cc b/ios/web/history_state_util.cc index 7d4f8c27..5c35df6 100644 --- a/ios/web/history_state_util.cc +++ b/ios/web/history_state_util.cc @@ -5,17 +5,31 @@ #include "ios/web/history_state_util.h" #include "base/check.h" +#include "base/feature.h" +#include "base/feature_list.h" #include "url/gurl.h" +#include "url/origin.h" namespace web { namespace history_state_util { +// Default enabled feature flag to control the non-standard URL fix. This is +// intended for use as a kill switch. +// TODO(crbug.com/553000452): Remove this flag once it's confirmed that the +// non-standard URL fix is working as intended. +BASE_FEATURE(kHistoryStateChangeNonStandardURLFix, + base::FEATURE_ENABLED_BY_DEFAULT); + bool IsHistoryStateChangeValid(const GURL& current_url, const GURL& to_url) { // These two checks are very important to the security of the page. We cannot // allow the page to change the state to an invalid URL. CHECK(current_url.is_valid()); CHECK(to_url.is_valid()); + if (base::FeatureList::IsEnabled(kHistoryStateChangeNonStandardURLFix)) { + return url::IsSameOriginWith(current_url, to_url); + } + return to_url.DeprecatedGetOriginAsURL() == current_url.DeprecatedGetOriginAsURL(); } diff --git a/ios/web/navigation/history_state_operations_inttest.mm b/ios/web/navigation/history_state_operations_inttest.mm index 36b04ad..a2d14a6 100644 --- a/ios/web/navigation/history_state_operations_inttest.mm +++ b/ios/web/navigation/history_state_operations_inttest.mm @@ -233,6 +233,30 @@ WaitForNoOpText(); } +// Tests that calling window.history.pushState() with a cross-origin blob URL is +// a no-op. +TEST_F(HistoryStateOperationsTest, NoOpPushCrossOriginBlobUrl) { + std::string empty_state; + std::string empty_title; + GURL cross_origin_blob_url("blob:https://accounts.google.com/signin"); + ASSERT_TRUE(IsOnLoadTextVisible()); + SetStateParams(empty_state, empty_title, cross_origin_blob_url); + ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kPushStateId)); + WaitForNoOpText(); +} + +// Tests that calling window.history.replaceState() with a cross-origin blob URL +// is a no-op. +TEST_F(HistoryStateOperationsTest, NoOpReplaceCrossOriginBlobUrl) { + std::string empty_state; + std::string empty_title; + GURL cross_origin_blob_url("blob:https://accounts.google.com/signin"); + ASSERT_TRUE(IsOnLoadTextVisible()); + SetStateParams(empty_state, empty_title, cross_origin_blob_url); + ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId)); + WaitForNoOpText(); +} + // Tests that calling window.history.replaceState() with a new state object // replaces the state object for the current NavigationItem. TEST_F(HistoryStateOperationsTest, StateReplacement) { diff --git a/ios/web/history_state_util_unittest.mm b/ios/web/history_state_util_unittest.mm index dbb9055a..15a66e1 100644 --- a/ios/web/history_state_util_unittest.mm +++ b/ios/web/history_state_util_unittest.mm @@ -53,6 +53,27 @@ // Invalid URL. {"http://foo.com", "http://fo o.c om/ba r", ""}, {"http://foo.com:80", "bar", "http://foo.com:80/bar"}, + // Valid blob URL changes (same inner origin). + {"blob:http://foo.com/1", "blob:http://foo.com/2", "blob:http://foo.com/2"}, + {"blob:https://foo.com/1", "blob:https://foo.com/2", + "blob:https://foo.com/2"}, + {"http://foo.com/bar", "blob:http://foo.com/1", "blob:http://foo.com/1"}, + {"blob:http://foo.com/1", "http://foo.com/bar", "http://foo.com/bar"}, + // Invalid blob URL changes (cross inner origin or scheme). + {"blob:http://foo.com/1", "blob:http://bar.com/2", ""}, + {"blob:http://foo.com/1", "blob:https://foo.com/2", ""}, + {"blob:http://foo.com:8080/1", "blob:https://accounts.google.com/signin", + ""}, + {"blob:http://foo.com:8080/1", "blob:http://foo.com:8081/2", ""}, + {"http://foo.com/bar", "blob:https://accounts.google.com/signin", ""}, + {"blob:http://foo.com/1", "https://foo.com/bar", ""}, + {"blob:http://foo.com/1", "http://bar.com/bar", ""}, + // Invalid non-standard / opaque origin URL changes. + {"data:text/html,foo", "data:text/html,bar", ""}, + {"about:blank", "about:blank", ""}, + {"http://foo.com", "data:text/html,foo", ""}, + {"data:text/html,foo", "http://foo.com", ""}, + {"blob:null/1", "blob:null/2", ""}, }); TEST_F(HistoryStateUtilTest, TestIsHistoryStateChangeValid) { @@ -104,5 +125,98 @@ EXPECT_FALSE(result.is_valid()); } +// Tests that blob URLs with the same inner origin are allowed, while blob URLs +// with different inner origins (different host, scheme, or port) are rejected. +TEST_F(HistoryStateUtilTest, TestBlobUrlHistoryStateChange) { + // Same-origin blob URLs. + GURL http_blob1("blob:http://foo.com/1"); + GURL http_blob2("blob:http://foo.com/2"); + EXPECT_TRUE( + history_state_util::IsHistoryStateChangeValid(http_blob1, http_blob2)); + + GURL https_blob1("blob:https://foo.com/1"); + GURL https_blob2("blob:https://foo.com/2"); + EXPECT_TRUE( + history_state_util::IsHistoryStateChangeValid(https_blob1, https_blob2)); + + // Cross-origin blob URLs (different host, scheme, or port). + GURL diff_host_blob("blob:http://bar.com/2"); + EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(http_blob1, + diff_host_blob)); + EXPECT_FALSE( + history_state_util::IsHistoryStateChangeValid(http_blob1, https_blob1)); + + GURL diff_port_blob("blob:http://foo.com:8080/1"); + EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(http_blob1, + diff_port_blob)); + + GURL attacker_target_blob("blob:https://accounts.google.com/signin"); + EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid( + diff_port_blob, attacker_target_blob)); + + // Standard URL to same-origin blob and vice versa. + GURL http_standard("http://foo.com/bar"); + EXPECT_TRUE( + history_state_util::IsHistoryStateChangeValid(http_standard, http_blob1)); + EXPECT_TRUE( + history_state_util::IsHistoryStateChangeValid(http_blob1, http_standard)); + + GURL https_standard("https://foo.com/bar"); + EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(https_standard, + https_blob1)); + EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(https_blob1, + https_standard)); + + // Standard URL to cross-origin blob and vice versa. + EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid( + http_standard, attacker_target_blob)); + EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(http_blob1, + https_standard)); +} + +// Tests that URLs with opaque origins (data:, about:blank, blob:null/...) are +// rejected as valid history state changes. +TEST_F(HistoryStateUtilTest, TestOpaqueOriginHistoryStateChange) { + GURL data_url1("data:text/html,<html>one</html>"); + GURL data_url2("data:text/html,<html>two</html>"); + EXPECT_FALSE( + history_state_util::IsHistoryStateChangeValid(data_url1, data_url2)); + + GURL about_blank("about:blank"); + EXPECT_FALSE( + history_state_util::IsHistoryStateChangeValid(about_blank, about_blank)); + + GURL opaque_blob1("blob:null/1"); + GURL opaque_blob2("blob:null/2"); + EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(opaque_blob1, + opaque_blob2)); + + GURL http_url("http://foo.com/bar"); + EXPECT_FALSE( + history_state_util::IsHistoryStateChangeValid(http_url, data_url1)); + EXPECT_FALSE( + history_state_util::IsHistoryStateChangeValid(data_url1, http_url)); +} + +// Tests that filesystem URLs with the same inner origin are allowed, while +// cross-origin filesystem URLs are rejected. +TEST_F(HistoryStateUtilTest, TestFileSystemUrlHistoryStateChange) { + GURL fs_url1("filesystem:http://foo.com/temporary/1"); + GURL fs_url2("filesystem:http://foo.com/temporary/2"); + EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(fs_url1, fs_url2)); + + GURL http_url("http://foo.com/bar"); + EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(http_url, fs_url1)); + EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(fs_url1, http_url)); + + GURL diff_host_fs("filesystem:http://bar.com/temporary/1"); + EXPECT_FALSE( + history_state_util::IsHistoryStateChangeValid(fs_url1, diff_host_fs)); + + GURL diff_scheme_fs("filesystem:https://foo.com/temporary/1"); + EXPECT_FALSE( + history_state_util::IsHistoryStateChangeValid(fs_url1, diff_scheme_fs)); +} + } // anonymous namespace } // namespace web
Regression Test / PoC
diff --git a/ios/web/history_state_util_unittest.mm b/ios/web/history_state_util_unittest.mm
index dbb9055a..15a66e1 100644
--- a/ios/web/history_state_util_unittest.mm
+++ b/ios/web/history_state_util_unittest.mm
@@ -53,6 +53,27 @@
// Invalid URL.
{"http://foo.com", "http://fo o.c om/ba r", ""},
{"http://foo.com:80", "bar", "http://foo.com:80/bar"},
+ // Valid blob URL changes (same inner origin).
+ {"blob:http://foo.com/1", "blob:http://foo.com/2", "blob:http://foo.com/2"},
+ {"blob:https://foo.com/1", "blob:https://foo.com/2",
+ "blob:https://foo.com/2"},
+ {"http://foo.com/bar", "blob:http://foo.com/1", "blob:http://foo.com/1"},
+ {"blob:http://foo.com/1", "http://foo.com/bar", "http://foo.com/bar"},
+ // Invalid blob URL changes (cross inner origin or scheme).
+ {"blob:http://foo.com/1", "blob:http://bar.com/2", ""},
+ {"blob:http://foo.com/1", "blob:https://foo.com/2", ""},
+ {"blob:http://foo.com:8080/1", "blob:https://accounts.google.com/signin",
+ ""},
+ {"blob:http://foo.com:8080/1", "blob:http://foo.com:8081/2", ""},
+ {"http://foo.com/bar", "blob:https://accounts.google.com/signin", ""},
+ {"blob:http://foo.com/1", "https://foo.com/bar", ""},
+ {"blob:http://foo.com/1", "http://bar.com/bar", ""},
+ // Invalid non-standard / opaque origin URL changes.
+ {"data:text/html,foo", "data:text/html,bar", ""},
+ {"about:blank", "about:blank", ""},
+ {"http://foo.com", "data:text/html,foo", ""},
+ {"data:text/html,foo", "http://foo.com", ""},
+ {"blob:null/1", "blob:null/2", ""},
});
TEST_F(HistoryStateUtilTest, TestIsHistoryStateChangeValid) {
@@ -104,5 +125,98 @@
EXPECT_FALSE(result.is_valid());
}
+// Tests that blob URLs with the same inner origin are allowed, while blob URLs
+// with different inner origins (different host, scheme, or port) are rejected.
+TEST_F(HistoryStateUtilTest, TestBlobUrlHistoryStateChange) {
+ // Same-origin blob URLs.
+ GURL http_blob1("blob:http://foo.com/1");
+ GURL http_blob2("blob:http://foo.com/2");
+ EXPECT_TRUE(
+ history_state_util::IsHistoryStateChangeValid(http_blob1, http_blob2));
+
+ GURL https_blob1("blob:https://foo.com/1");
+ GURL https_blob2("blob:https://foo.com/2");
+ EXPECT_TRUE(
+ history_state_util::IsHistoryStateChangeValid(https_blob1, https_blob2));
+
+ // Cross-origin blob URLs (different host, scheme, or port).
+ GURL diff_host_blob("blob:http://bar.com/2");
+ EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(http_blob1,
+ diff_host_blob));
+ EXPECT_FALSE(
+ history_state_util::IsHistoryStateChangeValid(http_blob1, https_blob1));
+
+ GURL diff_port_blob("blob:http://foo.com:8080/1");
+ EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(http_blob1,
+ diff_port_blob));
+
+ GURL attacker_target_blob("blob:https://accounts.google.com/signin");
+ EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(
+ diff_port_blob, attacker_target_blob));
+
+ // Standard URL to same-origin blob and vice versa.
+ GURL http_standard("http://foo.com/bar");
+ EXPECT_TRUE(
+ history_state_util::IsHistoryStateChangeValid(http_standard, http_blob1));
+ EXPECT_TRUE(
+ history_state_util::IsHistoryStateChangeValid(http_blob1, http_standard));
+
+ GURL https_standard("https://foo.com/bar");
+ EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(https_standard,
+ https_blob1));
+ EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(https_blob1,
+ https_standard));
+
+ // Standard URL to cross-origin blob and vice versa.
+ EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(
+ http_standard, attacker_target_blob));
+ EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(http_blob1,
+ https_standard));
+}
+
+// Tests that URLs with opaque origins (data:, about:blank, blob:null/...) are
+// rejected as valid history state changes.
+TEST_F(HistoryStateUtilTest, TestOpaqueOriginHistoryStateChange) {
+ GURL data_url1("data:text/html,<html>one</html>");
+ GURL data_url2("data:text/html,<html>two</html>");
+ EXPECT_FALSE(
+ history_state_util::IsHistoryStateChangeValid(data_url1, data_url2));
+
+ GURL about_blank("about:blank");
+ EXPECT_FALSE(
+ history_state_util::IsHistoryStateChangeValid(about_blank, about_blank));
+
+ GURL opaque_blob1("blob:null/1");
+ GURL opaque_blob2("blob:null/2");
+ EXPECT_FALSE(history_state_util::IsHistoryStateChangeValid(opaque_blob1,
+ opaque_blob2));
+
+ GURL http_url("http://foo.com/bar");
+ EXPECT_FALSE(
+ history_state_util::IsHistoryStateChangeValid(http_url, data_url1));
+ EXPECT_FALSE(
+ history_state_util::IsHistoryStateChangeValid(data_url1, http_url));
+}
+
+// Tests that filesystem URLs with the same inner origin are allowed, while
+// cross-origin filesystem URLs are rejected.
+TEST_F(HistoryStateUtilTest, TestFileSystemUrlHistoryStateChange) {
+ GURL fs_url1("filesystem:http://foo.com/temporary/1");
+ GURL fs_url2("filesystem:http://foo.com/temporary/2");
+ EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(fs_url1, fs_url2));
+
+ GURL http_url("http://foo.com/bar");
+ EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(http_url, fs_url1));
+ EXPECT_TRUE(history_state_util::IsHistoryStateChangeValid(fs_url1, http_url));
+
+ GURL diff_host_fs("filesystem:http://bar.com/temporary/1");
+ EXPECT_FALSE(
+ history_state_util::IsHistoryStateChangeValid(fs_url1, diff_host_fs));
+
+ GURL diff_scheme_fs("filesystem:https://foo.com/temporary/1");
+ EXPECT_FALSE(
+ history_state_util::IsHistoryStateChangeValid(fs_url1, diff_scheme_fs));
+}
+
} // anonymous namespace
} // namespace web
Original Bug Report
iOS Chrome address-bar spoof: blob-URL DeprecatedGetOriginAsURL empty-collision defeats IsHistoryStateChangeValid and CanTrustLastCommittedItem
Security Bug
VULNERABILITY DETAILS
GURL::DeprecatedGetOriginAsURL() returns an empty GURL() for
any URL whose scheme is not in the standard-schemes list
(url/gurl.cc:255-259; blob is absent from
url/url_util.cc:50-64). Chrome on iOS uses this function as the
same-origin predicate at three points on the history.replaceState
→ omnibox display path. When both operands are blob: URLs, both
sides evaluate to empty → equal, regardless of the inner origin. A
web page can therefore navigate to its own blob: URL, then post a
crafted didReplaceState message directly to the page-world
NavigationEventMessage script-message handler with
pageUrl='blob:https://<any-hostname>/…', and all three checks
pass.
The iOS steady-state location bar
(components/omnibox/browser/location_bar_model_impl.cc:123-133,
IS_IOS-only) then rewrites the resulting blob: VirtualURL via
url::Origin::Create() — which parses the inner URL
(url/origin.cc:46-51) — and formats with
kFormatUrlOmitHTTPS | kFormatUrlTrimAfterHost. The address bar
displays the bare attacker-chosen hostname over attacker-
controlled document content.
The three defeated checks
| # | Site | Predicate | Both-blob result |
|---|---|---|---|
| G1 | ios/web/history_state_util.cc:19-20 IsHistoryStateChangeValid |
to_url.DeprecatedGetOriginAsURL() == current_url.DeprecatedGetOriginAsURL() |
empty == empty → passes |
| G2 | ios/web/navigation/crw_js_navigation_handler.mm:165-166 (second call) |
IsHistoryStateChangeValid(navItem->GetURL(), replaceURL) |
empty == empty → passes |
| G3 | ios/web/navigation/navigation_manager_impl.mm:1403-1417 CanTrustLastCommittedItem |
GetVisibleWebViewOriginURL() (= GURLWithNSURL(WKWebView.URL).DGOAU(), :1499) == last_committed_url.DGOAU() |
empty == empty → true → spoofed item returned by GetLastCommittedItemImpl() :872 → GetVisibleItem() |
Not a missing check — three checks are present; each uses a
representation that collapses blob: to a single equivalence class.
Bypassing WebKit’s own guard
Chrome’s navigation.ts wraps History.prototype.replaceState and
calls originalWindowHistoryReplaceState.call(...) before
posting didReplaceState (ios/web/navigation/resources/ navigation.ts:127-146) — so the JS-API path is protected by
WebKit’s built-in same-origin check on replaceState. The attack
posts to window.webkit.messageHandlers.NavigationEventMessage
directly, so WebKit’s check never runs. This is reachable
because:
NavigationJavaScriptFeatureregisters inContentWorld::kPageContentWorld(ios/web/navigation/navigation_java_script_feature.mm:33), which isWKContentWorld.pageWorld(ios/web/js_messaging/java_script_feature_manager.mm:66-67);- the raw handler name
"NavigationEventMessage"is registered in that world with no obfuscation (ios/web/js_messaging/java_script_content_world.mm:276-293); ShouldHandleMessageFromOrigindefaults toOriginFilter::kPublic(ios/web/public/js_messaging/java_script_feature.h:268→java_script_feature.mm:283-284) — accepts any origin;- the
frame_idgate (navigation_java_script_feature.mm:80-88) requires the page-world main-frame ID, which is readable from page JS viawindow.__gCrWeb.getFrameId()(ios/web/public/js_messaging/resources/gcrweb.ts:56,166-168—__gCrWebis attached to the page-worldwindow); is_main_frame()(:66) is satisfied — the attack runs in the main frame.
The frame_id gate has a timing dependency: the browser’s
WebFramesManager learns the blob document’s frameId via a
FrameBecameAvailable message that native code triggers via
registerFrame() after commit
(ios/web/public/js_messaging/resources/gcrweb.ts:66-75; comment
at :80-82: “needs to be called by the native application on each
navigation”). A script running at document-parse time posts before
that registration lands, so its frame_id mismatches the browser’s
still-stale main-frame ID and is dropped at :86. The attached PoC
calls window.__gCrWeb.registerFrame() explicitly and retries the
post at 150/500/1200ms; the spoof reliably lands on the delayed
attempts.
Write path
crw_js_navigation_handler.mm:127-183
handleNavigationDidReplaceStateMessage:
GetHistoryStateChangeUrl(currentURL, GURL(baseUrl), pageUrl) (:147)
→ base_url.Resolve(pageUrl) returns pageUrl (absolute) →
IsHistoryStateChangeValid (G1) → URLEscapedForHistory →
replaceURL.is_valid() (blob:https://… is valid) → G2 →
replaceStateWithPageURL (:222) →
NavigationManagerImpl::UpdateCurrentItemForReplaceState →
navigation_manager_impl.mm:628-635 SetURL(url) on the current
item. NavigationItemImpl::GetVirtualURL()
(navigation_item_impl.mm:178-180) returns url_ when
virtual_url_ is empty (it is — SetURL doesn’t touch it).
Non-defenses examined
crw_web_view_navigation_observer.mm:400-411has anoriginAsURL.is_empty()early-reject — but that’s theWKWebView.URLKVO path. Direct handler invocation doesn’t changeWKWebView.URL, so KVO never fires.CanTrustLastCommittedItem:1404-1408,:1421-1425early-returns true forabout:/file:/app-specific —blob:isn’t listed and falls through to the empty==empty comparison.crw_js_navigation_handler.mm:139DCHECK(changingHistoryState)is DCHECK-only, and satisfiable via a precedingwillChangeStatemessage anyway.:151!replaceURL.is_valid()—blob:https://…is valid.:159-162GetItemCount() <= 1 && navItem->GetURL().is_empty()— blob-doc’s navItem URL is the (non-empty) blob URL.
VERSION
Chrome for iOS: 151.0.7922.112 (App Store, stable channel)
iOS: 26.3
Source examined at chromium/src origin/main @ 98caa88046
(2026-08-04). IsHistoryStateChangeValid predicate unchanged since
800532c0bf (2021-10-14, GetOrigin()→DeprecatedGetOriginAsURL()
rename) per GitHub-mirror history.
REPRODUCTION CASE
Attachments: poc_server.py, observed.png.
- On any host reachable from the test iPhone:
python3 poc_server.py - On the iPhone, open Chrome and navigate to
http://<host-ip>:8080/. - The launcher page navigates to a
blob:http://<host-ip>:8080/ <uuid>document, which readswindow.__gCrWeb.getFrameId(), callswindow.__gCrWeb.registerFrame(), and postswillChangeState+didReplaceState{pageUrl: 'blob:https://accounts.google.com/signin', baseUrl:'https://x/', stateObject:'', frame_id:<id>}directly towindow.webkit.messageHandlers.NavigationEventMessage, retrying at 150/500/1200ms.
OBSERVED (bug present)
The steady-state address bar displays accounts.google.com
(bare hostname, no blob: prefix) while the tab’s content is the
attacker-controlled document. Screenshot: observed.png.
The on-page diagnostic log confirms the real location.href is
blob:http://<host-ip>:8080/<uuid> and location.origin is the
attacker’s origin.
EXPECTED
Any of:
IsHistoryStateChangeValidcompares viaurl::Origin::Create(url)(which correctly extracts the inner origin forblob:) instead ofDeprecatedGetOriginAsURL();NavigationEventMessagehandler moves toContentWorld::kIsolatedWorld, or thedidPushState/didReplaceStatemessages carry an isolated-world-only nonce, so page JS cannot forge them;CanTrustLastCommittedItemtreats an emptyDGOAU()on either side as untrusted (the KVO path atcrw_web_view_navigation_observer.mm:407-411already does).
ROOT CAUSE
url/gurl.cc:255-259 |
DeprecatedGetOriginAsURL(): if (!is_valid_ || !IsStandard()) return GURL(); — blob is non-standard |
ios/web/history_state_util.cc:19-20 |
Same-origin predicate uses DGOAU() equality |
ios/web/navigation/navigation_java_script_feature.mm:33 |
kPageContentWorld — handler in page world |
ios/web/public/js_messaging/resources/gcrweb.ts:166-168 |
window.__gCrWeb in page world → frame_id readable |
ios/web/navigation/navigation_manager_impl.mm:1403-1417 |
Reconciliation predicate uses DGOAU() equality |
components/omnibox/browser/location_bar_model_impl.cc:123-133 |
IS_IOS: blob → url::Origin::Create → inner origin displayed |
SUGGESTED FIX
Replace DeprecatedGetOriginAsURL() with url::Origin::Create() in
IsHistoryStateChangeValid and CanTrustLastCommittedItem (and the
KVO-path helper for consistency). url::Origin::Create handles
blob:/filesystem: inner-URL extraction and opaque-origin nonce
comparison correctly. Six call-sites in //ios/web use DGOAU() as
a same-origin predicate on navigable URLs; all are candidates for
the same replacement.
Independently, NavigationJavaScriptFeature in
kPageContentWorld with a page-readable frame_id means page JS
can forge any of its messages (hashchange, didPushState,
didReplaceState) — moving to isolated world (as several other
//ios/web features already are) closes the direct-invocation path
regardless of the origin-comparison fix.
SECURITY IMPACT
Reachable from arbitrary web content with a stock renderer, no non-default flags, no user gesture. Any web page can cause the Chrome-on-iOS steady-state address bar to display an arbitrary attacker-chosen hostname over attacker-controlled document content, which is the canonical address-bar-spoof primitive for phishing.
Bounded: iOS only (desktop/Android use a different navigation
architecture with no WKScriptMessageHandler path; the
location_bar_model_impl.cc blob-strip is IS_IOS-gated). The
displayed URL retains no attacker-visible artifact once steady-state
formatting applies. document.location/document.origin inside the
page still reflect the attacker’s real blob origin, so same-origin
DOM access to the spoofed origin is not gained — this is a display
spoof, not a SOP bypass.
This is an address-bar hostname spoof, arbitrary target, no user interaction, on shipping stable.
RELATED
DeprecatedGetOriginAsURL()is documented-deprecated (url/gurl.h) with a migration TODO. The//ios/webnavigation path is distinguished from most other callers by having both comparison operands be page-navigable URLs with nois_valid()orSchemeIsHTTPOrHTTPS()pre-gate.crbug.com/40624624— in-tree TODO to move pushState/replaceState logic intoNavigationManager.crbug.com/41354482— in-tree TODO on thenavigation.tsHistory.prototypeinterception; notes the interception exists forDidStartLoadingcompatibility.
CREDIT
NH DEV