Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Web Authentication
DescriptionUse after free in Web Authentication
ComponentWeb Authentication
Bug ClassUAF
Tracker539754136
Fix commit40d4448ccb28 (chromium/src) +103/-77
CISA KEVNot listed
Creditedh3ee
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
enclave_request_callback_
chrome/browser/ui/webauthn/passkey_upgrade_request_controller.cc
modified
if
chrome/browser/ui/webauthn/passkey_upgrade_request_controller.cc
modified
MaybeGetRenderFrameHost
chrome/browser/ui/webauthn/passkey_upgrade_request_controller.cc
modified
if
chrome/browser/webauthn/authenticator_request_dialog_controller.cc
modified
for
chrome/browser/webauthn/authenticator_request_dialog_controller.cc
modified

Files Changed

  • chrome/browser/ui/webauthn/passkey_upgrade_request_controller.cc
  • chrome/browser/ui/webauthn/passkey_upgrade_request_controller.h
  • chrome/browser/webauthn/authenticator_request_dialog_controller.cc
From 40d4448ccb28f61a6dca9428f60800e8d2f00334 Mon Sep 17 00:00:00 2001
From: Ken Buchanan <kenrb@chromium.org>
Date: Tue, 04 Aug 2026 16:04:43 -0700
Subject: [PATCH] [WebAuthn] Defer AuthenticatorRequestDialogController deletion

In cases where the WebContents can be destroyed synchronously as a
result of observers during controller step transitions, the
controller is now destroyed on a subsequent message loop iteration.

This eliminates the need for the controller to be aware of what
step transitions have the possibility of destroying `this`, which is
hard to reason about at the call site.

Some changes are made to account for the period when the controller
is pending destruction, and the RFH might have been destroyed or
detached:
* It now holds a `scoped_refptr` to the ARDModel, to keep it alive
* It now guards against `GetRenderFrameHost` returning nullptr
* The OnModelDestroyed handler is removed, since it can never be called

Fixed: 539754136
Change-Id: I16fcf3abc5d8a8217bc296805e31ff64d2e8bfe1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8163611
Commit-Queue: Ken Buchanan <kenrb@chromium.org>
Reviewed-by: Martin Kreichgauer <martinkr@google.com>
Cr-Commit-Position: refs/heads/main@{#1673758}
---

diff --git a/chrome/browser/ui/webauthn/passkey_upgrade_request_controller.cc b/chrome/browser/ui/webauthn/passkey_upgrade_request_controller.cc
index 515d4d6..9c6167ea 100644
--- a/chrome/browser/ui/webauthn/passkey_upgrade_request_controller.cc
+++ b/chrome/browser/ui/webauthn/passkey_upgrade_request_controller.cc
@@ -66,8 +66,9 @@
     EnclaveRequestCallback enclave_request_callback,
     bool cmtg_key_requested)
     : frame_host_id_(rfh->GetGlobalId()),
