CVE-2026-79012
Overview
Background
- `ChromePasswordProtectionService`
- the browser-process Safe Browsing service that shows password-reuse warnings and dispatches the user’s response through
OnUserAction. - `content::WebContents`
- the object backing a browser tab; it is destroyed when its tab or window closes, and any raw pointer to it becomes dangling once that happens.
- Nested run loop
- a synchronous message loop that a blocking modal dialog spins on macOS, during which other events (including tab/
WebContentsteardown) can be processed before the blocking call returns. - `base::WeakPtr<content::WebContents>`
- a smart pointer obtained via
GetWeakPtr()that automatically becomes null when the pointee is destroyed, allowing a liveness check.
Root Cause Analysis
In the modal-warning path (ShowModalWarning, per the commit message; the enclosing function name is not in the @@ header so this is (inferred)), the code cached a raw content::WebContents* web_contents from request_content->web_contents() and then called the blocking ShowPasswordReuseModalWarningDialog(web_contents, ...). On macOS that blocking call spins a nested run loop, during which the WebContents (its tab) can be closed and destroyed, leaving web_contents dangling. After the dialog returned, the code unconditionally dereferenced and passed that stale pointer to LogWarningAction and the OnModalWarningShownFor*Password helpers, and the same stale raw pointer was also bound via base::Unretained(this) into the OnUserAction completion callback — a classic use-after-free.
The fix converts web_contents to a base::WeakPtr<content::WebContents> obtained with GetWeakPtr(), adds if (!web_contents) return; checks immediately after the fullscreen step and after the blocking dialog call, and changes OnUserAction to accept a WeakPtr and bail out via if (!web_contents || web_contents->IsBeingDestroyed()) return; before using it. Because the WeakPtr nulls itself when the WebContents is torn down, every post-dialog use is now guarded and the dangling dereference cannot occur.
content::WebContents* across a blocking modal call that spins a nested run loop capable of destroying that WebContents; the fix replaces the raw pointer with a base::WeakPtr and checks its validity after the dialog returns and again inside OnUserAction.Attack Path
- Trigger a password-reuse warning
The victim reuses a protected password on a page that Safe Browsing flags, causing
ChromePasswordProtectionServiceto reach the modal-warning path and call the blockingShowPasswordReuseModalWarningDialogwith a rawcontent::WebContents*. - Close the tab during the nested run loop
On macOS the blocking dialog spins a nested run loop; while it runs, the tab /
WebContentsis destroyed (the removed comment and new guard confirm this “nested run loop where WebContents could have been destroyed” window), freeing the object the raw pointer still references. - Use the freed pointer
After the dialog returns, the pre-patch code dereferences the stale
web_contentsinLogWarningActionandOnModalWarningShownFor*Password, and/or later invokes theOnUserActioncallback bound withbase::Unretainedon the same freed pointer, dereferencing freed memory in the browser process. -
Downstream escalation
(inferred)— Controlling the contents of the freed-then-reallocatedWebContentsregion to turn the dangling access into a more powerful read/write or code-execution primitive is not demonstrated by the diff and is(inferred).
Impact Assessment
content::WebContents in the privileged browser process on macOS, reachable when a Safe Browsing password-reuse modal is shown and its WebContents is destroyed during the modal’s nested run loop. The advisory classifies this as a use-after-free in Safebrowsing; any escalation to memory corruption, information disclosure, or code execution beyond the dangling dereference is (inferred) and not established by the patch. The vendor advisory rates this critical severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.6.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/safe_browsing/chrome_password_protection_service.cc |
modified | |
switchchrome/browser/safe_browsing/chrome_password_protection_service.cc |
modified | |
ifchrome/browser/ui/views/safe_browsing/password_reuse_modal_warning_dialog.cc |
modified | |
ifchrome/browser/ui/webui/reset_password/reset_password_ui.cc |
modified |
Files Changed
chrome/browser/ui/page_info/chrome_page_info_delegate.ccchrome/browser/safe_browsing/chrome_password_protection_service_unittest.ccchrome/browser/safe_browsing/chrome_password_protection_service.hchrome/browser/safe_browsing/chrome_password_protection_service_browsertest.ccchrome/browser/safe_browsing/chrome_password_protection_service.ccchrome/browser/ui/webui/reset_password/reset_password_ui.ccchrome/browser/ui/views/safe_browsing/password_reuse_modal_warning_dialog.cc
Audit Directions
- Raw pointers held across blocking/modal callsAudit any browser-process code that caches a raw
content::WebContents*(or other tab-scoped object) and then calls a synchronous dialog/Show*API that can spin a nested run loop on macOS; require aWeakPtrre-check after the call returns. - `base::Unretained` / raw pointers bound into completion callbacksReview
base::BindOncesites that captureWebContentsorthiswithbase::Unretainedfor UI callbacks whose target object may outlive or be destroyed before invocation; preferWeakPtrand anIsBeingDestroyed()guard. - Callbacks runnable after their target is torn downLook for proxy/done callbacks (like
make_done_callback) thatstd::move(...).Run()without first checking the callback is still valid, which can re-run or run stale callbacks during teardown.
Patch
From d92f8aef769f6d377cce757219a5a5063a64722f Mon Sep 17 00:00:00 2001 From: Nwokedi Idika <[email protected]> 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 <[email protected]> Reviewed-by: Elias Klim <[email protected]> Commit-Queue: Nwokedi Idika <[email protected]> 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/ui/page_info/chrome_page_info_delegate.cc b/chrome/browser/ui/page_info/chrome_page_info_delegate.cc index 657036fa..11e2bdd9 100644 --- a/chrome/browser/ui/page_info/chrome_page_info_delegate.cc +++ b/chrome/browser/ui/page_info/chrome_page_info_delegate.cc @@ -178,7 +178,7 @@ DCHECK(chrome_password_protection_service); chrome_password_protection_service->OnUserAction( - web_contents_, + web_contents_->GetWeakPtr(), chrome_password_protection_service ->reused_password_account_type_for_last_shown_warning(), safe_browsing::RequestOutcome::UNKNOWN, diff --git a/chrome/browser/ui/views/safe_browsing/password_reuse_modal_warning_dialog.cc b/chrome/browser/ui/views/safe_browsing/password_reuse_modal_warning_dialog.cc index bc56877..3f07fab 100644 --- a/chrome/browser/ui/views/safe_browsing/password_reuse_modal_warning_dialog.cc +++ b/chrome/browser/ui/views/safe_browsing/password_reuse_modal_warning_dialog.cc @@ -153,13 +153,13 @@ // The set_*_callback() methods below need a OnceCallback each and we only // have one (done_callback_), so create a proxy callback that references - // done_callback_ and use it for each of the set_*_callback() callbacks. Note - // that since only one of the three callbacks can ever be invoked, - // done_callback_ is still run at most once. + // done_callback_ and use it for each of the set_*_callback() callbacks. auto make_done_callback = [this](safe_browsing::WarningAction value) { return base::BindOnce( [](OnWarningDone* callback, safe_browsing::WarningAction value) { - std::move(*callback).Run(value); + if (*callback) { + std::move(*callback).Run(value); + } }, base::Unretained(&done_callback_), value); }; diff --git a/chrome/browser/ui/webui/reset_password/reset_password_ui.cc b/chrome/browser/ui/webui/reset_password/reset_password_ui.cc index f932771dc..2626007 100644 --- a/chrome/browser/ui/webui/reset_password/reset_password_ui.cc +++ b/chrome/browser/ui/webui/reset_password/reset_password_ui.cc @@ -78,7 +78,7 @@ ChromePasswordProtectionService::GetPasswordProtectionService(profile); if (service) { service->OnUserAction( - web_contents_, + web_contents_->GetWeakPtr(), service->reused_password_account_type_for_last_shown_warning(), RequestOutcome::UNKNOWN, LoginReputationClientResponse::VERDICT_TYPE_UNSPECIFIED, 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", ... (file diff truncated)
Regression Test / PoC
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);
}
Original Bug Report
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).
- An attacker sets up a malicious site and induces a user to enter a saved password.
- Chrome’s Safe Browsing detects the reuse and asynchronously calls
PasswordProtectionServiceBase::RequestFinishedon the UI thread, which callsShowModalWarning. - The UI thread becomes blocked in the macOS nested run loop inside
-[NSWindow beginSheet:]. - The attacker’s webpage executes
window.close()via JavaScript (e.g., using a timer set before the warning triggered). - An IPC is sent to the browser, which processes it synchronously inside the nested run loop, invoking the
~WebContentsImpldestructor. - During destruction,
WebContentsImplnotifies observers.RequestCancelerContent::WebContentsDestroyed()is called, which cancels the pending password protection request. - The
PasswordProtectionRequestContentis destroyed. Crucially, allraw_ptr<WebContents>members observing the object (likerequest_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.
Recommended Fix
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.