Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Safebrowsing
DescriptionUse after free in Safebrowsing
ComponentSafebrowsing
Bug ClassUAF
Tracker523704817
Fix commitd92f8aef769f (chromium/src) +102/-81
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
chrome/browser/safe_browsing/chrome_password_protection_service.cc
modified
switch
chrome/browser/safe_browsing/chrome_password_protection_service.cc
modified

Files Changed

  • chrome/browser/safe_browsing/chrome_password_protection_service.cc
  • chrome/browser/safe_browsing/chrome_password_protection_service.h
  • chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc
From d92f8aef769f6d377cce757219a5a5063a64722f Mon Sep 17 00:00:00 2001
From: Nwokedi Idika <nwokedi@chromium.org>
Date: Thu, 09 Jul 2026 08:36:55 -0700
Subject: [PATCH] [Safe Browsing] Fix UAF in ChromePasswordProtectionService

A potential Use-After-Free (UAF) exists in the browser process on macOS
within ChromePasswordProtectionService::ShowModalWarning. This occurs
because displaying a modal sheet synchronously spins a nested run loop,
during which the WebContents can be destroyed.

This CL fixes the issue by:
1. Using base::WeakPtr<WebContents> to track the lifetime of WebContents
across the blocking ShowPasswordReuseModalWarningDialog call.
2. Checking if the WebContents is still valid after the dialog returns
before proceeding with other calls.
3. Updating OnUserAction to take a WeakPtr<WebContents> and checking
if it is valid and not being destroyed before processing the action.
This prevents executing actions on a destroying WebContents when the
dialog is closed during WebContents destruction.

Bug: 523704817
Change-Id: I1f42f215cb9847227754ce0631e142424f23c764
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8016496
Reviewed-by: thefrog <thefrog@chromium.org>
Reviewed-by: Elias Klim <elklm@chromium.org>
Commit-Queue: Nwokedi Idika <nwokedi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1659581}
---

diff --git a/chrome/browser/safe_browsing/chrome_password_protection_service.cc b/chrome/browser/safe_browsing/chrome_password_protection_service.cc
index 71b82c5..379f306 100644
--- a/chrome/browser/safe_browsing/chrome_password_protection_service.cc
+++ b/chrome/browser/safe_browsing/chrome_password_protection_service.cc
@@ -484,48 +484,65 @@
           ReusedPasswordAccountType::SAVED_PASSWORD));
   PasswordProtectionRequestContent* request_content =
       static_cast<PasswordProtectionRequestContent*>(request);
-  content::WebContents* web_contents = request_content->web_contents();
+  content::WebContents* raw_web_contents = request_content->web_contents();
+  if (!raw_web_contents) {
+    return;
+  }
+  base::WeakPtr<content::WebContents> web_contents =
+      raw_web_contents->GetWeakPtr();
   RequestOutcome outcome = request->request_outcome();
   // Don't show warning again if there is already a modal warning showing.
-  if (IsModalWarningShowingInWebContents(web_contents))
+  if (IsModalWarningShowingInWebContents(web_contents.get())) {
     return;
+  }
 
   // Exit fullscreen if this |web_contents| is showing in fullscreen mode.
   if (web_contents->IsFullscreen())
     web_contents->ExitFullscreen(true);
+  // On MacOS, displaying a modal triggers a nested run loop where WebContents
+  // could have been destroyed. So, if WebContents was destroyed, return early.
+  if (!web_contents) {
+    return;
+  }
 
 #if BUILDFLAG(IS_ANDROID)
   (new PasswordReuseControllerAndroid(
-       web_contents, this, profile_->GetPrefs(), password_type,
+       web_contents.get(), this, profile_->GetPrefs(), password_type,
        base::BindOnce(&ChromePasswordProtectionService::OnUserAction,
-                      base::Unretained(this), web_contents, password_type,
-                      outcome, verdict_type, verdict_token,
+                      weak_ptr_factory_.GetWeakPtr(), web_contents,
+                      password_type, outcome, verdict_type, verdict_token,
                       WarningUIType::MODAL_DIALOG)))
       ->ShowDialog();
 #else   // !BUILDFLAG(IS_ANDROID)
   ShowPasswordReuseModalWarningDialog(
-      web_contents, this, password_type,
+      web_contents.get(), this, password_type,
       base::BindOnce(&ChromePasswordProtectionService::OnUserAction,
-                     base::Unretained(this), web_contents, password_type,
-                     outcome, verdict_type, verdict_token,
+                     weak_ptr_factory_.GetWeakPtr(), web_contents,
+                     password_type, outcome, verdict_type, verdict_token,
                      WarningUIType::MODAL_DIALOG));
 #endif  // BUILDFLAG(IS_ANDROID)
 