+      profile_(Profile::FromBrowserContext(rfh->GetBrowserContext())),
       enclave_manager_(
-          EnclaveManagerFactory::GetAsEnclaveManagerForProfile(profile())),
+          EnclaveManagerFactory::GetAsEnclaveManagerForProfile(profile_)),
       enclave_request_callback_(enclave_request_callback) {
   if (cmtg_key_requested) {
     cmtg_key_fetcher_ = std::make_unique<CmtgKeyFetcher>(
@@ -148,7 +149,13 @@
     return;
   }
 
-  GURL url = render_frame_host().GetLastCommittedOrigin().GetURL();
+  content::RenderFrameHost* rfh = MaybeGetRenderFrameHost();
+  if (!rfh) {
+    FinishRequest(PasskeyUpgradeResult::kPasswordStoreError);
+    return;
+  }
+
+  GURL url = rfh->GetLastCommittedOrigin().GetURL();
   password_manager::PasswordFormDigest form_digest(
       password_manager::PasswordForm::Scheme::kHtml,
       password_manager::GetSignonRealm(url), url);
@@ -245,11 +252,14 @@
   FinishRequest(PasskeyUpgradeResult::kSuccess);
 
   // Show the confirmation bubble.
-  PasswordsClientUIDelegate* manage_passwords_ui_controller =
-      PasswordsClientUIDelegateFromWebContents(
-          content::WebContents::FromRenderFrameHost(&render_frame_host()));
-  if (manage_passwords_ui_controller) {
-    manage_passwords_ui_controller->OnPasskeyUpgrade(rp_id_);
+  content::RenderFrameHost* rfh = MaybeGetRenderFrameHost();
+  if (rfh) {
+    PasswordsClientUIDelegate* manage_passwords_ui_controller =
+        PasswordsClientUIDelegateFromWebContents(
+            content::WebContents::FromRenderFrameHost(rfh));
+    if (manage_passwords_ui_controller) {
+      manage_passwords_ui_controller->OnPasskeyUpgrade(rp_id_);
+    }
   }
 }
 
@@ -257,15 +267,9 @@
   return EnclaveUserVerificationMethod::kNoUserVerificationAndNoUserPresence;
 }
 
-content::RenderFrameHost& PasskeyUpgradeRequestController::render_frame_host()
-    const {
-  auto* rfh = content::RenderFrameHost::FromID(frame_host_id_);
-  CHECK(rfh);
-  return *rfh;
-}
-
-Profile* PasskeyUpgradeRequestController::profile() const {
-  return Profile::FromBrowserContext(render_frame_host().GetBrowserContext());
+content::RenderFrameHost*
+PasskeyUpgradeRequestController::MaybeGetRenderFrameHost() const {
+  return content::RenderFrameHost::FromID(frame_host_id_);
 }
 
 void PasskeyUpgradeRequestController::OnEnclaveLoaded() {
@@ -281,7 +285,14 @@
   enclave_state_ = EnclaveState::kLoading;
   FIDO_LOG(EVENT) << "Fetching account state for upgrade request";
 
-  auto* rfh = content::RenderFrameHost::FromID(frame_host_id_);
+  auto* rfh = MaybeGetRenderFrameHost();
+  if (!rfh) {
+    enclave_state_ = EnclaveState::kError;
+    if (pending_request_) {
+      FinishRequest(PasskeyUpgradeResult::kEnclaveError);
+    }
+    return;
+  }
   auto* const identity_manager =
       IdentityManagerFactory::GetForProfile(profile());
   scoped_refptr<network::SharedURLLoaderFactory> testing_url_loader =
diff --git a/chrome/browser/ui/webauthn/passkey_upgrade_request_controller.h b/chrome/browser/ui/webauthn/passkey_upgrade_request_controller.h
index 28262652..78ccd7e 100644
--- a/chrome/browser/ui/webauthn/passkey_upgrade_request_controller.h
+++ b/chrome/browser/ui/webauthn/passkey_upgrade_request_controller.h
@@ -105,8 +105,10 @@
       const sync_pb::WebauthnCredentialSpecifics& passkey) override;
   EnclaveUserVerificationMethod GetUvMethod() override;
 
-  content::RenderFrameHost& render_frame_host() const;
-  Profile* profile() const;
+  // Returns the render frame host associated with this request. May return
+  // nullptr if the initiating frame or tab has been destroyed or detached.
+  content::RenderFrameHost* MaybeGetRenderFrameHost() const;
+  Profile* profile() const { return profile_; }
 
   void OnEnclaveLoaded();
   void OnAccountStateDownloaded(
@@ -117,6 +119,7 @@
   void FinishRequest(PasskeyUpgradeResult error);
 
   const content::GlobalRenderFrameHostId frame_host_id_;
+  const raw_ptr<Profile> profile_;
 
   const raw_ptr<EnclaveManager> enclave_manager_;
   EnclaveState enclave_state_ = EnclaveState::kUnknown;
diff --git a/chrome/browser/webauthn/authenticator_request_dialog_controller.cc b/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
index 480d05a..bb29c5d0 100644
--- a/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
+++ b/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
@@ -500,22 +500,16 @@
 
 AuthenticatorRequestDialogModel* AuthenticatorRequestDialogController::model()
     const {
-  return model_;
-}
-
-void AuthenticatorRequestDialogController::OnModelDestroyed(
-    AuthenticatorRequestDialogModel* model) {
-  // This stops the destructor of this object from trying to remove itself from
-  // the list of observers. But this is not a valid state for this object to be
-  // in: many functions will crash. So this is just to make destroying the two
-  // objects together not depend on the order of destruction.
-  CHECK_EQ(model, model_);
-  model_ = nullptr;
+  return model_.get();
 }
 
 void AuthenticatorRequestDialogController::StartOver() {
+  content::RenderFrameHost* render_frame_host = MaybeGetRenderFrameHost();
+  if (!render_frame_host) {
+    return;
+  }
   PrefService* pref_service =
-      Profile::FromBrowserContext(GetRenderFrameHost()->GetBrowserContext())
+      Profile::FromBrowserContext(render_frame_host->GetBrowserContext())
           ->GetOriginalProfile()
           ->GetPrefs();
   if (model_->step() == Step::kGPMTrustThisComputerCreation ||
@@ -596,9 +590,11 @@
 }
 
 void AuthenticatorRequestDialogController::OpenGpmSettings() {
-  auto* render_frame_host = GetRenderFrameHost();
   auto* web_contents =
-      content::WebContents::FromRenderFrameHost(render_frame_host);
+      content::WebContents::FromRenderFrameHost(MaybeGetRenderFrameHost());
+  if (!web_contents) {
+    return;
+  }
   BrowserWindowInterface* browser =
       GlobalBrowserCollection::GetInstance()->FindBrowserWithTab(web_contents);
   chrome::ShowPasswordManagerSettings(browser);
@@ -627,14 +623,9 @@
 
   // SetCurrentStep(Step::kClosed) can synchronously destroy the hosting
   // WebContents and therefore `this`. See crbug.com/522566295.
-  base::WeakPtr<AuthenticatorRequestDialogController> weak_this =
-      weak_factory_.GetWeakPtr();
   if (is_request_complete()) {
     SetCurrentStep(Step::kClosed);
   }
-  if (!weak_this) {
-    return;
-  }
 
   for (auto& observer : model_->observers) {
     observer.OnCancelRequest();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
index 2ded26a..1055c4b6 100644
--- a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
+++ b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
@@ -1881,6 +1881,7 @@
 
   void OnDestroy(ChromeAuthenticatorRequestDelegate* delegate) override {
     delegate_ = nullptr;
+    delegate_shown_future_.Clear();
   }
 
   // AuthenticatorRequestDialogModel::Observer:
@@ -1922,9 +1923,12 @@
     WebAuthnBrowserTest::TearDownOnMainThread();
   }
 
-  raw_ptr<ChromeAuthenticatorRequestDelegate> delegate_ = nullptr;
-  raw_ptr<AuthenticatorRequestDialogModel> model_ = nullptr;
-  base::test::TestFuture<ChromeAuthenticatorRequestDelegate*>
+  raw_ptr<ChromeAuthenticatorRequestDelegate, DisableDanglingPtrDetection>
+      delegate_ = nullptr;
+  raw_ptr<AuthenticatorRequestDialogModel, DisableDanglingPtrDetection> model_ =
+      nullptr;
+  base::test::TestFuture<
+      raw_ptr<ChromeAuthenticatorRequestDelegate, DisableDanglingPtrDetection>>
       delegate_shown_future_;
   bool web_contents_deleted_ = false;
 #if BUILDFLAG(IS_WIN)
Loading diff…

Original Bug Report

reported by yu...@gmail.com

Incomplete fix for 536584251: UAF in AuthenticatorRequestDialogController (two missed SetCurrentStep sites)

Security Bug

Important: Please do not change the component of this bug manually.

Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md

Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs

Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp

NOTE: Security bugs are normally made public once a fix has been widely deployed.


VULNERABILITY DETAILS Incomplete fix for crbug.com/536584251 — two additional unguarded SetCurrentStep() call sites in AuthenticatorRequestDialogController remain after two rounds of fixes (crbug.com/522566295 and crbug.com/536584251).

Bug 536584251 (commit 25e013dd59d3e, 2026-07-23) added WeakPtr guards after SetCurrentStep() in HideDialogAndDispatchToPlatformAuthenticator() and StartPasskeyUpgradeRequest(). Bug 522566295 (commit 0ed0be437d706, 2026-06-11) did the same for CancelAuthenticatorRequest(). Two call sites with post-call member access were missed by both rounds:

Site 1: StartGuidedFlowForMostLikelyTransportOrShowMechanismSelection() authenticator_request_dialog_controller.cc:810-811 Site 2: OnCableEvent() authenticator_request_dialog_controller.cc:1078-1083

Both use the identical destruction mechanism confirmed and fixed in 522566295 and 536584251:

SetCurrentStep(step) -> model_->SetStep(step) // model.cc:157 -> view_controller_.reset() // model.cc:167 (NONE-type steps) -> for (observer : observers) observer.OnStepTransition() // model.cc:177-179 -> observer closes tab -> ~WebContentsImpl -> ~AuthenticatorCommon -> ~ChromeAuthenticatorRequestDelegate -> ~AuthenticatorRequestDialogController // this freed

The controller is owned via unique_ptr<AuthenticatorRequestDialogController> in ChromeAuthenticatorRequestDelegate (h:309-310). MiraclePtr does NOT protect — the UAF is on this pointer (member access after controller destruction), not on a raw_ptr member.

SITE 1 — StartGuidedFlowForMostLikelyTransportOrShowMechanismSelection

authenticator_request_dialog_controller.cc:809-811:

if (pending_step_) { SetCurrentStep(*pending_step_); // CAN DESTROY this pending_step_.reset(); // UAF: read+write to freed member }

pending_step_ is populated by design: when SetCurrentStep() is called before the dialog is showing (started_ == false), the step is queued (line 1742: “Dialog isn’t showing yet. Remember to show this step when it appears.”). When StartFlow() sets started_ = true (line 746) and calls this function (line 775), the pending step is consumed. At this point started_ is true, so SetCurrentStep() takes the model_->SetStep() path — triggering view_controller_.reset() and the observer loop.

If the pending step is a StepUIType::NONE step (kClosed, kPlatformAuthenticator, kPasskeyUpgrade, kPasswordOsAuth, kPasskeyAutofill — model.cc:50-56), view_controller_.reset() fires, which is the exact destruction trigger confirmed in the ASAN output for bug 536584251:

#14 TabModel::~TabModel #17 TabStripModel::CloseWebContentsAt <- observer closes tab #18 AuthenticatorRequestDialogModel::SetStep

For non-NONE steps, the observer loop (model.cc:177-179) still dispatches OnStepTransition() to all observers, which can also trigger tab closure and controller destruction.

Call paths to this function:

  • StartFlow() line 775 (kModal/kModalImmediate presentation) -> web-reachable via navigator.credentials.create() / navigator.credentials.get()
  • TransitionToModalWebAuthnRequest() line 799 -> web-reachable via conditional UI -> modal transition (navigator.credentials.get({mediation: “conditional”}) + user clicks a passkey suggestion)

Compare with GUARDED CancelAuthenticatorRequest (same file, line 628+):

base::WeakPtr<AuthenticatorRequestDialogController> weak_this = weak_factory_.GetWeakPtr(); if (is_request_complete()) { SetCurrentStep(Step::kClosed); } if (!weak_this) { // GUARD return; }

SITE 2 — OnCableEvent

authenticator_request_dialog_controller.cc:1077-1083:

if (model_->step() != Step::kCableV2Connecting) { SetCurrentStep(Step::kCableV2Connecting); // CAN DESTROY this cable_connecting_sheet_timer_.Start( // UAF: method call on freed member FROM_HERE, base::Milliseconds(1250), base::BindOnce(&AuthenticatorRequestDialogController:: OnCableConnectingTimerComplete, weak_factory_.GetWeakPtr())); // UAF: freed member access }

kCableV2Connecting is StepUIType::DIALOG (model.cc:62-63, default case), so view_controller_.reset() does not fire. However, the observer loop at model.cc:177-179 runs unconditionally for all step types, and any observer that reacts to the step transition can close the tab and destroy the controller. After SetCurrentStep() returns, three member accesses follow: cable_connecting_sheet_timer_ (method call), weak_factory_ (address-of), and the implicit this for the BindOnce member function pointer.

OnCableEvent is called when a caBLE/hybrid Bluetooth event occurs during WebAuthn. This is web-reachable: a relying party using the hybrid transport (QR code / phone as authenticator) triggers BLE advertising events that route to this function.

SCOPE OF UNGUARDED SITES There are 40+ SetCurrentStep() calls in the file. Only 3 are guarded (lines 628, 1003, 2357 — added by the two fix commits). Most of the remaining ~37 are safe because SetCurrentStep is the last statement before return or end-of-function (tail position). The two sites above are the only ones with confirmed post-call member access in the current code.

BISECT Both sites were present before commit 25e013dd59d3e (2026-07-23, bug 536584251) which fixed HideDialogAndDispatchToPlatformAuthenticator and StartPasskeyUpgradeRequest but missed these two. The fix opportunity was this commit.

Site 1 (pending_step_ path) has been present since the pending_step_ mechanism was introduced. Site 2 (OnCableEvent) has been present since the caBLE V2 hybrid flow was added.

VERSION Chrome Version: 152.0.7974.0 (dev) Operating System: macOS 26.2

REPRODUCTION CASE Environment: ASAN build: is_asan=true is_debug=false is_component_build=false dcheck_always_on=true

Apply poc.patch (base: a227b14fea96e) and rebuild: cd chromium/src git apply poc.patch autoninja -C out/ASAN chrome

Serve poc.html over localhost (WebAuthn requires secure context; localhost over plain HTTP qualifies) and launch: python3 poc_server.py & ASAN_OPTIONS=“symbolize=1:external_symbolizer_path=third_party/llvm-build/
Release+Asserts/bin/llvm-symbolizer”
out/ASAN/Chromium.app/Contents/MacOS/Chromium
–no-first-run –disable-default-apps
–no-default-browser-check
–user-data-dir=/tmp/chrome-vrp-asan
http://localhost:8080/poc.html 2>&1 | tee asan_output.txt

  1. Click “Create Passkey” button
  2. ASAN crash: heap-use-after-free in AuthenticatorRequestDialogController:: StartGuidedFlowForMostLikelyTransportOrShowMechanismSelection (pending_step_.reset() on freed this)

What the patch does (production code only, no test modifications): All PoC modifications are guarded with !base::CommandLine::ForCurrentProcess()->HasSwitch(“type”) which ensures they only execute in the browser process (renderer and other child processes have –type=renderer/gpu/etc. on their command line).

  • PocDestructionObserver (anonymous namespace): A model observer that reacts to the step transition to kClosed by closing the hosting tab. This is the same observer-based destruction mechanism used in the Chromium team’s own fix regression test for crbug.com/522566295 and in the accepted PoC for crbug.com/536584251. The model’s SetStep() dispatches OnStepTransition() to all observers; the observer fires in that loop, closing the tab -> destroying WebContents -> destroying the controller.

  • StartFlow(): In the browser process, after setting started_=true, sets pending_step_ = Step::kClosed (a StepUIType::NONE step that triggers view_controller_.reset() in model_->SetStep()), registers PocDestructionObserver, then calls StartGuidedFlowForMostLikelyTransportOrShowMechanismSelection() directly. The function checks if (pending_step_) -> true, calls SetCurrentStep(*pending_step_) with started_=true -> model_->SetStep() -> observer fires -> tab closed -> controller freed -> returns to pending_step_.reset() on freed memory.

  • StartGuidedFlowForMostLikelyTransportOrShowMechanismSelection(): UNMODIFIED. The function’s existing code at line 810-811 is the vulnerability — SetCurrentStep(*pending_step_) followed by pending_step_.reset() with no WeakPtr guard.

Site 2 (OnCableEvent) is not covered by this PoC because caBLE/hybrid requires Bluetooth hardware interaction. The code analysis in the bug report demonstrates the same pattern — SetCurrentStep() followed by unguarded member access.

FOR CRASHES Type of crash: browser process

Crash state (Site 1, from asan_output.txt):

==43878==ERROR: AddressSanitizer: heap-use-after-free on address 0x61600025b708 at pc 0x00037cd8501c bp 0x00016fbe3d90 sp 0x00016fbe3d88 READ of size 1 at 0x61600025b708 thread T0 #0 AuthenticatorRequestDialogController:: StartGuidedFlowForMostLikelyTransportOrShowMechanismSelection() #1 AuthenticatorRequestDialogController::StartFlow(…) #2 ChromeAuthenticatorRequestDelegate::ShowUI(…) #3 UiReadinessBarrier::TryToShowUI() #4 ChromeAuthenticatorRequestDelegate:: OnTransportAvailabilityEnumerated(…) #5 device::FidoRequestHandlerBase:: MaybeSignalTransportsEnumerated() #6 device::FidoRequestHandlerBase::DiscoveryStarted(…) #7 device::fido::mac::FidoTouchIdDiscovery:: OnAuthenticatorAvailable(bool)

0x61600025b708 is located 136 bytes inside of 640-byte region [0x61600025b680,0x61600025b900)

freed by thread T0 here: #1 ChromeAuthenticatorRequestDelegate:: ~ChromeAuthenticatorRequestDelegate() #3 content::AuthenticatorCommonImpl::RequestState::~RequestState() #5 content::AuthenticatorImpl::~AuthenticatorImpl() #6 content::DocumentAssociatedData::~DocumentAssociatedData() #7 content::RenderFrameHostImpl::~RenderFrameHostImpl() #12 content::WebContentsImpl::~WebContentsImpl() #14 tabs::TabModel::~TabModel() #17 TabStripModel::CloseWebContentsAt(…) <- observer closes tab #18 AuthenticatorRequestDialogModel::SetStep(…) #19 AuthenticatorRequestDialogController:: StartGuidedFlowForMostLikelyTransportOrShowMechanismSelection() #20 AuthenticatorRequestDialogController::StartFlow(…)

previously allocated by thread T0 here: #2 ChromeAuthenticatorRequestDelegate:: ChromeAuthenticatorRequestDelegate(content::RenderFrameHost*) #3 AuthenticatorRequestScheduler:: CreateRequestDelegate(content::RenderFrameHost*)

MiraclePtr Status: NOT PROTECTED No raw_ptr<T> access to this region was detected prior to this crash. This crash is still exploitable with MiraclePtr.

Site 2 (OnCableEvent) is not reproduced in this PoC because caBLE/hybrid requires Bluetooth hardware interaction. The expected crash state:

==PID==ERROR: AddressSanitizer: heap-use-after-free READ of size N at 0x… thread T0 #0 base::OneShotTimer::Start (cable_connecting_sheet_timer_ on freed this) #1 AuthenticatorRequestDialogController::OnCableEvent freed by thread T0: (same destruction chain as Site 1)

RELATED BUGS

  • Bug 536584251 (commit 25e013dd59d3e): Fixed identical UAF in HideDialogAndDispatchToPlatformAuthenticator() and StartPasskeyUpgradeRequest() — same SetCurrentStep() destruction path. This is the PARENT bug that this report is an incomplete fix of.
  • Bug 522566295 (commit 0ed0be437d706): Fixed identical UAF in CancelAuthenticatorRequest() — first round of fix.

SUGGESTED FIX See attached fix.patch. Add WeakPtr guards after SetCurrentStep() at both sites, identical to the fix pattern established in commits 0ed0be437d706 and 25e013dd59d3e.

CREDIT INFORMATION Reporter credit: h3ee

View on issue tracker