High watch CVSS 3.1 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Mobile
DescriptionRace condition in Mobile
ComponentMobile
Sub-componentiOS
Bug ClassRace
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.
Tracker517581661
Fix commitcd32c341b8b3 (chromium/src) +89/-40
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-28 (per vendor advisory)
Fix landed2026-08-11 (public git)
Disclosed2026-09-08
Patch gap28 days — fix public in source before the advisory

Background

Bookmarklet
A bookmark whose URL uses the javascript: scheme (url::kJavaScriptScheme); tapping it runs the embedded script in the currently active tab via LoadJavaScriptURL.
UXSS (Universal Cross-Site Scripting)
Executing attacker script in the security context of an origin the attacker does not control, named explicitly in the diff’s own comments as the harm being guarded against.
TOCTOU race
A time-of-check/time-of-use gap where the state validated (the active tab’s URL) can change between when it is sampled and when the sensitive action (running the bookmarklet) occurs.
`WebState` URL accessors
GetLastCommittedURL() returns the origin of the committed document, while GetVisibleURL() reflects the URL shown in the omnibox including pending navigations; the check compares both against a snapshot.

Root Cause Analysis

Before the patch, the guard in openURLInCurrentTab:urlBeforeDismissal: compared the active WebState’s GetLastCommittedURL() and GetVisibleURL() against urlBeforeDismissal, a value that (per the removed code in the dismissal path) was captured at the moment the bookmarks UI began dismissing — i.e., right when the row was tapped. Because the underlying tab can navigate while the bookmark browser form sheet is covering it (before any row is tapped), that snapshot could already reflect the new URL, so comparing “current URL” against “URL captured a moment ago” trivially matched and the bookmarklet was allowed to run against the navigated-to origin. The invariant that should hold — the bookmarklet only runs against the same document the user saw when they chose to open bookmarks — was violated because the reference point was sampled too late.

The fix moves the capture earlier: it stores _lastCommittedURLBeforePresentation when the bookmarks UI is presented, threads that value (renamed urlBeforePresentation) through openUrls:... into openURLInCurrentTab:urlBeforePresentation:, and compares against it instead. Any navigation that happens after presentation now makes GetLastCommittedURL()/GetVisibleURL() differ from the snapshot, so the equality check fails and LoadJavaScriptURL is skipped.

The patch also resets _lastCommittedURLBeforePresentation = GURL() on teardown/reset paths so a stale snapshot cannot carry over.

Key insight
The single mistake was sampling the “trusted” origin snapshot at dismissal time rather than at presentation time, which left a window in which the underlying tab could navigate before the snapshot was even taken, defeating the equality check. The fix eliminates that window by snapshotting the URL the instant the bookmarks UI is presented, so any later navigation is detectable.

Attack Path

  1. Victim opens bookmarks over a trusted page The user is on some page and opens the bookmarks browser, which presents as a form sheet covering the active tab; pre-patch, no URL is recorded at this point.
  2. Underlying tab is navigated while the UI is open While the bookmark browser covers the tab, a background/pending navigation moves the active WebState to a different origin (the regression test drives this with loadURL:sensitiveURL waitForCompletion:NO while the UI is open), a navigation the attacker can influence (inferred).
  3. User taps a `javascript:` bookmarklet The dismissal path captures urlBeforeDismissal from the now-navigated WebState, so the snapshot equals the new URL.
  4. Guard passes trivially and bookmarklet executes In openURLInCurrentTab:, both GetLastCommittedURL() and GetVisibleURL() equal the just-captured urlBeforeDismissal, so LoadJavaScriptURL(url, browser, activeWebState) runs the script in the context of the navigated-to origin, yielding UXSS as labeled in the diff.

Impact Assessment

