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.