Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Chrome for iOS
DescriptionInappropriate implementation in Chrome for iOS
ComponentChrome for iOS
Bug ClassLogic Error
Tracker522074154
Fix commit4ff1a8c33cce (chromium/src) +42/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST_F
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
modified

Files Changed

  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
  • ios/chrome/browser/safe_browsing/model/resources/password_protection.ts
From 4ff1a8c33cceab364ec8c771495e1873e7e99cdb Mon Sep 17 00:00:00 2001
From: Alexis Hétu <sugoi@chromium.org>
Date: Wed, 10 Jun 2026 12:06:22 -0700
Subject: [PATCH] [iOS][Safe Browsing] Verify Unicode code points in password protection

Update keydown event validation to check the number of Unicode
scalar values (code points) rather than UTF-16 code units. This
ensures single key presses involving supplementary characters
(such as non-BMP symbols) are correctly validated and handled.

Bug: 522074154
Change-Id: I16a8974e9a09eb1759a9371b62e4d2e0cf2e272b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7920347
Commit-Queue: Alexis Hétu <sugoi@chromium.org>
Reviewed-by: Joemer Ramos <joemerramos@google.com>
Cr-Commit-Position: refs/heads/main@{#1644782}
---

diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
index 10981fd..f3ee7df 100644
--- a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
+++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
@@ -8,7 +8,7 @@
 #import "base/ios/ios_util.h"
 #import "base/no_destructor.h"
 #import "base/strings/sys_string_conversions.h"
-#import "base/strings/utf_string_conversions.h"
+#import "base/strings/utf_string_conversion_utils.h"
 #import "ios/chrome/browser/safe_browsing/model/input_event_observer.h"
 #import "ios/web/public/js_messaging/script_message.h"
 
@@ -78,8 +78,7 @@
     // A key event should consist of a single character. A longer string
     // means the message isn't well-formed, so might be coming from a
     // compromised WebProcess.
-    std::u16string text16 = base::UTF8ToUTF16(*text);
-    if (text16.length() != 1) {
+    if (base::CountUnicodeCharacters(*text) != 1) {
       return;
     }
     observer->OnKeyPressed(*text);
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 5c08e70..c322ad3 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
@@ -112,4 +112,43 @@
   EXPECT_TRUE(observer_->on_paste_called_);
 }
 
+// Tests that KeyDown events are correctly checked for a single Unicode code
+// point.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, KeyDownEventLengthCheck) {
+  // A single ASCII character (1 code point, 1 code unit in UTF-16).
+  base::Value body1(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "a"));
+  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());
+  feature_->ScriptMessageReceived(&web_state_, message1);
+  EXPECT_TRUE(observer_->on_key_pressed_called_);
+  observer_->on_key_pressed_called_ = false;
+
+  // A single supplementary Unicode code point (e.g., U+1F600 Grinning Face
+  // emoji). It takes 2 UTF-16 code units (surrogate pair) but is 1 Unicode code
+  // point.
+  base::Value body2(base::DictValue()
+                        .Set("eventType", "KeyDown")
+                        .Set("text", "\xF0\x9F\x98\x80"));
+  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_key_pressed_called_);
+  observer_->on_key_pressed_called_ = false;
+
+  // Multiple characters should be dropped.
+  base::Value body3(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "ab"));
+  web::ScriptMessage message3(std::make_unique<base::Value>(std::move(body3)),
+                              /*is_user_interacting=*/true,
+                              /*is_main_frame=*/true,
+                              /*request_url=*/std::nullopt, url::Origin());
+  feature_->ScriptMessageReceived(&web_state_, message3);
+  EXPECT_FALSE(observer_->on_key_pressed_called_);
+}
+
 }  // namespace
diff --git a/ios/chrome/browser/safe_browsing/model/resources/password_protection.ts b/ios/chrome/browser/safe_browsing/model/resources/password_protection.ts
index 80be253..eaeb1b4 100644
--- a/ios/chrome/browser/safe_browsing/model/resources/password_protection.ts
+++ b/ios/chrome/browser/safe_browsing/model/resources/password_protection.ts
@@ -17,7 +17,7 @@
 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 &&