An attacker can get a user-triggered javascript: bookmarklet to execute in the security context of a different origin than the one active when bookmarks were opened, i.e., UXSS in the browser’s web-content context on iOS Chrome, per the diff’s own comments. Preconditions are that the user has a javascript: bookmarklet, opens the bookmarks UI, taps that bookmarklet, and that the underlying tab navigates to an attacker-relevant origin while the UI is displayed. The advisory classifies this as a race condition in Mobile; any further escalation (e.g.The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1. A severity rating are not stated in the advisory.

Changed Functions

FunctionChangeNotes
if
ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
modified

Files Changed

  • ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
  • ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm

Audit Directions

  • Snapshot timing on covered/backgrounded content
    Review any flow where UI captures a “current” URL/origin/state to validate a later privileged action; confirm the capture happens before the covering UI appears, not at action/dismissal time.
  • Two-URL equality guards on `WebState`
    Look for other checks that gate script or navigation on GetLastCommittedURL()/GetVisibleURL() equality and verify the reference value cannot be moved by a navigation during the vulnerable window.
  • Cross-thread/async completion blocks holding origin state
    Audit CallbackToBlock/dismissal-completion blocks that carry captured URLs or origins into an asynchronous continuation, since navigations can commit between capture and block execution.
From cd32c341b8b3cb3d262e105d394aab673601e6a4 Mon Sep 17 00:00:00 2001
From: Huiting Yu <[email protected]>
Date: Tue, 11 Aug 2026 08:15:18 -0700
Subject: [PATCH] [iOS][Bookmarks] Snapshot tab URL when bookmarks UI is presented

The bookmarklet origin check captured the active tab's URL when the user
tapped a bookmark row, then compared it to the URL after the dismissal
animation. However, the underlying tab can navigate while the bookmark
browser form sheet is covering it, before any row is tapped, in which
case the snapshot already reflects the new URL and the check trivially
passes.

Capture the URL in -presentBookmarksAtDisplayedFolderNode: instead, so
any navigation that occurs while the bookmark browser is displayed is
detected and blocked from executing bookmarklets.

Added an EarlGrey regression test that navigates the underlying tab while
the bookmark browser is open and verifies the bookmarklet does not execute
on the new page.

Fixed: 517581661
Change-Id: I0f404c04f39ca3622b9dd840476689aa470b571d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8230501
Auto-Submit: Huiting Yu <[email protected]>
Commit-Queue: Huiting Yu <[email protected]>
Reviewed-by: Arthur Milchior <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1677220}
---
diff --git a/ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm b/ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
index 572c708..049ec95 100644
--- a/ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
+++ b/ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
@@ -132,6 +132,11 @@
   // Coordinator to display the "Set a reminder" UI for the user's selected
   // bookmark.
   ReminderNotificationsCoordinator* _reminderNotificationsCoordinator;
+
+  // The last committed URL of the active `WebState` when the bookmarks UI
+  // was presented. Used to prevent Universal Cross-Site Scripting (UXSS)
+  // if the underlying tab navigates while bookmarks UI is open.
+  GURL _lastCommittedURLBeforePresentation;
 }
 
 @synthesize sceneHandler = _sceneHandler;
@@ -185,6 +190,7 @@
   _currentProfile = nullptr;
   _bookmarkModel = nullptr;
   _mediator = nil;
+  _lastCommittedURLBeforePresentation = GURL();
   CHECK_EQ(PresentedState::NONE, self.currentPresentedState,
            base::NotFatalUntil::M152);
   CHECK(!self.bookmarkEditorCoordinator, base::NotFatalUntil::M152)
@@ -331,14 +337,7 @@
             _currentProfile.get()));
   }
 
-  GURL urlBeforeDismissal;
-  if (self.browser && self.browser->GetWebStateList()) {
-    web::WebState* activeWebState =
-        self.browser->GetWebStateList()->GetActiveWebState();
-    if (activeWebState) {
-      urlBeforeDismissal = activeWebState->GetLastCommittedURL();
-    }
-  }
+  GURL urlBeforePresentation = _lastCommittedURLBeforePresentation;
 
   // First the bookmark view should be dismissed to have the animation, and
   // the URLs should be opened.