+  // If web_contents was destroyed during the nested run loop (e.g. on Mac),
+  // we must not proceed.
+  if (!web_contents) {
+    return;
+  }
+
   LogWarningAction(WarningUIType::MODAL_DIALOG, WarningAction::SHOWN,
                    password_type);
   switch (password_type.account_type()) {
     case ReusedPasswordAccountType::SAVED_PASSWORD:
-      OnModalWarningShownForSavedPassword(web_contents, password_type,
+      OnModalWarningShownForSavedPassword(web_contents.get(), password_type,
                                           verdict_token);
       break;
     case ReusedPasswordAccountType::GMAIL:
     case ReusedPasswordAccountType::GSUITE:
-      OnModalWarningShownForGaiaPassword(web_contents, password_type,
+      OnModalWarningShownForGaiaPassword(web_contents.get(), password_type,
                                          verdict_token);
       break;
     case ReusedPasswordAccountType::NON_GAIA_ENTERPRISE:
-      OnModalWarningShownForEnterprisePassword(web_contents, password_type,
-                                               verdict_token);
+      OnModalWarningShownForEnterprisePassword(web_contents.get(),
+                                               password_type, verdict_token);
       break;
     default:
       return;
@@ -617,13 +634,16 @@
 }
 
 void ChromePasswordProtectionService::OnUserAction(
-    content::WebContents* web_contents,
+    base::WeakPtr<content::WebContents> web_contents,
     ReusedPasswordAccountType password_type,
     RequestOutcome outcome,
     LoginReputationClientResponse::VerdictType verdict_type,
     const std::string& verdict_token,
     WarningUIType ui_type,
     WarningAction action) {
+  if (!web_contents || web_contents->IsBeingDestroyed()) {
+    return;
+  }
   // Only log modal warning dialog action for all password types except for
   // signed-in non-syncing type for now. We log for signed-in non-syncing type
   // only when we are about to send the event to SecurityEventRecorder because
@@ -638,15 +658,15 @@
 
   switch (ui_type) {
     case WarningUIType::PAGE_INFO:
-      HandleUserActionOnPageInfo(web_contents, password_type, action);
+      HandleUserActionOnPageInfo(web_contents.get(), password_type, action);
       break;
     case WarningUIType::MODAL_DIALOG:
-      HandleUserActionOnModalWarning(web_contents, password_type, outcome,
+      HandleUserActionOnModalWarning(web_contents.get(), password_type, outcome,
                                      verdict_type, verdict_token, action);
       break;
     case WarningUIType::INTERSTITIAL:
       DCHECK_EQ(WarningAction::CHANGE_PASSWORD, action);
-      HandleResetPasswordOnInterstitial(web_contents, action);
+      HandleResetPasswordOnInterstitial(web_contents.get(), action);
       break;
     default:
       NOTREACHED();
diff --git a/chrome/browser/safe_browsing/chrome_password_protection_service.h b/chrome/browser/safe_browsing/chrome_password_protection_service.h
index 15a500b..ed94557 100644
--- a/chrome/browser/safe_browsing/chrome_password_protection_service.h
+++ b/chrome/browser/safe_browsing/chrome_password_protection_service.h
@@ -137,7 +137,7 @@
       content::WebContents* web_contents) override;
 
   // Called when user interacts with password protection UIs.
-  void OnUserAction(content::WebContents* web_contents,
+  void OnUserAction(base::WeakPtr<content::WebContents> web_contents,
                     ReusedPasswordAccountType password_type,
                     RequestOutcome outcome,
                     LoginReputationClientResponse::VerdictType verdict_type,
diff --git a/chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc b/chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc
index 9878267..35bdaf4 100644
--- a/chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc
+++ b/chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc
@@ -284,10 +284,10 @@
       GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking "Change Password" button on the modal dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::CHANGE_PASSWORD);
   content::WebContents* new_web_contents =
       browser()->tab_strip_model()->GetActiveWebContents();
   content::TestNavigationObserver observer(new_web_contents,
@@ -396,10 +396,10 @@
             GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking "Check Passwords" button on the modal dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::CHANGE_PASSWORD);
   content::WebContents* new_web_contents =
       browser()->tab_strip_model()->GetActiveWebContents();
   content::TestNavigationObserver observer(new_web_contents,
@@ -434,10 +434,10 @@
   service->set_saved_passwords_matching_reused_credentials({credentials});
 
   // Simulates clicking on "Mark site legitimate". Site is no longer dangerous.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc b/chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc
index 9878267..35bdaf4 100644
--- a/chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc
+++ b/chrome/browser/safe_browsing/chrome_password_protection_service_browsertest.cc
@@ -284,10 +284,10 @@
       GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking "Change Password" button on the modal dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::CHANGE_PASSWORD);
   content::WebContents* new_web_contents =
       browser()->tab_strip_model()->GetActiveWebContents();
   content::TestNavigationObserver observer(new_web_contents,
@@ -396,10 +396,10 @@
             GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking "Check Passwords" button on the modal dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::CHANGE_PASSWORD);
   content::WebContents* new_web_contents =
       browser()->tab_strip_model()->GetActiveWebContents();
   content::TestNavigationObserver observer(new_web_contents,
@@ -434,10 +434,10 @@
   service->set_saved_passwords_matching_reused_credentials({credentials});
 
   // Simulates clicking on "Mark site legitimate". Site is no longer dangerous.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::PAGE_INFO,
-                        WarningAction::MARK_AS_LEGITIMATE);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::PAGE_INFO, WarningAction::MARK_AS_LEGITIMATE);
   base::RunLoop().RunUntilIdle();
   EXPECT_FALSE(
       ChromePasswordProtectionService::ShouldShowPasswordReusePageInfoBubble(
@@ -487,10 +487,10 @@
       GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking "Ignore" button on the modal dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::IGNORE_WARNING);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::IGNORE_WARNING);
   base::RunLoop().RunUntilIdle();
   // No new tab opens. Security info doesn't change.
   ASSERT_EQ(1, browser()->tab_strip_model()->count());
@@ -503,10 +503,10 @@
       GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking on "Mark site legitimate". Site is no longer dangerous.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::PAGE_INFO,
-                        WarningAction::MARK_AS_LEGITIMATE);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::PAGE_INFO, WarningAction::MARK_AS_LEGITIMATE);
   base::RunLoop().RunUntilIdle();
   EXPECT_FALSE(
       ChromePasswordProtectionService::ShouldShowPasswordReusePageInfoBubble(
@@ -535,10 +535,10 @@
       "unused_token", account_type);
   base::RunLoop().RunUntilIdle();
   // Simulates clicking "Ignore" to close dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::IGNORE_WARNING);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::IGNORE_WARNING);
   base::RunLoop().RunUntilIdle();
   ASSERT_TRUE(
       ChromePasswordProtectionService::ShouldShowPasswordReusePageInfoBubble(
@@ -549,10 +549,10 @@
       GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking on "Change Password" in the page info bubble.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::PAGE_INFO,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::PAGE_INFO, WarningAction::CHANGE_PASSWORD);
   content::WebContents* new_web_contents =
       browser()->tab_strip_model()->GetActiveWebContents();
   content::TestNavigationObserver observer(new_web_contents,
@@ -742,10 +742,10 @@
             GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking "Change Password" button on the modal dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::CHANGE_PASSWORD);
   base::RunLoop().RunUntilIdle();
   content::WebContents* new_web_contents =
       browser()->tab_strip_model()->GetActiveWebContents();
@@ -780,10 +780,10 @@
             GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking on "Mark site legitimate". Site is no longer dangerous.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::PAGE_INFO,
-                        WarningAction::MARK_AS_LEGITIMATE);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::PAGE_INFO, WarningAction::MARK_AS_LEGITIMATE);
   base::RunLoop().RunUntilIdle();
   EXPECT_FALSE(
       ChromePasswordProtectionService::ShouldShowPasswordReusePageInfoBubble(
@@ -815,10 +815,10 @@
   base::RunLoop().RunUntilIdle();
 
   // Simulates clicking on "Change Password" in the page info bubble.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::PAGE_INFO,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::PAGE_INFO, WarningAction::CHANGE_PASSWORD);
   base::RunLoop().RunUntilIdle();
   content::WebContents* new_web_contents =
       browser()->tab_strip_model()->GetActiveWebContents();
@@ -1019,10 +1019,10 @@
       GetVisibleSecurityState(web_contents)->malicious_content_status);
 
   // Simulates clicking "Ignore" button on the modal dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::IGNORE_WARNING);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::IGNORE_WARNING);
   // Ensures that all asynchronous tasks are completed before verifying the
   // histogram sample.
   content::RunAllTasksUntilIdle();
@@ -1037,10 +1037,10 @@
       browser(), embedded_test_server()->GetURL(kLoginPageUrl)));
 
   // Simulates clicking "Change password" button on the modal dialog.
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::CHANGE_PASSWORD);
   // Ensures that all asynchronous tasks are completed before verifying the
   // histogram sample.
   content::RunAllTasksUntilIdle();
