Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Passwords
DescriptionInappropriate implementation in Passwords
ComponentPasswords
Bug ClassLogic Error
Tracker517455455
Fix commit1be0b1a78b9a (chromium/src) +65/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
for
components/password_manager/core/browser/password_reuse_detector_impl.cc
modified
TEST_F
components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc
modified

Files Changed

  • components/password_manager/core/browser/password_reuse_detector_impl.cc
  • components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc
From 1be0b1a78b9a07e358092732b2f98c33a4cdaa50 Mon Sep 17 00:00:00 2001
From: Viktor Semeniuk <[email protected]>
Date: Fri, 29 May 2026 02:04:46 -0700
Subject: [PATCH] Check host when domain is empty during password reuse check

Bug: 517455455
Change-Id: I6c9f5c19c38a2bd4c837e1dc195c8693ee9c7ac1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7882025
Reviewed-by: Ioana Treib <[email protected]>
Commit-Queue: Viktor Semeniuk <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1638338}
---

diff --git a/components/password_manager/core/browser/password_reuse_detector_impl.cc b/components/password_manager/core/browser/password_reuse_detector_impl.cc
index f7510a7..28457bde0 100644
--- a/components/password_manager/core/browser/password_reuse_detector_impl.cc
+++ b/components/password_manager/core/browser/password_reuse_detector_impl.cc
@@ -61,6 +61,40 @@
   return longest_match;
 }
 
+// Returns true if typing a password on `active_url` represents an authorized
+// reuse (e.g. because the domain or, in case of an empty domain, the host,
+// matches one of the saved credentials).
+bool IsAuthorizedReuse(const std::string& active_url_string,
+                       const std::set<MatchingReusedCredential>& credentials) {
+  const GURL active_url(active_url_string);
+  const std::string active_registry_controlled_domain =
+      GetRegistryControlledDomain(active_url);
+
+  // For IP addresses and single-label intranet hosts, the registry-controlled
+  // domain is empty. In this case, we compare the exact hosts to prevent
+  // distinct IP addresses or intranet sites from incorrectly matching each
+  // other as authorized domain exceptions.
+  if (active_registry_controlled_domain.empty()) {
+    std::string_view active_host = active_url.host();
+    for (const auto& credential : credentials) {
+      if (GetRegistryControlledDomain(credential.url).empty() &&
+          credential.url.host() == active_host) {
+        return true;
+      }
+    }
+  } else {
+    // For standard domains, we check if any credential matches the registry-
+    // controlled domain of the active page.
+    for (const auto& credential : credentials) {
+      if (GetRegistryControlledDomain(credential.url) ==
+          active_registry_controlled_domain) {
+        return true;
+      }
+    }
+  }
+  return false;
+}
+
 }  // namespace
 
 bool ReverseStringLess::operator()(const std::u16string& lhs,
@@ -249,9 +283,6 @@
     pwd_lengths.insert(it.second.password_length);
   }
 
-  const std::string registry_controlled_domain =
-      GetRegistryControlledDomain(GURL(domain));
-
   // Goes over all possible password lengths and checks input suffix of that
   // length against known password hashes.
   for (auto len : pwd_lengths) {
@@ -273,13 +304,7 @@
         passwords_iterator->second.matching_credentials;
     CHECK(!credentials.empty());
 
-    std::set<std::string> domains;
-    for (const auto& credential : credentials) {
-      domains.insert(GetRegistryControlledDomain(credential.url));
-    }
-    // If the page's URL matches a saved domain for this password,
-    // this isn't password-reuse.
-    if (domains.contains(registry_controlled_domain)) {
+    if (IsAuthorizedReuse(domain, credentials)) {
       continue;
     }
 
diff --git a/components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc b/components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc
index 5b4c81c..67b52f4d 100644
--- a/components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc
+++ b/components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc
@@ -231,6 +231,36 @@
                             &mockConsumer);
 }
 
