Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in iOS
DescriptionInappropriate implementation in iOS
ComponentChromium
Bug ClassLogic Error
Tracker508448586
Fix commit6e38727c2b13 (chromium/src) +209/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Files Changed

  • ios/chrome/browser/bookmarks/test/BUILD.gn
  • ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
  • ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
From 6e38727c2b132089a5483079873c393cd8b530b9 Mon Sep 17 00:00:00 2001
From: Huiting Yu <huitingyu@google.com>
Date: Thu, 07 May 2026 11:36:45 -0700
Subject: [PATCH] [iOS] Fix potential UXSS in iOS Bookmarks via TOCTOU.

During Bookmarks UI dismissal, execution of bookmarklets is deferred.
An attacker could navigate the background tab to a sensitive origin
during the animation delay, causing the script to run on the new origin.

This fix captures the active tab's URL before dismissal starts and
verifies it still matches immediately before executing the JavaScript.

Also added a comprehensive EarlGrey test to verify the mitigation:
- Created a new BookmarksSecurityTestCase.
- Verified it worked when opening bookmarklet on NTP.

TAG=agy
CONV=91198814-91cf-408a-ba91-457043a83a24

Fixed: 508448586
Change-Id: If54a36809689ac28944961471b4733cdcfe1e165
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7812908
Reviewed-by: Vincent Boisselle <vincb@google.com>
Reviewed-by: Arthur Milchior <arthurmilchior@chromium.org>
Commit-Queue: Huiting Yu <huitingyu@google.com>
Cr-Commit-Position: refs/heads/main@{#1627091}
---

diff --git a/ios/chrome/browser/bookmarks/test/BUILD.gn b/ios/chrome/browser/bookmarks/test/BUILD.gn
index 79d33e9f..c2f9bf3 100644
--- a/ios/chrome/browser/bookmarks/test/BUILD.gn
+++ b/ios/chrome/browser/bookmarks/test/BUILD.gn
@@ -11,6 +11,7 @@
     "bookmarks_entries_egtest.mm",
     "bookmarks_interaction_egtest.mm",
     "bookmarks_search_egtest.mm",
+    "bookmarks_security_egtest.mm",
     "managed_bookmarks_egtest.mm",
   ]
   deps = [
diff --git a/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm b/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
new file mode 100644
index 0000000..50746e4
--- /dev/null
+++ b/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
@@ -0,0 +1,144 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import <UIKit/UIKit.h>
+#import <XCTest/XCTest.h>
+
+#import "ios/chrome/browser/bookmarks/model/bookmark_storage_type.h"
+#import "ios/chrome/browser/bookmarks/public/bookmarks_ui_constants.h"
+#import "ios/chrome/browser/bookmarks/test/bookmark_earl_grey.h"
+#import "ios/chrome/browser/bookmarks/test/bookmark_earl_grey_ui.h"
+#import "ios/chrome/test/earl_grey/chrome_coordinator_app_interface.h"
+#import "ios/chrome/test/earl_grey/chrome_earl_grey.h"
+#import "ios/chrome/test/earl_grey/chrome_earl_grey_ui.h"
+#import "ios/chrome/test/earl_grey/chrome_matchers.h"
+#import "ios/chrome/test/earl_grey/chrome_test_case.h"
+#import "ios/testing/earl_grey/earl_grey_test.h"
+#import "net/test/embedded_test_server/embedded_test_server.h"
+
+using chrome_test_util::TappableBookmarkNodeWithLabel;
+
+@interface BookmarksSecurityTestCase : ChromeTestCase
+@end
+
+@implementation BookmarksSecurityTestCase
+
+- (void)setUp {
+  [super setUp];
+  [BookmarkEarlGrey waitForBookmarkModelLoaded];
+  [BookmarkEarlGrey clearBookmarks];
+}
+
+- (void)tearDownHelper {
+  [ChromeCoordinatorAppInterface reset];
+  [super tearDownHelper];
+  [BookmarkEarlGrey clearBookmarks];
+  [BookmarkEarlGrey clearBookmarksPositionCache];
+}
+
+// Tests that a bookmarklet executed during Bookmarks UI dismissal is blocked
+// if the active tab navigated to a different origin in the background during
+// the animation.
+- (void)testBookmarkletTOCTOUMitigation {
+  // 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];
+
+  // Tap the bookmarklet in the UI.
+  [[EarlGrey selectElementWithMatcher:TappableBookmarkNodeWithLabel(
+                                          @"TOCTOU_Bookmarklet")]
+      performAction:grey_tap()];
+
+  // Trigger background navigation programmatically immediately after tapping
+  // the bookmarklet.
+  GURL sensitiveURL = self.testServer->GetURL("/toctou_sensitive.html");
+  [ChromeEarlGrey loadURL:sensitiveURL];
+
+  // Tapping the bookmarklet should close the bookmarks UI.
+  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
+                                          kBookmarksHomeTableViewIdentifier)]
+      assertWithMatcher:grey_nil()];
+
+  // Verify that the bookmarklet was NOT executed on the sensitive page.
+  [ChromeEarlGrey waitForWebStateContainingText:"Not executed"];
+}
+
+// Tests that a bookmarklet executed during Bookmarks UI dismissal works
+// successfully in the common case (where no background origin navigation
+// occurs).
+- (void)testBookmarkletExecutionInCommonCase {
+  // 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 sensitive page directly (which has no background navigation).
+  [ChromeEarlGrey loadURL:self.testServer->GetURL("/toctou_sensitive.html")];
+
+  // Open Bookmarks UI using the real UI helper.
+  [BookmarkEarlGreyUI openBookmarks];
+  [BookmarkEarlGreyUI openMobileBookmarks];
+
+  // Tap the bookmarklet in the UI.
+  [[EarlGrey selectElementWithMatcher:TappableBookmarkNodeWithLabel(
+                                          @"TOCTOU_Bookmarklet")]
+      performAction:grey_tap()];
+
+  // Tapping the bookmarklet should close the bookmarks UI.
+  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
+                                          kBookmarksHomeTableViewIdentifier)]
+      assertWithMatcher:grey_nil()];
+
+  // Verify that the bookmarklet WAS executed successfully on the page.
+  [ChromeEarlGrey waitForWebStateContainingText:"EXECUTED"];
+}
+
+// Tests that a bookmarklet executed during Bookmarks UI dismissal works
+// successfully on a fresh blank tab (where the last committed URL is empty).
+- (void)testBookmarkletExecutionOnBlankTab {
+  // Add the bookmarklet programmatically.
+  NSString* bookmarkletURL =
+      @"javascript:document.getElementById('result').innerText='EXECUTED';";
+  [BookmarkEarlGrey addBookmarkWithTitle:@"TOCTOU_Bookmarklet"
+                                     URL:bookmarkletURL
+                               inStorage:BookmarkStorageType::kLocalOrSyncable];
+
+  // Open a fresh new blank tab.
+  [ChromeEarlGrey openNewTab];
+
+  // Open Bookmarks UI using the real UI helper.
+  [BookmarkEarlGreyUI openBookmarks];
+  [BookmarkEarlGreyUI openMobileBookmarks];
+
+  // Tap the bookmarklet in the UI.
+  [[EarlGrey selectElementWithMatcher:TappableBookmarkNodeWithLabel(
+                                          @"TOCTOU_Bookmarklet")]
+      performAction:grey_tap()];
+
+  // Tapping the bookmarklet should close the bookmarks UI successfully.
+  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
+                                          kBookmarksHomeTableViewIdentifier)]
+      assertWithMatcher:grey_nil()];
+}
+
+@end
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 1255de4d..4c8d24e5 100644
--- a/ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
+++ b/ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
@@ -333,18 +333,28 @@
             _currentBrowserState.get()));
   }
 