@@ -1106,7 +1106,7 @@
     ReusedPasswordAccountType account_type;
     account_type.set_account_type(ReusedPasswordAccountType::SAVED_PASSWORD);
     GetService(/*is_incognito=*/false)
-        ->OnUserAction(GetWebContents(), account_type,
+        ->OnUserAction(GetWebContents()->GetWeakPtr(), account_type,
                        RequestOutcome::SUCCEEDED,
                        LoginReputationClientResponse::PHISHING, "unused_token",
                        WarningUIType::MODAL_DIALOG, action);
@@ -1612,10 +1612,10 @@
   ReusedPasswordAccountType account_type;
   account_type.set_account_type(ReusedPasswordAccountType::GSUITE);
   account_type.set_is_account_syncing(true);
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::CLOSE);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::CLOSE);
   base::RunLoop().RunUntilIdle();
 }
 
@@ -1639,10 +1639,10 @@
   ReusedPasswordAccountType account_type;
   account_type.set_account_type(ReusedPasswordAccountType::GSUITE);
   account_type.set_is_account_syncing(true);
-  service->OnUserAction(web_contents, account_type, RequestOutcome::UNKNOWN,
-                        LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED,
-                        "unused_token", WarningUIType::MODAL_DIALOG,
-                        WarningAction::CHANGE_PASSWORD);
+  service->OnUserAction(
+      web_contents->GetWeakPtr(), account_type, RequestOutcome::UNKNOWN,
+      LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
+      WarningUIType::MODAL_DIALOG, WarningAction::CHANGE_PASSWORD);
   base::RunLoop().RunUntilIdle();
 }
 #endif
