# DF-5752 — VERDICT

**Status: reproduced (panic, deterministic two-stage).**
**Impact: panic / kernel memory corruption (lwkt port queue operated through freed mbufs).**
**Confidence: certain.**

## Root cause

DragonFly's `netisr_queue(num, m)` embeds the netmsg **inside the mbuf**
(`m->m_hdr.mh_netmsg`, offset 40 on this kernel — probe output in the run
log: `mh_netmsg@40 lmsg@40 nm_dispatch@104`) and links that embedded
`lmsg.ms_node` onto the target netisr thread's port queue
(`sys/net/netisr.c:424-431`). Ownership contract: the mbuf must stay alive
until the netisr thread has dequeued the message.

netbt violates that contract on every rx path in `sys/netbt/hci_unit.c`:

* `hci_input_event` (hci_unit.c:436-437): `IF_ENQUEUE(&unit->hci_eventq, m)`
  **and** `netisr_queue(NETISR_BLUETOOTH, m)` — the same mbuf is on the
  unit's private ifqueue *and* its embedded netmsg is on the netisr port.
* identical pattern in `hci_input_acl` (hci_unit.c:458-459),
  `hci_input_sco` (hci_unit.c:480-481), `hci_complete_sco`
  (hci_unit.c:542-543).

`btintr()` (bt_input.c:32) ignores the netmsg it was dispatched with and
drains **all** unit queues via `hci_intr()`. `hci_intr()` dequeues every
packet and `hci_event()`/`hci_sco_recv()` free them
(`m_freem` at hci_event.c:229). With N packets queued before the netisr
thread runs (any rx burst: two back-to-back packets from a controller
interrupt suffice), dispatch of netmsg #1 frees **all N mbufs** while
netmsgs #2..#N are still linked on the port queue. `hci_disable()`'s
`IF_DRAIN`s (hci_unit.c:252-262) are a second, root-triggered instance of
the same violation (devlock does not help — the netisr port is not
guarded by it).

## Stage 1 — the dangling window is unconditional (baseline run)

`hci_mtap()` (called by `hci_intr()` for every dequeued packet) verified
the invariant directly: for every packet processed, every *previously*
processed packet's mbuf is already freed (hci_event.c:229) — yet its
`lmsg.ms_flags` still has `MSGF_QUEUED` (0x4) set, proving the freed
mbuf's netmsg is still linked on the port. **21 dangling observations in a
single 8-packet burst** (see run.log; the pattern grows quadratically with
burst size). The system survives this only because the freed objcache
chunks still contain their stale (intact) bytes — the port queue is being
operated *through freed memory*.

## Stage 2 — chunk reuse during the window corrupts the port queue (panic)

A second rx arriving during the drain (a controller interrupt on the same
CPU — reproduced in the rig by re-injecting from netisr context inside
`hci_mtap()`) allocates from the same per-CPU objcache magazine (LIFO), so
the new mbuf **is** a chunk whose netmsg is still port-queued.
`netisr_queue()` re-initialises that netmsg and `TAILQ_INSERT_TAIL`s the
same node a second time. When `netmsg_service_loop()` pulls the messages,
`_lwkt_pullmsg()`'s `TAILQ_REMOVE` trips the queue-consistency assertion:

```
panic: netisr worker held 1 spinlock. link elm 0xfffff80117da8428 next->prev != elm
lwkt_spin_getport() at lwkt_spin_getport+0x134
netmsg_service_loop() at netmsg_service_loop+0xaf
cpuid = 0
```

On kernels without that assertion the corruption proceeds silently: the
port queue linkage and `nm_dispatch`/`nm_so` live in freed, reallocatable
memory, and the netisr thread dequeues and **dispatches through
`msg->nm_dispatch(msg)`** read from that memory. Because mbuf reuse on
this kernel writes m_hdr fields (offsets 0-40) but leaves the netmsg
region (40-136) stale, the practical exploitation path is (a) re-queueing
a reused chunk (demonstrated — same node linked twice) and (b) slab-page
recycling under memory pressure; both turn the works-by-luck behaviour
into arbitrary queue/function-pointer corruption in the lwkt core.

## Threat

Requires a Bluetooth controller driver attached to netbt (no in-tree
DragonFly driver currently calls `hci_attach()`; third-party/ported ubt
class drivers do). Given one, the trigger is **unauthenticated and
remote**: any rx burst of >= 2 packets (trivially produced by any peer
traffic, e.g. an ACL fragment burst or event storm) creates the dangling
window; sustained rx creates the reuse. Local unprivileged users can also
produce rx via raw HCI sockets on a connected system. The DF-5734 rig
family (128 back-to-back injections in DF-5736) has been exercising this
works-by-luck path all along.

## Fix validation

`fix.diff` replaces the four `netisr_queue(NETISR_BLUETOOTH, m)` calls
with `schednetisr(NETISR_BLUETOOTH)` (the kick API for exactly this
handler-drains-private-queue design, cf. `ngintr()`), removing the
embedded netmsgs entirely; `btintr()` additionally replies the kick
message (the `schednetisr` contract, as ngintr does at
sys/netgraph/netgraph/ng_base.c:2032).

* baseline (unfixed): `dangling_observations=21`, then panic on fire.
* fixed: `dangling_observations=0` (both baseline and fire), all packets
  drain through the kicks (`mtap_calls=8` / `11`), **no panic** across
  repeated runs including the DF-5753 (1500-round SCO) fix-check in the
  same boot (fix_run.log).

Note: the rig's `REUSE:` line still prints post-fix ("reuse_hits=2") —
that detector only observes *chunk address reuse*, which is now harmless
(no netmsg lives in mbufs any more); the decisive post-fix metrics are
`dangling_observations=0` and the absence of the panic.

## Guest

DragonFly dfbsd 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), 6 x vCPU,
kernel of 2026-07-02; netbt not in GENERIC (module-compiled, verbatim
production sources, md5s in setup.log).

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