CVE-2026-9971
Overview
Files Changed
ios/chrome/browser/bookmarks/test/BUILD.gnios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mmios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
Patch
From 6e38727c2b132089a5483079873c393cd8b530b9 Mon Sep 17 00:00:00 2001 From: Huiting Yu <[email protected]> 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 <[email protected]> Reviewed-by: Arthur Milchior <[email protected]> Commit-Queue: Huiting Yu <[email protected]> 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()) {
Regression Test / PoC
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
Original Bug Report
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.
- An attacker tricks a user into saving a malicious bookmarklet.
- The user visits the attacker’s website (e.g.,
https://attacker.com). - The attacker’s site includes a script that listens for the
blurevent on the window. - The user opens the Bookmarks UI. This causes the underlying web page to lose focus, triggering the
blurevent. - The attacker’s script immediately initiates a navigation to a sensitive origin, such as
https://mail.google.com. - In the Bookmarks UI, the user taps the malicious bookmarklet.
- The UI dismissal animation begins (taking ~0.4 seconds). Bookmarklet execution is deferred.
- During the animation, the background navigation to
https://mail.google.comcommits in theWKWebView. - The animation completes, and
openURLInCurrentTab:fetches the activeWebState, which now points tomail.google.com. - The malicious JavaScript executes within the context of
mail.google.com, potentially allowing the attacker to access sensitive data.
Recommended Mitigation
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.