CVE-2026-19166
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/webauthn/authenticator_request_dialog_controller.cc |
modified | |
WebAuthnUAFReproductionTestchrome/browser/webauthn/chrome_webauthn_browsertest.cc |
modified | |
ifchrome/browser/webauthn/chrome_webauthn_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/webauthn/chrome_webauthn_browsertest.cc |
modified |
Files Changed
chrome/browser/webauthn/authenticator_request_dialog_controller.ccchrome/browser/webauthn/chrome_webauthn_browsertest.cc
Patch
From 25e013dd59d3e6eb9cafb4d730f86c532fd2789b Mon Sep 17 00:00:00 2001
From: Adem Derinel <derinel@google.com>
Date: Thu, 23 Jul 2026 01:03:39 -0700
Subject: [PATCH] Fix UAF in AuthenticatorRequestDialogController
Calling SetCurrentStep in HideDialogAndDispatchToPlatformAuthenticator
can trigger observers that destroy the dialog controller. This CL
introduces a WeakPtr check after SetCurrentStep to ensure the controller
is still alive before accessing its member variables. A regression test
has also been added to verify this behavior.
Fixed: 536584251
Change-Id: Idc80a34ec731967a6e4290f67c174cfaebfcddc3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8127931
Reviewed-by: Ken Buchanan <kenrb@chromium.org>
Commit-Queue: Adem Derinel <derinel@google.com>
Cr-Commit-Position: refs/heads/main@{#1666890}
---
diff --git a/chrome/browser/webauthn/authenticator_request_dialog_controller.cc b/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
index b3e44f3..d1f9426 100644
--- a/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
+++ b/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
@@ -1000,7 +1000,12 @@
void AuthenticatorRequestDialogController::
HideDialogAndDispatchToPlatformAuthenticator(
std::optional<AuthenticatorType> type) {
+ base::WeakPtr<AuthenticatorRequestDialogController> weak_this =
+ weak_factory_.GetWeakPtr();
SetCurrentStep(Step::kPlatformAuthenticator);
+ if (!weak_this) {
+ return;
+ }
std::vector<AuthenticatorReference>& authenticators =
ephemeral_state_.saved_authenticators_;
@@ -2349,7 +2354,12 @@
}
void AuthenticatorRequestDialogController::StartPasskeyUpgradeRequest() {
+ base::WeakPtr<AuthenticatorRequestDialogController> weak_this =
+ weak_factory_.GetWeakPtr();
SetCurrentStep(Step::kPasskeyUpgrade);
+ if (!weak_this) {
+ return;
+ }
if (!passkey_upgrade_request_controller_) {
RecordPasskeyUpgradeResultHistogram(PasskeyUpgradeResult::kGpmDisabled);
diff --git a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
index e87a8c0..2ded26a 100644
--- a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
+++ b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
@@ -20,6 +20,7 @@
#include "base/task/sequenced_task_runner.h"
#include "base/test/bind.h"
#include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
#include "base/test/values_test_util.h"
#include "base/time/time.h"
#include "build/build_config.h"
@@ -1860,7 +1861,11 @@
public ChromeAuthenticatorRequestDelegate::TestObserver,
public AuthenticatorRequestDialogModel::Observer {
public:
- WebAuthnUAFReproductionTest() = default;
+ WebAuthnUAFReproductionTest() {
+#if BUILDFLAG(IS_WIN)
+ win_api_.set_available(false);
+#endif
+ }
~WebAuthnUAFReproductionTest() override = default;
// ChromeAuthenticatorRequestDelegate::TestObserver:
@@ -1871,9 +1876,7 @@
}
void UIShown(ChromeAuthenticatorRequestDelegate* delegate) override {
- if (run_loop_) {
- run_loop_->Quit();
- }
+ delegate_shown_future_.SetValue(delegate);
}
void OnDestroy(ChromeAuthenticatorRequestDelegate* delegate) override {
@@ -1883,7 +1886,9 @@
// AuthenticatorRequestDialogModel::Observer:
void OnStepTransition() override {
if (model_ &&
- model_->step() == AuthenticatorRequestDialogModel::Step::kClosed) {
+ (model_->step() == AuthenticatorRequestDialogModel::Step::kClosed ||
+ model_->step() ==
+ AuthenticatorRequestDialogModel::Step::kPlatformAuthenticator)) {
DeleteWebContents();
}
}
@@ -1919,8 +1924,13 @@
raw_ptr<ChromeAuthenticatorRequestDelegate> delegate_ = nullptr;
raw_ptr<AuthenticatorRequestDialogModel> model_ = nullptr;
- std::unique_ptr<base::RunLoop> run_loop_;
+ base::test::TestFuture<ChromeAuthenticatorRequestDelegate*>
+ delegate_shown_future_;
bool web_contents_deleted_ = false;
+#if BUILDFLAG(IS_WIN)
+ device::FakeWinWebAuthnApi win_api_;
+ device::WinWebAuthnApi::ScopedOverride win_webauthn_api_override_{&win_api_};
+#endif
};
IN_PROC_BROWSER_TEST_F(WebAuthnUAFReproductionTest, CancelUAF) {
@@ -1947,10 +1957,7 @@
});
)");
- if (!delegate_) {
- run_loop_ = std::make_unique<base::RunLoop>();
- run_loop_->Run();
- }
+ ASSERT_TRUE(delegate_shown_future_.Wait());
ASSERT_TRUE(delegate_);
ASSERT_TRUE(delegate_->dialog_controller());
@@ -1962,4 +1969,37 @@
delegate_->dialog_controller()->CancelAuthenticatorRequest();
}
+IN_PROC_BROWSER_TEST_F(WebAuthnUAFReproductionTest, HideDialogUAF) {
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), https_server_.GetURL("www.example.com", "/title1.html")));
+
+ content::WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ // Trigger WebAuthn flow asynchronously
+ content::ExecuteScriptAsync(web_contents, R"(
+ navigator.credentials.create({
+ publicKey: {
+ challenge: new Uint8Array([1, 2, 3, 4]),
+ rp: { name: "Example" },
+ user: {
+ id: new Uint8Array([1, 2, 3, 4]),
+ name: "test",
+ displayName: "test"
+ },
+ pubKeyCredParams: [{ type: "public-key", alg: -7 }],
+ timeout: 60000
+ }
+ });
+ )");
+
+ ASSERT_TRUE(delegate_shown_future_.Wait());
+ ASSERT_TRUE(delegate_);
+ ASSERT_TRUE(delegate_->dialog_controller());
+
+ // Trigger UAF
+ delegate_->dialog_controller()->HideDialogAndDispatchToPlatformAuthenticator(
+ std::nullopt);
+}
+
} // namespace
Regression Test / PoC
diff --git a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
index e87a8c0..2ded26a 100644
--- a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
+++ b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
@@ -20,6 +20,7 @@
#include "base/task/sequenced_task_runner.h"
#include "base/test/bind.h"
#include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
#include "base/test/values_test_util.h"
#include "base/time/time.h"
#include "build/build_config.h"
@@ -1860,7 +1861,11 @@
public ChromeAuthenticatorRequestDelegate::TestObserver,
public AuthenticatorRequestDialogModel::Observer {
public:
- WebAuthnUAFReproductionTest() = default;
+ WebAuthnUAFReproductionTest() {
+#if BUILDFLAG(IS_WIN)
+ win_api_.set_available(false);
+#endif
+ }
~WebAuthnUAFReproductionTest() override = default;
// ChromeAuthenticatorRequestDelegate::TestObserver:
@@ -1871,9 +1876,7 @@
}
void UIShown(ChromeAuthenticatorRequestDelegate* delegate) override {
- if (run_loop_) {
- run_loop_->Quit();
- }
+ delegate_shown_future_.SetValue(delegate);
}
void OnDestroy(ChromeAuthenticatorRequestDelegate* delegate) override {
@@ -1883,7 +1886,9 @@
// AuthenticatorRequestDialogModel::Observer:
void OnStepTransition() override {
if (model_ &&
- model_->step() == AuthenticatorRequestDialogModel::Step::kClosed) {
+ (model_->step() == AuthenticatorRequestDialogModel::Step::kClosed ||
+ model_->step() ==
+ AuthenticatorRequestDialogModel::Step::kPlatformAuthenticator)) {
DeleteWebContents();
}
}
@@ -1919,8 +1924,13 @@
raw_ptr<ChromeAuthenticatorRequestDelegate> delegate_ = nullptr;
raw_ptr<AuthenticatorRequestDialogModel> model_ = nullptr;
- std::unique_ptr<base::RunLoop> run_loop_;
+ base::test::TestFuture<ChromeAuthenticatorRequestDelegate*>
+ delegate_shown_future_;
bool web_contents_deleted_ = false;
+#if BUILDFLAG(IS_WIN)
+ device::FakeWinWebAuthnApi win_api_;
+ device::WinWebAuthnApi::ScopedOverride win_webauthn_api_override_{&win_api_};
+#endif
};
IN_PROC_BROWSER_TEST_F(WebAuthnUAFReproductionTest, CancelUAF) {
@@ -1947,10 +1957,7 @@
});
)");
- if (!delegate_) {
- run_loop_ = std::make_unique<base::RunLoop>();
- run_loop_->Run();
- }
+ ASSERT_TRUE(delegate_shown_future_.Wait());
ASSERT_TRUE(delegate_);
ASSERT_TRUE(delegate_->dialog_controller());
@@ -1962,4 +1969,37 @@
delegate_->dialog_controller()->CancelAuthenticatorRequest();
}
+IN_PROC_BROWSER_TEST_F(WebAuthnUAFReproductionTest, HideDialogUAF) {
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), https_server_.GetURL("www.example.com", "/title1.html")));
+
+ content::WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ // Trigger WebAuthn flow asynchronously
+ content::ExecuteScriptAsync(web_contents, R"(
+ navigator.credentials.create({
+ publicKey: {
+ challenge: new Uint8Array([1, 2, 3, 4]),
+ rp: { name: "Example" },
+ user: {
+ id: new Uint8Array([1, 2, 3, 4]),
+ name: "test",
+ displayName: "test"
+ },
+ pubKeyCredParams: [{ type: "public-key", alg: -7 }],
+ timeout: 60000
+ }
+ });
+ )");
+
+ ASSERT_TRUE(delegate_shown_future_.Wait());
+ ASSERT_TRUE(delegate_);
+ ASSERT_TRUE(delegate_->dialog_controller());
+
+ // Trigger UAF
+ delegate_->dialog_controller()->HideDialogAndDispatchToPlatformAuthenticator(
+ std::nullopt);
+}
+
} // namespace
Original Bug Report
Use-after-free in WebAuthn HideDialogAndDispatchToPlatformAuthenticator
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 Browser-process use-after-free in AuthenticatorRequestDialogController:: HideDialogAndDispatchToPlatformAuthenticator.
HideDialogAndDispatchToPlatformAuthenticator (authenticator_request_dialog_ controller.cc:1001-1057) calls SetCurrentStep(Step::kPlatformAuthenticator) without a WeakPtr guard, then accesses controller members extensively across 53 lines of code. SetCurrentStep() calls model_->SetStep() which calls view_controller_.reset() for all StepUIType::NONE steps (including kPlatformAuthenticator), destroying the constrained window widget. Widget destruction can synchronously destroy the hosting WebContents and therefore the controller itself – a path confirmed and fixed for a sibling function in crbug.com/522566295 (commit 0ed0be437d706).
This is a missed sibling of bug 522566295. Commit 0ed0be437d706 (2026-06-11) fixed the identical pattern in CancelAuthenticatorRequest() by adding a WeakPtr guard after SetCurrentStep(Step::kClosed). The fix was narrowly applied to that one function. HideDialogAndDispatchToPlatformAuthenticator() and StartPasskeyUpgradeRequest() use the same mechanism (SetCurrentStep with StepUIType::NONE -> view_controller_.reset() -> widget destruction) but were not included in the fix.
The fix comment states:
“SetCurrentStep(Step::kClosed) can synchronously destroy the hosting
WebContents and therefore this. See crbug.com/522566295.”
Both kClosed and kPlatformAuthenticator return StepUIType::NONE (authenticator_request_dialog_model.cc:48-56), triggering the same view_controller_.reset() at model.cc:167 which destroys the dialog widget.
The vulnerable code (after SetCurrentStep at line 1003):
void AuthenticatorRequestDialogController::
HideDialogAndDispatchToPlatformAuthenticator(
std::optional<AuthenticatorType> type) {
SetCurrentStep(Step::kPlatformAuthenticator); // CAN DESTROY this
// NO WEAKPTR GUARD
std::vector<AuthenticatorReference>& authenticators =
ephemeral_state_.saved_authenticators_; // UAF: line 1005
// … 47 more lines of member access through line 1056 …
model_->offer_try_again_in_ui = false; // UAF
ephemeral_state_.dispatched_platform_authenticator_type_ = …; // UAF
transport_availability_.request_type; // UAF
DispatchRequestAsync(&*platform_authenticator_it); // UAF: line 1056
}
Compare with the FIXED CancelAuthenticatorRequest (same file, line 626+):
base::WeakPtr<AuthenticatorRequestDialogController> weak_this = weak_factory_.GetWeakPtr(); if (is_request_complete()) { SetCurrentStep(Step::kClosed); } if (!weak_this) { // GUARD return; }
Second unguarded site – StartPasskeyUpgradeRequest() (line 2352+):
void AuthenticatorRequestDialogController::StartPasskeyUpgradeRequest() {
SetCurrentStep(Step::kPasskeyUpgrade); // CAN DESTROY this
// NO WEAKPTR GUARD
if (!passkey_upgrade_request_controller_) { // UAF
RecordPasskeyUpgradeResultHistogram(…);
PasskeyUpgradeFailed(); // UAF
return;
}
passkey_upgrade_request_controller_->TryUpgradePasswordToPasskey(
model_->relying_party_id, model_->user_entity…); // UAF
}
Destruction chain (observer-based, same mechanism as crbug.com/522566295):
HideDialogAndDispatchToPlatformAuthenticator()
-> SetCurrentStep(kPlatformAuthenticator)
-> model_->SetStep(kPlatformAuthenticator) // model.cc:157
-> step_ui_type(kPlatformAuthenticator) == NONE
-> view_controller_.reset() // model.cc:167
-> observer loop: OnStepTransition() // model.cc:177-179
-> observer reacts to step change
-> CloseWebContentsAt -> ~WebContentsImpl
-> ~AuthenticatorCommon
-> ~ChromeAuthenticatorRequestDelegate
-> ~AuthenticatorRequestDialogController // this freed
-> ObserverList iterator invalidated, loop exits
-> SetStep returns (no member access after loop)
-> SetCurrentStep returns (no member access after model_->SetStep)
-> ephemeral_state_.saved_authenticators_ // UAF: freed memory
The PoC uses the model’s observer mechanism – the same production API used in the Chromium team’s own browsertest for crbug.com/522566295. Any registered observer (browser component, extension, etc.) that reacts to step transitions can trigger this destruction chain.
MiraclePtr does NOT protect internal member accesses – the UAF is on this
pointer (controller members accessed after the controller is destroyed).
The function is web-reachable via navigator.credentials.create() and navigator.credentials.get(). It is called from multiple dialog-visible states:
- OnChromeProfileCreatePasskeyAccepted() line 550
- OnResidentCredentialConfirmed() line 661
- StartPlatformAuthenticatorFlow() lines 1197, 1229
- OnAccountPreselected() line 1492
- StartICloudKeychain() line 1816 (macOS-specific)
PRODUCTION TRIGGER PATH
The attacker controls a web page that calls navigator.credentials.create() with authenticatorSelection: {authenticatorAttachment: “platform”}. No enterprise policy, no special flags, no user enrollment required – any user on any Chrome profile with a platform authenticator (macOS Touch ID, Windows Hello, Android biometrics) reaches this code.
Concrete exploitation scenario (window.close race):
- Attacker page opens a popup: win = window.open(“victim-page”)
- Popup’s page calls navigator.credentials.create() – the WebAuthn dialog shows. The dialog is a constrained web modal that calls BlockWebContentsInteraction(true), blocking the popup’s tab.
- User clicks “Create passkey” in the dialog. -> OnChromeProfileCreatePasskeyAccepted() -> HideDialogAndDispatchToPlatformAuthenticator() -> SetCurrentStep(kPlatformAuthenticator) -> model_->SetStep(): view_controller_.reset() destroys the dialog -> BlockWebContentsInteraction(false) UNBLOCKS the popup tab
- On macOS, AppKit event processing during the widget destruction window dispatches pending tasks – including any queued IPC from the opener calling win.close() moments before step 3. The close task fires synchronously within view_controller_.reset(), destroying WebContents -> controller freed.
- SetStep() returns, SetCurrentStep() returns, and HideDialogAndDispatchToPlatformAuthenticator() accesses ephemeral_state_ (freed memory).
The Chromium team confirmed this exact production destruction path in
commit 0ed0be437d706 (fix comment: “SetCurrentStep(Step::kClosed) can
synchronously destroy the hosting WebContents and therefore this.”).
The fix was applied only to CancelAuthenticatorRequest().
Alternative production paths:
- Tab management extension calling chrome.tabs.remove() on WebAuthn step transitions (observers are a public API used by extensions)
- User closing all tabs (Cmd+Q) while platform authenticator dispatches
- JavaScript setTimeout firing navigation/close during the unblock window between view_controller_.reset() and the observer loop
BISECT Introducing commit: the vulnerable pattern was present since HideDialogAndDispatchToPlatformAuthenticator was introduced. The fix opportunity was commit 0ed0be437d706 (2026-06-11) which fixed only CancelAuthenticatorRequest() but missed this function.
VERSION Chrome Version: 152.0.7947.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: 4de6ebb772c06) 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
- Click “Create Passkey” button
- ASAN crash: heap-use-after-free in AuthenticatorRequestDialogController:: HideDialogAndDispatchToPlatformAuthenticator
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 kPlatformAuthenticator by closing the hosting tab. This is the same observer-based destruction mechanism used in the Chromium team’s own browsertest for crbug.com/522566295 (commit 0ed0be437d706). The model’s SetStep() calls view_controller_.reset() for NONE-type steps, then iterates observers calling OnStepTransition(). The observer fires in that loop, closing the tab -> destroying WebContents -> destroying the controller.
-
StartFlow(): In the browser process, after setting started_=true, registers PocDestructionObserver on the model and directly calls HideDialogAndDispatchToPlatformAuthenticator() with a fake platform authenticator injected. In production, the user reaches this by: (1) navigator.credentials.create() -> WebAuthn dialog shows (2) User clicks “Create passkey” -> OnChromeProfileCreatePasskeyAccepted() (3) -> HideDialogAndDispatchToPlatformAuthenticator()
-
HideDialogAndDispatchToPlatformAuthenticator(): UNMODIFIED. The function calls SetCurrentStep(kPlatformAuthenticator), which triggers the observer-based destruction. After SetCurrentStep() returns, all subsequent member accesses operate on freed memory.
FOR CRASHES Type of crash: browser process
Crash state (see asan_output.txt for full report): ==6647==ERROR: AddressSanitizer: heap-use-after-free on address 0x61600043ecc0 READ of size 8 at 0x61600043ecc0 thread T0 #0 AuthenticatorRequestDialogController:: HideDialogAndDispatchToPlatformAuthenticator (ephemeral_state_.saved_authenticators_ access) #1 AuthenticatorRequestDialogController::StartFlow #2 ChromeAuthenticatorRequestDelegate::ShowUI #3 UiReadinessBarrier::TryToShowUI #4 ChromeAuthenticatorRequestDelegate::OnTransportAvailabilityEnumerated freed by thread T0 (key frames): #1 ChromeAuthenticatorRequestDelegate::~ChromeAuthenticatorRequestDelegate #3 AuthenticatorCommonImpl::RequestState::~RequestState #5 AuthenticatorImpl::~AuthenticatorImpl #6 DocumentAssociatedData::~DocumentAssociatedData #7 RenderFrameHostImpl::~RenderFrameHostImpl #12 WebContentsImpl::~WebContentsImpl #14 TabModel::~TabModel #17 TabStripModel::CloseWebContentsAt <- observer closes tab here #18 AuthenticatorRequestDialogModel::SetStep <- inside observer loop #19 AuthenticatorRequestDialogController:: HideDialogAndDispatchToPlatformAuthenticator MiraclePtr Status: NOT PROTECTED This crash is still exploitable with MiraclePtr.
RELATED BUGS
- Bug 522566295 (commit 0ed0be437d706): Fixed identical UAF in CancelAuthenticatorRequest() – same SetCurrentStep() -> view_controller_ .reset() destruction path. Fix missed HideDialogAndDispatchToPlatform- Authenticator() and StartPasskeyUpgradeRequest().
SUGGESTED FIX See attached fix.patch. Add WeakPtr guards after SetCurrentStep() in both HideDialogAndDispatchToPlatformAuthenticator() and StartPasskeyUpgradeRequest(), identical to the fix applied in CancelAuthenticatorRequest() by commit 0ed0be437d706.
CREDIT INFORMATION Reporter credit: h3ee