β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-5764

l2cap_start() increments lc_pending before the fallible hci_acl_send(): any failure permanently wedges the channel TX path and disables graceful disconnect, and the phantom count compounds on every retry

Summary

The increment precedes the fallible call: on failure hci_acl_send frees the mbuf and creates NO PDU - but lc_pending is only ever decremented for a PDU found on hl_txq, so the counter is permanently inflated and every later send burns another blackholed SDU (each returns 0, nothing transmitted), the graceful disconnect handshake never starts (stuck in L2CAP_OPEN until force-close), and the SHUTDOWN flush is unreachable. Rig on verbatim production code: W1 first send ENETDOWN with lc_pending==1 and hl_txq EMPTY; W2 64 further sends all return 0 piling SDUs; W3 disconnect dead; W4 compounding; healthy control proves the round-trip works. count-only-on-success fix validated (wedge_proven 1->0, control unregressed). One ENOMEM/ENETDOWN failure under an established socket - local or link-teardown-window remote.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5764 Β· 16 files
FileTypeDescriptionSize
README.md β€” 4.0 KB ↓ raw
VERDICT.md β€” 7.7 KB ↓ raw
harness.c β€” 14.5 KB view raw
stub.c β€” 4.4 KB view raw
l2uh.h β€” 507 B view raw
Makefile β€” 677 B ↓ download
build.sh β€” 255 B view raw
run.sh β€” 230 B view raw
fix.diff β€” 342 B view raw
build.log β€” 6.0 KB view raw
setup.log β€” 639 B view raw
run.log β€” 3.5 KB view raw
fix_run.log β€” 3.2 KB view raw
env.txt β€” 375 B view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 5.1 KB view raw

DF-5764 β€” l2cap_start() unbalanced lc_pending on hci_acl_send() failure

sys/netbt/l2cap_lower.c:203-204 (pass 2)

What

l2cap_start() raises chan->lc_pending before the fallible hci_acl_send() call:

IF_DEQUEUE(&chan->lc_txq, m);           /* l2cap_lower.c:195 */
...
chan->lc_pending++;             /* :203 */
return hci_acl_send(m, chan->lc_link, chan);    /* :204 β€” can FAIL */

hci_acl_send() has two failure returns that free m and create no l2cap_pdu: ENETDOWN (link->hl_state == HCI_LINK_CLOSED, hci_link.c:546-549) and ENOMEM (zalloc/m_split, hci_link.c:551-553, 568-570, 592-599). lc_pending is only ever decremented for a PDU found on link->hl_txq with lp_chan == chan (hci_acl_complete, hci_link.c:730-742) β€” a PDU that does not exist. The counter is permanently inflated for the channel's lifetime, and the leak COMPOUNDS (every later l2cap_start() caller β€” l2cap_send(), hci_acl_complete(), hci_acl_start() streaming β€” burns another blackholed SDU and raises it again).

Consequences, each gated on lc_pending == 0:

  1. l2cap_send() (l2cap_upper.c:398) never calls l2cap_start() again: every further send(2) silently succeeds into lc_txq and nothing is ever transmitted β€” permanent TX blackhole for the connection.
  2. l2cap_disconnect(chan, linger) (l2cap_upper.c:244) never starts the graceful L2CAP disconnect handshake β€” the channel can only die by force-close (close(2) β†’ detach).
  3. l2cap_start()'s own L2CAP_SHUTDOWN flush path (l2cap_lower.c:173-179) is unreachable.

Evidence (findings/poc/DF-5764/)

KLD rig (l2pw_harness.ko) compiling the VERBATIM production l2cap_upper.c, l2cap_lower.c (file under audit), l2cap_misc.c, l2cap_signal.c and hci_link.c (so the ENETDOWN failure branch under test is production code) against minimal stubs. Channel/link state is exactly what production builds: l2cap_attach() + l2cap_cid_alloc() + one hci_acl_open() reference + L2CAP_OPEN on a HCI_LINK_CLOSED link (what hci_link_alloc() returns β€” and the state left behind by the DF-5737 teardown race window; an ENOMEM under memory pressure produces the identical imbalance).

Build & run INSIDE the guest as root:

cd /root/l2pw && ./build.sh && ./run.sh

EXPECTED (unfixed): hw.l2pw_wedge_proven = 1, hw.l2pw_ctl_ok = 1, no panic. Round prints go to the console (run.sh dumps them):

W1 send#1 ret=50 (ENETDOWN=50) lc_pending=1 link_txq_empty=1 lc_txq_len=0
W1 PASS: pending==1 but NO pdu exists on the link that could ever decrement it
W2 64 more sends: ret==0 all, lc_txq_len=64 (blackholed) lc_pending=1
W3 l2cap_disconnect(linger) ret=0 state=5 (OPEN=5) reqs_empty=1   <- handshake dead
W4 l2cap_start ret=50 lc_txq_len=63 lc_pending=2                   <- leak compounds

