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

netbt hands rx mbufs to netisr_queue() (embedded netmsg) and frees them while still port-queued: the lwkt netisr port queue is operated through freed mbuf memory and corrupts (deterministic panic; same-node double-link on chunk reuse)

Summary

Every rx path double-queues: IF_ENQUEUE on the private ifqueue AND netisr_queue (which embeds the netmsg inside the mbuf and links it on the port). btintr ignores the netmsg and drains all queues via hci_intr, freeing mbufs whose netmsgs #2..#N are still port-queued - any rx burst >=2 packets. On reuse during the window a still-queued netmsg is re-initialized and the SAME node inserted twice: reproduced as 'panic: link elm next->prev != elm' in lwkt_spin_getport (21 freed-with-MSGF_QUEUED observations per 8-packet burst; hard panic with same-CPU re-injection). Any attached controller driver exposes this to unauthenticated remote rx bursts; local users generate rx via raw HCI sockets. kick-API + replymsg fix (the ngintr contract) validated (dangling 21->0, panic gone).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5752 Β· 17 files
FileTypeDescriptionSize
harness.c β€” 22.3 KB view raw
hci_stub.c β€” 2.4 KB view raw
Makefile β€” 624 B ↓ download
build.sh β€” 84 B view raw
run.sh β€” 180 B view raw
xfer.sh β€” 801 B view raw
build.log β€” 13.0 KB view raw
setup.log β€” 871 B view raw
run.log β€” 10.8 KB view raw
fix_run.log β€” 4.5 KB view raw
panic.txt β€” 455 B view raw
fix.diff β€” 1.7 KB view raw
README.md β€” 2.0 KB ↓ raw
VERDICT.md β€” 5.8 KB ↓ raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 4.9 KB view raw
env.txt β€” 206 B view raw

DF-5752 β€” netbt hands rx mbufs to netisr_queue() (embedded netmsg) and then frees them while still port-queued

File: sys/netbt/hci_unit.c (pass 2) Trigger surface: hci_input_event / hci_input_acl / hci_input_sco / hci_complete_sco

Build (inside the guest, as root)

cd /root/hciuu
sh build.sh          # copies VERBATIM production netbt sources (md5s recorded), builds hci_unit_harness.ko

Run (inside the guest, as root)

kldload /root/hciuu/hci_unit_harness.ko
sysctl hw.hciu_setup=1        # attach fake unit + ACL link (hci_attach, production)
sysctl hw.hciu_fire=2         # BASELINE: 8 filler events; prints dangling-netmsg proof lines
sysctl hw.hciu_fire=1         # FIRE: same burst + mid-drain re-injection from netisr context

Expected

  • fire=2 (baseline): the guest survives, but prints up to 16 DF-5752 PROOF: mbuf 0x... was already freed by hci_event() ... still had MSGF_QUEUED set lines β€” the netisr port queue is holding messages whose backing mbufs are already freed (dangling_observations=21 observed).
  • fire=1 (fire): kernel panic
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

The re-injection allocations reuse the just-freed chunks (objcache LIFO) while their netmsgs are still linked on the netisr thread's port queue; netisr_queue() re-initialises a live message node and TAILQ_INSERT_TAILs the same node a second time β€” the port queue is corrupted and _lwkt_pullmsg()'s TAILQ_REMOVE trips the queue-consistency check.

Fix check

Apply fix.diff to /usr/src/sys/netbt (hci_unit.c: 4x netisr_queue(NETISR_BLUETOOTH, m) β†’ schednetisr(NETISR_BLUETOOTH); bt_input.c: btintr must lwkt_replymsg the kick message β€” same contract as ngintr()), rebuild the module, reboot, re-run fire=2 and fire=1: dangling_observations=0 and no panic (see fix_run.log).

VERDICT.md
↓ download raw

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_DRAINs (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_TAILs 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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied fix.diff to the guest /usr/src/sys/netbt (verbatim-source module rebuild): post-fix fire=2 and fire=1 both report dangling_observations=0 and no panic; the module kept draining correctly across repeated kicks (mtap_calls=8/11) proving the replymsg contract was needed and works; the 1500-round DF-5753 fix-check ran in the same boot without any netisr anomaly.

findings/poc/DF-5752/fix_run.log: '[harness] done: dangling_observations=0 reuse_hits=0 mtap_calls=8' (baseline) and 'dangling_observations=0 reuse_hits=2 mtap_calls=11' (fire, previously the panic trigger) with the system surviving; contrast run.log panic
↓ fix.diffper-fix-DF-5752

Confirmed kernel references

Detail

Exploit chain

remote/local rx burst (>=2 packets, any controller traffic) -> mbufs freed with netmsgs still port-queued -> netisr thread operates TAILQ linkage and nm_dispatch out of freed objcache chunks (works by luck while chunks stay intact) -> chunk reuse during the window re-initialises a live netmsg and double-links the node -> port queue corruption (panic on assert kernels); escalation path on non-assert kernels: controlled re-init/double-queue of the node and, via slab recycling, dispatch through attacker-influenced memory

Evidence (decisive lines)

run.log: 16+ 'DF-5752 PROOF ... already freed by hci_event() ... still had MSGF_QUEUED set (flags=0x4)' lines (21 observations per burst) and the panic 'link elm 0xfffff80117da8428 next->prev != elm / lwkt_spin_getport+0x134 / netmsg_service_loop+0xaf'; fix_run.log: post-fix dangling_observations=0 on both baseline and fire, no panic; setup.log: md5s proving verbatim production sources

PoC changes

harness tracks every injected mbuf + embedded lmsg; the hci_mtap() hook (called by hci_intr for every packet before hci_event frees the previous ones) records freed-with-MSGF_QUEUED observations in memory (netisr-context kprintf gets dropped by the serial driver) and re-injects 3 events mid-drain from netisr context to force same-cpu objcache LIFO reuse of chunks with queued netmsgs; probe sysctl prints the mbuf/netmsg layout (mh_netmsg@40, nm_dispatch@104) which killed the initial pkthdr-overlay theory and redirected the PoC to the double-queue corruption

Verified recommended fix

Replace the four netisr_queue(NETISR_BLUETOOTH, m) calls with schednetisr(NETISR_BLUETOOTH) and reply the kick message in btintr() (ngintr contract)

Verdict

Reproduced deterministically in two stages on the DF guest. Stage 1 (every rx burst >= 2 packets): hci_input_event/acl/sco and hci_complete_sco hand each mbuf to netisr_queue() (embedding the netmsg inside the mbuf, sys/net/netisr.c:424-431) while hci_intr()/hci_event() free the mbufs during the first btintr() drain, leaving 1..N-1 netmsgs MSGF_QUEUED on the netisr port through freed memory (21 dangling observations observed per 8-packet burst; hci_disable's IF_DRAIN is a second instance). Stage 2 (chunk reuse during the window, e.g. a same-CPU controller rx interrupt during the drain): netisr_queue() re-initialises a still-queued netmsg and TAILQ_INSERT_TAILs the same node twice -> panic 'link elm ... next->prev != elm' in lwkt_spin_getport <- netmsg_service_loop. On kernels without the queue assertion this is silent corruption of the lwkt port queue including the dispatch-through-freed-memory path (msg->nm_dispatch). Fix validated: schednetisr() kick instead of netisr_queue() (plus btintr replying the kick per the ngintr contract) gives dangling_observations=0 and no panic on the previously-crashing trigger.