+  GURL urlBeforeDismissal;
+  if (self.browser && self.browser->GetWebStateList()) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/bookmarks/test/BUILD.gn b/ios/chrome/browser/bookmarks/test/BUILD.gn
index 79d33e9f..c2f9bf3 100644
--- a/ios/chrome/browser/bookmarks/test/BUILD.gn
+++ b/ios/chrome/browser/bookmarks/test/BUILD.gn
@@ -11,6 +11,7 @@
     "bookmarks_entries_egtest.mm",
     "bookmarks_interaction_egtest.mm",
     "bookmarks_search_egtest.mm",
+    "bookmarks_security_egtest.mm",
     "managed_bookmarks_egtest.mm",
   ]
   deps = [
diff --git a/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm b/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
new file mode 100644
index 0000000..50746e4
--- /dev/null
+++ b/ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mm
@@ -0,0 +1,144 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import <UIKit/UIKit.h>
+#import <XCTest/XCTest.h>
+
+#import "ios/chrome/browser/bookmarks/model/bookmark_storage_type.h"
+#import "ios/chrome/browser/bookmarks/public/bookmarks_ui_constants.h"
+#import "ios/chrome/browser/bookmarks/test/bookmark_earl_grey.h"
+#import "ios/chrome/browser/bookmarks/test/bookmark_earl_grey_ui.h"
+#import "ios/chrome/test/earl_grey/chrome_coordinator_app_interface.h"
+#import "ios/chrome/test/earl_grey/chrome_earl_grey.h"
+#import "ios/chrome/test/earl_grey/chrome_earl_grey_ui.h"
+#import "ios/chrome/test/earl_grey/chrome_matchers.h"
+#import "ios/chrome/test/earl_grey/chrome_test_case.h"
+#import "ios/testing/earl_grey/earl_grey_test.h"
+#import "net/test/embedded_test_server/embedded_test_server.h"
+
+using chrome_test_util::TappableBookmarkNodeWithLabel;
+
+@interface BookmarksSecurityTestCase : ChromeTestCase
+@end
+
+@implementation BookmarksSecurityTestCase
+
+- (void)setUp {
+  [super setUp];
+  [BookmarkEarlGrey waitForBookmarkModelLoaded];
+  [BookmarkEarlGrey clearBookmarks];
+}
+
+- (void)tearDownHelper {
+  [ChromeCoordinatorAppInterface reset];
+  [super tearDownHelper];
+  [BookmarkEarlGrey clearBookmarks];
+  [BookmarkEarlGrey clearBookmarksPositionCache];
+}
+
+// Tests that a bookmarklet executed during Bookmarks UI dismissal is blocked
+// if the active tab navigated to a different origin in the background during
+// the animation.
+- (void)testBookmarkletTOCTOUMitigation {
+  // 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];
+
+  // Tap the bookmarklet in the UI.
+  [[EarlGrey selectElementWithMatcher:TappableBookmarkNodeWithLabel(
+                                          @"TOCTOU_Bookmarklet")]
+      performAction:grey_tap()];
+
+  // Trigger background navigation programmatically immediately after tapping
+  // the bookmarklet.
+  GURL sensitiveURL = self.testServer->GetURL("/toctou_sensitive.html");
+  [ChromeEarlGrey loadURL:sensitiveURL];
+
+  // Tapping the bookmarklet should close the bookmarks UI.
+  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
+                                          kBookmarksHomeTableViewIdentifier)]
+      assertWithMatcher:grey_nil()];
+
+  // Verify that the bookmarklet was NOT executed on the sensitive page.
+  [ChromeEarlGrey waitForWebStateContainingText:"Not executed"];
+}
+
+// Tests that a bookmarklet executed during Bookmarks UI dismissal works
+// successfully in the common case (where no background origin navigation
+// occurs).
+- (void)testBookmarkletExecutionInCommonCase {
+  // 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 sensitive page directly (which has no background navigation).
+  [ChromeEarlGrey loadURL:self.testServer->GetURL("/toctou_sensitive.html")];
+
+  // Open Bookmarks UI using the real UI helper.
+  [BookmarkEarlGreyUI openBookmarks];
+  [BookmarkEarlGreyUI openMobileBookmarks];
+
+  // Tap the bookmarklet in the UI.
+  [[EarlGrey selectElementWithMatcher:TappableBookmarkNodeWithLabel(
+                                          @"TOCTOU_Bookmarklet")]
+      performAction:grey_tap()];
+
+  // Tapping the bookmarklet should close the bookmarks UI.
+  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
+                                          kBookmarksHomeTableViewIdentifier)]
+      assertWithMatcher:grey_nil()];
+
+  // Verify that the bookmarklet WAS executed successfully on the page.
+  [ChromeEarlGrey waitForWebStateContainingText:"EXECUTED"];
+}
+
+// Tests that a bookmarklet executed during Bookmarks UI dismissal works
+// successfully on a fresh blank tab (where the last committed URL is empty).
+- (void)testBookmarkletExecutionOnBlankTab {
+  // Add the bookmarklet programmatically.
+  NSString* bookmarkletURL =
+      @"javascript:document.getElementById('result').innerText='EXECUTED';";
+  [BookmarkEarlGrey addBookmarkWithTitle:@"TOCTOU_Bookmarklet"
+                                     URL:bookmarkletURL
+                               inStorage:BookmarkStorageType::kLocalOrSyncable];
+
+  // Open a fresh new blank tab.
+  [ChromeEarlGrey openNewTab];
+
+  // Open Bookmarks UI using the real UI helper.
+  [BookmarkEarlGreyUI openBookmarks];
+  [BookmarkEarlGreyUI openMobileBookmarks];
+
+  // Tap the bookmarklet in the UI.
+  [[EarlGrey selectElementWithMatcher:TappableBookmarkNodeWithLabel(
+                                          @"TOCTOU_Bookmarklet")]
+      performAction:grey_tap()];
+
+  // Tapping the bookmarklet should close the bookmarks UI successfully.
+  [[EarlGrey selectElementWithMatcher:grey_accessibilityID(
+                                          kBookmarksHomeTableViewIdentifier)]
+      assertWithMatcher:grey_nil()];
+}
+
+@end
diff --git a/ios/testing/data/http_server_files/toctou_attacker.html b/ios/testing/data/http_server_files/toctou_attacker.html
new file mode 100644
index 0000000..96def491
--- /dev/null
+++ b/ios/testing/data/http_server_files/toctou_attacker.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Attacker Page</title>
+  <script>
+    // Attacker page with no listeners.
+  </script>
+</head>
+<body>
+  <h1>Attacker Page</h1>
+  <p>Open bookmarks and tap the bookmarklet.</p>
+</body>
+</html>
diff --git a/ios/testing/data/http_server_files/toctou_sensitive.html b/ios/testing/data/http_server_files/toctou_sensitive.html
new file mode 100644
index 0000000..c6058aa
--- /dev/null
+++ b/ios/testing/data/http_server_files/toctou_sensitive.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Sensitive Page</title>
+</head>
+<body>
+  <h1>Sensitive Page</h1>
+  <p>This is a sensitive origin.</p>
+  <div id="result">Not executed</div>
+</body>
+</html>
diff --git a/ios/testing/http_server_bundle_data.filelist b/ios/testing/http_server_bundle_data.filelist
index 2db87838..e665ca4 100644
--- a/ios/testing/http_server_bundle_data.filelist
+++ b/ios/testing/http_server_bundle_data.filelist
@@ -73,6 +73,8 @@
 data/http_server_files/state_operations.js
 data/http_server_files/tall_page.html
 data/http_server_files/testpage.pdf