The CONTROL round (link OPEN + production hci_acl_complete()) proves the healthy lc_pending 1β†’0 round-trip works and that the SHUTDOWN disconnect fires exactly when pending reaches 0 β€” i.e. the wedge is caused precisely by the failed-send increment.

Fix

fix.diff (validated): only count the PDU when one was actually created β€”

-   chan->lc_pending++;
-   return hci_acl_send(m, chan->lc_link, chan);
+   err = hci_acl_send(m, chan->lc_link, chan);
+   if (err == 0)
+       chan->lc_pending++;
+   return err;

Apply to /usr/src inside the guest, rebuild the module (./build.sh re-copies the verbatim source), re-run: wedge_proven flips to 0 (failed send leaves lc_pending == 0; further sends get honest ENETDOWN per call with an empty queue; l2cap_disconnect() closes the channel), ctl_ok stays 1. See fix_run.log.

Impact

Local/remote DoS of an established L2CAP connection's transmit path (silent data blackhole + stuck graceful-disconnect), triggerable by any hci_acl_send() failure while OPEN β€” ENOMEM under memory pressure, or ENETDOWN in the link-teardown window (co-fires with DF-5737's race). No memory corruption; stuck mbufs are bounded by the socket send buffer (l2cap_ssend appends the original to so_snd before handing the copy down). Severity Low.

VERDICT.md
↓ download raw

DF-5764 β€” VERDICT

Status: reproduced (KLD rig, production code compiled verbatim; fix validated) Impact: dos (connection-level TX blackhole + graceful-disconnect disabled) Confidence: certain (defect proven deterministically through shipped code paths; fix discriminates baseline vs patched)

Root cause (path:line)

sys/netbt/l2cap_lower.c:195-204 β€” l2cap_start():

IF_DEQUEUE(&chan->lc_txq, m);           /* :195  m leaves the channel queue */
KKASSERT(chan->lc_link != NULL);        /* :197 */
KKASSERT(m != NULL);                    /* :198 */
chan->lc_pending++;                     /* :203  raised BEFORE the fallible call */
return hci_acl_send(m, chan->lc_link, chan);   /* :204  ENETDOWN / ENOMEM */

hci_acl_send() failure returns free m and create no l2cap_pdu:

  • ENETDOWN β€” link->hl_state == HCI_LINK_CLOSED β†’ m_freem(m); return ENETDOWN; (sys/netbt/hci_link.c:546-549)
  • ENOMEM β€” zalloc(l2cap_pdu_pool) NULL (hci_link.c:551-553) or m_split() NULL (hci_link.c:566-570) β†’ nomem: frees everything (hci_link.c:592-599)

lc_pending is only decremented in hci_acl_complete() for a PDU found on link->hl_txq with lp_chan == chan (hci_link.c:730-742). After a failed send no such PDU exists, so the counter is permanently inflated β€” and every subsequent l2cap_start() invocation (from l2cap_send() when lc_pending == 0 never fires; but also from hci_acl_complete()/hci_acl_start() streaming callers) that dequeues another SDU increments it again: compounding.

All three consumers of lc_pending gate on == 0:

consumer gate consequence when permanently > 0
l2cap_send() l2cap_upper.c:398 if (chan->lc_pending == 0) return l2cap_start(chan); every further send(2) silently succeeds into lc_txq; nothing ever transmitted (W2: 64 SDUs, ret 0 each)
l2cap_disconnect(chan, linger) l2cap_upper.c:244 (IF_QEMPTY(&chan->lc_txq) && chan->lc_pending == 0) \|\| linger == 0 graceful disconnect handshake never starts; channel state stays L2CAP_OPEN (W3)
l2cap_start() shutdown flush l2cap_lower.c:173-174 L2CAP_SHUTDOWN && chan->lc_pending == 0 flush path unreachable (W4)

Reproduction (baseline)

Rig: l2pw_harness.ko β€” VERBATIM production l2cap_upper.c, l2cap_lower.c, l2cap_misc.c, l2cap_signal.c, hci_link.c (the failure branch under test is production code), minimal stubs for the transport/socket layers (md5s recorded in setup.log). Channel state built with production calls: l2cap_attach() + l2cap_cid_alloc() + one hci_acl_open()-style reference + L2CAP_OPEN (post-config state, l2cap_signal.c:605) on a link in HCI_LINK_CLOSED (what hci_link_alloc() returns, hci_link.c:909 β€” and the state the DF-5737 teardown race window leaves behind; ENOMEM under pressure hits the same imbalance).

Observed (run.log, decisive lines):

