CVE-2026-11641
Overview
Background
- `BluetoothGattDiscovererWinrt`
- The WinRT-backed helper in
device/bluetooth/bluetooth_gatt_discoverer_winrt.ccthat walks a device’s GATT services and characteristics to build up its attribute list. - `callback_`
- A one-shot
base::OnceCallbackthat reports discovery success or failure exactly once and is consumed viastd::move(callback_).Run(...). - `RunCallbackIfDone`
- The method invoked after the service/characteristic iteration loop completes that runs
callback_a second time once all pending async results have returned. - Use-after-free (UAF)
- A memory-safety bug in which code dereferences or acts on an object after it has been destroyed, here triggered by invoking an already-consumed callback that tears down the discoverer.
Root Cause Analysis
While iterating over the GATT service and characteristic lists, OnGetGattServices and OnGetCharacteristics issue per-element WinRT calls such as GattDeviceService::OpenAsync() and base::win::PostAsyncResults. When one of these calls returns a failure hr, the code logs the error and reports failure by consuming the one-shot callback with std::move(callback_).Run(false), but it did not stop the loop afterward. Because control fell through, the loop continued and later reached RunCallbackIfDone, which invoked the already-moved callback_ a second time. Running a consumed base::OnceCallback — and running the completion path twice — violates the invariant that the discovery callback fires exactly once; the failure callback typically destroys or advances the owning discovery state, so the second invocation operates on freed state.
The fix adds an early return immediately after the failure std::move(callback_).Run(false) in both methods, so the callback is consumed and dispatched exactly once and no stale second dispatch occurs.
callback_ but let the enclosing loop continue into RunCallbackIfDone, double-invoking a moved-from callback; adding return after std::move(callback_).Run(false) guarantees the callback fires only once.Attack Path
- Start GATT discovery
An attacker-controlled or malicious Bluetooth peripheral is connected and the browser begins GATT service/characteristic discovery via
BluetoothGattDiscovererWinrt. - Force an async failure mid-iteration
During the loop in
OnGetGattServicesorOnGetCharacteristics, one WinRT call such asOpenAsync()orPostAsyncResultsis induced to return a failurehrwhile further list elements remain. - First callback consumes state
The failure branch runs
std::move(callback_).Run(false), reporting failure and tearing down or advancing the discovery state that owns the discoverer. - Fall-through to second invocation
Without the missing
return, control continues out of the loop intoRunCallbackIfDone, which invokes the now moved-fromcallback_again against freed state, producing the use-after-free.
Impact Assessment
Files Changed
device/bluetooth/bluetooth_gatt_discoverer_winrt.cc
Audit Directions
- Consumed one-shot callback without returnSearch loops that call
std::move(callback_).Run(...)on error but continue iterating or fall through to a shared completion helper likeRunCallbackIfDone. - Deferred completion after early failureAudit async iteration patterns where a per-element failure reports the result immediately yet a post-loop “if done” aggregator can also fire the same callback.
- Missing early exit in error branchesReview WinRT and other async backends for error paths that log and run a callback but omit the terminating
return, breaking the exactly-once invariant.
Patch
From 4bb7a1ea296c757b7ce5a0ae7bedfc4226f15df2 Mon Sep 17 00:00:00 2001
From: Matt Reynolds <mattreynolds@google.com>
Date: Mon, 01 Jun 2026 19:23:34 -0700
Subject: [PATCH] bluetooth: Avoid double invoking callback after failed winrt call
In OnGetGattServices and OnGetCharacteristics, if an API call fails while
iterating over a list then the callback is invoked twice: once to report
the failure and then again when RunCallbackIfDone is called after the
loop exits. This CL ensures these methods return after invoking the
callback the first time.
Bug: 517418936
Change-Id: I6ed22b9a7d2aede6c807054b88f427d8564cf649
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7883805
Commit-Queue: Matt Reynolds <mattreynolds@chromium.org>
Reviewed-by: Alvin Ji <alvinji@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1639876}
---
diff --git a/device/bluetooth/bluetooth_gatt_discoverer_winrt.cc b/device/bluetooth/bluetooth_gatt_discoverer_winrt.cc
index ae77850..5f2c354 100644
--- a/device/bluetooth/bluetooth_gatt_discoverer_winrt.cc
+++ b/device/bluetooth/bluetooth_gatt_discoverer_winrt.cc
@@ -302,6 +302,7 @@
BLUETOOTH_LOG(DEBUG) << "GattDeviceService::OpenAsync() failed: "
<< logging::SystemErrorCodeToString(hr);
std::move(callback_).Run(false);
+ return;
}
hr = base::win::PostAsyncResults(
@@ -423,6 +424,7 @@
BLUETOOTH_LOG(DEBUG) << "PostAsyncResults failed: "
<< logging::SystemErrorCodeToString(hr);
std::move(callback_).Run(false);
+ return;
}
}
Original Bug Report
Potential Use-After-Free in BluetoothGattDiscovererWinrt::OnGetCharacteristics
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential Use-After-Free vulnerability exists in the Windows Web Bluetooth implementation when handling descriptor retrieval failures. If base::win::PostAsyncResults fails during discovery, it synchronously executes a callback that destroys the discoverer object, yet the function continues to loop over freed member variables. This can lead to a browser-process crash or potential control flow hijack.
Affected files:
device/bluetooth/bluetooth_gatt_discoverer_winrt.ccdevice/bluetooth/bluetooth_gatt_discoverer_winrt.hdevice/bluetooth/bluetooth_device_winrt.cc
Estimated timestamp from git blame: 2018-08-06
Detailed Description
A potential Use-After-Free (UAF) vulnerability has been identified in BluetoothGattDiscovererWinrt::OnGetCharacteristics in device/bluetooth/bluetooth_gatt_discoverer_winrt.cc.
In OnGetCharacteristics, the code obtains a reference to a nested vector of characteristics stored inside the member service_to_characteristics_map_:
// device/bluetooth/bluetooth_gatt_discoverer_winrt.cc
378: auto& characteristics_list =
379: service_to_characteristics_map_[service_attribute_handle];
It then iterates over this list using a range-based for loop to retrieve descriptors for each characteristic:
386: for (const auto& gatt_characteristic : characteristics_list) {
...
416: hr = base::win::PostAsyncResults(
417: std::move(get_descriptors_op),
418: base::BindOnce(&BluetoothGattDiscovererWinrt::OnGetDescriptors,
419: weak_ptr_factory_.GetWeakPtr(),
420: characteristic_attribute_handle));
421:
422: if (FAILED(hr)) {
423: BLUETOOTH_LOG(DEBUG) << "PostAsyncResults failed: "
424: << logging::SystemErrorCodeToString(hr);
425: std::move(callback_).Run(false);
426: }
427: }
If base::win::PostAsyncResults fails (returning a failure HRESULT due to an unexpected disconnect or internal COM state error), the code invokes std::move(callback_).Run(false). Running this callback triggers a synchronous sequence of events that resolves to BluetoothDeviceWinrt::OnGattDiscoveryComplete, which deletes the owning BluetoothGattDiscovererWinrt instance via gatt_discoverer_.reset() (at device/bluetooth/bluetooth_device_winrt.cc:800).
Because there is no return or break statement after line 425, the range-based for loop continues to the next iteration. However, the destruction of the discoverer object has already deallocated the parent map (service_to_characteristics_map_), freeing the backing memory of the characteristics_list vector. Consequently, the loop’s compiler-generated iterators are left dangling, resulting in a Use-After-Free when they are incremented/dereferenced in subsequent iterations.
Note that our analysis is based on code inspection and static tracing; our automated tools have not run code or verified a dynamic proof-of-concept.
Potential Attack Steps / Triggering Conditions
An attacker trying to trigger this issue would potentially follow these steps:
- Persuade a user to connect to a custom, malicious Bluetooth LE peripheral via the Web Bluetooth chooser interface.
- During GATT discovery, when the browser attempts to retrieve descriptors for the characteristics of a service, the peripheral can abort or disconnect the connection or trigger an RPC failure.
- This connection drop causes
GetDescriptorsAsyncor the subsequentPostAsyncResultscall to fail (FAILED(hr)resolves to true). - The callback runs synchronously, destroying the discoverer, while the loop continues iterating on the freed heap memory.
Suggested Remediation
To resolve this issue, add a return; statement immediately after invoking the callback inside the FAILED(hr) block to stop further execution of the loop and safely exit the method:
if (FAILED(hr)) {
BLUETOOTH_LOG(DEBUG) << "PostAsyncResults failed: "
<< logging::SystemErrorCodeToString(hr);
std::move(callback_).Run(false);
return;
}
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.