diff --git a/chrome/browser/safe_browsing/chrome_password_protection_service_unittest.cc b/chrome/browser/safe_browsing/chrome_password_protection_service_unittest.cc
index 5dc0d409..7773badf 100644
--- a/chrome/browser/safe_browsing/chrome_password_protection_service_unittest.cc
+++ b/chrome/browser/safe_browsing/chrome_password_protection_service_unittest.cc
@@ -1938,7 +1938,8 @@
     password_account_type.set_is_account_syncing(is_syncing);
 
     service_->OnUserAction(
-        web_contents(), password_account_type, RequestOutcome::UNKNOWN,
+        web_contents()->GetWeakPtr(), password_account_type,
+        RequestOutcome::UNKNOWN,
         LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, "unused_token",
         WarningUIType::MODAL_DIALOG, WarningAction::CHANGE_PASSWORD);
   }
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential Use-After-Free in ChromePasswordProtectionService::ShowModalWarning on macOS

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 Use-After-Free (UAF) exists in the browser process on macOS within ChromePasswordProtectionService::ShowModalWarning. The vulnerability occurs because displaying a modal sheet synchronously spins a nested run loop, during which the WebContents can be destroyed and its memory freed, leaving a dangling stack pointer. This issue bypasses MiraclePtr protections and could potentially lead to Remote Code Execution.

Affected files:

  • chrome/browser/safe_browsing/chrome_password_protection_service.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A potential Use-After-Free (UAF) vulnerability exists in ChromePasswordProtectionService::ShowModalWarning on macOS. The issue is caused by the interaction between the lifetime of a WebContents object and the synchronous nested run loop spun by AppKit when displaying a modal sheet.

