netbt_dispose wired as dom_dispose is a dead-code booby trap: it destroys the three GLOBAL netbt vm_zones per socket flush β one PR_RIGHTS flag bit away from a deterministic global UAF/double-free (rig-proven, 3/3 panics at zdestroy+0x19)
Summary
dom_dispose is DFly's PER-SOCKET PR_RIGHTS mbuf-disposal callback (fired on every sorflush), not a domain-teardown hook - netbt_dispose ignores its mbuf argument and unconditionally zdestroys the global pdu/req/credit pools (zdestroy frees the zone struct itself: second flush = double-destroy, later zalloc = UAF). Unreachable today (no netbt protosw sets PR_RIGHTS - control-proven 8 rounds, pools bit-identical); arming exactly one flag bit made the next shutdown panic deterministically 3/3 across two clean boots (Fatal trap 12 in zdestroy). Any future PR_RIGHTS support/flag refactor turns every socket close into mass UAF of global bluetooth state. Unwire-the-hook fix validated (armed phase clean, dom_dispose=0). hci_ioctl.c and l2cap_misc.c CLEAN beyond their known families (full negative proofs incl. the ouroboros request-ID soundness and the single-context netisr serialization chain).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5763 Β· 23 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 5.5 KB | β raw | |
| btdisp_rig.c | β | 3.7 KB | view raw | |
| stub.c | β | 2.2 KB | view raw | |
| btdisp.c | β | 1.3 KB | view raw | |
| Makefile | β | 460 B | β download | |
| build.sh | β | 85 B | view raw | |
| control.sh | β | 500 B | view raw | |
| armed.sh | β | 501 B | view raw | |
| run.sh | β | 109 B | view raw | |
| build.log | β | 8.8 KB | view raw | |
| setup.log | β | 123 B | view raw | |
| env.txt | β | 449 B | view raw | |
| code_hashes.txt | β | 227 B | view raw | |
| run.control.log | β | 895 B | view raw | |
| panic.txt | β | 2.1 KB | view raw | |
| panic.2.txt | β | 2.2 KB | view raw | |
| run.log | β | 2.1 KB | view raw | |
| fix.diff | β | 719 B | view raw | |
| fix_build.log | β | 8.7 KB | view raw | |
| fix_run.log | β | 1.6 KB | view raw | |
| manifest.json | β | 1.1 KB | view raw | |
| verdict.json | β | 4.6 KB | view raw |
DF-5763 β netbt_dispose wired as dom_dispose is a dead-code booby trap: it destroys the global netbt vm_zones per socket flush, sys/netbt/bt_proto.c
btdomain.dom_dispose = netbt_dispose (bt_proto.c:163) misreads the
DragonFly dom_dispose contract: that hook is the per-socket,
per-flush rights-mbuf disposal callback, invoked from
sys/kern/uipc_socket.c:1611 and :1987 on every
sorflush()/receive-cleanup of a PR_RIGHTS socket β it is not a
domain-teardown hook. netbt_dispose() (bt_proto.c:128-134) ignores its
mbuf argument and unconditionally zdestroy()s the three global
vm_zones (l2cap_pdu_pool, l2cap_req_pool, rfcomm_credit_pool).
zdestroy() frees the zone structure itself (vm_zone.c:543), so a
second flush of any bluetooth socket operates on freed memory, and any
later zalloc from those pools is a UAF.
Currently dead code: no netbt protosw sets PR_RIGHTS
(bt_proto.c:73,87,102,116), and both call sites gate on it β proven on
the guest: 8 socket+shutdown rounds with production flags never disturb
the pools.
One bit away from a global UAF: the rig flips exactly one flag bit
(btsw[0].pr_flags |= PR_RIGHTS, e.g. a future PR_RIGHTS-capable
protosw or flag refactor) and the very next socket shutdown runs
production netbt_dispose β deterministic Fatal trap 12 in
zdestroy+0x19 (3/3 runs, 2 clean boots).
Contents
btdisp_rig.c,stub.c,Makefileβ KLD rig;build.shcompiles the verbatim production/usr/src/sys/netbt/bt_proto.c(md5e68d6f9f555dc28d0d4ff6d9b26fe4ed, matches the audit tree) into the rig and registers the productionbtdomain.btdisp.cβ userland trigger:socket(AF_BLUETOOTH, SOCK_RAW, BTPROTO_HCI)+shutdown(SHUT_RD).control.shβ control phase (production flags; must be clean).armed.shβ armsPR_RIGHTSonbtsw[0]viasysctl hw.btdisp.arm=1, then fires 4 rounds.run.shβ both phases (armed phase expected to panic).build.log,setup.log,env.txtβ verbatim-source proof, build output, guest environment.run.control.logβ control evidence (pools stable, 8 rounds clean).panic.txt,panic.2.txt,run.logβ three deterministic baseline panics atzdestroy+0x19from three separate boots.fix.diffβ one-line fix:.dom_dispose = NULL(+contract comment).fix_build.log,fix_run.logβ fix validation: patched rig survives the armed phase.
Reproduce
# guest, as root:
sh build.sh
sh control.sh # CONTROL: 8 rounds, no dispose, guest healthy
# (baseline) armed phase β EXPECT kernel panic:
sh armed.sh # Fatal trap 12 ... Stopped at zdestroy+0x19
Expected (vulnerable wiring, armed): btdisp: ARMING btsw[0]
pr_flags 0x00000003 -> 0x00000013 then
Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffff80118584068 fault code = supervisor read data, page not present current process = Idle (netisr protocol thread) Stopped at zdestroy+0x19: testb $0x40,0x4068(%rdi)
Expected (fix.diff applied to /usr/src, rig rebuilt): armed phase
completes all 4 rounds, dom_dispose=0, pools untouched, guest healthy.
Environment
DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, Jul 2 2026), cc 8.3
[DragonFly], netbt not compiled into GENERIC (KLD), 6 vCPU
(net.netisr.ncpus: 6).
DF-5763 β VERDICT
Status: reproduced (mechanism; production-config deadness proven by control). Severity Info β the trigger precondition does not exist in the shipped kernel. Fix validated.
What the finding claims
sys/netbt/bt_proto.c:128-134 defines netbt_dispose(), which calls
zdestroy() on the three global netbt vm_zones
(l2cap_pdu_pool, l2cap_req_pool, rfcomm_credit_pool) and ignores
its struct mbuf * argument. sys/netbt/bt_proto.c:163 wires it as
btdomain.dom_dispose.
In DragonFly, dom_dispose is not a domain-teardown hook. Both of
its only call sites β sys/kern/uipc_socket.c:1609-1611 (receive
cleanup) and sys/kern/uipc_socket.c:1986-1987 (sorflush) β invoke it
per socket flush, gated on pr->pr_flags & PR_RIGHTS, passing the
flushed receive-buffer mbuf chain. The unix domain uses it to release
externalized descriptors (unp_dispose). netbt's btsw[] protosw
entries set only PR_ADDR|PR_ATOMIC / PR_CONNREQUIRED|PR_ATOMIC /
PR_CONNREQUIRED|PR_WANTRCVD (bt_proto.c:73, 87, 102, 116) β none
sets PR_RIGHTS β so production never calls netbt_dispose. The
module's real teardown protection is netbt_modevent returning EBUSY
on MOD_UNLOAD (bt_proto.c:48-49); the pools are created exactly once
by dom_init (netbt_init, SI_SUB_PROTO_DOMAIN via DOMAIN_SET,
uipc_domain.c:86-87).
Why it is a finding anyway (the booby trap)
If netbt_dispose ever ran it would, on the first flush of any
bluetooth socket, destroy three vm_zones that are still in global use;
zdestroy() frees the zone structure itself (sys/vm/vm_zone.c:543)
and releases the backing slabs (:534-538). The second flush of
any bt socket (same or different socket: sorflush runs on
shutdown(SHUT_RD) and on socket close) calls zdestroy() on the
freed zone β use-after-free read and write (LIST_REMOVE at
vm_zone.c:527, kfree again at :543, double-free). Any subsequent
zalloc from a destroyed pool is a UAF dereference of freed memory.
This is not hypothetical hardening polish: the distance from armed to
firing is exactly one flag bit on any protosw of the domain β the kind
of bit a "add SCM_RIGHTS to raw HCI sockets" refactor or a copy-paste
from unp_protosw would set, at which point every socket close
becomes a mass-UAF of the global bluetooth state.
Evidence (see README for file map)
- Verbatim production code under test:
build.shcopies/usr/src/sys/netbt/bt_proto.cinto the rig; md5e68d6f9f555dc28d0d4ff6d9b26fe4edequals the audit tree'ssys/netbt/bt_proto.c(setup.log).nmshowsnetbt_disposeat module offset0x200; at runtimehw.btdisp.infoprinteddom_dispose=0xffffffff82600200with the module linked at0xffffffff82600000β the domain hook is productionnetbt_dispose. - Control (production flags):
run.control.logβ kldload registers the production domain (netbt_initzinit's the pools:pdu=0xfffff80118580000 req=0xfffff80118585000 credit=0xfffff8011858a000), 8 Γ socket+shutdown(SHUT_RD) rounds, all succeed, pools bit-identical before/after, guest healthy. Production config never firesdom_dispose. (This is the deadness proof.) - Armed (one bit set):
sysctl hw.btdisp.arm=1setsbtsw[0].pr_flags |= PR_RIGHTS(0x00000003 -> 0x00000013, console kprintf). The next trigger round ran productionsorflush -> dom_dispose -> netbt_dispose -> zdestroyand the guest took a deterministic page fault β 3/3 runs across 2 clean boots (panic.txt,panic.2.txt,run.log):Fatal trap 12 ... supervisor read data, page not present ... Stopped at zdestroy+0x19: testb $0x40,0x4068(%rdi)β thezflagstest on a destroyed zone whose slab was already released (fault virtual address = pdu_pool + 0x4068).current process = Idle= the netisr protocol port thread wheresoshutdownnetmsgs execute (uipc_socket.c:259so->so_port = netisr_cpuport(0)). - Fix validation:
fix.diff(one line:.dom_dispose = NULL, plus a contract comment) applied to the guest's/usr/src(patch"Hunk #1 succeeded at 160", md578082bfbβ¦), rig rebuilt (fix_build.log,setup.logmd5s match patched source),hw.btdisp.infonow showsdom_dispose=0; control clean and the armed phase (PR_RIGHTS set) completes all 4 rounds with pools untouched and the guest healthy (fix_run.log). baseline panic 3/3 β patched 0/1 β fixed.
Classification reasoning
- Not reachable in the shipped kernel (control-proven) β not High/Medium; no attacker-controlled input path reaches it.
- Demonstrated catastrophic behavior at one bit of distance β more than style; it is exactly the "hardening opportunity / defense-in-depth" tier: Info, confidence certain.
- Impact recorded as
panicrefers to the armed-precondition demonstration (the code's actual behavior when its documented gate passes), not to any production exposure.
Fix
fix.diff: .dom_dispose = NULL on btdomain (keep netbt_dispose
itself β netbt_init's boot-fail path at bt_proto.c:154 still calls
it). If domain teardown is ever wanted, it belongs in MOD_UNLOAD
handling (after draining users), not in the per-flush rights hook.
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
fixedfix.diff applied to guest /usr/src/sys/netbt/bt_proto.c (patch hunk #1 succeeded at 160); rig rebuilt from the patched verbatim source (md5 78082bfb...); hw.btdisp.info reports dom_dispose=0; control clean AND armed phase (PR_RIGHTS set) completes 4/4 rounds with pools untouched and guest healthy β baseline panic 3/3 vs patched 0/1
fix_build.log, fix_run.log (dom_dispose=0, 'round 4/4 ... still alive'), setup.log patched md5s; baseline evidence in panic.txt/panic.2.txt/run.log
Confirmed kernel references
Detail
Exploit chain
precondition: any netbt protosw gains PR_RIGHTS (one bit; e.g. SCM_RIGHTS-capable socket refactor) -> userland socket(AF_BLUETOOTH,SOCK_RAW,BTPROTO_HCI) + shutdown(SHUT_RD) -> sorflush (uipc_socket.c:1986-1987) -> netbt_dispose -> zdestroy x3 (global pools, zone structs kfree'd) -> second flush of any bt socket (trivially: close() of the same socket) -> zdestroy on freed zone -> UAF read/write + double-free; any subsequent l2cap request/pdu allocation zallocs from a destroyed pool
Evidence (decisive lines)
run.control.log (control: 8 rounds, pools bit-identical, dom_dispose=0xffffffff82600200 == nm netbt_dispose module+0x200); panic.txt / panic.2.txt / run.log (3x deterministic 'Fatal trap 12 ... Stopped at zdestroy+0x19: testb $0x40,0x4068(%rdi)' right after the ARMING console line); setup.log+build.log (verbatim bt_proto.c md5 e68d6f9f...); fix_run.log (patched: dom_dispose=0, armed 4 rounds survive)
PoC changes
built from scratch (no seed): KLD rig compiles the VERBATIM production bt_proto.c (md5-matched) with stubbed socket-layer symbols; userland trigger socket+shutdown(SHUT_RD); arm sysctl flips one bit (PR_RIGHTS) on btsw[0]
Verified recommended fix
set btdomain.dom_dispose = NULL in sys/netbt/bt_proto.c (see fix.diff); domain teardown, if ever needed, belongs in MOD_UNLOAD after draining users
Verdict
netbt_dispose (bt_proto.c:128-134) is wired as btdomain.dom_dispose (bt_proto.c:163), but DragonFly's dom_dispose is the per-socket PR_RIGHTS flush hook (uipc_socket.c:1611,1987), not a domain-teardown hook; it unconditionally zdestroy()s the three GLOBAL netbt vm_zones, and zdestroy frees the zone struct itself (vm_zone.c:543), so any second bt-socket flush or later zalloc is a UAF/double-free. Production never calls it because no netbt protosw sets PR_RIGHTS β proven on the guest: 8 socket+shutdown rounds with production flags left the pools untouched (control). Setting exactly one flag bit (btsw[0].pr_flags |= PR_RIGHTS) made the next shutdown run production sorflush->netbt_dispose->zdestroy and take a deterministic Fatal trap 12 at zdestroy+0x19, 3/3 runs across 2 clean boots; the module's real unload protection is MOD_UNLOAD returning EBUSY. Dead code today, one bit from a global bluetooth-state UAF, hence severity Info; fix.diff (.dom_dispose = NULL) validated: armed phase survives on the patched rig.
No comments yet.