CVE-2026-79091
Overview
Background
- `BluetoothL2capChannelMac` / `BluetoothRfcommChannelMac`
- C++ wrappers around a macOS
IOBluetoothL2CAPChannel/IOBluetoothRFCOMMChannelthat own the native channel and its Objective-C delegate. - Delegate self-retain (`_strongSelf`)
- a mechanism where the channel’s delegate keeps a strong reference to itself so it survives until the native framework finishes delivering enqueued callbacks.
- `closeChannel`
- an
IOBluetoothmethod that tears down a channel and can trigger delegate callbacks (e.g. a close notification) on the main run loop. - `is_opened_`
- the new boolean added by the patch that records whether the channel actually reached a successful open.
Root Cause Analysis
When an outgoing Bluetooth connection failed, the wrapper was destroyed while the open was still pending or had failed. Before the patch, the destructor unconditionally called [channel_ closeChannel], and the delegate’s _strongSelf retain was only armed on a successful open — so on the failure path the delegate had no self-retain to keep it alive. The removed comment confirms the delegate is meant to be kept alive across one run-loop turn precisely because _strongSelf is not armed on a failed open, but calling closeChannel on a never-opened channel could still drive delegate callbacks against an object that was being torn down, producing a use-after-free of the delegate.
The fix adds is_opened_ (set true only when the open status is kIOReturnSuccess, cleared on close) and gates the destructor’s closeChannel call on it, so a channel that never opened is never closed and cannot emit spurious callbacks into freed state. The commit also reverts to arming _strongSelf only on successful open, restoring the invariant that “opened” and “self-retained” agree.
IOBluetooth to deliver delegate callbacks after the wrapper/delegate lifetime assumptions no longer held; gating closeChannel on the new is_opened_ flag ensures teardown only happens for channels whose open (and matching _strongSelf retain) actually occurred.Attack Path
- Initiate a failing outgoing connection A page or app-level path that opens an L2CAP/RFCOMM socket to a device triggers an outgoing connection that does not complete successfully.
- Wrapper destroyed mid-open
The
BluetoothL2capChannelMac/BluetoothRfcommChannelMacis torn down while the open is pending or failed, so_strongSelfwas never armed andis_opened_(conceptually) is false. - Unconditional `closeChannel`
The pre-patch destructor calls
[channel_ closeChannel]on the never-opened channel, prompting the native framework to dispatch delegate callbacks. - Use-after-free Those callbacks execute against the delegate whose lifetime was not extended, dereferencing freed memory (inferred as to the exact freed object and callback).
Impact Assessment
(inferred) and not demonstrated by the patch. The advisory classifies this as a use-after-free in Bluetooth; no exploited-in-the-wild status is stated.Changed Functions
| Function | Change | Notes |
|---|---|---|
delegate_device/bluetooth/bluetooth_l2cap_channel_mac.mm |
modified | |
ifdevice/bluetooth/bluetooth_l2cap_channel_mac.mm |
modified | |
delegate_device/bluetooth/bluetooth_rfcomm_channel_mac.mm |
modified | |
ifdevice/bluetooth/bluetooth_rfcomm_channel_mac.mm |
modified |
Files Changed
device/bluetooth/bluetooth_rfcomm_channel_mac.hdevice/bluetooth/bluetooth_l2cap_channel_mac.hdevice/bluetooth/bluetooth_rfcomm_channel_mac.mmdevice/bluetooth/bluetooth_l2cap_channel_mac.mm
Audit Directions
- Destructor teardown of not-fully-initialized objectsLook for destructors that unconditionally call teardown/close APIs on resources that may never have reached a valid/open state.
- Asymmetric lifetime armingAudit patterns where a self-retain or ownership hand-off is armed only on the success path but the cleanup path assumes it was always armed.
- Framework callbacks during teardownReview Objective-C/IOKit delegates whose native
close/disconnectcalls can synchronously or asynchronously re-enter delegate methods after the owner has begun destruction.
Patch
From 725513ffa178c24467ee4b1bcb78d29784b5c68b Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Wed, 15 Jul 2026 12:03:01 -0700
Subject: [PATCH] [macOS][Bluetooth] Only close channel in destructor if opened
When an outgoing connection fails, the delegate's self-retain
mechanism was bypassed if we didn't arm it, but if we did arm it
unconditionally, we would leak the delegate because closeChannel
was always called in the destructor.
This CL updates the destructors of BluetoothL2capChannelMac and
BluetoothRfcommChannelMac to only call closeChannel if the channel
was successfully opened. We also revert to only arming _strongSelf
on successful open.
TAG=agy
CONV=739fb0cc-cf81-4f3d-a995-a113aa8120ff
Bug: 523557855
Change-Id: I410c8daa5eb671867f5cf92adf3e447a057d7468
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7947649
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1662771}
---
diff --git a/device/bluetooth/bluetooth_l2cap_channel_mac.h b/device/bluetooth/bluetooth_l2cap_channel_mac.h
index f42d4c6..d0280b3 100644
--- a/device/bluetooth/bluetooth_l2cap_channel_mac.h
+++ b/device/bluetooth/bluetooth_l2cap_channel_mac.h
@@ -62,6 +62,9 @@
// The delegate for the native channel.
BluetoothL2capChannelDelegate* __strong delegate_;
+
+ // True if the channel is currently open.
+ bool is_opened_;
};
} // namespace device
diff --git a/device/bluetooth/bluetooth_l2cap_channel_mac.mm b/device/bluetooth/bluetooth_l2cap_channel_mac.mm
index 58436795..463e83bb 100644
--- a/device/bluetooth/bluetooth_l2cap_channel_mac.mm
+++ b/device/bluetooth/bluetooth_l2cap_channel_mac.mm
@@ -116,8 +116,7 @@
BluetoothL2capChannelMac::BluetoothL2capChannelMac(
BluetoothSocketMac* socket,
IOBluetoothL2CAPChannel* channel)
- : channel_(channel),
- delegate_(nil) {
+ : channel_(channel), delegate_(nil), is_opened_(channel != nil) {
SetSocket(socket);
}
@@ -128,7 +127,9 @@
// for events that occur after our destruction.
[delegate_ resetOwner];
[channel_ setDelegate:nil];
- [channel_ closeChannel];
+ if (is_opened_) {
+ [channel_ closeChannel];
+ }
// `delegate_`'s self-retain (`_strongSelf`) is only armed after a successful
// open. If we are destroyed during a pending or failed open, keep the
// delegate alive across one main-run-loop turn so any already-enqueued
@@ -203,6 +204,10 @@
DCHECK_EQ(status, kIOReturnSuccess);
}
+ if (status == kIOReturnSuccess) {
+ is_opened_ = true;
+ }
+
base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(&BluetoothSocketMac::OnChannelOpenComplete,
base::WrapRefCounted(socket()),
@@ -215,6 +220,7 @@
IOBluetoothL2CAPChannel* channel) {
DCHECK_EQ(channel_, channel);
channel_ = nil;
+ is_opened_ = false;
[delegate_ resetOwner];
delegate_ = nil;
socket()->OnChannelClosed();
diff --git a/device/bluetooth/bluetooth_rfcomm_channel_mac.h b/device/bluetooth/bluetooth_rfcomm_channel_mac.h
index 3c9fbf5b..15b83d9 100644
--- a/device/bluetooth/bluetooth_rfcomm_channel_mac.h
+++ b/device/bluetooth/bluetooth_rfcomm_channel_mac.h
@@ -63,6 +63,9 @@
// The delegate for the native channel.
BluetoothRfcommChannelDelegate* __strong delegate_;
+
+ // True if the channel is currently open.
+ bool is_opened_;
};
} // namespace device
diff --git a/device/bluetooth/bluetooth_rfcomm_channel_mac.mm b/device/bluetooth/bluetooth_rfcomm_channel_mac.mm
index 2354bad..7b893c78a 100644
--- a/device/bluetooth/bluetooth_rfcomm_channel_mac.mm
+++ b/device/bluetooth/bluetooth_rfcomm_channel_mac.mm
@@ -116,8 +116,7 @@
BluetoothRfcommChannelMac::BluetoothRfcommChannelMac(
BluetoothSocketMac* socket,
IOBluetoothRFCOMMChannel* channel)
- : channel_(channel),
- delegate_(nil) {
+ : channel_(channel), delegate_(nil), is_opened_(channel != nil) {
SetSocket(socket);
}
@@ -128,7 +127,9 @@
// for events that occur after our destruction.
[delegate_ resetOwner];
[channel_ setDelegate:nil];
- [channel_ closeChannel];
+ if (is_opened_) {
+ [channel_ closeChannel];
+ }
// `delegate_`'s self-retain (`_strongSelf`) is only armed after a successful
// open. If we are destroyed during a pending or failed open, keep the
// delegate alive across one main-run-loop turn so any already-enqueued
@@ -203,6 +204,10 @@
DCHECK_EQ(status, kIOReturnSuccess);
}
+ if (status == kIOReturnSuccess) {
+ is_opened_ = true;
+ }
+
base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(&BluetoothSocketMac::OnChannelOpenComplete,
base::WrapRefCounted(socket()),
@@ -215,6 +220,7 @@
IOBluetoothRFCOMMChannel* channel) {
DCHECK_EQ(channel_, channel);
channel_ = nil;
+ is_opened_ = false;
[delegate_ resetOwner];
delegate_ = nil;
socket()->OnChannelClosed();
Original Bug Report
Potential Use-After-Free in macOS Bluetooth Delegates on failed connections
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 macOS implementation of Bluetooth RFCOMM and L2CAP channels. When an outgoing connection fails, the delegate’s self-retain mechanism is bypassed, and a short-term keep-alive in the destructor is insufficient to protect against late-arriving asynchronous callbacks from the OS. An attacker could intentionally delay over-the-air protocol responses to reliably trigger the UAF, potentially leading to remote code execution.
Affected files:
device/bluetooth/bluetooth_rfcomm_channel_mac.mmdevice/bluetooth/bluetooth_l2cap_channel_mac.mm
Estimated timestamp from git blame: Unknown (Google3 checkout)
Overview
A potential Use-After-Free (UAF) vulnerability exists in the macOS implementation of Bluetooth RFCOMM and L2CAP channels, specifically within BluetoothRfcommChannelDelegate and BluetoothL2capChannelDelegate (device/bluetooth/bluetooth_rfcomm_channel_mac.mm and bluetooth_l2cap_channel_mac.mm).
To mitigate a known issue where macOS’s IOBluetooth framework may fire callbacks even after a delegate is cleared (Apple Feedback FB13705522), Chromium implements a self-retain mechanism (_strongSelf) during successful connections and a fallback short-term keep-alive in the C++ owner’s destructor. However, these protections are incomplete on the failed outgoing connection path.
Root Cause Analysis
In rfcommChannelOpenComplete:status: (and the equivalent L2CAP method), the _strongSelf self-retain is only armed if the connection is successful:
- (void)rfcommChannelOpenComplete:(IOBluetoothRFCOMMChannel*)rfcommChannel
status:(IOReturn)error {
CHECK(_rfcommChannel);
if (error == kIOReturnSuccess) {
_strongSelf = self; // Only armed on SUCCESS
}
// ...
}
When an outgoing connection fails (error != kIOReturnSuccess), _strongSelf remains nil. The delegate’s lifetime is then tied solely to its C++ owner, BluetoothRfcommChannelMac.
When the C++ owner is destroyed (typically immediately following the connection failure), its destructor attempts to protect the delegate by capturing it in a dispatch_async block, keeping it alive for exactly one main-run-loop turn:
BluetoothRfcommChannelMac::~BluetoothRfcommChannelMac() {
[delegate_ resetOwner];
[channel_ setDelegate:nil];
[channel_ closeChannel];
// One-turn keep-alive
BluetoothRfcommChannelDelegate* __strong delegate = delegate_;
dispatch_async(dispatch_get_main_queue(), ^{ (void)delegate; });
}
Because [channel_ closeChannel] initiates an asynchronous, over-the-air protocol teardown (e.g., an RFCOMM DISC command), the resulting rfcommChannelClosed: callback from the OS can arrive much later. If an attacker intentionally delays the protocol-level response from their malicious Bluetooth device, the one-turn keep-alive will expire, and the delegate will be deallocated. When the OS eventually receives the delayed response and fires the terminal callback on the dangling pointer, an Objective-C message is sent to freed memory.
Suggested Exploit Steps
Note: These are potential steps as our tooling agent does not have the ability to run a live Proof of Concept.
- An attacker sets up a malicious physical Bluetooth device and places it in proximity to the victim.
- The victim visits an attacker-controlled website, which uses the Web Serial or Web Bluetooth API to request a connection to the malicious device.
- The malicious device accepts the initial request but intentionally rejects the RFCOMM/L2CAP protocol connection, returning an error.
- The
rfcommChannelOpenComplete:status:callback fires with an error. The_strongSelfretain is skipped, and the C++ channel object is destroyed. - The C++ destructor calls
closeChannel(sending a disconnect command over the air) and posts the 1-turn keep-alive block. - The malicious device deliberately delays its acknowledgment of the disconnect command.
- The main thread’s keep-alive block finishes executing, and the
BluetoothRfcommChannelDelegateobject is deallocated by ARC. - The attacker sprays the Browser process heap via standard Web API IPCs, replacing the freed delegate memory with controlled data (including a fake
isapointer). - The malicious device finally replies to the disconnect command.
- The
IOBluetoothframework (due to bug FB13705522) fires therfcommChannelClosed:callback on the freed delegate, hijacking control flow.
Impact
Since this occurs in the highly-privileged Browser process where macOS Bluetooth services are handled, this UAF can lead to a complete Sandbox Escape and Remote Code Execution (RCE). As Objective-C objects do not typically benefit from MiraclePtr protections when passed to external frameworks, the exploitability of this issue is considered high.
Suggested Fix
Unconditionally arm the _strongSelf self-reference in the openComplete callbacks, regardless of the connection status.
- (void)rfcommChannelOpenComplete:(IOBluetoothRFCOMMChannel*)rfcommChannel
status:(IOReturn)error {
CHECK(_rfcommChannel);
// Keep the delegate alive until rfcommChannelClosed.
_strongSelf = self;
// ...
}
Because the OS guarantees that rfcommChannelClosed: (or the L2CAP equivalent) will eventually fire, it is safe to arm the retain cycle here; the terminal callback will accurately clear _strongSelf, breaking the cycle and allowing safe deallocation.
Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb
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.