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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5763 Β· 23 files
FileTypeDescriptionSize
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.sh compiles the verbatim production /usr/src/sys/netbt/bt_proto.c (md5 e68d6f9f555dc28d0d4ff6d9b26fe4ed, matches the audit tree) into the rig and registers the production btdomain.
  • 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 β€” arms PR_RIGHTS on btsw[0] via sysctl 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 at zdestroy+0x19 from 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).

VERDICT.md
↓ download raw

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.sh copies /usr/src/sys/netbt/bt_proto.c into the rig; md5 e68d6f9f555dc28d0d4ff6d9b26fe4ed equals the audit tree's sys/netbt/bt_proto.c (setup.log). nm shows netbt_dispose at module offset 0x200; at runtime hw.btdisp.info printed dom_dispose=0xffffffff82600200 with the module linked at 0xffffffff82600000 β€” the domain hook is production netbt_dispose.
  • Control (production flags): run.control.log β€” kldload registers the production domain (netbt_init zinit'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 fires dom_dispose. (This is the deadness proof.)
  • Armed (one bit set): sysctl hw.btdisp.arm=1 sets btsw[0].pr_flags |= PR_RIGHTS (0x00000003 -> 0x00000013, console kprintf). The next trigger round ran production sorflush -> dom_dispose -> netbt_dispose -> zdestroy and 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) β€” the zflags test on a destroyed zone whose slab was already released (fault virtual address = pdu_pool + 0x4068). current process = Idle = the netisr protocol port thread where soshutdown netmsgs execute (uipc_socket.c:259 so->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", md5 78082bfb…), rig rebuilt (fix_build.log, setup.log md5s match patched source), hw.btdisp.info now shows dom_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 panic refers 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 x86_64 (single-file fix validated via verbatim-source rig; netbt is not part of GENERIC)

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.