If the WebContents is destroyed while the thread is blocked in this nested run loop, a raw stack pointer is left dangling. Because well-behaved observers clear their raw_ptr references during destruction, MiraclePtr’s reference count drops to zero, unquarantining the memory. When the nested run loop finishes, the dangling pointer is dereferenced, potentially leading to Remote Code Execution (RCE) in the unsandboxed browser process.

Technical Details

In chrome/browser/safe_browsing/chrome_password_protection_service.cc, ShowModalWarning extracts a raw C++ pointer to a WebContents object:

void ChromePasswordProtectionService::ShowModalWarning(...) {
  // ...
  content::WebContents* web_contents = request_content->web_contents();
  // ...
  ShowPasswordReuseModalWarningDialog(web_contents, ...);

On macOS, ShowPasswordReuseModalWarningDialog eventually calls NativeWidgetNSWindowBridge::ShowAsModalSheet(). Because standard browser windows are in-process, this synchronously calls the AppKit API -[NSWindow beginSheet:completionHandler:].

As explicitly documented in Chromium’s source (native_widget_ns_window_bridge.mm:2198), this native call blocks the UI thread and spins a nested run loop to handle the presentation animation:

  // -[NSWindow beginSheet:completionHandler:] will block the UI thread while
  // the animation runs...

While the C++ call stack is suspended inside ShowModalWarning, the browser’s MessagePumpNSApplication continues to process incoming IPC messages within the nested run loop.

Potential Exploitation Scenario

(Note: These are suggested steps based on code analysis; a full PoC has not been executed).

  1. An attacker sets up a malicious site and induces a user to enter a saved password.
  2. Chrome’s Safe Browsing detects the reuse and asynchronously calls PasswordProtectionServiceBase::RequestFinished on the UI thread, which calls ShowModalWarning.
  3. The UI thread becomes blocked in the macOS nested run loop inside -[NSWindow beginSheet:].
  4. The attacker’s webpage executes window.close() via JavaScript (e.g., using a timer set before the warning triggered).
  5. An IPC is sent to the browser, which processes it synchronously inside the nested run loop, invoking the ~WebContentsImpl destructor.
  6. During destruction, WebContentsImpl notifies observers. RequestCancelerContent::WebContentsDestroyed() is called, which cancels the pending password protection request.
  7. The PasswordProtectionRequestContent is destroyed. Crucially, all raw_ptr<WebContents> members observing the object (like request_canceler_->web_contents_) are cleared as the observers are destroyed.

MiraclePtr Bypass

MiraclePtr (BackupRefPtr) does not protect against this scenario.

First, the pointer held in ShowModalWarning is a raw stack pointer, which does not increment the BRP reference count. Second, because WebContentsImpl cleanly notifies its observers during destruction, all active raw_ptr instances pointing to it are safely cleared or destroyed. Consequently, the MiraclePtr reference count drops to zero, completely unquarantining the memory and returning it to the PartitionAlloc heap.

An attacker can then use IPCs (e.g., Blob URIs) to spray the heap and replace the freed WebContentsImpl object with malicious data containing a fake vtable.

The Crash / Hijack

When the modal sheet is eventually dismissed, the AppKit nested run loop terminates and -[NSWindow beginSheet:] returns. The C++ call stack unwinds back to ShowModalWarning.

The stack-allocated web_contents pointer is now a dangling pointer pointing to attacker-controlled memory. Execution proceeds to:

OnModalWarningShownForSavedPassword(web_contents, password_type, verdict_token);

Which calls:

void ChromePasswordProtectionService::UpdateSecurityState(..., content::WebContents* web_contents) {
  // ...
  const GURL url = web_contents->GetLastCommittedURL();

GetLastCommittedURL() is a pure virtual method. The CPU reads the attacker-supplied vtable pointer from the freed memory and executes the virtual call, diverting control flow to the attacker’s payload and achieving RCE in the browser process.

Stack-allocated pointers to objects whose lifetime is not strictly guaranteed should not be held across calls that may spin a nested run loop.

The web_contents pointer in ShowModalWarning should be converted to a base::WeakPtr<content::WebContents>. After the call to ShowPasswordReuseModalWarningDialog (and ExitFullscreen), the code must verify the weak pointer is still valid before passing it to subsequent functions like OnModalWarningShownForSavedPassword.

auto weak_contents = web_contents->GetWeakPtr();

// ... call ShowPasswordReuseModalWarningDialog ...

if (!weak_contents) {
  return;
}

// Safely proceed

Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb


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