@@ -346,13 +345,13 @@
   // bookmark view without animation.
   ProceduralBlock dismissCompletion = base::CallbackToBlock(base::BindOnce(
       [](__weak __typeof(self) weakSelf, std::vector<GURL> urls_to_open,
-         BOOL in_incognito, BOOL new_tab, GURL url_before_dismissal) {
+         BOOL in_incognito, BOOL new_tab, GURL url_before_presentation) {
         [weakSelf openUrls:urls_to_open
-                   inIncognito:in_incognito
-                        newTab:new_tab
-            urlBeforeDismissal:url_before_dismissal];
+                      inIncognito:in_incognito
+                           newTab:new_tab
+            urlBeforePresentation:url_before_presentation];
       },
-      self, urlsToOpen, inIncognito, newTab, urlBeforeDismissal));
+      self, urlsToOpen, inIncognito, newTab, urlBeforePresentation));
 
   if (self.baseViewController.presentedViewController) {
     [self.baseViewController dismissViewControllerAnimated:animated
@@ -388,6 +387,7 @@
   self.bookmarkNavigationController.presentationController.delegate = nil;
   self.bookmarkNavigationController.delegate = nil;
   self.bookmarkNavigationController = nil;
+  _lastCommittedURLBeforePresentation = GURL();
   self.currentPresentedState = PresentedState::NONE;
 }
 
@@ -495,13 +495,12 @@
 //   foreground, others are opened in background tabs.
 // `inIncognito`: Whether the URLs should be opened in an incognito tab.
 // `newTab`: Whether the URLs should be forced to open in a new tab.
-// `urlBeforeDismissal`: The GURL of the active web state before the bookmarks
-//   UI dismissal animation started. Used to prevent Universal Cross-Site
-//   Scripting (UXSS).
+// `urlBeforePresentation`: The GURL of the active `WebState` when the bookmarks
+//   UI was presented. Used to prevent Universal Cross-Site Scripting (UXSS).
 - (void)openUrls:(const std::vector<GURL>&)urls
-           inIncognito:(BOOL)inIncognito
-                newTab:(BOOL)newTab
-    urlBeforeDismissal:(const GURL&)urlBeforeDismissal {
+              inIncognito:(BOOL)inIncognito
+                   newTab:(BOOL)newTab
+    urlBeforePresentation:(const GURL&)urlBeforePresentation {
   if (!_currentProfile || !self.browser) {
     return;
   }
@@ -536,7 +535,8 @@
         [self openURLInNewTab:url inIncognito:inIncognito inBackground:NO];
       } else {
         // Open in current tab otherwise.
-        [self openURLInCurrentTab:url urlBeforeDismissal:urlBeforeDismissal];
+        [self openURLInCurrentTab:url
+            urlBeforePresentation:urlBeforePresentation];
       }
     } else {
       // Open other URLs (if any) in background tabs.
@@ -702,18 +702,19 @@
 }
 
 - (void)openURLInCurrentTab:(const GURL&)url
-         urlBeforeDismissal:(const GURL&)urlBeforeDismissal {
+      urlBeforePresentation:(const GURL&)urlBeforePresentation {
   Browser* browser = self.browser;
   WebStateList* webStateList = browser->GetWebStateList();
   if (url.SchemeIs(url::kJavaScriptScheme) && webStateList) {  // bookmarklet
     web::WebState* activeWebState = webStateList->GetActiveWebState();
-    // Both the last committed URL and visible URL of the active WebState must
-    // be equal to the URL before dismissal in order to avoid UXSS (Universal
-    // Cross-Site Scripting) caused by background/pending navigations during
-    // Bookmarks UI dismissal animation.
+    // Both the last committed URL and visible URL of the active `WebState` must
+    // be equal to the URL when the Bookmarks UI was presented in order to
+    // avoid UXSS (Universal Cross-Site Scripting) caused by background/pending
+    // navigations while the Bookmarks UI was open or during its dismissal
+    // animation.
     if (activeWebState &&
-        activeWebState->GetLastCommittedURL() == urlBeforeDismissal &&
-        activeWebState->GetVisibleURL() == urlBeforeDismissal) {
+        activeWebState->GetLastCommittedURL() == urlBeforePresentation &&
+        activeWebState->GetVisibleURL() == urlBeforePresentation) {
       LoadJavaScriptURL(url, browser, activeWebState);
     }
     return;
@@ -754,6 +755,16 @@
   DUMP_WILL_BE_CHECK_EQ(PresentedState::NONE, self.currentPresentedState);
   DUMP_WILL_BE_CHECK(!self.bookmarkNavigationController) << [self description];
 
+  _lastCommittedURLBeforePresentation = GURL();
+  if (self.browser && self.browser->GetWebStateList()) {
+    web::WebState* activeWebState =
+        self.browser->GetWebStateList()->GetActiveWebState();
+    if (activeWebState) {
+      _lastCommittedURLBeforePresentation =
+          activeWebState->GetLastCommittedURL();
+    }
+  }
+
   self.bookmarkBrowser =
       [[BookmarksHomeViewController alloc] initWithBrowser:self.browser];
   self.bookmarkBrowser.homeDelegate = self;
diff --git a/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm b/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
index 7c96ac5..f036d56bd 100644
--- a/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
+++ b/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
@@ -67,12 +67,53 @@
   // Trigger background navigation programmatically immediately after tapping
   // the bookmarklet.
   GURL sensitiveURL = self.testServer->GetURL("/toctou_sensitive.html");
-  [ChromeEarlGrey loadURL:sensitiveURL];
+  [ChromeEarlGrey loadURL:sensitiveURL waitForCompletion:NO];
+  [ChromeEarlGrey waitForPageToFinishLoading];
 
   // Tapping the bookmarklet should close the bookmarks UI.
-  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
-                                          kBookmarksHomeTableViewIdentifier)]
-      assertWithMatcher:grey_nil()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
+
+  // Verify that the bookmarklet was NOT executed on the sensitive page.
+  [ChromeEarlGrey waitForWebStateContainingText:"Not executed"];
+}
+
+// Tests that a bookmarklet is blocked if the active tab navigated to a
+// different origin in the background while the Bookmarks UI was open, before
+// the bookmarklet was tapped.
+- (void)testBookmarkletTOCTOUMitigationWhileBookmarksOpen {
+  // Add the bookmarklet programmatically.
+  NSString* bookmarkletURL =
+      @"javascript:document.getElementById('result').innerText='EXECUTED';";
+  [BookmarkEarlGrey addBookmarkWithTitle:@"TOCTOU_Bookmarklet"
+                                     URL:bookmarkletURL
+                               inStorage:BookmarkStorageType::kLocalOrSyncable];
+
+  // Start the test server.
+  GREYAssertTrue(self.testServer->Start(), @"Test server failed to start.");
+
+  // Load the attacker page.
+  GURL attackerURL = self.testServer->GetURL("/toctou_attacker.html");
+  [ChromeEarlGrey loadURL:attackerURL];
+
+  // Open Bookmarks UI using the real UI helper.
+  [BookmarkEarlGreyUI openBookmarks];
+  [BookmarkEarlGreyUI openMobileBookmarks];
+
+  // Trigger background navigation while the Bookmarks UI is open, BEFORE
+  // tapping the bookmarklet.
+  GURL sensitiveURL = self.testServer->GetURL("/toctou_sensitive.html");
+  [ChromeEarlGrey loadURL:sensitiveURL waitForCompletion:NO];
+  [ChromeEarlGrey waitForPageToFinishLoading];
+
+  // Tap the bookmarklet in the UI.
+  [[EarlGrey selectElementWithMatcher:TappableBookmarkNodeWithLabel(
+                                          @"TOCTOU_Bookmarklet")]
+      performAction:grey_tap()];
+
+  // Tapping the bookmarklet should close the bookmarks UI.
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
 
   // Verify that the bookmarklet was NOT executed on the sensitive page.
   [ChromeEarlGrey waitForWebStateContainingText:"Not executed"];
@@ -105,9 +146,8 @@
       performAction:grey_tap()];
 
   // Tapping the bookmarklet should close the bookmarks UI.
-  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
-                                          kBookmarksHomeTableViewIdentifier)]
-      assertWithMatcher:grey_nil()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
 
   // Verify that the bookmarklet WAS executed successfully on the page.
   [ChromeEarlGrey waitForWebStateContainingText:"EXECUTED"];