l2pw: W1 send#1 ret=50 (ENETDOWN=50) lc_pending=1 link_txq_empty=1 lc_txq_len=0
l2pw: W1 PASS: pending==1 but NO pdu exists ...
l2pw: W2 64 more sends: ret==0 all, lc_txq_len=64 (blackholed) lc_pending=1
l2pw: W3 l2cap_disconnect(linger) ret=0 state=5 (OPEN=5) reqs_empty=1 SHUTDOWN=1
l2pw: W4 l2cap_start ret=50 lc_txq_len=63 lc_pending=2 state=5   <- compounding
l2pw: WEDGE ROUND PROVEN (assertion failures: 0)
l2pw: CONTROL ROUND OK (assertion failures: 0)                    <- healthy accounting works

hw.l2pw_wedge_proven = 1, hw.l2pw_ctl_ok = 1, no panic. The CONTROL round (link OPEN + production hci_acl_complete(link,1)) shows lc_pending 1β†’0 round-trip works and the SHUTDOWN disconnect fires at 0 β€” proving the wedge is caused precisely by the failed-send increment, not by rig artifacts.

Fix validation

fix.diff applied to the guest's /usr/src/sys/netbt/l2cap_lower.c (increment only on success), module rebuilt from the re-copied verbatim source, exact same PoC re-run (fix_run.log):

assertion baseline (unfixed) patched
W1 lc_pending after failed send 1 (phantom) 0
W2 lc_txq after 64 more sends 64 blackholed, ret 0 0 queued, honest ENETDOWN per send
W3 disconnect(linger) state OPEN, no request state CLOSED (0) β€” handshake path reachable
W4 l2cap_start retry pending 1β†’2 no-op
hw.l2pw_wedge_proven 1 0
hw.l2pw_ctl_ok (regression) 1 1 (C1 pending==1 backed by real PDU; C3 1β†’0 round-trip intact)

fix_status: fixed.

