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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5735 Β· 15 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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

fixed
baseline reproduced→ patch + rebuild →patched clean

On 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)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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).