+data/http_server_files/toctou_attacker.html
+data/http_server_files/toctou_sensitive.html
 data/http_server_files/two_pages.pdf
 data/http_server_files/uff_login_forms.html
 data/http_server_files/user_agent_test_page.html
Loading diff…

Original Bug Report

reported by li...@chromium.org

Potential UXSS in iOS Bookmarks via TOCTOU during dismissal animation

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

Overview: A Time-Of-Check to Time-Of-Use (TOCTOU) vulnerability exists in the iOS Bookmarks UI when executing bookmarklets. The execution is deferred until the UI dismissal animation completes, allowing a malicious site to navigate the active tab in the background to a sensitive origin before the bookmarklet runs.

Affected files:

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

Estimated timestamp from git blame: 2025-07-02

Summary

A Time-Of-Check to Time-Of-Use (TOCTOU) race condition in the iOS Bookmarks implementation may allow for Universal Cross-Site Scripting (UXSS). When a user selects a bookmarklet (javascript: URL) from the Bookmarks UI, its execution is deferred until after the Bookmarks modal finishes its dismissal animation (which takes approximately 0.3 - 0.5 seconds). Because the target execution context is resolved only after this delay, a malicious site can navigate the tab to a sensitive origin during the animation, causing the bookmarklet to execute on the new origin.

