CVE-2026-79078
Overview
Background
- FedCM
- the Federated Credential Management API, which lets a relying party (RP) page ask the browser to show an account chooser for an identity provider (IdP); the browser-side logic lives in
content/browser/webid, mainly inRequestandAccountsFetcher. - "Use a different account" / login popup
- a dialog button that calls
Request::LoginToIdP, which registers theRequestas an IdP sign-in-status observer (AddIdpSigninStatusObserver) and opens the IdP’s login URL in a modal popup (DialogType::kLoginToIdpPopup). - `AccountsFetcher`
- the object that fetches the IdP accounts endpoint and handles the reply in
OnAccountsResponseReceived, which callsUpdateIdpSigninStatusForAccountsEndpointResponseto record the IdP sign-in status. - Synchronous observer re-entrancy
- updating the sign-in status notifies registered observers synchronously, so an observer can run arbitrary logic, including destroying the object that is mid-callback, before the status update returns.
Root Cause Analysis
According to the commit message, when the user clicked “Use a different account” a second time (after refocusing the FedCM dialog) while an accounts fetch was in flight, Request::LoginToIdP registered Request as an IdpSigninStatusObserver again. When the pending accounts response arrived, AccountsFetcher::OnAccountsResponseReceived called UpdateIdpSigninStatusForAccountsEndpointResponse, which synchronously notified observers. The re-registered Request reacted by starting a new accounts fetch, which deleted the current AccountsFetcher while its OnAccountsResponseReceived frame was still on the stack. When the status update returned, the method kept using this (for example network_manager_ and the parse-status handling that follows), which is a use-after-free of the fetcher.
The fix works at two levels. In LoginToIdP, if dialog_type_ is already kLoginToIdpPopup it just calls ShowModalDialog again to refocus the popup and returns before AddIdpSigninStatusObserver, so observer registration is not repeated. In OnAccountsResponseReceived, a base::WeakPtr<AccountsFetcher> taken before the status update is checked afterwards, so the method returns if the fetcher was destroyed re-entrantly.
OnAccountsResponseReceived called out to a synchronous observer notification that could destroy its own AccountsFetcher, and then kept using this; the repeated LoginToIdP observer registration made that re-entrancy reachable. The fix removes the duplicate registration and adds a WeakPtr liveness check after the call-out.Attack Path
- RP invokes FedCM A page calls the FedCM API, and the browser shows a dialog offering “Use a different account” (the test reaches this through the IdP sign-in-status mismatch dialog).
- First "Use a different account"
The user clicks it, so
Request::LoginToIdPaddsRequestas a sign-in-status observer and opens the IdP login popup while an accounts fetch is outstanding. - Second click while fetch is in flight
The user refocuses the FedCM dialog and clicks the button again; before the fix
LoginToIdPre-registered the observer and reset its state. A page or IdP that delays its accounts endpoint would widen this window (inferred). - Re-entrant destruction
The accounts response arrives,
UpdateIdpSigninStatusForAccountsEndpointResponsenotifiesRequest, which starts a new fetch and destroys the currentAccountsFetcher; execution then returns into the freed fetcher’sOnAccountsResponseReceived. - Use of freed fetcher
Later code in
OnAccountsResponseReceivedtouches members of the destroyedAccountsFetcher, giving a browser-process UAF. Turning that into controlled memory corruption or code execution is(inferred).
Impact Assessment
(inferred) and not shown by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/webid/accounts_fetcher.cc |
modified | |
ifcontent/browser/webid/request.cc |
modified | |
TEST_Fcontent/browser/webid/accounts_fetcher_unittest.cc |
modified | |
TEST_Fcontent/browser/webid/request_unittest.cc |
modified |
Files Changed
content/browser/webid/accounts_fetcher_unittest.cccontent/browser/webid/accounts_fetcher.cccontent/browser/webid/request_unittest.cccontent/browser/webid/request.cc
Audit Directions
- Call-outs that can delete `this`In
content/browser/webid, check every place whereAccountsFetcher,Requestor related helpers call permission-delegate setters, observer lists or completion callbacks, then continue using members; each needs aWeakPtrcheck or must be the last statement. - Idempotency of UI-triggered registrationLook for button handlers (
LoginToIdP, “continue”, “add account”,ShowModalDialogflows) that can run repeatedly while a popup or fetch is pending and that add observers or restart fetchers without checking current state. - Observer-triggered restartsAudit
OnIdpSigninStatusReceived-style observer methods that start new network fetches and reset owned fetcher objects synchronously; deferring the restart with a posted task avoids destroying objects that are still on the stack.
Patch
From 7cc254e978dd7699145eeda120873c03e9abf762 Mon Sep 17 00:00:00 2001 From: Nicolás Peña <[email protected]> Date: Wed, 19 Aug 2026 14:14:47 -0700 Subject: [PATCH] [FedCM] Fix UAF when double clicking user other account When the user clicks "Use a different account" in a FedCM dialog, Request::LoginToIdP registers Request as an IdpSigninStatusObserver and opens the IdP login popup. If the user refocuses the FedCM dialog and clicks on the "Use a different account" again while the accounts fetch is in flight, we previously re-registered Request as an observer. When the pending response arrived, updating the sign-in status would synchronously notify observers. Because Request was observing again, it would start a new accounts fetch, deleting the current AccountsFetcher while on OnAccountsResponseReceived, causing a UAF. This CL fixes this by: 1. Checking if the login popup is already open, and avoiding registering as an observer again. 2. Adding a WeakPtr guard in OnAccountsResponseReceived so it returns safely if destroyed during synchronous callbacks. Fixed: 548340637 Change-Id: Ie7f7e5cc43809c6b1143d99d366fb1c52c480189 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8270646 Reviewed-by: Yi Gu <[email protected]> Commit-Queue: Nicolás Peña <[email protected]> Cr-Commit-Position: refs/heads/main@{#1682618} --- diff --git a/content/browser/webid/accounts_fetcher.cc b/content/browser/webid/accounts_fetcher.cc index dc77cd3b..09b3907 100644 --- a/content/browser/webid/accounts_fetcher.cc +++ b/content/browser/webid/accounts_fetcher.cc @@ -377,9 +377,13 @@ const std::optional<bool> old_idp_signin_status = permission_delegate_->GetIdpSigninStatus( url::Origin::Create(idp_config_url)); + base::WeakPtr<AccountsFetcher> weak_this = weak_ptr_factory_.GetWeakPtr(); UpdateIdpSigninStatusForAccountsEndpointResponse( idp_config_url, status, idp_info->has_failing_idp_signin_status, permission_delegate_); + if (!weak_this) { + return; + } if (status.parse_status != ParseStatus::kSuccess) { if (IsFedCmNativeIdPsEnabled() && network_manager_) { diff --git a/content/browser/webid/request.cc b/content/browser/webid/request.cc index 8ecd8fe..548a89ad 100644 --- a/content/browser/webid/request.cc +++ b/content/browser/webid/request.cc @@ -1498,16 +1498,18 @@ // the popup window is open. When using the active flow the dialog may // still be up in some cases, but we do not expect that browser automation // needs to interact with the account chooser in this case. - if (dialog_type_ != DialogType::kNone) { + if (dialog_type_ != DialogType::kNone && dialog_type_ != dialog_type) { // This call ensures that we send a dialogClosed event if an account // chooser or mismatch dialog is open. devtools_instrumentation::DidCloseFedCmDialog(render_frame_host()); } // TODO(crbug.com/336815315): Should we notify browser automation of this // dialog? + if (dialog_type_ != dialog_type) { + UMA_HISTOGRAM_ENUMERATION("Blink.FedCm.Popup.DialogType", dialog_type); + } dialog_type_ = dialog_type; config_url_ = idp_config_url; - UMA_HISTOGRAM_ENUMERATION("Blink.FedCm.Popup.DialogType", dialog_type_); auto create_registry_async = [](base::WeakPtr<Request> weak_this, const GURL& idp_config_url, @@ -2440,6 +2442,12 @@ // needed. MaybeAppendQueryParameters(it->second, &login_url); } + + if (dialog_type_ == DialogType::kLoginToIdpPopup) { + ShowModalDialog(DialogType::kLoginToIdpPopup, idp_config_url, login_url); + return; + } + permission_delegate()->AddIdpSigninStatusObserver(this); account_ids_before_login_.clear(); diff --git a/content/browser/webid/accounts_fetcher_unittest.cc b/content/browser/webid/accounts_fetcher_unittest.cc index 26669da7..0e3cdbd 100644 --- a/content/browser/webid/accounts_fetcher_unittest.cc +++ b/content/browser/webid/accounts_fetcher_unittest.cc @@ -478,4 +478,92 @@ SetBrowserClientForTesting(original_client); } +// Verifies that if AccountsFetcher is destroyed re-entrantly during +// SetIdpSigninStatus (e.g. via observer callbacks), OnAccountsResponseReceived +// returns cleanly without use-after-free. +TEST_F(AccountsFetcherTest, ReentrantDestructionInAccountsResponse) { + const GURL kIdpConfigUrl("https://idp.example/fedcm.json"); + const GURL kAccountsEndpoint("https://idp.example/accounts.json"); + const GURL kTokenEndpoint("https://idp.example/token.json"); + + auto network_manager = + std::make_unique<StrictMock<MockIdpNetworkRequestManager>>(); + + IdpNetworkRequestManager::AccountsRequestCallback accounts_callback; + EXPECT_CALL(*network_manager, SendAccountsRequest) + .WillOnce( + WithArg<2>([&accounts_callback]( + IdpNetworkRequestManager::AccountsRequestCallback cb) { + accounts_callback = std::move(cb); + return true; + })); + + std::unique_ptr<AccountsFetcher> fetcher = std::make_unique<AccountsFetcher>( + *main_rfh(), network_manager.get(), api_permission_delegate_.get(), + permission_delegate_.get(), + AccountsFetcher::FedCmFetchingParams( + blink::mojom::RpMode::kPassive, /*icon_ideal_size=*/0, + /*icon_minimum_size=*/0, + password_manager::CredentialMediationRequirement::kOptional), + base::BindLambdaForTesting( + [](base::TimeTicks, std::vector<AccountsFetcher::Result>) { + FAIL() << "Callback should not be called after destruction"; + })); + + auto config = blink::mojom::IdentityProviderConfig::New(); + config->config_url = kIdpConfigUrl; + + auto options = blink::mojom::IdentityProviderRequestOptions::New( + std::move(config), "nonce", /*login_hint=*/"", /*domain_hint=*/"", + /*fields=*/std::nullopt, /*params_json=*/std::nullopt, + /*format=*/std::nullopt); + + IdpNetworkRequestManager::Endpoints endpoints; + endpoints.accounts = kAccountsEndpoint; + endpoints.token = kTokenEndpoint; + + auto idp_info = std::make_unique<IdentityProviderInfo>( + options, endpoints, IdentityProviderMetadata(), + blink::mojom::RpContext::kSignIn, blink::mojom::RpMode::kPassive, + /*format=*/std::nullopt); + + std::vector<std::unique_ptr<IdentityProviderInfo>> cached_idp_infos; + cached_idp_infos.push_back(std::move(idp_info)); + + base::flat_map<GURL, AccountsFetcher::IdentityProviderGetInfo> + token_request_get_infos; + token_request_get_infos.emplace( + kIdpConfigUrl, + AccountsFetcher::IdentityProviderGetInfo( + options.Clone(), blink::mojom::RpContext::kSignIn, + blink::mojom::RpMode::kPassive, /*format=*/std::nullopt)); + + fetcher->FetchAccountsForIdps( + std::move(cached_idp_infos), token_request_get_infos, metrics_.get(), + url::Origin::Create(GURL("https://rp.example")), base::DoNothing()); + + EXPECT_CALL(*permission_delegate_, SetIdpSigninStatus) + .WillOnce( + [&fetcher](const url::Origin&, bool, + base::optional_ref< + const blink::common::webid::LoginStatusOptions>) { + fetcher.reset(); + }); + + IdpNetworkRequestManager::AccountsResponse accounts_response; + accounts_response.accounts.push_back( + base::MakeRefCounted<IdentityRequestAccount>( + "123", "[email protected]", "User Name", "[email protected]", + "User Name", "User", GURL(), /*phone=*/"", /*username=*/"", + /*potentially_approved_site_hashes=*/std::vector<std::string>(), + /*login_hints=*/std::vector<std::string>(), + /*domain_hints=*/std::vector<std::string>(), + /*labels=*/std::vector<std::string>())); + + ASSERT_TRUE(accounts_callback); + std::move(accounts_callback) + .Run({ParseStatus::kSuccess, net::HTTP_OK}, std::move(accounts_response)); + EXPECT_FALSE(fetcher); +} + } // namespace content::webid diff --git a/content/browser/webid/request_unittest.cc b/content/browser/webid/request_unittest.cc index 775d244..90795857 100644 --- a/content/browser/webid/request_unittest.cc +++ b/content/browser/webid/request_unittest.cc @@ -9789,4 +9789,46 @@ } } +// Verifies that calling LoginToIdP when the login popup is already open +// refocuses the popup without re-registering the IdpSigninStatus observer or +// resetting state, and that query parameters are preserved. +TEST_F(RequestTest, LoginToIdPRefocusesWhenPopupAlreadyOpen) { + RequestParameters parameters = kDefaultRequestParameters; + parameters.identity_providers[0].domain_hint = kDomainHint; + + test_permission_delegate_ + ->idp_signin_statuses_[OriginFromString(kProviderUrlFull)] = true; + + auto dialog_controller = + std::make_unique<TestDialogController>(kConfigurationValid); + base::WeakPtr<TestDialogController> weak_dialog_controller = + dialog_controller->AsWeakPtr(); + SetDialogController(std::move(dialog_controller)); + + std::string expected_url = + std::string(kIdpLoginUrl) + "?domain_hint=domain%40corp.com"; + std::unique_ptr<WebContents> modal(CreateTestWebContents()); + EXPECT_CALL(*weak_dialog_controller, + ShowModalDialog(GURL(expected_url), _, _, _, _)) + .Times(2) + .WillRepeatedly(::testing::Return(modal.get())); + + // AddIdpSigninStatusObserver should only be called once when opening the + // popup the first time, and NOT again when LoginToIdP is called while the + // popup is already open. + EXPECT_CALL(*test_permission_delegate_, AddIdpSigninStatusObserver).Times(1); + + RunDontWaitForCallback(parameters, kConfigurationValid); + EXPECT_TRUE(did_show_idp_signin_status_mismatch_dialog()); + + // First LoginToIdP call: opens the popup, adds observer, sets dialog_type_. + SimulateLoginToIdP(); + EXPECT_EQ(request_->GetDialogType(), Request::DialogType::kLoginToIdpPopup); + + // Second LoginToIdP call while popup is already open: refocuses the popup + // without re-registering observer or resetting state. + SimulateLoginToIdP(); + EXPECT_EQ(request_->GetDialogType(), Request::DialogType::kLoginToIdpPopup); +} + } // namespace content::webid
Regression Test / PoC
diff --git a/content/browser/webid/accounts_fetcher_unittest.cc b/content/browser/webid/accounts_fetcher_unittest.cc
index 26669da7..0e3cdbd 100644
--- a/content/browser/webid/accounts_fetcher_unittest.cc
+++ b/content/browser/webid/accounts_fetcher_unittest.cc
@@ -478,4 +478,92 @@
SetBrowserClientForTesting(original_client);
}
+// Verifies that if AccountsFetcher is destroyed re-entrantly during
+// SetIdpSigninStatus (e.g. via observer callbacks), OnAccountsResponseReceived
+// returns cleanly without use-after-free.
+TEST_F(AccountsFetcherTest, ReentrantDestructionInAccountsResponse) {
+ const GURL kIdpConfigUrl("https://idp.example/fedcm.json");
+ const GURL kAccountsEndpoint("https://idp.example/accounts.json");
+ const GURL kTokenEndpoint("https://idp.example/token.json");
+
+ auto network_manager =
+ std::make_unique<StrictMock<MockIdpNetworkRequestManager>>();
+
+ IdpNetworkRequestManager::AccountsRequestCallback accounts_callback;
+ EXPECT_CALL(*network_manager, SendAccountsRequest)
+ .WillOnce(
+ WithArg<2>([&accounts_callback](
+ IdpNetworkRequestManager::AccountsRequestCallback cb) {
+ accounts_callback = std::move(cb);
+ return true;
+ }));
+
+ std::unique_ptr<AccountsFetcher> fetcher = std::make_unique<AccountsFetcher>(
+ *main_rfh(), network_manager.get(), api_permission_delegate_.get(),
+ permission_delegate_.get(),
+ AccountsFetcher::FedCmFetchingParams(
+ blink::mojom::RpMode::kPassive, /*icon_ideal_size=*/0,
+ /*icon_minimum_size=*/0,
+ password_manager::CredentialMediationRequirement::kOptional),
+ base::BindLambdaForTesting(
+ [](base::TimeTicks, std::vector<AccountsFetcher::Result>) {
+ FAIL() << "Callback should not be called after destruction";
+ }));
+
+ auto config = blink::mojom::IdentityProviderConfig::New();
+ config->config_url = kIdpConfigUrl;
+
+ auto options = blink::mojom::IdentityProviderRequestOptions::New(
+ std::move(config), "nonce", /*login_hint=*/"", /*domain_hint=*/"",
+ /*fields=*/std::nullopt, /*params_json=*/std::nullopt,
+ /*format=*/std::nullopt);
+
+ IdpNetworkRequestManager::Endpoints endpoints;
+ endpoints.accounts = kAccountsEndpoint;
+ endpoints.token = kTokenEndpoint;
+
+ auto idp_info = std::make_unique<IdentityProviderInfo>(
+ options, endpoints, IdentityProviderMetadata(),
+ blink::mojom::RpContext::kSignIn, blink::mojom::RpMode::kPassive,
+ /*format=*/std::nullopt);
+
+ std::vector<std::unique_ptr<IdentityProviderInfo>> cached_idp_infos;
+ cached_idp_infos.push_back(std::move(idp_info));
+
+ base::flat_map<GURL, AccountsFetcher::IdentityProviderGetInfo>
+ token_request_get_infos;
+ token_request_get_infos.emplace(
+ kIdpConfigUrl,
+ AccountsFetcher::IdentityProviderGetInfo(
+ options.Clone(), blink::mojom::RpContext::kSignIn,
+ blink::mojom::RpMode::kPassive, /*format=*/std::nullopt));
+
+ fetcher->FetchAccountsForIdps(
+ std::move(cached_idp_infos), token_request_get_infos, metrics_.get(),
+ url::Origin::Create(GURL("https://rp.example")), base::DoNothing());
+
+ EXPECT_CALL(*permission_delegate_, SetIdpSigninStatus)
+ .WillOnce(
+ [&fetcher](const url::Origin&, bool,
+ base::optional_ref<
+ const blink::common::webid::LoginStatusOptions>) {
+ fetcher.reset();
+ });
+
+ IdpNetworkRequestManager::AccountsResponse accounts_response;
+ accounts_response.accounts.push_back(
+ base::MakeRefCounted<IdentityRequestAccount>(
+ "123", "[email protected]", "User Name", "[email protected]",
+ "User Name", "User", GURL(), /*phone=*/"", /*username=*/"",
+ /*potentially_approved_site_hashes=*/std::vector<std::string>(),
+ /*login_hints=*/std::vector<std::string>(),
+ /*domain_hints=*/std::vector<std::string>(),
+ /*labels=*/std::vector<std::string>()));
+
+ ASSERT_TRUE(accounts_callback);
+ std::move(accounts_callback)
+ .Run({ParseStatus::kSuccess, net::HTTP_OK}, std::move(accounts_response));
+ EXPECT_FALSE(fetcher);
+}
+
} // namespace content::webid
diff --git a/content/browser/webid/request_unittest.cc b/content/browser/webid/request_unittest.cc
index 775d244..90795857 100644
--- a/content/browser/webid/request_unittest.cc
+++ b/content/browser/webid/request_unittest.cc
@@ -9789,4 +9789,46 @@
}
}
+// Verifies that calling LoginToIdP when the login popup is already open
+// refocuses the popup without re-registering the IdpSigninStatus observer or
+// resetting state, and that query parameters are preserved.
+TEST_F(RequestTest, LoginToIdPRefocusesWhenPopupAlreadyOpen) {
+ RequestParameters parameters = kDefaultRequestParameters;
+ parameters.identity_providers[0].domain_hint = kDomainHint;
+
+ test_permission_delegate_
+ ->idp_signin_statuses_[OriginFromString(kProviderUrlFull)] = true;
+
+ auto dialog_controller =
+ std::make_unique<TestDialogController>(kConfigurationValid);
+ base::WeakPtr<TestDialogController> weak_dialog_controller =
+ dialog_controller->AsWeakPtr();
+ SetDialogController(std::move(dialog_controller));
+
+ std::string expected_url =
+ std::string(kIdpLoginUrl) + "?domain_hint=domain%40corp.com";
+ std::unique_ptr<WebContents> modal(CreateTestWebContents());
+ EXPECT_CALL(*weak_dialog_controller,
+ ShowModalDialog(GURL(expected_url), _, _, _, _))
+ .Times(2)
+ .WillRepeatedly(::testing::Return(modal.get()));
+
+ // AddIdpSigninStatusObserver should only be called once when opening the
+ // popup the first time, and NOT again when LoginToIdP is called while the
+ // popup is already open.
+ EXPECT_CALL(*test_permission_delegate_, AddIdpSigninStatusObserver).Times(1);
+
+ RunDontWaitForCallback(parameters, kConfigurationValid);
+ EXPECT_TRUE(did_show_idp_signin_status_mismatch_dialog());
+
+ // First LoginToIdP call: opens the popup, adds observer, sets dialog_type_.
+ SimulateLoginToIdP();
+ EXPECT_EQ(request_->GetDialogType(), Request::DialogType::kLoginToIdpPopup);
+
+ // Second LoginToIdP call while popup is already open: refocuses the popup
+ // without re-registering observer or resetting state.
+ SimulateLoginToIdP();
+ EXPECT_EQ(request_->GetDialogType(), Request::DialogType::kLoginToIdpPopup);
+}
+
} // namespace content::webid
Original Bug Report
FedCM AccountsFetcher use-after-free due to synchronous re-entrancy into FetchEndpointsForIdps from OnAccountsResponseReceived
Report description
FedCM AccountsFetcher use-after-free due to synchronous re-entrancy into FetchEndpointsForIdps from OnAccountsResponseReceived
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Vulnerability Overview
When the browser process handles a successful IDP accounts endpoint response, FedCM unconditionally writes that IDP’s login status as signed-in and synchronously notifies every IdpSigninStatusObserver. After the user chooses “Use other account” again, Request re-registers itself as an observer. If an AccountsFetcher HTTP response is still outstanding at that point, the observer is already attached when the response enters OnAccountsResponseReceived. SetIdpSigninStatus(true) then synchronously runs FetchEndpointsForIdps, which overwrites the std::unique_ptr<AccountsFetcher> and immediately destroys the object that is still executing. The function continues and reads fedcm_metrics_ through the implicit this, causing a heap-use-after-free in the browser process. Both ASan reproductions reported MiraclePtr Status: NOT PROTECTED.
Bisect
Introducing Commit: 08ab7b8fa258fdd62a535edc6c3206bbd578c4c2
Date: 2025-05-15
Author: Nicolás Peña
Review: https://chromium-review.googlesource.com/c/chromium/src/+/6545875
This commit moved OnAccountsResponseReceived from FederatedAuthRequestImpl
onto FedCmAccountsFetcher, while FetchEndpointsForIdps continued to
replace Request's unique_ptr<FedCmAccountsFetcher>. After this change,
SetIdpSigninStatus(true) during an accounts response can synchronously
destroy the object whose member function is still on the stack.
Before this change, OnAccountsResponseReceived was a member of FederatedAuthRequestImpl. FetchEndpointsForIdps in the observer callback only replaced fedcm_accounts_fetcher_; the this that was executing remained FederatedAuthRequestImpl and was not freed by that assignment. Commit 2d7181c48de2f1898d27473329928657c2ac9e95 on 2023-11-02 already made SetIdpSigninStatus(true) notify observers even when the status did not change (so “Add Account” could refresh the account list). The re-entrancy path existed then, but the object being destroyed was not the one on the current stack frame.
This commit moved accounts-response handling onto FedCmAccountsFetcher and continued to call UpdateIdpSigninStatusForAccountsEndpointResponse from OnAccountsResponseReceived. OnIdpSigninStatusReceived still called FetchEndpointsForIdps, which still did fedcm_accounts_fetcher_ = std::make_unique<FedCmAccountsFetcher>(...). If the user chose “Use other account” a second time after the new accounts request had been sent but before its response arrived, Request was re-registered as an observer. When the response later arrived, SetIdpSigninStatus(true) in that callback synchronously deleted the fetcher that was still running OnAccountsResponseReceived. This UAF became reachable only after this change.
Vulnerability Details
1. The RP starts FedCM, and the browser creates a Request
The renderer enters RequestService::StartTokenRequest over Mojo. That function constructs a Request and passes it to InitiateTokenRequest:
void RequestService::StartTokenRequest(
std::vector<blink::mojom::IdentityProviderGetParametersPtr> idp_get_params,
MediationRequirement requirement,
mojo::PendingReceiver<blink::mojom::FederatedRequest> request_receiver,
StartTokenRequestCallback callback) {
...
RenderFrameHost& rfh = render_frame_host();
auto new_request = std::make_unique<Request>(&rfh, *this);
new_request->BindReceiver(std::move(request_receiver));
...
InitiateTokenRequest(std::move(new_request), std::move(idp_get_params),
requirement, /*navigation_handle=*/nullptr, GURL(),
std::move(wrapped_callback));
}
Request is both the token session and the IdP sign-in status observer:
class CONTENT_EXPORT Request
: public blink::mojom::FederatedRequest,
public FederatedIdentityPermissionContextDelegate::
IdpSigninStatusObserver,
...
void OnIdpSigninStatusReceived(const url::Origin& idp_config_origin,
bool idp_signin_status) override;
At this point it has not registered as an observer, and it does not yet have an AccountsFetcher.
2. Request creates an AccountsFetcher and issues the accounts request
Request holds the in-progress accounts fetch in the fedcm_accounts_fetcher_ member:
std::unique_ptr<AccountsFetcher> fedcm_accounts_fetcher_;
FetchEndpointsForIdps allocates the first AccountsFetcher:
void Request::FetchEndpointsForIdps(const std::set<GURL>& idp_config_urls) {
...
fedcm_accounts_fetcher_ = std::make_unique<AccountsFetcher>(
render_frame_host(), network_manager_.get(), api_permission_delegate(),
permission_delegate(),
AccountsFetcher::FedCmFetchingParams(
rp_mode_, icon_ideal_size, icon_minimum_size, mediation_requirement_),
base::BindOnce(&Request::OnAccountsResultsReceived,
weak_ptr_factory_.GetWeakPtr()));
That fetcher binds the network response to OnAccountsResponseReceived:
if (network_manager_->SendAccountsRequest(
url::Origin::Create(config_url), accounts_endpoint,
base::BindOnce(
&AccountsFetcher::OnAccountsResponseReceived,
weak_ptr_factory_.GetWeakPtr(),
std::make_unique<IdentityProviderInfo>(*idp_info)))) {
fedcm_metrics_->RecordAccountsRequestSent(config_url);
}
When the first HTTP response completes, no observer has been registered yet. OnAccountsResponseReceived updates the login status, but there is no observer to notify, so the first response does not destroy this fetcher. The account list is then handed to the UI.
3. The account dialog binds a repeating LoginToIdP callback
Request opens the account chooser and passes LoginToIdP to the dialog as a RepeatingCallback:
if (!GetDialogController()->ShowAccountsDialog(
CreateRpData(/*client_metadata_received=*/true),
idp_data_for_display_, accounts_, filtered_accounts_, rp_mode_,
base::BindOnce(&Request::OnAccountSelected,
weak_ptr_factory_.GetWeakPtr()),
base::BindRepeating(&Request::LoginToIdP,
weak_ptr_factory_.GetWeakPtr(),
/*can_append_hints=*/false),
...
On desktop UI, “Use a different account” enters FedCmAccountSelectionView::OnLoginToIdP and then reaches the controller:
void FedCmAccountSelectionView::OnLoginToIdP(const GURL& idp_config_url,
const GURL& idp_login_url,
const ui::Event& event) {
...
delegate_->OnLoginToIdP(idp_config_url, idp_login_url);
}
void IdentityDialogController::OnLoginToIdP(const GURL& idp_config_url,
const GURL& idp_login_url) {
CHECK(on_login_);
on_login_.Run(idp_config_url, idp_login_url);
}
This binding is not removed after the first click. The account dialog remains clickable while the login popup is open, so the same FedCM request can enter Request::LoginToIdP more than once.
4. First click of “Use a different account”: register the observer and open the login popup
void Request::LoginToIdP(bool can_append_hints,
const GURL& idp_config_url,
GURL login_url) {
...
permission_delegate()->AddIdpSigninStatusObserver(this);
...
ShowModalDialog(DialogType::kLoginToIdpPopup, idp_config_url, login_url);
}
AddIdpSigninStatusObserver puts the Request* into the permission object’s observer list. A duplicate add is ignored; adding it again after it has been removed takes effect:
void FederatedIdentityPermissionContext::AddIdpSigninStatusObserver(
IdpSigninStatusObserver* observer) {
if (idp_signin_status_observer_list_.HasObserver(observer)) {
return;
}
idp_signin_status_observer_list_.AddObserver(observer);
}
ShowModalDialog opens the IDP login_url. From this point, Request starts receiving sign-in status notifications for that IDP.
5. The login page writes signed-in: remove the observer and replace the AccountsFetcher
The IDP document calls navigator.login.setStatus('logged-in'), or the response includes Set-Login: logged-in. The former enters RequestService::SetIdpSigninStatus:
void RequestService::SetIdpSigninStatus(...) {
...
if (!IsLightweightModeEnabled()) {
permission_delegate_->SetIdpSigninStatus(
idp_origin, status == blink::mojom::IdpSigninStatus::kSignedIn,
/*options=*/std::nullopt);
The Set-Login header reaches the same permission object through IdentityUrlLoaderThrottle. FederatedIdentityPermissionContext::SetIdpSigninStatus always notifies when the new status is signed-in, even if the old status was already signed-in:
void FederatedIdentityPermissionContext::SetIdpSigninStatus(...) {
std::optional<bool> old_idp_signin_status = GetIdpSigninStatus(idp_origin);
// We always notify if idp_signin_status is true because the list of logged
// in accounts may have changed.
if (!idp_signin_status && (idp_signin_status == old_idp_signin_status)) {
return;
}
idp_signin_context_->SetSigninStatus(idp_origin, idp_signin_status, options);
for (IdpSigninStatusObserver& observer : idp_signin_status_observer_list_) {
observer.OnIdpSigninStatusReceived(idp_origin, idp_signin_status);
}
}
The Request that was just registered in step 4 therefore enters:
void Request::OnIdpSigninStatusReceived(const url::Origin& idp_config_origin,
bool idp_signin_status) {
if (!idp_signin_status) {
return;
}
for (const auto& [get_idp_config_url, get_info] : token_request_get_infos_) {
if (url::Origin::Create(get_idp_config_url) == idp_config_origin) {
permission_delegate()->RemoveIdpSigninStatusObserver(this);
idps_user_tried_to_signin_to_.insert(get_idp_config_url);
FetchEndpointsForIdps({get_idp_config_url});
break;
}
}
}
It first removes itself from the observer list, then calls FetchEndpointsForIdps. fedcm_accounts_fetcher_ = std::make_unique<AccountsFetcher>(...) destroys the first fetcher, allocates a second one, and issues a new accounts HTTP request. After that request is sent, Request is no longer an observer. If the status does not change again, SetIdpSigninStatus(true) in the second HTTP response will have nobody listening.
6. Second click of “Use a different account”: re-register the observer before the new HTTP response returns
The account dialog is still showing, and the LoginToIdP callback is still valid. Clicking “Use a different account” again repeats step 4: AddIdpSigninStatusObserver(this), then a second login popup. The second click does not need to complete login.
At this point the second AccountsFetcher is still waiting for HTTP. Request is back on idp_signin_status_observer_list_. The condition step 5 relied on — remove the observer before replacing the fetcher — no longer holds.
From the start of OnAccountsResponseReceived through SetIdpSigninStatus is a single UI task, so this click cannot run in the middle of it. The window exists only after the new accounts request has been sent and before its response returns. When the endpoint is slow, that interval is long enough for a second LoginToIdP.
7. The second accounts response destroys the running fetcher on the stack
After HTTP completes, IdpNetworkRequestManager::OnAccountsRequestParsed calls this method on the current (second) AccountsFetcher:
void AccountsFetcher::OnAccountsResponseReceived(
std::unique_ptr<IdentityProviderInfo> idp_info,
FetchStatus status,
IdpNetworkRequestManager::AccountsResponse accounts) {
base::TimeTicks accounts_fetched_time = base::TimeTicks::Now();
...
UpdateIdpSigninStatusForAccountsEndpointResponse(
idp_config_url, status, idp_info->has_failing_idp_signin_status,
permission_delegate_);
if (status.parse_status != ParseStatus::kSuccess) {
...
return;
}
CHECK(fedcm_metrics_);
When parsing succeeds, that helper writes the IDP as signed-in again:
void UpdateIdpSigninStatusForAccountsEndpointResponse(...) {
...
if (fetch_status.parse_status == ParseStatus::kSuccess) {
...
if (!does_idp_have_failing_signin_status) {
permission_delegate->SetIdpSigninStatus(idp_origin, true, std::nullopt);
}
}
The IDP is already signed-in from step 5, but SetIdpSigninStatus(true) does not compare the old value and still walks the observers synchronously. The Request re-registered in step 6 enters OnIdpSigninStatusReceived again and runs FetchEndpointsForIdps again.
The assignment at line 574 of FetchEndpointsForIdps (https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=574) destroys the second AccountsFetcher that owns the current stack frame. The network callback already locked the object through a WeakPtr when it entered OnAccountsResponseReceived; this synchronous destruction happens inside the member function, so the WeakPtr no longer applies. When control returns to the CHECK(fedcm_metrics_) at line 417, this has already been freed. That read accesses the object’s fedcm_metrics_:
raw_ptr<Metrics> fedcm_metrics_;
This causes a heap-use-after-free.
Reproduction steps
- Compile
chrome.exewith the following build arguments:
is_debug = false
is_asan = true
dcheck_always_on = false
symbol_level = 1
blink_symbol_level = 0
v8_symbol_level = 0
enable_nacl = false
use_siso = false
autoninja -C out\asan chrome
- Run
server.py:
python.exe server.py
- Run chrome.exe:
chrome.exe --lang=en-US --user-data-dir="C:\temp\poc" http://127.0.0.1:8000/index.html
- After clicking
Start FedCM, clickUse a different accountin the dialog that appears, then clickUse a different accountagain.
Note: To reproduce this crash each time, rerun python.exe server.py.
Impact analysis
Memory corruption in the browser process
The cause
What version of Chrome have you found the security issue in?
Version 153.0.8010.0 (Developer Build) (64-bit)
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a non-sandboxed process)
How would you like to be publicly acknowledged for your report?
m0omo0d
- http://127.0.0.1:8000/index.html
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium-review.googlesource.com/c/chromium/src/+/6545875
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.cc;drc=20281770dded5b0fbce9474da212dc84b8c41756;l=800
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/webid/federated_identity_permission_context.cc;drc=513d1c221d0a157d2fe8fc2a5ef5bc0b53fa4199;l=168
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/webid/federated_identity_permission_context.cc;drc=513d1c221d0a157d2fe8fc2a5ef5bc0b53fa4199;l=51
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/accounts_fetcher.cc;drc=eef6efa0adf66c840479de25daeb0ebc6159da5b;l=174
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/accounts_fetcher.cc;drc=eef6efa0adf66c840479de25daeb0ebc6159da5b;l=370
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=1055
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=2432
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=534
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=557
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=574
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request_service.cc;drc=562273008c5efc49a07d8adbb66a4cb50ee98dd8;l=146
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request_service.cc;drc=562273008c5efc49a07d8adbb66a4cb50ee98dd8;l=755
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/webid_utils.cc;drc=06efacbce0c8076fa7a1d662ff70f8579cf2d879;l=156