High watch CVSS 5.4 chrome Spoofing 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in SecurityIndicators
DescriptionUI misrepresentation in SecurityIndicators
ComponentSecurityIndicators
Sub-componentUI>Browser>Omnibox>SecurityIndicators
Bug ClassSpoofing
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker543471693
Fix commit038c04e0bd5d (chromium/src) +152/-0
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedNH DEV
Reported2026-08-07 · bounty $5,000 (per vendor advisory)
Fix landed2026-08-26 (public git)
Disclosed2026-09-22
Patch gap27 days — fix public in source before the advisory

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 web helper in ios/web/history_state_util.cc that decides whether a requested history state URL change is permitted from the current URL.
`DeprecatedGetOriginAsURL`
A GURL method that flattens a URL to an origin-shaped GURL, 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.h helper that compares two URLs using real url::Origin semantics, correctly distinguishing inner origins of blob: 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.

Key insight
The single mistake was using 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

  1. Load an attacker page The victim opens an attacker-controlled page that has (or can create) a blob: URL under its own origin.
  2. Invoke history state change The page calls window.history.pushState() or window.history.replaceState() with a cross-origin blob: URL such as blob:https://accounts.google.com/signin.
  3. Bypass the origin check IsHistoryStateChangeValid compares DeprecatedGetOriginAsURL values, which collapse both blob: URLs to equivalent non-distinguishing origins, so the cross-origin change is wrongly accepted.
  4. 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

An attacker gains UI misrepresentation in the security indicators: a web page in the renderer/browser context can drive the displayed history URL to name an origin it does not control, such as 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

FunctionChangeNotes
TEST_F
ios/web/navigation/history_state_operations_inttest.mm
modified
TEST_F
ios/web/history_state_util_unittest.mm
modified

Files Changed

  • ios/web/history_state_util_unittest.mm
  • ios/web/navigation/history_state_operations_inttest.mm
  • ios/web/history_state_util.cc

Audit Directions

  • Pattern: `DeprecatedGetOriginAsURL` for security decisions
    Search for security checks that compare DeprecatedGetOriginAsURL results and replace them with url::IsSameOriginWith or url::Origin comparisons, since the deprecated helper mishandles non-standard and opaque URLs.
  • Pattern: non-standard URL origin handling
    Audit any same-origin gate that must handle blob:, filesystem:, data:, and about:blank URLs to confirm it derives inner/true origins rather than string- or URL-flattened forms.
  • Pattern: opaque-origin equality
    Review comparisons that could treat two opaque origins (data:, blob:null/...) as equal, ensuring they are always rejected as distinct per web origin semantics.
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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:

  • NavigationJavaScriptFeature registers in ContentWorld::kPageContentWorld (ios/web/navigation/navigation_java_script_feature.mm:33), which is WKContentWorld.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);
  • ShouldHandleMessageFromOrigin defaults to OriginFilter::kPublic (ios/web/public/js_messaging/java_script_feature.h:268 → java_script_feature.mm:283-284) — accepts any origin;
  • the frame_id gate (navigation_java_script_feature.mm:80-88) requires the page-world main-frame ID, which is readable from page JS via window.__gCrWeb.getFrameId() (ios/web/public/js_messaging/resources/gcrweb.ts:56,166-168 — __gCrWeb is attached to the page-world window);
  • 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-411 has an originAsURL.is_empty() early-reject — but that’s the WKWebView.URL KVO path. Direct handler invocation doesn’t change WKWebView.URL, so KVO never fires.
  • CanTrustLastCommittedItem :1404-1408,:1421-1425 early-returns true for about:/file:/app-specific — blob: isn’t listed and falls through to the empty==empty comparison.
  • crw_js_navigation_handler.mm:139 DCHECK(changingHistoryState) is DCHECK-only, and satisfiable via a preceding willChangeState message anyway.
  • :151 !replaceURL.is_valid() — blob:https://… is valid.
  • :159-162 GetItemCount() <= 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.

  1. On any host reachable from the test iPhone:
    python3 poc_server.py
    
  2. On the iPhone, open Chrome and navigate to http://<host-ip>:8080/.
  3. The launcher page navigates to a blob:http://&lt;host-ip&gt;:8080/ &lt;uuid&gt; document, which reads window.__gCrWeb.getFrameId(), calls window.__gCrWeb.registerFrame(), and posts willChangeState + didReplaceState{pageUrl: 'blob:https://accounts.google.com/signin', baseUrl:'https://x/', stateObject:'', frame_id:&lt;id&gt;} directly to window.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:

  • IsHistoryStateChangeValid compares via url::Origin::Create(url) (which correctly extracts the inner origin for blob:) instead of DeprecatedGetOriginAsURL();
  • NavigationEventMessage handler moves to ContentWorld::kIsolatedWorld, or the didPushState/ didReplaceState messages carry an isolated-world-only nonce, so page JS cannot forge them;
  • CanTrustLastCommittedItem treats an empty DGOAU() on either side as untrusted (the KVO path at crw_web_view_navigation_observer.mm:407-411 already 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.

  • DeprecatedGetOriginAsURL() is documented-deprecated (url/gurl.h) with a migration TODO. The //ios/web navigation path is distinguished from most other callers by having both comparison operands be page-navigable URLs with no is_valid() or SchemeIsHTTPOrHTTPS() pre-gate.
  • crbug.com/40624624 — in-tree TODO to move pushState/replaceState logic into NavigationManager.
  • crbug.com/41354482 — in-tree TODO on the navigation.ts History.prototype interception; notes the interception exists for DidStartLoading compatibility.

CREDIT

NH DEV

View on issue tracker