Technical Details

In ios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm, the method dismissBookmarkBrowserAnimated:urlsToOpen:inIncognito:newTab: handles the dismissal of the bookmarks UI. The URL opening logic is wrapped in a dismissCompletion block that executes after the animation finishes:

ProceduralBlock dismissCompletion = base::CallbackToBlock(base::BindOnce(
    [](__weak __typeof(self) weakSelf, std::vector<GURL> urls_to_open,
       BOOL in_incognito, BOOL new_tab) {
      [weakSelf openUrls:urls_to_open
             inIncognito:in_incognito
                  newTab:new_tab];
    },
    self, urlsToOpen, inIncognito, newTab));

When the animation completes, dismissCompletion eventually triggers openURLInCurrentTab:, which fetches the currently active WebState to execute the JavaScript:

- (void)openURLInCurrentTab:(const GURL&)url {
  Browser* browser = self.browser;
  WebStateList* webStateList = browser->GetWebStateList();
  if (url.SchemeIs(url::kJavaScriptScheme) && webStateList) {
    LoadJavaScriptURL(url, browser, webStateList->GetActiveWebState());
    return;
  }
  // ...
}

The vulnerability stems from the fact that webStateList->GetActiveWebState() and subsequently its last committed URL are evaluated at the time of use (after the animation) rather than at the time the user tapped the bookmarklet.