Why not higher severity

  • No memory corruption: the counter is a uint8_t bookkeeping field; nothing dereferences through it.
  • Stuck mbufs are bounded: l2cap_ssend() (l2cap_socket.c:450-451) appends the original mbuf to so_snd (flow-controlled by sbspace) and only the copy goes down; the wedged copies sit in lc_txq (freed at detach, l2cap_upper.c:275 IF_DRAIN).
  • Trigger requires an established (OPEN) channel plus one hci_acl_send() failure: ENOMEM under memory pressure, or ENETDOWN in the link-teardown window (co-fires with DF-5737's race β€” on a racing-teardown box the wedge is one of the outcomes of the same window).
  • Recovery exists: close(2) β†’ detach β†’ force-close reclaims everything.

Scope note: the ENETDOWN-via-teardown-race window itself is DF-5737's (owned there). This finding is the error-path accounting defect in l2cap_lower.c that makes any failure permanent for the channel β€” filed narrowly on the ENOMEM/all-failures root cause at l2cap_lower.c:203-204.

What was checked and found clean in this file (pass-2 negatives)

  • l2cap_recv_frame 4-byte m_copydata (l2cap_lower.c:115): the sole caller hci_acl_recv guarantees β‰₯ 4 payload bytes (START short-frame drop, hci_link.c:476-481; fragments only append). DF m_copydata (sys/kern/uipc_mbuf.c:1671-1696) is bounds-checked with always-on KASSERTs anyway; no OOB on this path.
  • l2cap_recv_frame length check (:124): dead but harmless β€” want == 0 exactness (hci_link.c:509-517) means only exact frames are delivered; the overshoot case stalls in reassembly (owned by DF-0566).
  • Dispatch ordering (:127-142): SIGNAL_CID handoff guarded by l2cap_recv_signal's own loop guards (l2cap_signal.c:73-83) and internal left arithmetic in the CONFIG handlers (l2cap_signal.c:424-431, 632-641); data-CID dispatch requires lc_link == link && lc_state == L2CAP_OPEN with globally-unique lcids (l2cap_cid_alloc, l2cap_misc.c:207-236). Sound.
  • l2cap_close ordering (:96-98): setting lc_state = L2CAP_CLOSED BEFORE hci_acl_close() makes any re-entrant hci_link_free() channel walk (hci_link.c:944-951) a no-op for this channel β€” single-threaded safe; no non-race path reaches :78/:86 with lc_link == NULL (all lc_state != CLOSED transitions assign lc_link first: l2cap_upper.c:171/178/190, l2cap_signal.c:289/302).
  • No single-threaded re-entrant hci_link_free via l2cap_close: every hci_link_free entry with hl_refcnt > 0 (discon_compl hci_event.c:631, con_compl failure hci_event.c:559, RESET completion hci_event.c:1068) runs with link state OPEN/WAIT_*, so the inner hci_acl_close arms the expiry callout instead of recursing. The concurrent-callout variant is DF-5739's.
  • lc_pending uint8_t overflow: in-flight PDUs per channel ≀ 2-3 by construction (streaming prefetch only); unreachable.
  • The racy :69 state read and the unlocked lc_link lifetime are owned by DF-5749 / DF-5737 respectively (not re-reported).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src/sys/netbt/l2cap_lower.c, module rebuilt from re-copied verbatim source, exact PoC re-run: every wedge behavior is GONE (lc_pending stays 0 after the failed send; further sends return honest ENETDOWN with lc_txq empty; l2cap_disconnect(linger) now reaches the handshake and the channel closes; retry no longer compounds) while the control round still passes (healthy 1->0 round-trip intact, no regression). hw.l2pw_wedge_proven: 1 -> 0; hw.l2pw_ctl_ok: 1 -> 1.

findings/poc/DF-5764/fix_run.log: 'W1 send#1 ret=50 (ENETDOWN=50) lc_pending=0', 'W2 64 more sends: ret==0 all, lc_txq_len=0', 'W3 l2cap_disconnect(linger) ret=50 state=0 (OPEN=5)', 'WEDGE ROUND NOT proven (assertion failures: 5)', 'CONTROL ROUND OK (assertion failures: 0)', 'wedge_proven = 0 / ctl_ok = 1'.
↓ fix.diffper-fix-DF-5764

Confirmed kernel references

Detail

Exploit chain

established (L2CAP_OPEN) channel + any single hci_acl_send failure while OPEN -- ENOMEM under memory pressure, or ENETDOWN in the link-teardown window (co-fires with DF-5737's race) -> lc_pending permanently > 0 -> silent send(2) blackhole for the connection + graceful disconnect disabled until force-close(2). No escalation path (no corruption primitive).

Evidence (decisive lines)

findings/poc/DF-5764/run.log decisive lines: 'W1 send#1 ret=50 (ENETDOWN=50) lc_pending=1 link_txq_empty=1 lc_txq_len=0', 'W2 64 more sends: ret==0 all, lc_txq_len=64 (blackholed) lc_pending=1', 'W3 l2cap_disconnect(linger) ret=0 state=5 (OPEN=5) reqs_empty=1', 'W4 l2cap_start ret=50 lc_txq_len=63 lc_pending=2', 'WEDGE ROUND PROVEN (assertion failures: 0)', 'CONTROL ROUND OK'. fix_run.log: W1 'lc_pending=0', W2 'lc_txq_len=0', W3 'state=0 (CLOSED)', 'WEDGE ROUND NOT proven', 'CONTROL ROUND OK', hw.l2pw_wedge_proven=0 / hw.l2pw_ctl_ok=1.

PoC changes

Authored from scratch (no seed): KLD rig per the proven DF-5749 pattern, extended to compile hci_link.c VERBATIM so the hci_acl_send ENETDOWN failure branch under test is production code. Iterations during bring-up: added symbol-only stubs (sco_detach, hci_unit_lookup, hci_route_lookup, hci_memo_find, hci_memo_new, bluetooth_debug, sco_pcb list), flat _hw SYSCTL_PROC names (DF KLDs), and recalibrated W4 to assert the compounding behavior (retry raises pending 1->2, burns another SDU) which the first run revealed is worse than the initial prediction.

Verified recommended fix

In l2cap_start(), only count the PDU when one was created: assign err = hci_acl_send(...) and increment chan->lc_pending only when err == 0 (see fix.diff).

Verdict

l2cap_start() (sys/netbt/l2cap_lower.c:203-204) increments chan->lc_pending before the fallible hci_acl_send(); on failure (production ENETDOWN branch hci_link.c:546-549 or ENOMEM hci_link.c:551-599) the mbuf is freed and NO l2cap_pdu is created, and lc_pending is only ever decremented for a PDU found on hl_txq with lp_chan==chan (hci_link.c:730-742) -- so the counter is permanently inflated and COMPOUNDS on every later l2cap_start() caller. All three lc_pending==0 gates then never fire: l2cap_send() (l2cap_upper.c:398) silently accepts every further send(2) into lc_txq (permanent TX blackhole), l2cap_disconnect(linger) (l2cap_upper.c:244) never starts the graceful disconnect handshake, and the L2CAP_SHUTDOWN flush (l2cap_lower.c:173-179) is unreachable. Proven with a KLD rig compiling VERBATIM production l2cap_upper.c/l2cap_lower.c/l2cap_misc.c/l2cap_signal.c/hci_link.c: wedge round asserts every stage (W1 phantom count, W2 64 blackholed SDUs, W3 dead handshake, W4 compounding to 2), control round proves the healthy 1->0 round-trip works. fix.diff (increment only on success) flips wedge_proven 1->0 with ctl_ok unchanged. Severity Low: no memory corruption, stuck mbufs bounded by so_snd (l2cap_ssend keeps the original in the socket send buffer), recovery via close(2).