CVE-2025-13633
Overview
Background
- Digital Credentials API
- a web API through which a site can request identity credentials. On desktop Chrome this can use a cross-device flow, where the browser shows a QR code and talks to a phone over BLE.
- `DigitalIdentityProviderDesktop`
- the browser-process object in
chrome/browser/digital_credentials/that drives the desktop request UI and receives the transaction result. It is owned byDigitalIdentityRequestImpl, which may destroy it when the request ends. - `TransactionImpl`
- the cross-device transaction in
content/browser/digital_credentials/cross_device_transaction_impl.cc. It checks the Bluetooth adapter and its permission state, and reports success or failure through a one-shotcallback_. - Re-entrant destruction
- a callback that runs synchronously, while its caller is still on the stack, frees the caller’s object. The caller then touches
thisafter it has been freed, which is a use-after-free.
Root Cause Analysis
According to the commit message, TransactionImpl’s constructor called its completion callback synchronously on its error paths. These are the “No BLE adapter” case (!adapter_->IsPresent() → SystemError::kNoBleSupport) and the PermissionStatus::kDenied cases (SystemError::kPermissionDenied). That callback reaches DigitalIdentityProviderDesktop, which ends the request through EndRequestWithError. This in turn lets DigitalIdentityRequestImpl destroy the provider while a provider method that started the transaction was still executing, so control returned into a freed object (heap-use-after-free). The violated invariant is that a constructor (or any setup call) must not synchronously run a completion callback whose effects can delete the object that created it.
The fix changes all three error sites to base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(...), so the callback runs on a later task after the creating call has unwound. Separately, the provider’s result handler (named DigitalIdentityProviderDesktop::OnFinished in the commit message; the hunk itself does not show the name) is restructured. It now computes a RequestStatusForMetrics status inside the std::visit lambdas, which capture &status instead of this, and calls EndRequestWithError(status) once as the last statement, with a comment that it “may delete this”.
TransactionImpl’s construction. That callback could destroy its caller (DigitalIdentityProviderDesktop) while the caller was still on the stack. The fix posts the callback asynchronously and makes EndRequestWithError the final action in OnFinished, so no code runs on this after a possible deletion.Attack Path
- Page initiates a digital credential request
A web page calls the Digital Credentials API, and the browser chooses the desktop cross-device flow, creating a
DigitalIdentityProviderDesktopand aTransactionImpl. - Hit a synchronous error path
The environment has no BLE adapter (
!adapter_->IsPresent()) or Bluetooth permission iskDenied, so pre-patchTransactionImplrunsstd::move(callback_).Run(base::unexpected(...))immediately. - Callback tears down the provider
The error reaches the provider’s result handler and
EndRequestWithError, andDigitalIdentityRequestImpldestroys theDigitalIdentityProviderDesktopwhile a provider method is still on the stack. - Use of freed provider
When the call stack unwinds back into the provider method, it uses the freed
this, causing a browser-process heap-use-after-free. Turning this into memory corruption beyond a crash is (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchchrome/browser/digital_credentials/digital_identity_provider_desktop.cc |
modified | |
ifcontent/browser/digital_credentials/cross_device_transaction_impl.cc |
modified |
Files Changed
chrome/browser/digital_credentials/digital_identity_provider_desktop.cccontent/browser/digital_credentials/cross_device_transaction_impl.cccontent/browser/digital_credentials/cross_device_transaction_impl_unittest.cc
Audit Directions
- Synchronous callbacks from constructors / `Start()` methodsLook across
content/browser/digital_credentials/, WebAuthn/FIDO (device/fido/), and similar request/transaction objects for early-error paths that callstd::move(callback_).Run(...)inline, where the callee may delete the owner. - Owner-deleting terminal callsFind methods like
EndRequestWithError,Finish, andCompletethat can destroythisor its owner, and check that nothing (lambdas capturingthis, member access, logging) runs after them. Prefer weak-pointer checks or making them the final statement. - `std::visit`/lambda captures of `this`Review visitor lambdas that capture
thisand call teardown functions inside aswitch. Execution continues after the lambda returns, which is easy to miss.
Patch
From 31b757cf79d9c69f9b5451c8892b0159ddbb0d35 Mon Sep 17 00:00:00 2001 From: Mohamed Amir Yosef <[email protected]> Date: Thu, 06 Nov 2025 11:05:00 -0800 Subject: [PATCH] [DC] Fix a heap-use-after-free crash in the digital credentials cross-device flow. The crash occurred because a DigitalIdentityProviderDesktop object could be destroyed while one of its methods was still on the stack. This happened when TransactionImpl's constructor synchronously invoked its completion callback on an error path (e.g., no Bluetooth adapter). This callback would cause DigitalIdentityRequestImpl to destroy the provider, leading to the UAF. The fix is to post the completion callback to the task runner in these error paths, making the call asynchronous and breaking the re-entrant destruction. Additionally, this CL: - Refactors DigitalIdentityProviderDesktop::OnFinished for robustness against similar re-entrancy issues. - Adds a unit test to verify the async callback behavior in cross_device_transaction_impl_unittest.cc. Fixed: 458082926 Test: Added content_unittests Change-Id: I463ed5e5c056d020b33e0f25d666ac0b925f1636 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7127620 Commit-Queue: Mohamed Amir Yosef <[email protected]> Reviewed-by: Adem Derinel <[email protected]> Cr-Commit-Position: refs/heads/main@{#1541335} --- diff --git a/chrome/browser/digital_credentials/digital_identity_provider_desktop.cc b/chrome/browser/digital_credentials/digital_identity_provider_desktop.cc index d84f860b..1a5ed042 100644 --- a/chrome/browser/digital_credentials/digital_identity_provider_desktop.cc +++ b/chrome/browser/digital_credentials/digital_identity_provider_desktop.cc @@ -235,33 +235,34 @@ return; } + RequestStatusForMetrics status; std::visit( - absl::Overload{ - [this](SystemError error) { - EndRequestWithError(RequestStatusForMetrics::kErrorOther); - }, - [this](ProtocolError error) { - EndRequestWithError(RequestStatusForMetrics::kErrorOther); - }, - [this](RemoteError error) { - switch (error) { - case RemoteError::kNoCredential: - EndRequestWithError( - RequestStatusForMetrics::kErrorNoCredential); - break; - case RemoteError::kUserCanceled: - EndRequestWithError( - RequestStatusForMetrics::kErrorUserDeclined); - break; - case RemoteError::kDeviceAborted: - EndRequestWithError(RequestStatusForMetrics::kErrorAborted); - break; - case RemoteError::kOther: - EndRequestWithError(RequestStatusForMetrics::kErrorOther); - break; - } - }}, + absl::Overload{[&status](SystemError error) { + status = RequestStatusForMetrics::kErrorOther; + }, + [&status](ProtocolError error) { + status = RequestStatusForMetrics::kErrorOther; + }, + [&status](RemoteError error) { + switch (error) { + case RemoteError::kNoCredential: + status = RequestStatusForMetrics::kErrorNoCredential; + break; + case RemoteError::kUserCanceled: + status = RequestStatusForMetrics::kErrorUserDeclined; + break; + case RemoteError::kDeviceAborted: + status = RequestStatusForMetrics::kErrorAborted; + break; + case RemoteError::kOther: + status = RequestStatusForMetrics::kErrorOther; + break; + } + }}, result.error()); + EndRequestWithError(status); + // NOTE: `EndRequestWithError` may delete `this`, so it must be the last + // thing called in this method. } DigitalIdentityMultiStepDialog* diff --git a/content/browser/digital_credentials/cross_device_transaction_impl.cc b/content/browser/digital_credentials/cross_device_transaction_impl.cc index a09a12e..6298771c 100644 --- a/content/browser/digital_credentials/cross_device_transaction_impl.cc +++ b/content/browser/digital_credentials/cross_device_transaction_impl.cc @@ -156,7 +156,10 @@ if (!adapter_->IsPresent()) { FIDO_LOG(EVENT) << "No BLE adapter."; - std::move(callback_).Run(base::unexpected(SystemError::kNoBleSupport)); + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, + base::BindOnce(std::move(callback_), + base::unexpected(SystemError::kNoBleSupport))); return; } @@ -171,8 +174,10 @@ return; case device::BluetoothAdapter::PermissionStatus::kDenied: FIDO_LOG(EVENT) << "BLE permission denied."; - std::move(callback_).Run( - base::unexpected(SystemError::kPermissionDenied)); + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, + base::BindOnce(std::move(callback_), + base::unexpected(SystemError::kPermissionDenied))); return; case device::BluetoothAdapter::PermissionStatus::kAllowed: break; @@ -197,8 +202,10 @@ if (status == device::BluetoothAdapter::PermissionStatus::kDenied) { FIDO_LOG(EVENT) << "BLE permission denied."; if (callback_) { - std::move(callback_).Run( - base::unexpected(SystemError::kPermissionDenied)); + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, + base::BindOnce(std::move(callback_), + base::unexpected(SystemError::kPermissionDenied))); } return; } diff --git a/content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc b/content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc index 27dadeb..d9d1c7a 100644 --- a/content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc +++ b/content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc @@ -111,6 +111,8 @@ std::unique_ptr<Transaction> transaction = Transaction::New( RequestInfo(request_type(), origin(), request()), qr_generator_key(), network_context_factory(), base::DoNothing(), callback_.GetCallback()); + // Callback should not have been called synchronously. + EXPECT_FALSE(callback_.IsReady()); EXPECT_THAT(callback_.Take(), ContainsError(SystemError::kNoBleSupport)); }
Regression Test / PoC
diff --git a/content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc b/content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc
index 27dadeb..d9d1c7a 100644
--- a/content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc
+++ b/content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc
@@ -111,6 +111,8 @@
std::unique_ptr<Transaction> transaction = Transaction::New(
RequestInfo(request_type(), origin(), request()), qr_generator_key(),
network_context_factory(), base::DoNothing(), callback_.GetCallback());
+ // Callback should not have been called synchronously.
+ EXPECT_FALSE(callback_.IsReady());
EXPECT_THAT(callback_.Take(), ContainsError(SystemError::kNoBleSupport));
}
Original Bug Report
mojo_js_in_process_fuzzer: Heap-use-after-free in DigitalIdentityProviderDesktop::Create
Detailed Report: https://clusterfuzz.com/testcase?key=5682786211921920
Fuzzing Engine: libFuzzer Fuzz Target: mojo_js_in_process_fuzzer Job Type: libfuzzer_chrome_asan Platform Id: linux
Crash Type: Heap-use-after-free READ 8 Crash Address: 0x78ac87048068 Crash State: DigitalIdentityProviderDesktop::Create content::DigitalIdentityRequestImpl::Create blink::mojom::DigitalIdentityRequestStubDispatch::AcceptWithResponder
Sanitizer: address (ASAN)
Recommended Security Severity: Critical
Crash Revision: https://clusterfuzz.com/revisions?job=libfuzzer_chrome_asan&revision=1533338
Reproducer Testcase: https://clusterfuzz.com/download?testcase_id=5682786211921920
Issue filed automatically.
See https://chromium.googlesource.com/chromium/src/+/master/testing/libfuzzer/reproducing.md for instructions on reproducing this bug locally.
************************* UNREPRODUCIBLE ************************* Note: This crash might not be reproducible with the provided testcase. That said, for the past 14 days, we’ve been seeing this crash frequently.
It may be possible to reproduce by trying the following options:
- Run testcase multiple times for a longer duration.
- Run fuzzing without testcase argument to hit the same crash signature.
If it still does not reproduce, try a speculative fix based on the crash stacktrace and verify if it works by looking at the crash statistics in the report. We will auto-close the bug if the crash is not seen for 14 days.