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)
PoC verification
Evidence pack
findings/poc/DF-5752 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 16DF-5752 PROOF: mbuf 0x... was already freed by hci_event() ... still had MSGF_QUEUED setlines β the netisr port queue is holding messages whose backing mbufs are already freed (dangling_observations=21observed).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).
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)andnetisr_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
fixedApplied 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
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.
No comments yet.