High watch CVSS 9.6 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in FedCM
DescriptionUse after free in FedCM
ComponentFedCM
Sub-componentFedCM
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker548340637
Fix commit7cc254e978dd (chromium/src) +144/-2
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
Creditedm0omo0d
Reported2026-08-18 (per vendor advisory)
Fix landed2026-08-19 (public git)
Disclosed2026-08-25
Patch gap6 days — fix public in source before the advisory

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 in Request and AccountsFetcher.
"Use a different account" / login popup
a dialog button that calls Request::LoginToIdP, which registers the Request as 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 calls UpdateIdpSigninStatusForAccountsEndpointResponse to 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.

Key insight
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

  1. 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).
  2. First "Use a different account" The user clicks it, so Request::LoginToIdP adds Request as a sign-in-status observer and opens the IdP login popup while an accounts fetch is outstanding.
  3. Second click while fetch is in flight The user refocuses the FedCM dialog and clicks the button again; before the fix LoginToIdP re-registered the observer and reset its state. A page or IdP that delays its accounts endpoint would widen this window (inferred).
  4. Re-entrant destruction The accounts response arrives, UpdateIdpSigninStatusForAccountsEndpointResponse notifies Request, which starts a new fetch and destroys the current AccountsFetcher; execution then returns into the freed fetcher’s OnAccountsResponseReceived.
  5. Use of freed fetcher Later code in OnAccountsResponseReceived touches members of the destroyed AccountsFetcher, giving a browser-process UAF. Turning that into controlled memory corruption or code execution is (inferred).

Impact Assessment

This is a use-after-free in the browser process, reached through FedCM UI flows and not through a compromised renderer. It needs a page that uses FedCM with an IdP, plus specific user interaction: clicking “Use a different account” twice, with a dialog refocus in between, while an accounts request is pending. The advisory lists the bug only as “Use after free in FedCM” with no in-the-wild status; heap corruption leading to code execution in the browser process would be serious but is (inferred) and not shown by the patch.

Changed Functions

FunctionChangeNotes
if
content/browser/webid/accounts_fetcher.cc
modified
if
content/browser/webid/request.cc
modified
TEST_F
content/browser/webid/accounts_fetcher_unittest.cc
modified
TEST_F
content/browser/webid/request_unittest.cc
modified

Files Changed

  • content/browser/webid/accounts_fetcher_unittest.cc
  • content/browser/webid/accounts_fetcher.cc
  • content/browser/webid/request_unittest.cc
  • content/browser/webid/request.cc

Audit Directions

  • Call-outs that can delete `this`
    In content/browser/webid, check every place where AccountsFetcher, Request or related helpers call permission-delegate setters, observer lists or completion callbacks, then continue using members; each needs a WeakPtr check or must be the last statement.
  • Idempotency of UI-triggered registration
    Look for button handlers (LoginToIdP, “continue”, “add account”, ShowModalDialog flows) that can run repeatedly while a popup or fetch is pending and that add observers or restart fetchers without checking current state.
  • Observer-triggered restarts
    Audit 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.
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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:

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request_service.cc;drc=562273008c5efc49a07d8adbb66a4cb50ee98dd8;l=146

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:

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=557

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:

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/accounts_fetcher.cc;drc=eef6efa0adf66c840479de25daeb0ebc6159da5b;l=174

    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:

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=1055

    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:

https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.cc;drc=20281770dded5b0fbce9474da212dc84b8c41756;l=800

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

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=2432

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:

https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/webid/federated_identity_permission_context.cc;drc=513d1c221d0a157d2fe8fc2a5ef5bc0b53fa4199;l=51

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:

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request_service.cc;drc=562273008c5efc49a07d8adbb66a4cb50ee98dd8;l=755

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:

https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/webid/federated_identity_permission_context.cc;drc=513d1c221d0a157d2fe8fc2a5ef5bc0b53fa4199;l=168

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:

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/request.cc;drc=758ac9d87d897f6fa82853264cc0b873855c084e;l=534

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:

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/accounts_fetcher.cc;drc=eef6efa0adf66c840479de25daeb0ebc6159da5b;l=370

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:

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/webid/webid_utils.cc;drc=06efacbce0c8076fa7a1d662ff70f8579cf2d879;l=156

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

  1. Compile chrome.exe with 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
  1. Run server.py:
python.exe server.py
  1. Run chrome.exe:
chrome.exe --lang=en-US --user-data-dir="C:\temp\poc" http://127.0.0.1:8000/index.html
  1. After clicking Start FedCM, click Use a different account in the dialog that appears, then click Use a different account again.

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)

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

View on issue tracker
Links in the report