CVE-2026-17828
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mm |
modified | |
forios/chrome/browser/safe_browsing/model/password_protection_egtest.mm |
modified | |
InputEventObserverios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h |
modified |
Files Changed
ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.hios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mmios/chrome/browser/safe_browsing/model/BUILD.gnios/chrome/browser/safe_browsing/model/input_event_observer.hios/chrome/browser/safe_browsing/model/password_protection_egtest.mmios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h
Patch
From f72536d3f9737aa8ac1cb0263dcd5009f80f44c8 Mon Sep 17 00:00:00 2001 From: Joshua Hood <[email protected]> Date: Tue, 23 Jun 2026 13:51:04 -0700 Subject: [PATCH] [iOS] Detect paste keyboard shortcuts in PhishGuard Bug: 517702279 Change-Id: I11ac62eceda961b4863bc055ed2b154c539beef4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7921650 Reviewed-by: Daniel White <[email protected]> Reviewed-by: Tommy Martino <[email protected]> Commit-Queue: jdh <[email protected]> Cr-Commit-Position: refs/heads/main@{#1651247} --- diff --git a/ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.h b/ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.h index 3aa72eb..6950d582 100644 --- a/ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.h +++ b/ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.h @@ -93,6 +93,7 @@ // InputEventObserver: void OnKeyPressed(std::string text) override; void OnPaste(std::string text) override; + void OnPasteKeyDetected() override; web::WebState* web_state() const override; __weak id<IOSChromePasswordReuseDetectionManagerClientBridge> bridge_; diff --git a/ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mm b/ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mm index d2408ac..d07fe3b 100644 --- a/ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mm +++ b/ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mm @@ -4,6 +4,8 @@ #import "ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.h" +#import <UIKit/UIKit.h> + #import <memory> #import <utility> @@ -164,6 +166,23 @@ password_reuse_detection_manager_.OnPaste(base::UTF8ToUTF16(text)); } +void IOSChromePasswordReuseDetectionManagerClient::OnPasteKeyDetected() { + UIPasteboard* const pasteboard = [UIPasteboard generalPasteboard]; + if ([pasteboard hasStrings]) { + NSString* pasted_text = pasteboard.string; + if (pasted_text.length > 0) { + // Slicing to a generous suffix prevents expensive UTF16 conversions on + // huge clipboards while preserving actionable data. + if (pasted_text.length > 1000) { + pasted_text = + [pasted_text substringFromIndex:pasted_text.length - 1000]; + } + password_reuse_detection_manager_.OnPaste( + base::SysNSStringToUTF16(pasted_text)); + } + } +} + web::WebState* IOSChromePasswordReuseDetectionManagerClient::web_state() const { return bridge_.webState; } diff --git a/ios/chrome/browser/safe_browsing/model/BUILD.gn b/ios/chrome/browser/safe_browsing/model/BUILD.gn index 61614d3..3c487798 100644 --- a/ios/chrome/browser/safe_browsing/model/BUILD.gn +++ b/ios/chrome/browser/safe_browsing/model/BUILD.gn @@ -89,6 +89,7 @@ "//ios/chrome/browser/shared/model/application_context", "//ios/chrome/browser/shared/model/profile", "//ios/chrome/browser/shared/model/profile:profile_keyed_service_factory", + "//ios/chrome/browser/shared/public/features", "//ios/chrome/browser/signin/model", "//ios/chrome/browser/sync/model", "//ios/components/security_interstitials", @@ -217,6 +218,7 @@ "//ios/chrome/browser/prerender/model", "//ios/chrome/browser/shared/model/profile", "//ios/chrome/browser/shared/model/profile/test", + "//ios/chrome/browser/shared/public/features", "//ios/chrome/browser/sync/model", "//ios/chrome/test:test_support", "//ios/components/security_interstitials/safe_browsing", diff --git a/ios/chrome/browser/safe_browsing/model/input_event_observer.h b/ios/chrome/browser/safe_browsing/model/input_event_observer.h index c607100..82ef71f 100644 --- a/ios/chrome/browser/safe_browsing/model/input_event_observer.h +++ b/ios/chrome/browser/safe_browsing/model/input_event_observer.h @@ -21,6 +21,9 @@ // Called when text is pasted. virtual void OnPaste(std::string text) {} + // Called when a paste keyboard shortcut (Cmd+V/Ctrl+V) is detected. + virtual void OnPasteKeyDetected() {} + // Returns the WebState for which events are being observed. virtual web::WebState* web_state() const = 0; }; diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm b/ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm index a4595b5..803a619 100644 --- a/ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm +++ b/ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm @@ -36,7 +36,22 @@ std::unique_ptr<net::test_server::HttpResponse> HandleRequest( const net::test_server::HttpRequest& request) { auto http_response = std::make_unique<net::test_server::BasicHttpResponse>(); - if (request.relative_url.find("preventDefault=true") != std::string::npos) { + if (request.relative_url.find("bypass=true") != std::string::npos) { + http_response->set_content( + "Input: <input type='text' id='input'>" + "<script>" + " document.getElementById('input').addEventListener('keydown', " + "function(e) {" + " if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') {" + " e.preventDefault();" + " navigator.clipboard.readText().then(text => {" + " document.getElementById('input').value = text;" + " });" + " }" + " });" + "</script>"); + } else if (request.relative_url.find("preventDefault=true") != + std::string::npos) { http_response->set_content( "Input: <input type='text' id='input'>" "<script>" @@ -84,6 +99,7 @@ isRunningTest:@selector( testPasswordReuseDetectionKeydownPreventDefault)] || [self isRunningTest:@selector(testPasswordReuseDetectionPaste)] || + [self isRunningTest:@selector(testPasswordReuseDetectionPasteBypass)] || [self isRunningTest: @selector(testPasswordReuseDetectionPasteWithKeyboardShortcut)]) { @@ -125,19 +141,13 @@ for (NSString* character in @[ @"a", @"s", @"s", @"w", @"o", @"r", @"d" ]) { [ChromeEarlGrey simulatePhysicalKeyboardEvent:character flags:0]; } - - [ChromeEarlGrey - waitForJavaScriptCondition: - [NSString stringWithFormat: - @"document.getElementById('%s').value.includes('%@');", - kInputElement, @"Password"]]; } - (void)waitForPasswordProtectionWarningWithoutSync { // Disable synchronization to instruct EarlGrey to inspect the view // hierarchy immediately instead of waiting for the app to become idle, // which can block the test and cause a timeout during the modal's - // presentation animation. + // presentation animation on slow bots. ScopedSynchronizationDisabler disabler; [ChromeEarlGrey waitForUIElementToAppearWithMatcher:PasswordProtectionMatcher() @@ -167,6 +177,27 @@ [self waitForPasswordProtectionWarningWithoutSync]; } +// Tests that password protection UI is shown even when the webpage intercepts +// Cmd+V/Ctrl+V and cancels the keydown and paste events. +- (void)testPasswordReuseDetectionPasteBypass { + [ChromeEarlGrey + loadURL:GURL(base::StrCat({_phishingURL.spec(), "?bypass=true"}))]; + [ChromeEarlGrey waitForWebStateContainingText:kInputPage]; + + // Tap input to focus it. + [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()] + performAction:chrome_test_util::TapWebElementWithId(kInputElement)]; + + ScopedSynchronizationDisabler disabler; + + // Copy password to clipboard and simulate Cmd+V paste. + [ChromeEarlGrey copyTextToPasteboard:@"Password"]; + [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"v" + flags:UIKeyModifierCommand]; + + [self waitForPasswordProtectionWarningWithoutSync]; +} + // Tests that password protection UI is not shown when saved password is reused // on safe site. - (void)testPasswordProtectionNotShownForAllowListedURL { diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h index 87bdbeb3..b8a89b2f 100644 --- a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h +++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h @@ -6,9 +6,12 @@ #define IOS_CHROME_BROWSER_SAFE_BROWSING_MODEL_PASSWORD_PROTECTION_JAVA_SCRIPT_FEATURE_H_ #include <map> +#include <memory> #include "base/time/time.h" +#include "base/timer/timer.h" #include "ios/web/public/js_messaging/java_script_feature.h" +#include "third_party/abseil-cpp/absl/container/flat_hash_map.h" class InputEventObserver; @@ -48,7 +51,20 @@
Regression Test / PoC
diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
index c322ad3..7591e702 100644
--- a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
+++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
@@ -4,9 +4,11 @@
#import "ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h"
+#import "base/test/scoped_feature_list.h"
#import "base/time/time.h"
#import "base/values.h"
#import "ios/chrome/browser/safe_browsing/model/input_event_observer.h"
+#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/web/public/js_messaging/script_message.h"
#import "ios/web/public/test/fakes/fake_web_state.h"
#import "ios/web/public/test/web_task_environment.h"
@@ -27,10 +29,12 @@
on_paste_called_ = true;
pasted_text_ = text;
}
+ void OnPasteKeyDetected() override { on_paste_key_detected_called_ = true; }
web::WebState* web_state() const override { return web_state_; }
bool on_key_pressed_called_ = false;
bool on_paste_called_ = false;
+ bool on_paste_key_detected_called_ = false;
std::string pasted_text_;
raw_ptr<web::WebState> web_state_;
};
@@ -151,4 +155,156 @@
EXPECT_FALSE(observer_->on_key_pressed_called_);
}
+// Tests that a paste key detected event is forwarded to the observer after the
+// coalescing timer.
+TEST_F(PasswordProtectionJavaScriptFeatureTest,
+ PasteKeyDetectedEventForwarded) {
+ base::Value body(base::DictValue().Set("eventType", "PasteKeyDetected"));
+
+ web::ScriptMessage message(std::make_unique<base::Value>(std::move(body)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ feature_->ScriptMessageReceived(&web_state_, message);
+
+ // Key event is queued, not yet forwarded.
+ EXPECT_FALSE(observer_->on_paste_key_detected_called_);
+
+ // Advance time by 100ms for the coalescing timer.
+ task_environment_.FastForwardBy(base::Milliseconds(100));
+
+ EXPECT_TRUE(observer_->on_paste_key_detected_called_);
+}
+
+// Tests that paste key detected events are rate limited.
+TEST_F(PasswordProtectionJavaScriptFeatureTest,
+ PasteKeyDetectedEventRateLimited) {
+ base::Value body1(base::DictValue().Set("eventType", "PasteKeyDetected"));
+
+ web::ScriptMessage message1(std::make_unique<base::Value>(std::move(body1)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+ // First paste key event should be allowed and start the timer.
+ feature_->ScriptMessageReceived(&web_state_, message1);
+ EXPECT_FALSE(observer_->on_paste_key_detected_called_);
+
+ // Fast forward by 100ms. Timer fires.
+ task_environment_.FastForwardBy(base::Milliseconds(100));
+ EXPECT_TRUE(observer_->on_paste_key_detected_called_);
+ observer_->on_paste_key_detected_called_ = false;
+
+ // Second paste key event immediately after (elapsed 100ms since first)
+ // should be dropped.
+ base::Value body2(base::DictValue().Set("eventType", "PasteKeyDetected"));
+
+ web::ScriptMessage message2(std::make_unique<base::Value>(std::move(body2)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+ feature_->ScriptMessageReceived(&web_state_, message2);
+ // Fast forward by 100ms. Timer should not fire since the event was dropped.
+ task_environment_.FastForwardBy(base::Milliseconds(100));
+ EXPECT_FALSE(observer_->on_paste_key_detected_called_);
+
+ // Advance time to 300ms since first event (elapsed > 200ms).
+ task_environment_.FastForwardBy(base::Milliseconds(100));
+
+ // Third paste key event should be allowed.
+ feature_->ScriptMessageReceived(&web_state_, message2);
+ task_environment_.FastForwardBy(base::Milliseconds(100));
+ EXPECT_TRUE(observer_->on_paste_key_detected_called_);
+}
+
+// Tests that paste key detected event is cancelled if a text pasted event
+// arrives within the coalescing window.
+TEST_F(PasswordProtectionJavaScriptFeatureTest,
+ PasteKeyDetectedAndTextPastedCoalescing) {
+ base::Value body1(base::DictValue().Set("eventType", "PasteKeyDetected"));
+
+ web::ScriptMessage message1(std::make_unique<base::Value>(std::move(body1)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ // Paste key event should be allowed and start timer.
+ feature_->ScriptMessageReceived(&web_state_, message1);
+ EXPECT_FALSE(observer_->on_paste_key_detected_called_);
+
+ // Text pasted event immediately after should cancel the timer and trigger
+ // OnPaste immediately.
+ base::Value body2(base::DictValue()
+ .Set("eventType", "TextPasted")
+ .Set("text", "password1"));
+
+ web::ScriptMessage message2(std::make_unique<base::Value>(std::move(body2)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ feature_->ScriptMessageReceived(&web_state_, message2);
+ EXPECT_TRUE(observer_->on_paste_called_);
+
+ // Fast forward 200ms to let any timer expire. OnPasteKeyDetected should
+ // NOT be called.
+ task_environment_.FastForwardBy(base::Milliseconds(200));
+ EXPECT_FALSE(observer_->on_paste_key_detected_called_);
+}
+
+// Tests that if the text pasted event arrives after the coalescing window
+// has expired (and UIPasteboard has already been read), the text pasted event
+// is ignored (rate limited).
+TEST_F(PasswordProtectionJavaScriptFeatureTest,
+ PasteKeyDetectedAndTextPastedLateArriving) {
+ base::Value body1(base::DictValue().Set("eventType", "PasteKeyDetected"));
+
+ web::ScriptMessage message1(std::make_unique<base::Value>(std::move(body1)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ // Paste key event starts the timer.
+ feature_->ScriptMessageReceived(&web_state_, message1);
+
+ // Fast forward 100ms. Timer expires and triggers OnPasteKeyDetected.
+ task_environment_.FastForwardBy(base::Milliseconds(100));
+ EXPECT_TRUE(observer_->on_paste_key_detected_called_);
+
+ // Text pasted event arrives 50ms later (total 150ms elapsed, which is < 200ms
+ // rate limit).
+ base::Value body2(base::DictValue()
+ .Set("eventType", "TextPasted")
+ .Set("text", "password1"));
+
+ web::ScriptMessage message2(std::make_unique<base::Value>(std::move(body2)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ feature_->ScriptMessageReceived(&web_state_, message2);
+ // It should be rate limited since we already processed the paste.
+ EXPECT_FALSE(observer_->on_paste_called_);
+}
+
+// Tests that paste key detected events are ignored when the feature flag is
+// disabled.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteKeyDetectedDisabled) {
+ base::test::ScopedFeatureList feature_list;
+ feature_list.InitAndDisableFeature(kIOSPhishGuardPasteShortcutDetection);
+
+ base::Value body(base::DictValue().Set("eventType", "PasteKeyDetected"));
+ web::ScriptMessage message(std::make_unique<base::Value>(std::move(body)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ feature_->ScriptMessageReceived(&web_state_, message);
+
+ // Fast forward by 200ms (twice the timer duration). Timer should NOT fire,
+ // and OnPasteKeyDetected should NOT be called.
+ task_environment_.FastForwardBy(base::Milliseconds(200));
+ EXPECT_FALSE(observer_->on_paste_key_detected_called_);
+}
+
} // namespace
Original Bug Report
Potential iOS PhishGuard paste-detection bypass via preventDefault on Cmd+V keydown
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 logic bypass in Chrome for iOS allows malicious websites to bypass PhishGuard password-reuse detection during hardware keyboard-based paste actions (e.g., Cmd+V). By calling preventDefault() on the capturing keydown event, a page can suppress the DOM paste event while still reading the clipboard data programmatically. This prevents PhishGuard’s listeners from detecting the password insertion, potentially allowing silent credential harvesting.
Affected files:
ios/chrome/browser/safe_browsing/model/resources/password_protection.tsios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mmios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mm
Estimated timestamp from git blame: 2021-02-11
Description & Root Cause
PhishGuard (Safe Browsing password protection) on iOS monitors password reuse by injecting an isolated-world content script (password_protection.ts) that listens to capturing DOM keydown and paste events to report potential password entry to the browser process.
However, a potential logic gap exists during keyboard-initiated paste events (e.g., pressing Cmd+V on an external/iPad hardware keyboard).
In ios/chrome/browser/safe_browsing/model/resources/password_protection.ts, the capturing listener for the keydown event is defined as follows:
function onKeydownEvent(event: KeyboardEvent): void {
// Only forward events where the entered key has length 1, to avoid
// forwarding special keys like "Enter".
if (event.isTrusted && event.key.length === 1 && !event.ctrlKey &&
!event.metaKey) {
sendWebKitMessage('PasswordProtectionTextEntered',
{eventType: 'KeyDown', text: event.key});
}
}
When a user presses Cmd+V (or Ctrl+V), the keydown event has event.metaKey === true or event.ctrlKey === true. Consequently, onKeydownEvent ignores the event and does not forward any message to the browser process.
Per the W3C UI Events specification, executing a paste operation via a keyboard shortcut dispatches a cancelable keydown event prior to executing the paste command. If a page-level script listens to the capturing keydown event and calls event.preventDefault(), the browser cancels the editing command and the standard paste event is never fired.
Because the isolated-world onKeydownEvent ignores events with modifier keys, it does not record the keydown shortcut. Because preventDefault() is called on the page side, the trusted paste event is canceled, and PhishGuard’s onPasteEvent is never triggered. Meanwhile, the page can still programmatically read the copied password from the clipboard via navigator.clipboard.readText(). This completely bypasses PhishGuard’s password reuse check.
Note: Our tooling does not currently have the capability to execute and test code live, so these steps and behaviors are analyzed statically and remain potential/theorized based on the source code structure.
Potential Reproduction Steps
- Use an iOS device (e.g., iPad with a hardware keyboard) with Safe Browsing enabled and at least one saved password.
- Serve a webpage containing a password field and the following script:
window.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') { e.preventDefault(); navigator.clipboard.readText().then(text => { document.getElementById('password_field').value += text; // Perform potential exfiltration of 'text' here }); } }, true); - Copy a saved password to the clipboard.
- Focus the password input on the page and press Cmd+V.
- Tapping ‘Allow’ on the standard iOS paste permission toast will permit the clipboard read operation, and the password will be inserted into the input field. PhishGuard will potentially fail to detect this paste event.
Suggested Fix
To mitigate this potential bypass, the isolated-world script should robustly account for modifier-based paste inputs:
- Update
onKeydownEventinpassword_protection.tsto recognize keyboard paste combinations (e.g.,event.key === 'v'orevent.key === 'V'withctrlKeyormetaKeymodifier flags) and report them to the browser side before any page-level script can cancel them viapreventDefault(). - Consider analyzing content mutations inside password input fields via the
inputorbeforeinputevents. If a significant bulk insertion of text matching a saved password hash occurs, notify the password reuse manager even if a DOMpasteevent was not explicitly dispatched.
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.