CVE-2026-87517
Overview
Background
- Bookmarklet
- A bookmark whose URL uses the
javascript:scheme (url::kJavaScriptScheme); tapping it runs the embedded script in the currently active tab viaLoadJavaScriptURL. - 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, whileGetVisibleURL()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.
Attack Path
- 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.
- Underlying tab is navigated while the UI is open
While the bookmark browser covers the tab, a background/pending navigation moves the active
WebStateto a different origin (the regression test drives this withloadURL:sensitiveURL waitForCompletion:NOwhile the UI is open), a navigation the attacker can influence (inferred). - User taps a `javascript:` bookmarklet
The dismissal path captures
urlBeforeDismissalfrom the now-navigatedWebState, so the snapshot equals the new URL. - Guard passes trivially and bookmarklet executes
In
openURLInCurrentTab:, bothGetLastCommittedURL()andGetVisibleURL()equal the just-capturedurlBeforeDismissal, soLoadJavaScriptURL(url, browser, activeWebState)runs the script in the context of the navigated-to origin, yielding UXSS as labeled in the diff.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm |
modified |
Files Changed
ios/chrome/browser/bookmarks/test/bookmarks_security_egtest.mmios/chrome/browser/bookmarks/ui_bundled/home/bookmarks_coordinator.mm
Audit Directions
- Snapshot timing on covered/backgrounded contentReview 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 stateAudit
CallbackToBlock/dismissal-completion blocks that carry captured URLs or origins into an asynchronous continuation, since navigations can commit between capture and block execution.
Patch
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];
Regression Test / PoC
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];
Original Bug Report
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.
- A user on a malicious page (
http://attacker.example) has previously saved an attacker-controlledjavascript:bookmarklet (e.g.,javascript:alert(document.domain)). - The user opens the Bookmarks menu, which displays the Bookmarks controller as a
UIModalPresentationFormSheetsheet. - 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). - Once the navigation commits, the active
WebState’s committed and visible URLs update to the target origin. - The user taps the saved bookmarklet in the modal list.
-[BookmarksCoordinator dismissBookmarkBrowserAnimated:...]runs, capturingurlBeforeDismissalas the post-navigation URL (https://mail.google.com).- During
-[BookmarksCoordinator openURLInCurrentTab:urlBeforeDismissal:], the comparisonsactiveWebState->GetLastCommittedURL() == urlBeforeDismissalandactiveWebState->GetVisibleURL() == urlBeforeDismissalboth evaluate totrue(evaluating"https://mail.google.com" == "https://mail.google.com"). - The security check is bypassed, and
LoadJavaScriptURLexecutes the malicious script inside the context ofhttps://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.