READ_BUFFER_SIZE completion with max_acl_size=0 poisons the unit ACL MTU (DF-5733 lens): hci_acl_send() fragmentation loop is structurally infinite and drains the global mbuf pool; link tx blackholed
Summary
The controller-reported max_acl_size is published with no lower bound: a 14-byte injected Command_Complete during the init window sets the MTU to 0, and every subsequent L2CAP transmit spins in the fragmentation loop (m_split(m,0) hands all data to the tail, plen never decreases) allocating empty mbufs until the GLOBAL pool is exhausted - guest-verified 0.17s spin, ENOMEM, netstat memory-denied. Per-call system-wide transient mbuf starvation + permanent bt-tx blackhole. reject-zero-report fix validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5735 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | β | 15.4 KB | view raw | |
| hci_stub.c | β | 2.5 KB | view raw | |
| Makefile | β | 535 B | β download | |
| build.sh | β | 83 B | view raw | |
| run.sh | β | 168 B | view raw | |
| build.log | β | 463 B | view raw | |
| run.log | β | 708 B | view raw | |
| fix.diff | β | 2.7 KB | view raw | |
| fix_run.log | β | 666 B | view raw | |
| fix_build.log | β | 7.3 KB | view raw | |
| README.md | β | 3.1 KB | β raw | |
| VERDICT.md | β | 4.0 KB | β raw | |
| verdict.json | β | 3.2 KB | view raw | |
| manifest.json | β | 846 B | view raw | |
| env.txt | β | 667 B | view raw |
DF-5735 β READ_BUFFER_SIZE completion with max_acl_size=0 poisons the ACL MTU (DF-5733 lens)
File: sys/netbt/hci_event.c (pass 2) + sink sys/netbt/hci_link.c
Severity: Medium β CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H (CWE-834 + CWE-400)
Verdict: REPRODUCED (poison lands via injected event; sink spin + pool drain observed).
Root cause
hci_cmd_read_buffer_size() (sys/netbt/hci_event.c:869-892) copies the
controller-reported max_acl_size into unit->hci_max_acl_size
(sys/netbt/hci_event.c:884) with no lower bound β the same defect class
as DF-5733 (RFCOMM PN MTU=0 β divide-by-zero), here in the HCI init
completion. During the init window (BTF_INIT_BUFFER_SIZE), one injected
14-byte Command_Complete event sets the unit's ACL payload MTU to 0.
Sink: hci_acl_send() (sys/netbt/hci_link.c:534-600, the production call
site for every L2CAP transmission, sys/netbt/l2cap_lower.c:204) fragments
with mlen = unit->hci_max_acl_size:
while (plen > 0) {
if (plen > mlen) {
n = m_split(m, mlen, M_NOWAIT); /* m_split(m, 0): tail
gets ALL the data,
m becomes empty */
...
} else { mlen = plen; }
IF_ENQUEUE(&pdu->lp_data, m); /* enqueue EMPTY mbuf */
m = n;
plen -= mlen; /* -= 0: plen unchanged */
}
The loop is structurally infinite (plen never decreases); it terminates only
when m_split()'s m_gethdr(M_NOWAIT) fails β after transiently allocating
empty packet-header mbufs into pdu->lp_data until the global mbuf pool is
exhausted. Observed on the guest: 0.17 s of spinning ending in ENOMEM with
netstat -m 1 requests for memory denied; the nomem path then frees its
own queue. Every subsequent transmission on the unit repeats the drain;
between drains, the mbuf starvation is system-wide. Additionally, the
hci_num_acl_pkts/hci_num_sco_pkts window values from the same event are
accepted unvalidated (0xFFFF window poisoning).
Build & run (inside the guest, as root)
cd /root/hcih && sh build.sh kldload ./hci_event_harness.ko sysctl hw.hcih_setup=1 sysctl hw.hcih_fire_mtupoison=1 # 2 = sane completion (fix-check)
Expected result (baseline)
[harness] hci_max_acl_size = 0 <-- POISONED [harness] calling hci_acl_send(64 bytes): ... [harness] hci_acl_send returned 12 (only after the mbuf pool was drained)
netstat -m: 1 requests for memory denied; sysctl wall time 0.17 s.
See run.log.
Fix validation
fix.diff rejects the completion when max_acl_size == 0 (upper hunk in
hci_event.c). On the patched build: poison completion β
completion NOT applied (flag still set) β the zero can no longer land via
an event; a sane completion (hw.hcih_fire_mtupoison=2) is accepted
(hci_max_acl_size = 64). In production the rejected init leaves
BTF_INIT_BUFFER_SIZE set so hci_enable() never brings the unit up β no
hci_acl_send is possible on a zero-MTU unit.
DF-5735 VERDICT β REPRODUCED (event-driven MTU poisoning; mbuf-pool drain spin; tx blackhole)
Bottom line
Reproduced, root-caused, fix validated. hci_cmd_read_buffer_size()
(sys/netbt/hci_event.c:869-892) accepts the controller's
Read_Buffer_Size return parameters with no lower bound on max_acl_size
(sys/netbt/hci_event.c:884). A 14-byte injected Command_Complete event
during the init window sets unit->hci_max_acl_size = 0 β verified on the
guest console (hci_max_acl_size = 0 <-- POISONED).
The sink is the exact production transmit call site
(l2cap_lower.c:204 β hci_acl_send()): the fragmentation loop at
sys/netbt/hci_link.c:566-583 uses mlen = hci_max_acl_size; with
mlen == 0, m_split(m, 0) (sys/kern/uipc_mbuf.c:2171-2227) hands all
data to the tail mbuf and leaves m empty, plen -= 0 never decreases, and
each iteration allocates one empty packet-header mbuf into pdu->lp_data.
The loop is structurally infinite; on the guest it terminated after 0.17 s of
spinning when m_gethdr(M_NOWAIT) was denied β with the global mbuf pool
(netstat -m: 1 requests for memory denied) having been drained
transiently; the nomem path then frees the queued empty mbufs and returns
ENOMEM. Every further transmission on the unit re-runs the drain
(system-wide transient network starvation under load), and between attempts
all L2CAP traffic on the unit fails with ENOMEM β a blackhole until the unit
is reset/re-initialized. The same event also writes the unvalidated
num_acl_pkts/num_sco_pkts window (0xFFFF poisoning, DoS amplifier for
hci_acl_start's send window).
Honest impact assessment
On this guest (6 CPUs, 146632-mbuf pool) the spin is allocator-bounded per call rather than a permanent wedge: severity Medium rather than High. Two aggravating notes: (1) the drained pool is global β concurrent network allocations fail while the spin runs; (2) any local user traffic on the poisoned unit re-triggers it indefinitely. No memory-safety primitive: the empty mbufs are enqueued, not written OOB; no attacker-controlled kernel write occurs. Same threat model as DF-5734 (malicious controller / injected HCI events); NetBSD's netbt shares the missing check.
Why the injected path is the production path
Same verbatim KLD rig as DF-5734 (md5s in build.log/env.txt). The harness
opens the init window (BTF_INIT_BUFFER_SIZE β set by hci_enable(),
sys/netbt/hci_unit.c:167), injects the completion via
hci_input_event() β netisr β hci_event() β hci_event_command_compl()
(sys/netbt/hci_event.c:294-357) β hci_cmd_read_buffer_size(); the
handler's flag-clear + wakeup(unit) was observed by the harness wait loop,
proving the production dispatch path processed the event. The subsequent
hci_acl_send(m, link, NULL) call is byte-for-byte the production call.
Fix validation
fix.diff (sys/netbt/hci_event.c hunk): reject the completion when
letoh16(rp.max_acl_size) == 0. On the patched build:
hw.hcih_fire_mtupoison=1 β completion NOT applied (flag still set) (the
zero never lands via an event; hci_enable() keeps the unit down, so no
hci_acl_send can occur on a zero-MTU unit in production);
hw.hcih_fire_mtupoison=2 (sane max_acl_size=0x40) β accepted,
hci_max_acl_size = 64. Guest alive throughout.
Note on the fixed-run console: the harness's first fixed run still printed
hci_max_acl_size = 0 <-- POISONED after completion NOT applied β that
value is the unit's never-initialized zero (harness forced hci_acl_send on
a unit that production would never have brought up); the decisive fixed-run
marker is completion NOT applied (flag still set) plus the accepted sane
completion in run 2.
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
fixedOn the patched build the zero-MTU completion is rejected ('completion NOT applied (flag still set)' - BTF_INIT_BUFFER_SIZE never clears, hci_enable() never brings the unit up, no hci_acl_send possible on a zero-MTU unit) while a sane completion is accepted and applied (hci_max_acl_size = 64); guest alive throughout.
fix_run.log (F2 FIXED RUN blocks: poison rejected + sane accepted)
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log: 'hci_max_acl_size = 0 <-- POISONED' marker + 'hci_acl_send returned 12' + netstat before/after (7/146632, denied 0 -> 1) + 0.17s wall time; VERDICT.md: sink analysis (hci_link.c:566-583, uipc_mbuf.c m_split semantics); fix_run.log: 'completion NOT applied (flag still set)' on patched build + sane completion accepted
PoC changes
harness m_copyback2 was wrapped in no-op KKASSERT (never executed without INVARIANTS) - replaced with real error checks; this masked the spin on the first attempt (plen=0 -> zero iterations)
Verified recommended fix
hci_cmd_read_buffer_size(): ignore the completion when letoh16(rp.max_acl_size) == 0
Verdict
hci_cmd_read_buffer_size (sys/netbt/hci_event.c:884) accepts max_acl_size=0 from an injected Command_Complete event with no lower bound (DF-5733 lens); verified on the guest that the unit's ACL MTU becomes 0 via the production event path. Every subsequent hci_acl_send (production call site l2cap_lower.c:204) enters a structurally-infinite fragmentation loop (m_split(m,0) keeps all data in the tail; plen -= 0) that transiently drains the GLOBAL mbuf pool (netstat -m: 1 requests for memory denied, 0.17s spin) and returns ENOMEM, blackholing all bt tx and starving concurrent network allocations; repeatable per write. Fix (reject max_acl_size==0) validated: poison completion is ignored (flag stays set, unit never enabled), sane completion accepted (max=64).
No comments yet.