@@ -136,9 +176,8 @@
       performAction:grey_tap()];
 
   // Tapping the bookmarklet should close the bookmarks UI successfully.
-  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
-                                          kBookmarksHomeTableViewIdentifier)]
-      assertWithMatcher:grey_nil()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
 }
 
 // Tests that opening a bookmark when there are no active tabs (e.g. all tabs
@@ -165,9 +204,8 @@
       performAction:grey_tap()];
 
   // Tapping the bookmark should close the bookmarks UI.
-  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
-                                          kBookmarksHomeTableViewIdentifier)]
-      assertWithMatcher:grey_nil()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
 
   // Verify that a tab was opened with the bookmark's URL.
   [ChromeEarlGrey waitForMainTabCount:1];
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm b/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
index 7c96ac5..f036d56bd 100644
--- a/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
+++ b/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
@@ -67,12 +67,53 @@
   // Trigger background navigation programmatically immediately after tapping
   // the bookmarklet.
   GURL sensitiveURL = self.testServer->GetURL("/toctou_sensitive.html");
-  [ChromeEarlGrey loadURL:sensitiveURL];
+  [ChromeEarlGrey loadURL:sensitiveURL waitForCompletion:NO];
+  [ChromeEarlGrey waitForPageToFinishLoading];
 
   // Tapping the bookmarklet should close the bookmarks UI.
