CVE-2026-11635
Overview
Background
- `IOBluetoothUserNotification`
- a macOS IOBluetooth callback registration whose target Objective-C listener is invoked when a device connects or disconnects.
- `dispatch_get_main_queue`
- the macOS main run loop queue onto which IOBluetooth notification callbacks are enqueued and later fired.
- `_ui_task_runner`
- the listener’s pointer to the C++ owner’s task runner, used to
PostTaskconnect/disconnect notifications back toBluetoothAdapterMac. - UAF (Use-After-Free)
- a bug where code dereferences an object after its backing memory has been freed, yielding memory corruption or hijackable control flow.
Root Cause Analysis
The macOS Bluetooth listener objects registered via IOBluetoothUserNotification outlive their C++ owners because notification callbacks are enqueued on the main run loop and can fire after the owner is torn down. When the C++ owner is destroyed and stopListening unregisters the notification, an already-enqueued callback such as deviceConnected:device: could still fire and dereference the now-freed _ui_task_runner (or, in the classic-device listener, a freed _device), violating the invariant that a callback never touches owner state after teardown.
The fix makes deviceConnected:device: bail out early when !_ui_task_runner, nulls out _ui_task_runner and _adapter in stopListening, and — following the FB13705522 workaround — keeps self alive briefly by capturing a __strong strongSelf inside a block dispatched to dispatch_get_main_queue. This guarantees the listener object is not deallocated until any pending main-run-loop notifications have drained, and those notifications become harmless no-ops because the state pointers are already null.
_ui_task_runner/_device so they become no-ops.Attack Path
- Establish a Bluetooth listener
The renderer/site drives the Web Bluetooth stack so a
deviceConnectedordeviceDisconnectIOBluetoothUserNotificationis registered against the macOS listener object. - Trigger a queued notification An attacker-influenced connect/disconnect event causes IOBluetooth to enqueue a callback on the main run loop while the callback still references live owner state.
- Destroy the owner
The C++ owner (
BluetoothAdapterMacor the classic device) is torn down andstopListeningunregisters the notification, but the enqueued callback remains pending and the listener would be freed. - Fire the stale callback
The queued
deviceConnected:device:callback runs on the main run loop and dereferences the freed_ui_task_runner(or freed_device), producing a use-after-free. - Exploit the freed access
The attacker leverages the dangling dereference and
PostTaskon freed memory to corrupt state or steer control flow in the browser process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifdevice/bluetooth/bluetooth_adapter_mac.mm |
modified |
Files Changed
device/bluetooth/bluetooth_adapter_mac.mmdevice/bluetooth/bluetooth_classic_device_mac.mm
Audit Directions
- Objective-C listeners over run-loop callbacksAudit any
IOBluetoothUserNotificationor delegate/listener whose callbacks are enqueued ondispatch_get_main_queueand can fire after the owning C++ object is destroyed. - Teardown without lifetime extensionReview
stopListening-style teardown paths that unregister notifications but do not keep the listener alive long enough for already-enqueued callbacks to drain. - Nullable-state no-op guardsCheck that every callback which reads owner state (like
_ui_task_runneror_device) null-checks that state so a post-teardown invocation degrades to a no-op rather than a freed-memory dereference.
Patch
From 75b964de97d2781d1f8329f2fb754862dc212c63 Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Fri, 29 May 2026 13:59:07 -0700
Subject: [PATCH] bluetooth: Prevent UAF in macOS Bluetooth listeners
Implement the FB13705522 workaround for BluetoothDevicesConnectListener
and BluetoothDeviceDisconnectListener on macOS. This ensures that these
listeners stay alive past any already-enqueued notifications on the main
run loop, preventing Use-After-Free (UAF) when the C++ owner is
destroyed.
Bug: 516987814
Change-Id: I542200f213f499370dc89a7cf68dde9fc6299285
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7884382
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1638719}
---
diff --git a/device/bluetooth/bluetooth_adapter_mac.mm b/device/bluetooth/bluetooth_adapter_mac.mm
index f300e8d3..b71deca 100644
--- a/device/bluetooth/bluetooth_adapter_mac.mm
+++ b/device/bluetooth/bluetooth_adapter_mac.mm
@@ -100,6 +100,9 @@
- (void)deviceConnected:(IOBluetoothUserNotification*)notification
device:(IOBluetoothDevice*)device {
+ if (!_ui_task_runner) {
+ return;
+ }
_ui_task_runner->PostTask(
FROM_HERE,
base::BindOnce(&device::BluetoothAdapterMac::OnConnectNotification,
@@ -108,6 +111,17 @@
- (void)stopListening {
[_connectNotification unregister];
+ _ui_task_runner = nullptr;
+ _adapter = nullptr;
+
+ // Keep self alive for a brief period to allow any already-enqueued
+ // notifications on the main run loop to fire safely (and become no-ops
+ // since _ui_task_runner is now null) rather than hitting a deallocated
+ // object. See FB13705522.
+ __strong auto strongSelf = self;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ (void)strongSelf;
+ });
}
@end
diff --git a/device/bluetooth/bluetooth_classic_device_mac.mm b/device/bluetooth/bluetooth_classic_device_mac.mm
index 659328c..99544514 100644
--- a/device/bluetooth/bluetooth_classic_device_mac.mm
+++ b/device/bluetooth/bluetooth_classic_device_mac.mm
@@ -87,6 +87,15 @@
// destruction will see a null |_device| and become a no-op instead of
// dereferencing a freed object.
_device = nullptr;
+
+ // Keep self alive for a brief period to allow any already-enqueued
+ // notifications on the main run loop to fire safely (and become no-ops
+ // since _device is now null) rather than hitting a deallocated object.
+ // See FB13705522.
+ __strong auto strongSelf = self;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ (void)strongSelf;
+ });
}
@end
Original Bug Report
Potential UAF in BluetoothDevicesConnectListener on macOS due to uncancelled enqueued notifications
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 (UAF) vulnerability exists in the macOS browser process because BluetoothDevicesConnectListener does not safely prolong its lifetime when stopListening is called. Due to a known macOS behavior where unregistering does not cancel already-enqueued run loop events, a late notification can execute on a deallocated listener. This could potentially allow an attacker to trigger code execution in the unsandboxed browser process.
Affected files:
device/bluetooth/bluetooth_adapter_mac.mmdevice/bluetooth/bluetooth_adapter_mac.h
Estimated timestamp from git blame: 2024-06-07
Technical Details
In device/bluetooth/bluetooth_adapter_mac.mm, the helper class BluetoothDevicesConnectListener registers itself with the macOS IOBluetooth framework to receive device connection notifications. It is owned solely via a strong reference (connect_listener_) by BluetoothAdapterMac.
As documented in Apple Feedback report FB13705522 (and handled in sibling classes like BluetoothSocketMac), calling unregister on an IOBluetoothUserNotification successfully stops future notifications from being registered but does not cancel notifications that the system has already enqueued on the main run loop.
When BluetoothAdapterMac is destroyed, its destructor calls [connect_listener_ stopListening] and sets connect_listener_ = nil. This synchronously deallocates the BluetoothDevicesConnectListener instance. If a connection notification was already enqueued on the main run loop, the loop will subsequently attempt to deliver it to the deallocated listener address via objc_msgSend, causing a Use-After-Free (UAF) in the unsandboxed browser process.
Potential Trigger Path
Note: The following steps are theoretical and represent a potential attack vector, as our tooling cannot currently execute or run proof-of-concept code.
- Listener Registration: A web page in a sandboxed renderer requests a Web Bluetooth operation (e.g.,
navigator.bluetooth.requestDevice), which prompts the browser process to initializeBluetoothAdapterMac. This instantiatesBluetoothDevicesConnectListener, registering it for connection notifications. - Notification Enqueue: A proximate Bluetooth Classic device initiates a connection, causing
IOBluetoothto queue a notification on the main run loop targeting the listener. - Adapter Destruction: The renderer frame is closed or navigated, destroying
WebBluetoothServiceImpl. This releases the last reference to the ref-countedBluetoothAdapterMacinstance, synchronously executing its destructor. - Listener Deallocation:
~BluetoothAdapterMaccalls[connect_listener_ stopListening]and sets the pointer tonil, immediately reclaiming the listener’s memory. - Use-After-Free: When the main run loop drains and processes the pending notification, it attempts to dispatch the event to the deallocated listener, resulting in a UAF.
Sibling Workaround Pattern
Other macOS Bluetooth classes in Chrome (such as BluetoothSocketMac in device/bluetooth/bluetooth_socket_mac.mm) proactively mitigate this OS-level limitation by clearing their back-pointers and asynchronously dispatching a block to the main queue that holds a strong reference to self to safely outlive any enqueued messages:
- (void)stopListening {
[_rfcommNewChannelNotification unregister];
_socket = nullptr;
__strong auto strongSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
(void)strongSelf;
});
}
Proposed Fix
Modify BluetoothDevicesConnectListener::stopListening in device/bluetooth/bluetooth_adapter_mac.mm to match this safety pattern:
- (void)stopListening {
[_connectNotification unregister];
_adapter.reset();
__strong auto strongSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
(void)strongSelf;
});
}
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.