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

nvmm MOD_UNLOAD tears down the hypervisor (nvmm_fini: all machine/vcpu locks lockuninit'd, impl freed, nvmm_impl=NULL) while /dev/nvmm is still dispatching ioctls β€” destroy_dev() runs last; unprivileged ioctl storm races kldunload into NULL-deref panic (UAF paths code-proven)

Summary

The EBUSY gate is unsynchronized and nvmm_fini runs BEFORE destroy_dev (the only draining op): in-flight ioctls deref NULL/freed nvmm_impl, machines created in the window survive with destroyed locks, and a held-open fd later runs the dtor through unmapped module text. VERIFIED live on guest (vmx backend): unpriv racer vs root kldunload crashed 2/2 at nvmm_ioctl+0x444 (rax=nvmm_impl=NULL); fix (open-count gate + destroy_dev-first + re-check) validated dynamically - 40/40 EBUSY, zero panics across 36M ioctls/684k machine cycles, clean final unload. Group-nvmm reachable (O_RDONLY suffices).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4926 Β· 15 files
FileTypeDescriptionSize
nvmm_race.c β€” 4.0 KB view raw
race_unload.sh β€” 1.2 KB view raw
run.sh β€” 183 B view raw
README.md β€” 2.4 KB ↓ raw
VERDICT.md β€” 5.3 KB ↓ raw
verdict.json β€” 4.1 KB view raw
build.log β€” 80 B view raw
run.log β€” 589 B view raw
panic.txt β€” 1.7 KB view raw
fix.diff β€” 2.7 KB view raw
fix_build.log β€” 12.1 KB view raw
fix_run.log β€” 615 B view raw
race_driver_fixed.log β€” 5.8 KB view raw
run_fixed_racer.log β€” 1.6 KB view raw
env.txt β€” 693 B view raw

DF-4926 β€” nvmm module unload tears down the hypervisor before the device is dead

nvmm_dragonfly.c destroys the NVMM implementation state while /dev/nvmm is still live, inverting the required destroy_dev()-first order:

  • nvmm_detach() (nvmm_dragonfly.c:366-374): unsynchronized nmachines == 0 check at :369, then nvmm_fini() at :372 β€” which lockuninit()s all 128Γ—128 machine/vcpu locks (nvmm.c:998-1003), runs nvmm_impl->fini() (VMXON regions, MSR bitmaps freed) and sets nvmm_impl = NULL (nvmm.c:1005-1006).
  • nvmm_modevent() MOD_UNLOAD (nvmm_dragonfly.c:399-405) calls destroy_dev(dev) only after nvmm_detach() has already done all of that β€” the device is open and dispatching ioctls the entire time.

Consequences (all reachable by an unprivileged user in group nvmm, given an admin running kldunload nvmm):

  1. In-flight ioctl during nvmm_fini(): nvmm_capability() (nvmm.c:245,251) dereferences nvmm_impl on every call β†’ NULL-page kernel deref; any machine ioctl uses lockuninited locks or freed impl state (UAF).
  2. Machine-create racing the nmachines check: a machine allocated between :369 and nvmm_fini() survives into destroyed-lock land.
  3. Open fd across a successful unload: dfbsd_nvmm_dtor() (nvmm_dragonfly.c:325-335) is a module-text function pointer stored in the file's cdevpriv; closing the fd after unload calls nvmm_kill_machines() through unmapped text.

Build (on the DragonFly guest, as root)

cc -O2 -pthread -o nvmm_race nvmm_race.c

Run

As root, end-to-end (creates the testnv group-nvmm user if missing, builds the racer, loads nvmm, races 40 unload/reload cycles, then a final unload; the racer keeps the fd open for 75 s and closes it at exit):

./run.sh                  # panics are expected; watch the serial log

Note: /dev/nvmm is 0640 root:nvmm, so the unprivileged racer opens it O_RDONLY β€” DFly does not require FWRITE for ioctls, which is exactly how an unprivileged group-nvmm user drives every NVMM_IOC_* command.

The guest must be booted with the host CPU exposed so nvmm attaches (QEMU_EXTRA="-cpu host" ./vm.sh up).

Expected output

  • racer /tmp/race.log: t=1ds cap=... create=... destroy=... counters
  • kernel: Fatal trap 12: page fault while in kernel mode with nvmm_capability in the trace (NULL nvmm_impl), or a lockmgr/freed-text panic from the other detonation paths; guest goes down.
VERDICT.md
↓ download raw

DF-4926 VERDICT β€” REPRODUCED (unload-race kernel crash; fix validated: FIXED)

Finding

sys/dev/virtual/nvmm/nvmm_dragonfly.c tears the NVMM hypervisor down in the wrong order on module unload:

  • nvmm_detach() (:366-374): an unsynchronized nmachines == 0 check at :369, then nvmm_fini() at :372 β€” which lockuninit()s all 128Γ—128 machine/vcpu locks (nvmm.c:998-1003), runs nvmm_impl->fini() (VMXON regions / MSR bitmaps freed) and sets nvmm_impl = NULL (nvmm.c:1005-1006).
  • nvmm_modevent() MOD_UNLOAD (:399-405) calls destroy_dev(dev) only after nvmm_detach() has done all of that β€” the device is open and dispatching ioctls the entire time. destroy_dev() is precisely the operation that revectors ops to dead_dev_ops, drains in-flight calls and blocks new ones (kern/kern_conf.c:334-349, devfs destroy path); it must come FIRST.

Three detonation paths for an unprivileged user in group nvmm (device is crw-r----- root nvmm; DFly ioctls do not require FWRITE, so an O_RDONLY open drives every NVMM_IOC_*):

  1. in-flight ioctl during nvmm_fini() β†’ nvmm_impl deref (NULL or freed impl) β€” e.g. nvmm_capability() nvmm.c:245,251;
  2. NVMM_IOC_MACHINE_CREATE landing between the :369 check and nvmm_fini() β†’ machine alive with lockuninited locks and freed impl state (UAF);
  3. fd kept open across a successful unload β†’ dfbsd_nvmm_dtor() (:325-335, module text, stored as the cdevpriv dtor at :315) runs nvmm_kill_machines() through unmapped text at close.

Reproduction (2026-09-25, guest DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC, backend x86-vmx, QEMU -cpu host)

  • Racer (unprivileged testnv, uid 1002, group nvmm, O_RDONLY open): ~1.1M NVMM_IOC_CAPABILITY/s + ~20k machine create/destroy cycles/s (standalone calibration in run.log header; fixed-run totals: 36M caps, 684k cycles).
  • Admin side: kldunload nvmm (root) while the racer runs.

Baseline (stock /boot/kernel/nvmm.ko): TWO runs, both panicked the guest with the identical crash site:

Fa[tal trap ...] Stopped at nvmm_ioctl+0x444:  movq 0x48(%rax),%rax

Run 2 panicked on the first kldunload. %rax == nvmm_impl == NULL (just set by nvmm_fini() at nvmm.c:1006 during the in-flight unload); movq 0x48(%rax) is the nvmm_impl->capability function-pointer load in the dispatch (offset 0x48 of struct nvmm_impl, nvmm_internal.h:106-133). Guest down both times; vm.sh status β‡’ down. (The "Fatal trap 12" banner was truncated by the crashing serial console β€” the Fa fragment β€” but the faulting RIP/instruction and the dead guest are on the serial log; see panic.txt.)

Patched (fix.diff applied to guest /usr/src, module rebuilt, same race):

  • all 40 kldunload attempts during the storm returned EBUSY (rc=1 Γ— 40, rc=0 Γ— 0) β€” the new nvmm_opens gate held;
  • no panic; guest survived the full storm (36M ioctls, 684k machine cycles);
  • after the racer died (fd closed β†’ dtor ran against the LIVE module), the final kldunload returned 0 and the module unloaded cleanly.

Impact ceiling

Race-condition kernel memory-safety bug at the hypervisor boundary: the demonstrated result is a NULL-deref panic from unprivileged-steerable ioctls racing an admin unload; paths (2)/(3) give UAF on freed implementation state (VMXON regions, MSR bitmaps, machine/vcpu locks) and execution through unmapped module text β€” plausible corruption/escalation material for the group-nvmm user, not weaponized here (weaponization needs the admin to run kldunload concurrently, hence Medium rather than High). CVSS: CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:N/I:L/A:H (UI:R = the required concurrent kldunload by root).

Fix

fix.diff (git-apply clean against sys/):

  1. an nvmm_opens counter (inc at successful open, dec at dtor β€” after nvmm_kill_machines(), so a concurrent detach observes nmachines==0 before opens==0);
  2. nvmm_detach() refuses while any fd is open or any machine exists, then destroy_dev() first, then (stable, drained) re-check with device restore on bail, and only then nvmm_fini();
  3. MOD_UNLOAD no longer destroys the dev after the teardown.

Validated end-to-end on the guest (baseline panic vs patched EBUSY/clean unload under the identical storm; race_driver_fixed.log, run_fixed_racer.log, fix_build.log).

PoC changes vs any seed

No executable seed (fresh racer + driver authored). Practical fixes made while bringing it up: su -m testnv -c '... >/tmp/race.log 2>&1 &' runs via root's csh β†’ "Ambiguous output redirect"; replaced with a #!/bin/sh wrapper script executed via su -m testnv -c /home/testnv/goracer.sh &. The racer opens /dev/nvmm O_RDONLY (0640 gives group read only; DFly ioctls need no FWRITE β€” first attempt with O_RDWR got EACCES).

Guest hygiene

vm.sh reset with-src performed at the end of the run (guest left clean; the testnv user, /root packs and /usr/src patch are on the reverted disk state only).

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 reproduced

FIXED dynamically: baseline (stock nvmm.ko) panicked the guest twice at nvmm_ioctl+0x444 under the race; with fix.diff applied to guest /usr/src (module rebuilt, fix_build.log) the identical workload produced 40/40 kldunload EBUSY (open-count gate), zero panics across 36M capability ioctls and 684k machine create/destroy cycles, and a clean final unload after the racer's fd closed (race_driver_fixed.log, run_fixed_racer.log, fix_run.log).

fix.diff (git-apply clean); fix_build.log (BUILD_RC=0); fix_run.log (40x EBUSY vs baseline panic, clean final unload, guest up); panic.txt (baseline crashes)
↓ fix.diffper-fix-DF-4926

Confirmed kernel references

Detail

Evidence (decisive lines)

panic.txt (two baseline serial-console crash excerpts, both 'Stopped at nvmm_ioctl+0x444: movq 0x48(%rax),%rax', guest down); run.log (racer calibration: 1.1M cap ioctls/s, 20k machine cycles/s as uid 1002); race_driver_fixed.log + run_fixed_racer.log + fix_run.log (patched module: 40/40 EBUSY, no panic, clean final unload); fix_build.log (patched module build)

PoC changes

No executable seed (fresh racer + driver authored). Fixes made while bringing it up: (1) su -m executes -c via root's csh -> 'Ambiguous output redirect'; replaced with a #!/bin/sh wrapper script; (2) racer opens /dev/nvmm O_RDONLY β€” 0640 gives group read-only and DFly ioctls need no FWRITE (O_RDWR got EACCES); (3) driver tolerant of an already-loaded module.

Verified recommended fix

Count open descriptors, refuse MOD_UNLOAD while any are open (or machines exist), and destroy_dev() BEFORE nvmm_fini() β€” see fix.diff.

Verdict

REPRODUCED twice (identical crash site) and FIX VALIDATED dynamically. nvmm_dragonfly.c tears down the hypervisor BEFORE killing the device: nvmm_detach() checks nmachines unsynchronized (:369) and runs nvmm_fini() (:372) β€” lockuninit of all 128x128 machine/vcpu locks, impl->fini(), nvmm_impl=NULL β€” while /dev/nvmm is still dispatching ioctls; destroy_dev() only runs afterwards (:404). An unprivileged group-nvmm user storming NVMM_IOC_CAPABILITY (~1.1M/s) + machine create/destroy cycles (~20k/s) against a root kldunload panicked the stock guest twice at nvmm_ioctl+0x444: movq 0x48(%rax),%rax (rax = nvmm_impl = NULL). With fix.diff (open-count gate + destroy_dev-first ordering), the identical race produced 40/40 EBUSY, zero panics over 36M ioctls / 684k machine cycles, and a clean final unload.