rfcomm.sys SessionDisconnect connect retry storm DoS
RFCOMM is the Bluetooth serial-cable replacement protocol (derived from
TS 07.10), running over an L2CAP link to a peer device and multiplexing
multiple RFCOMM channels — each addressing a virtual serial port — over a
single L2CAP session. An RFCOMM session in the Windows stack is the state
object for one such multiplex: it carries the L2CAP channel handle, the
per-channel table, and a pending-connect queue
(session->PendingConnects) holding in-flight ChannelConnect operations
that have not yet completed. Each ChannelConnect opens an RFCOMM channel
to a peer by posting an L2CAP BRB (Bluetooth Request Block, the
Bluetooth-stack analogue of a Winsock IOCTL) and waiting for completion.
When the session is told to disconnect — peer hung up, underlying L2CAP
dropped, or we locally decided to tear it down — SessionDisconnect runs,
and its job is to drain PendingConnects and dispose of each entry
consistently with the teardown. The invariant it has to hold is
idempotency: a teardown routine must not, in the act of draining its
pending work, re-arm the work it is supposed to be failing. The
vulnerable build violates that on the locally-initiated path:
// SessionDisconnect - vulnerable build
// session->PendingConnects : LIST_HEAD at session->PendingConnects /* +0x60 */
// queued entries : LIST_ENTRY at addrFile->PendingConnectLink /* +0x118 */
// status : NTSTATUS handed in from BrbConnectCompletion
// remoteIndicated : byte flag from arg3, loaded then ignored
ListDrainLocked(&local, &session->PendingConnects); /* +0x60 */
while ((link = RemoveHeadList(&local)) != &local) {
addrFile = CONTAINING_RECORD(link, ADDR_FILE, PendingConnectLink); /* link - 0x118 */
ChannelConnect(addrFile); /* (1) re-submit the L2CAP BRB */
TdiCompleteConnect(addrFile, status); /* (2) complete the old request */
RefObj_ReleaseEx(&addrFile->RefCount, /* (3) RefCount +0x18, TAG_CONN, line 0x43d */
TAG_CONN, RFCOMM_SESSION_C, 0x43d);
}
For every drained pending-connect entry, the function calls
ChannelConnect() again. The intent reads reasonably in isolation:
when the disconnect was remotely indicated (the peer hung up),
re-submitting the connect is appropriate, because L2CAP may have just
transiently reconnected and the pending RFCOMM channels might still
complete. The problem is that the same code path runs when the disconnect
was locally initiated — an L2CAP BRB just completed with failure, the
session is being torn down, and the right thing to do with every pending
connect is fail it. The remoteIndicated flag is loaded but never
tested; every drained entry gets re-submitted regardless of why the
disconnect fired.
So the drain that is supposed to stop pending connects instead re-arms
them, and the cycle is self-sustaining as soon as a re-submitted connect
can fail: disconnect drains PendingConnects, ChannelConnect re-submit
fires, the connect fails because L2CAP is broken, BRB completion with
failure calls SessionDisconnect again, drain again, re-submit again.
Each iteration allocates fresh queue state, fresh BRB context, and fresh
kernel pool; each iteration’s failure fires the next. There is no
backoff, no maximum-retry counter, and no distinction between a transient
disconnect worth retrying (remote peer hung up, link might come back) and
a final one (we are tearing the session down). The kernel is now talking
to itself.
What makes this remotely drivable rather than a slow local retry is the
Bluetooth attacker model. RFCOMM connection requests are signalled at the
protocol layer before any pairing is required for the higher-layer
service, so any peer in radio range can flood the victim with RFCOMM
connect requests across multiple L2CAP channels without ever completing a
pairing handshake. The victim’s rfcomm.sys accepts each request and
queues it on session->PendingConnects. The attacker then either stops
sending or actively misbehaves at the L2CAP/RFCOMM layer so the BRBs
complete with failure; BrbConnectCompletion calls SessionDisconnect
with remoteIndicated = FALSE — the L2CAP BRB we posted just failed —
and the vulnerable path drains the queue and re-submits every entry;
each retry fails and re-enters SessionDisconnect.
The cycle runs independent of the attacker from that point on, and kernel
pool and CPU are consumed until the system bugchecks through pool
exhaustion or becomes unusable through CPU saturation. On a laptop with
Bluetooth enabled in a public space, this is reachable with a directional
antenna at roughly class-2 radio range (~10 m, more for class-1), with no
prior interaction between the attacker and the victim.
The patch
// SessionDisconnect - patched
// the byte that was ignored is now the branch
ListDrainLocked(&local, &session->PendingConnects); /* +0x60 */
while ((link = RemoveHeadList(&local)) != &local) {
addrFile = CONTAINING_RECORD(link, ADDR_FILE, PendingConnectLink); /* link - 0x118 */
if (remoteIndicated) { /* arg3 != 0: retry only on remote-indicated disconnect */
ChannelConnect(addrFile); /* the link may have transiently dropped */
TdiCompleteConnect(addrFile, status);
}
RefObj_ReleaseEx(&addrFile->RefCount, /* RefCount +0x18, TAG_CONN, line 0x43d */
TAG_CONN, RFCOMM_SESSION_C, 0x43d);
}
The remoteIndicated parameter that was previously loaded and discarded
is now the branch condition. On a remote-indicated disconnect the
function still retries, because the link may have transiently dropped;
on a locally-initiated disconnect it only releases the reference, failing
the pending connect cleanly and breaking the cycle. Teardown must be
idempotent — a final disconnect fails its pending work instead of
re-arming it. Every drain loop in a protocol-teardown path needs the
question “can this iteration’s resubmission re-enter this function?”, and
the unbounded loop is the vulnerable code path — the attack and the
fix point at the same line.
Attack path
sequenceDiagram
participant P as remote Bluetooth peer
participant S as rfcomm session
participant Q as PendingConnects queue
P->>S: flood RFCOMM connect requests, no pairing
S->>Q: enqueue each connect, L2CAP BRBs complete with failure
S->>S: locally-initiated SessionDisconnect, drains PendingConnects
S->>S: vulnerable path re-submits ChannelConnect for every entry, each retry fails and re-enters SessionDisconnect
Note over S: unbounded retry loop, kernel pool and CPU exhausted to DoS
The trigger is proximity only. No pairing, no authentication, no pre-existing relationship between attacker and victim beyond radio range.