+TEST_F(PasswordReuseDetectorTest, EmptyRegistryControlledDomainReuseEvent) {
+  PasswordReuseDetectorImpl reuse_detector;
+  // Store a credential for a local IP address.
+  std::vector<StoredCredential> credentials_with_empty_registry = GetForms({
+      {"http://192.168.1.1/", "routerAdmin", "router_secret_password"},
+  });
+  reuse_detector.OnGetPasswordStoreResults(
+      std::move(credentials_with_empty_registry));
+  MockPasswordReuseDetectorConsumer mockConsumer;
+
+  // Typing the password on the same IP address should not raise a reuse event.
+  EXPECT_CALL(mockConsumer, OnReuseCheckDone(false, _, _, _, _, _, _));
+  reuse_detector.CheckReuse(u"router_secret_password", "http://192.168.1.1/",
+                            &mockConsumer);
+  testing::Mock::VerifyAndClearExpectations(&mockConsumer);
+
+  // Typing the password on a different IP address or intranet host should raise
+  // a reuse event.
+  const std::vector<MatchingReusedCredential> expected_credentials = {
+      {"http://192.168.1.1/", GURL("http://192.168.1.1/"), u"routerAdmin",
+       PasswordForm::Store::kProfileStore}};
+  EXPECT_CALL(mockConsumer,
+              OnReuseCheckDone(true, strlen("router_secret_password"),
+                               Matches(NO_GAIA_OR_ENTERPRISE_REUSE),
+                               UnorderedElementsAreArray(expected_credentials),
+                               1, _, _));
+  reuse_detector.CheckReuse(u"router_secret_password", "http://192.168.1.100/",
+                            &mockConsumer);
+}
+
 TEST_F(PasswordReuseDetectorTest, TooShortPasswordNoReuseEvent) {
   PasswordReuseDetectorImpl reuse_detector;
   reuse_detector.OnGetPasswordStoreResults(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc b/components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc
index 5b4c81c..67b52f4d 100644
--- a/components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc
+++ b/components/password_manager/core/browser/password_reuse_detector_impl_unittest.cc
@@ -231,6 +231,36 @@
                             &mockConsumer);
 }
 
