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

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Digital Credentials
DescriptionUse after free in Digital Credentials
ComponentDigital Credentials
Sub-componentDC
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.
Tracker458082926
Fix commit31b757cf79d9 (chromium/src) +40/-30
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedChrome
Reported2025-11-05 (per vendor advisory)
Fix landed2025-11-06 (public git)
Disclosed2025-12-02
Patch gap26 days — fix public in source before the advisory

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 by DigitalIdentityRequestImpl, 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-shot callback_.
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 this after 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”.

Key insight
The core mistake was running a completion callback synchronously from inside 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

  1. 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 DigitalIdentityProviderDesktop and a TransactionImpl.
  2. Hit a synchronous error path The environment has no BLE adapter (!adapter_->IsPresent()) or Bluetooth permission is kDenied, so pre-patch TransactionImpl runs std::move(callback_).Run(base::unexpected(...)) immediately.
  3. Callback tears down the provider The error reaches the provider’s result handler and EndRequestWithError, and DigitalIdentityRequestImpl destroys the DigitalIdentityProviderDesktop while a provider method is still on the stack.
  4. 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

The bug is a heap use-after-free in the browser process, reachable when a site’s digital-credential request enters the desktop cross-device flow on a machine with no BLE adapter or with Bluetooth permission denied. These are host conditions, not something the attacker creates; the error path itself needs no compromised renderer. The diff does not show whether the API or its cross-device flow is enabled by default, or whether it requires a user gesture or other UI steps first, so web-content reachability in a stock build is not established by the patch. The vendor severity is High; any exploitation beyond a crash (for example, code execution in the browser process) is (inferred).

Changed Functions

FunctionChangeNotes
switch
chrome/browser/digital_credentials/digital_identity_provider_desktop.cc
modified
if
content/browser/digital_credentials/cross_device_transaction_impl.cc
modified

Files Changed

  • chrome/browser/digital_credentials/digital_identity_provider_desktop.cc
  • content/browser/digital_credentials/cross_device_transaction_impl.cc
  • content/browser/digital_credentials/cross_device_transaction_impl_unittest.cc

Audit Directions

  • Synchronous callbacks from constructors / `Start()` methods
    Look across content/browser/digital_credentials/, WebAuthn/FIDO (device/fido/), and similar request/transaction objects for early-error paths that call std::move(callback_).Run(...) inline, where the callee may delete the owner.
  • Owner-deleting terminal calls
    Find methods like EndRequestWithError, Finish, and Complete that can destroy this or its owner, and check that nothing (lambdas capturing this, 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 this and call teardown functions inside a switch. Execution continues after the lambda returns, which is easy to miss.
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));
 }
 
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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.


View on issue tracker