Potential Exploitation Scenario

Note: These are suggested steps; our tooling agent has not executed a live proof-of-concept.

  1. An attacker tricks a user into saving a malicious bookmarklet.
  2. The user visits the attacker’s website (e.g., https://attacker.com).
  3. The attacker’s site includes a script that listens for the blur event on the window.
  4. The user opens the Bookmarks UI. This causes the underlying web page to lose focus, triggering the blur event.
  5. The attacker’s script immediately initiates a navigation to a sensitive origin, such as https://mail.google.com.
  6. In the Bookmarks UI, the user taps the malicious bookmarklet.
  7. The UI dismissal animation begins (taking ~0.4 seconds). Bookmarklet execution is deferred.
  8. During the animation, the background navigation to https://mail.google.com commits in the WKWebView.
  9. The animation completes, and openURLInCurrentTab: fetches the active WebState, which now points to mail.google.com.
  10. The malicious JavaScript executes within the context of mail.google.com, potentially allowing the attacker to access sensitive data.

The execution context should be pinned to the state at the moment the bookmarklet is tapped, not after the animation completes.

Simply capturing the web::WebState* at the time of the click is insufficient, because in iOS Chrome, a WebState object represents a tab and its pointer remains the same across cross-origin navigations.

A robust fix should capture an identifier of the committed document (e.g., the GetLastCommittedURL()) at the time the user taps the bookmarklet, and verify that the WebState’s last committed URL still matches this captured URL immediately before executing the JavaScript in LoadJavaScriptURL or openURLInCurrentTab:.

Evaluated with Chrome root at commit: cc901875d53bf4e4fe0e01f02843871da4106e70


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.

View on issue tracker