+TEST_F(PasswordReuseDetectorTest, EmptyRegistryControlledDomainReuseEvent) {
+  PasswordReuseDetectorImpl reuse_detector;
+  // Store a credential for a local IP address.
+  std::vector<StoredCredential> credentials_with_empty_registry = GetForms({
+      {"http://192.168.1.1/", "routerAdmin", "router_secret_password"},
+  });
+  reuse_detector.OnGetPasswordStoreResults(
+      std::move(credentials_with_empty_registry));
+  MockPasswordReuseDetectorConsumer mockConsumer;
+
+  // Typing the password on the same IP address should not raise a reuse event.
+  EXPECT_CALL(mockConsumer, OnReuseCheckDone(false, _, _, _, _, _, _));
+  reuse_detector.CheckReuse(u"router_secret_password", "http://192.168.1.1/",
+                            &mockConsumer);
+  testing::Mock::VerifyAndClearExpectations(&mockConsumer);
+
+  // Typing the password on a different IP address or intranet host should raise
+  // a reuse event.
+  const std::vector<MatchingReusedCredential> expected_credentials = {
+      {"http://192.168.1.1/", GURL("http://192.168.1.1/"), u"routerAdmin",
+       PasswordForm::Store::kProfileStore}};
+  EXPECT_CALL(mockConsumer,
+              OnReuseCheckDone(true, strlen("router_secret_password"),
+                               Matches(NO_GAIA_OR_ENTERPRISE_REUSE),
+                               UnorderedElementsAreArray(expected_credentials),
+                               1, _, _));
+  reuse_detector.CheckReuse(u"router_secret_password", "http://192.168.1.100/",
+                            &mockConsumer);
+}
+
 TEST_F(PasswordReuseDetectorTest, TooShortPasswordNoReuseEvent) {
   PasswordReuseDetectorImpl reuse_detector;
   reuse_detector.OnGetPasswordStoreResults(
Loading diff…

Original Bug Report

reported by [email protected]

Potential PhishGuard saved password-reuse detection bypass on IP-literal pages

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 flaw in PasswordReuseDetectorImpl allows a malicious site hosted on a raw public IP address to silently bypass PhishGuard saved password-reuse warnings. If a victim has a stored credential for a local IP or intranet host that reuses the same password, empty registry-controlled domain strings collide and incorrectly trigger an exclusion. This bypasses both the warning display and phished-credential marking.

Affected files:

  • components/password_manager/core/browser/password_reuse_detector_impl.cc

Estimated timestamp from git blame: 2024-10-11

Root Cause Analysis

In PasswordReuseDetectorImpl::CheckSavedPasswordReuseBasedOnHash (components/password_manager/core/browser/password_reuse_detector_impl.cc), the browser checks if a typed password represents a reuse event. If the active page’s registry-controlled domain is present in the set of registry-controlled domains of any stored credentials sharing that password, the event is treated as authorized/non-reuse, and detection is skipped:

const std::string registry_controlled_domain =
    GetRegistryControlledDomain(GURL(domain));
...
std::set<std::string> domains;
for (const auto& credential : credentials) {
  domains.insert(GetRegistryControlledDomain(credential.url));
}
if (domains.contains(registry_controlled_domain)) {
  continue; // Skip warning/reporting
}

GetRegistryControlledDomain resolves registry-controlled domains via net::registry_controlled_domains::GetDomainAndRegistry. This helper returns an empty string ("") for IP literals (such as local 192.168.1.1 or public IPv4/IPv6 addresses) and single-label intranet hosts (like http://nas/).

Because the empty string returned from both the stored credential (e.g. a home router) and the active phishing page match ("".contains("") evaluates to true), the detector incorrectly treats this as a matching-domain exception.

Furthermore, because the credentials are grouped by password hash, a single local IP or intranet credential with the same password will suppress reuse detection warnings for all other high-value domain credentials (such as financial or email accounts) sharing that password.

Potential Attack Scenario / Steps to Reproduce

Since our security tooling does not currently run executable tests, the following steps represent a potential scenario to trigger and demonstrate the issue:

  1. In a fresh Chrome profile with Safe Browsing enabled, save a credential for http://192.168.1.1/ (e.g., local home router) with the password RouterPass123!.
  2. Save the same password RouterPass123! for a high-value site like https://example-bank.test/.
  3. The attacker hosts a phishing page containing a password input field on a public, routable IP address (e.g., http://203.0.113.1/).
  4. The victim navigates to the public IP address and types the password RouterPass123! character-by-character.
  5. Observe via chrome://password-manager-internals (or by setting a breakpoint in PasswordReuseDetectionManager::OnReuseCheckDone) whether is_reuse_found evaluates to false, allowing the password to be captured without triggering a PhishGuard warning or marking the credential as phished.

Suggested Fix

Modify CheckSavedPasswordReuseBasedOnHash to prevent empty-to-empty registry-controlled domain matches. If either the active page or the credential lacks a registrable domain, the exclusion should only be granted if their exact hosts match (ensuring we still allow typing a password on the exact same IP/intranet host it was saved on, but do not allow cross-IP or cross-intranet matches):

const std::string registry_controlled_domain =
    GetRegistryControlledDomain(GURL(domain));
const std::string target_host = GURL(domain).host();
...
std::set<std::string> domains;
bool exact_host_matched = false;
for (const auto& credential : credentials) {
  std::string cred_domain = GetRegistryControlledDomain(credential.url);
  if (!cred_domain.empty()) {
    domains.insert(cred_domain);
  } else if (!target_host.empty() && credential.url.host() == target_host) {
    exact_host_matched = true;
  }
}

if (exact_host_matched || 
    (!registry_controlled_domain.empty() && domains.contains(registry_controlled_domain))) {
  continue;
}

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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