-  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
-                                          kBookmarksHomeTableViewIdentifier)]
-      assertWithMatcher:grey_nil()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
+
+  // Verify that the bookmarklet was NOT executed on the sensitive page.
+  [ChromeEarlGrey waitForWebStateContainingText:"Not executed"];
+}
+
+// Tests that a bookmarklet is blocked if the active tab navigated to a
+// different origin in the background while the Bookmarks UI was open, before
+// the bookmarklet was tapped.
+- (void)testBookmarkletTOCTOUMitigationWhileBookmarksOpen {
+  // Add the bookmarklet programmatically.
+  NSString* bookmarkletURL =
+      @"javascript:document.getElementById('result').innerText='EXECUTED';";
+  [BookmarkEarlGrey addBookmarkWithTitle:@"TOCTOU_Bookmarklet"
+                                     URL:bookmarkletURL
+                               inStorage:BookmarkStorageType::kLocalOrSyncable];
+
+  // Start the test server.
+  GREYAssertTrue(self.testServer->Start(), @"Test server failed to start.");
+
+  // Load the attacker page.
+  GURL attackerURL = self.testServer->GetURL("/toctou_attacker.html");
+  [ChromeEarlGrey loadURL:attackerURL];
+
+  // Open Bookmarks UI using the real UI helper.
+  [BookmarkEarlGreyUI openBookmarks];
+  [BookmarkEarlGreyUI openMobileBookmarks];
+
+  // Trigger background navigation while the Bookmarks UI is open, BEFORE
+  // tapping the bookmarklet.
+  GURL sensitiveURL = self.testServer->GetURL("/toctou_sensitive.html");
+  [ChromeEarlGrey loadURL:sensitiveURL waitForCompletion:NO];
+  [ChromeEarlGrey waitForPageToFinishLoading];
+
+  // Tap the bookmarklet in the UI.
+  [[EarlGrey selectElementWithMatcher:TappableBookmarkNodeWithLabel(
+                                          @"TOCTOU_Bookmarklet")]
+      performAction:grey_tap()];
+
+  // Tapping the bookmarklet should close the bookmarks UI.
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
 
   // Verify that the bookmarklet was NOT executed on the sensitive page.
   [ChromeEarlGrey waitForWebStateContainingText:"Not executed"];
@@ -105,9 +146,8 @@
       performAction:grey_tap()];
 
   // Tapping the bookmarklet should close the bookmarks UI.
-  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
-                                          kBookmarksHomeTableViewIdentifier)]
-      assertWithMatcher:grey_nil()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
 
   // Verify that the bookmarklet WAS executed successfully on the page.
   [ChromeEarlGrey waitForWebStateContainingText:"EXECUTED"];
@@ -136,9 +176,8 @@
       performAction:grey_tap()];
 
   // Tapping the bookmarklet should close the bookmarks UI successfully.
-  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
-                                          kBookmarksHomeTableViewIdentifier)]
-      assertWithMatcher:grey_nil()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
 }
 
 // Tests that opening a bookmark when there are no active tabs (e.g. all tabs