+  if (event.isTrusted && [...event.key].length === 1 && !event.ctrlKey &&
       !event.metaKey) {
     sendWebKitMessage(
         'PasswordProtectionTextEntered',
Loading diff…

Regression Test / PoC

shipped with the fix
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 5c08e70..c322ad3 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
@@ -112,4 +112,43 @@
   EXPECT_TRUE(observer_->on_paste_called_);
 }
 
+// Tests that KeyDown events are correctly checked for a single Unicode code
+// point.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, KeyDownEventLengthCheck) {
+  // A single ASCII character (1 code point, 1 code unit in UTF-16).
+  base::Value body1(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "a"));
+  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());
+  feature_->ScriptMessageReceived(&web_state_, message1);
+  EXPECT_TRUE(observer_->on_key_pressed_called_);
+  observer_->on_key_pressed_called_ = false;
+
+  // A single supplementary Unicode code point (e.g., U+1F600 Grinning Face
+  // emoji). It takes 2 UTF-16 code units (surrogate pair) but is 1 Unicode code
+  // point.
+  base::Value body2(base::DictValue()
+                        .Set("eventType", "KeyDown")
+                        .Set("text", "\xF0\x9F\x98\x80"));
+  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_key_pressed_called_);
+  observer_->on_key_pressed_called_ = false;
+
+  // Multiple characters should be dropped.
+  base::Value body3(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "ab"));
+  web::ScriptMessage message3(std::make_unique<base::Value>(std::move(body3)),
+                              /*is_user_interacting=*/true,
+                              /*is_main_frame=*/true,
+                              /*request_url=*/std::nullopt, url::Origin());
+  feature_->ScriptMessageReceived(&web_state_, message3);
+  EXPECT_FALSE(observer_->on_key_pressed_called_);
+}
+
 }  // namespace
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential iOS PhishGuard Bypass via Non-BMP Characters (Emojis) in Passwords

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

Overview: The Safe Browsing password reuse detection (PhishGuard) on iOS incorrectly drops keystrokes for characters outside the Basic Multilingual Plane (BMP), such as emojis. This is because both the injected JavaScript and the native C++ receiver mandate a UTF-16 string length of exactly 1, which excludes surrogate pairs. Consequently, passwords containing these characters can be entered into phishing sites without triggering a warning.

Affected files:

  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
  • ios/chrome/browser/safe_browsing/model/resources/password_protection.ts

Estimated timestamp from git blame: 2021-02-11

The PhishGuard implementation on iOS, which provides Safe Browsing password reuse detection, contains a logic error in how it handles keystrokes for non-BMP characters. The system monitors user input by listening for keydown events in an injected JavaScript file (ios/chrome/browser/safe_browsing/model/resources/password_protection.ts) and forwarding the event.key value to the browser process.

Both the JavaScript and C++ components enforce a restriction that the captured key’s text length must be exactly 1 UTF-16 code unit. This check is intended to exclude special keys like ‘Enter’ or ‘Shift’. However, because JavaScript strings and C++ std::u16string represent characters outside the Basic Multilingual Plane (BMP) as surrogate pairs (length 2), this logic inadvertently excludes them. This includes emojis and certain mathematical symbols.

In password_protection.ts:

if (event.isTrusted && event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
  sendWebKitMessage('PasswordProtectionTextEntered', {eventType: 'KeyDown', text: event.key});
}

In ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm:

std::u16string text16 = base::UTF8ToUTF16(*text);
if (text16.length() != 1) {
  return;
}

Because these non-BMP characters are silently dropped, the PasswordReuseDetectionManager fails to receive the full password sequence when it is typed into a phishing site. For example, if a user’s saved password is mypassword😀, only mypassword will be recorded in the internal keystroke buffer. The subsequent hash comparison against the user’s stored password hashes will fail. Consequently, the user will not receive a PhishGuard warning, potentially allowing an attacker to capture their password.

Potential Steps to Reproduce

Note: These steps represent a potential attack sequence, as our tooling has not executed this against a live target.

  1. An attacker sets up a malicious webpage that visually mimics a legitimate login portal.
  2. A victim, who has a saved password for the legitimate site containing at least one non-BMP character (e.g., mypassword😀), navigates to the phishing site using Chrome on iOS.
  3. The victim types their saved password into the site’s input field.
  4. As they type, the injected password_protection.ts script successfully captures and forwards the BMP characters (m, y, p, etc.).
  5. When the victim types the non-BMP character (😀), event.key.length evaluates to 2. The script silently drops the keystroke.
  6. The PhishGuard buffer in the browser process only records mypassword.
  7. The hash comparison fails, bypassing the Safe Browsing warning interstitial entirely.

Suggested Fix

The length check should be updated to verify the number of Unicode scalar values (code points) rather than UTF-16 code units.

In password_protection.ts, this can be achieved by checking if the string contains a single code point (e.g., using Array.from(event.key).length === 1 or [...event.key].length === 1).

In password_protection_java_script_feature.mm, the C++ check should ensure the string corresponds to a single Unicode code point rather than text16.length() == 1.

Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff


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