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)
PoC verification
Evidence pack
findings/poc/DF-5764 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
l2cap_send()(l2cap_upper.c:398) never callsl2cap_start()again: every furthersend(2)silently succeeds intolc_txqand nothing is ever transmitted β permanent TX blackhole for the connection.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).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.
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) orm_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 toso_snd(flow-controlled bysbspace) and only the copy goes down; the wedged copies sit inlc_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_frame4-bytem_copydata(l2cap_lower.c:115): the sole callerhci_acl_recvguarantees β₯ 4 payload bytes (START short-frame drop, hci_link.c:476-481; fragments only append). DFm_copydata(sys/kern/uipc_mbuf.c:1671-1696) is bounds-checked with always-on KASSERTs anyway; no OOB on this path.l2cap_recv_framelength check (:124): dead but harmless βwant == 0exactness (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
leftarithmetic in the CONFIG handlers (l2cap_signal.c:424-431, 632-641); data-CID dispatch requireslc_link == link && lc_state == L2CAP_OPENwith globally-unique lcids (l2cap_cid_alloc, l2cap_misc.c:207-236). Sound. l2cap_closeordering (:96-98): settinglc_state = L2CAP_CLOSEDBEFOREhci_acl_close()makes any re-entranthci_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 withlc_link == NULL(alllc_state != CLOSEDtransitions assignlc_linkfirst: l2cap_upper.c:171/178/190, l2cap_signal.c:289/302).- No single-threaded re-entrant
hci_link_freevial2cap_close: everyhci_link_freeentry withhl_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 innerhci_acl_closearms the expiry callout instead of recursing. The concurrent-callout variant is DF-5739's. lc_pendinguint8_t overflow: in-flight PDUs per channel β€ 2-3 by construction (streaming prefetch only); unreachable.- The racy
:69state read and the unlockedlc_linklifetime 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
fixedfix.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'.
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).
No comments yet.