@@ -165,9 +204,8 @@
       performAction:grey_tap()];
 
   // Tapping the bookmark should close the bookmarks UI.
-  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
-                                          kBookmarksHomeTableViewIdentifier)]
-      assertWithMatcher:grey_nil()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_accessibilityID(kBookmarksHomeTableViewIdentifier)];
 
   // Verify that a tab was opened with the bookmark's URL.
   [ChromeEarlGrey waitForMainTabCount:1];
Loading diff…

Original Bug Report

reported by [email protected]

Potential UXSS guard bypass via presentation-time TOCTOU in iOS Bookmarks

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the Bookmarks UI of Chrome for iOS, allowing bypass of the Universal Cross-Site Scripting (UXSS) guard. Because the baseline URL snapshot is captured when a user taps a bookmarklet rather than when the Bookmarks modal is originally presented, a webpage can silently navigate in the background. If a user subsequently selects a bookmarklet, the script evaluates in the context of the newly navigated target origin.

Affected files:

  • ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm

Estimated timestamp from git blame: 2026-05-07

Root Cause

In ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm, the UXSS guard inside -[BookmarksCoordinator openURLInCurrentTab:urlBeforeDismissal:] checks whether the active WebState’s current URL matches urlBeforeDismissal. If they match, a bookmarklet’s javascript: payload is executed via LoadJavaScriptURL.

However, the urlBeforeDismissal snapshot is captured too late: inside -[BookmarksCoordinator dismissBookmarkBrowserAnimated:urlsToOpen:inIncognito:newTab:] which executes when the user taps on the bookmark row, rather than when the Bookmarks modal was originally presented to the user.

// ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
- (void)dismissBookmarkBrowserAnimated:(BOOL)animated
                            urlsToOpen:(const std::vector<GURL>&)	urlsToOpen
                           inIncognito:(BOOL)inIncognito
                                newTab:(BOOL)newTab {
  ...
  GURL urlBeforeDismissal;
  if (self.browser && self.browser->GetWebStateList()) {
    web::WebState* activeWebState =
        self.browser->GetWebStateList()->GetActiveWebState();
    if (activeWebState) {
      urlBeforeDismissal = activeWebState->GetLastCommittedURL();   // <-- Captured at TAP time
    }
  }
  ...
}

Because Bookmarks is presented with UIModalPresentationFormSheet (line 688), the underlying WKWebView is not suspended or removed from the active window hierarchy. It continues to run JavaScript and execute top-level navigations while obscured by the Bookmarks sheet.

Potential Trigger Path

Note: These are potential steps deduced from static analysis; our tooling does not currently run dynamic proof-of-concept code.

  1. A user on a malicious page (http://attacker.example) has previously saved an attacker-controlled javascript: bookmarklet (e.g., javascript:alert(document.domain)).
  2. The user opens the Bookmarks menu, which displays the Bookmarks controller as a UIModalPresentationFormSheet sheet.
  3. While the Bookmarks sheet is open, the background page (attacker.example) initiates a cross-origin navigation to a target origin (e.g., https://mail.google.com).
  4. Once the navigation commits, the active WebState’s committed and visible URLs update to the target origin.
  5. The user taps the saved bookmarklet in the modal list.
  6. -[BookmarksCoordinator dismissBookmarkBrowserAnimated:...] runs, capturing urlBeforeDismissal as the post-navigation URL (https://mail.google.com).
  7. During -[BookmarksCoordinator openURLInCurrentTab:urlBeforeDismissal:], the comparisons activeWebState->GetLastCommittedURL() == urlBeforeDismissal and activeWebState->GetVisibleURL() == urlBeforeDismissal both evaluate to true (evaluating "https://mail.google.com" == "https://mail.google.com").
  8. The security check is bypassed, and LoadJavaScriptURL executes the malicious script inside the context of https://mail.google.com.

Suggested Fix

Capture the active WebState’s last committed URL at presentation-time (e.g., inside -[BookmarksCoordinator presentBookmarksAtDisplayedFolderNode:selectingBookmark:]) and store it as an instance variable on the coordinator. Compare the current URL against this presentation-time snapshot inside openURLInCurrentTab:urlBeforeDismissal: rather than using a tap-time snapshot.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker
Links in the report