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

nfs_unmount() continues teardown after vflush(FORCECLOSE) fails on a busy device vnode: frees the nfsmount and destroys the nfsnode objcache with a live nfsnode (UAF condition, allocator-confirmed) and dounmount() then panics deterministically ('unmount: dangling vnode')

Summary

vflush(mp,1,FORCECLOSE) can fail: vflush_scan() clears FORCECLOSE for VCHR/VBLK vnodes and counts them busy (vfs_mount.c:1145-1146/1194), returning EBUSY whenever a device vnode on the NFS mount still has VREFCNT>1. nfs_unmount() treats FORCECLOSE failure as 'continue anyway' (:1218-1224) and completes the teardown - nfs_free_mount() does kmalloc_destroy_obj(&nmp->nm_mnode) and kfree(nmp) while the surviving device vnode and its nfsnode are still alive. dounmount() then panics unconditionally because the vnode never left mnt_nvnodelist (vfs_syscalls.c:1085-1086). VERIFIED on guest: 'vflush: Warning, cannot destroy busy device vnode', 'NFS inodes: slab ... 1 objects were still allocated', then 'panic: unmount: dangling vnode' at dounmount+0x87c. The device vnode comes from the NFS server (LOOKUP/GETATTR attrs type=NF3CHR - the DF-2998 server-retyping surface) or any device node on the export. Any unpriv local user primes the state permanently: open the device node, park the fd in a unix-socket SCM_RIGHTS receive buffer - the fd belongs to no process so dounmount's kill-retry loop finds nobody to kill. Root's routine 'umount -f' (the documented remedy for a stale/hung NFS mount - precisely what a hostile server creates) then panics after freeing live kernel state. A blocked-in-NFS holder does NOT work (nfs_sigintr aborts all in-flight RPCs once MNTK_UNMOUNTF set) - SCM_RIGHTS parking is the robust primitive. Impact honestly bounded at deterministic panic/local DoS: UAF window real (allocator-confirmed) but panic-gated in-syscall before another thread can act on the freed nmp. Fix validated in-guest (return vflush error unconditionally + clear NFSMNT_FORCE so the surviving mount is not wedged): same PoC returns EBUSY, guest alive, mount recoverable.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3073 Β· 16 files
FileTypeDescriptionSize
README.md β€” 4.1 KB ↓ raw
VERDICT.md β€” 5.8 KB ↓ raw
fakesrv2.c β€” 15.0 KB view raw
holder.c β€” 1.6 KB view raw
holder2.c β€” 2.8 KB view raw
build.sh β€” 218 B view raw
run.sh β€” 1.8 KB view raw
build.log β€” 260 B view raw
run.log β€” 2.4 KB view raw
run.fixed.log β€” 2.0 KB view raw
panic.txt β€” 991 B view raw
fixed.console.txt β€” 946 B view raw
env.txt β€” 1.0 KB view raw
fix.diff β€” 438 B view raw
verdict.json β€” 6.1 KB view raw
manifest.json β€” 1.3 KB view raw

DF-3073 β€” nfs_unmount frees the nfsmount after a failed forced vflush (busy device vnode) β†’ use-after-free + deterministic "unmount: dangling vnode" panic

What

sys/vfs/nfs/nfs_vfsops.c:nfs_unmount() (lines 1217-1224): when vflush(mp, 1, flags) fails, NFS continues tearing down the mount anyway if MNT_FORCE was requested ("If this doesn't work and we are doing a forced unmount we continue anyway"). But vflush() can legitimately fail even with FORCECLOSE: vflush_scan() (sys/kern/vfs_mount.c:1145-1146) clears FORCECLOSE for VCHR/VBLK vnodes and counts them busy (:1194), returning EBUSY whenever a device vnode on the mount still has VREFCNT>1.

Continuing anyway means:

  1. nfssvc_iod_stop1/2, nfs_disconnect, removal from nfs_mountq, then nfs_free_mount(nmp) (nfs_vfsops.c:1232-1243) β€” which does kmalloc_destroy_obj(&nmp->nm_mnode) and kfree(nmp, M_NFS) while the surviving device vnode and its nfsnode are still alive (nfs_free_mount, nfs_vfsops.c:1248-1262; allocator warning "NFS inodes: … 1 objects were still allocated" on the INVARIANTS kernel). From this point any touch of that vnode (nfs_inactive β†’ lwkt_gettoken(&nmp->nm_token) at sys/vfs/nfs/nfs_node.c:383-387; nfs_reclaim β†’ kfree_obj(np, nmp->nm_mnode) at nfs_node.c:439,485) is a use-after-free on the freed nmp and on a destroyed malloc type (kmalloc_destroy frees the struct malloc_type itself, sys/kern/kern_slaballoc.c:624-631).
  2. dounmount() then panics unconditionally because the vnode is still on mp->mnt_nvnodelist: panic("unmount: dangling vnode") (sys/kern/vfs_syscalls.c:1085-1086) β€” observed as dounmount+0x87c.

Threat model

  • The device vnode itself comes from the NFS server (LOOKUP/GETATTR attributes type=NF3CHR with a chosen rdev β€” the DF-2998 server-side-retyping surface), or from a device node root created on the export. A hostile/compromised server controls this fully.
  • Any unprivileged local user can hold the device vnode referenced in a way no unmount cleanup can break: open the node, then park the fd in a unix-socket SCM_RIGHTS receive buffer (holder2.c). The fd then belongs to no process, so dounmount()'s kill-retry loop (unmount_allproc_cb/process_uses_mount, SIGINT retry 3 / SIGKILL retry 7, vfs_syscalls.c:768-947) matches nobody; the namecache ref and vnode ref persist indefinitely.
  • Root's routine umount -f of a (e.g. stale) NFS mount then takes the machine down, after having freed the mount's kernel state out from under a live vnode.

Files

  • fakesrv2.c β€” loopback fake rpcbind/mountd/NFSv3 server (TCP): serves a root dir with dev (VCHR, rdev 14:2 = /dev/null on the stock guest) and slow (VREG). holder.c (NFS-blocked variant) is kept for history; holder2.c (SCM_RIGHTS variant) is the one that works β€” nfs_sigintr() aborts every in-flight RPC the moment MNTK_UNMOUNTF is set (nfs_socket.c:2059-2066), so an NFS-blocked process is always released by umount -f.
  • build.sh / run.sh β€” compile and three-phase run (control, EBUSY, panic).

Build

sh build.sh        # cc -O2 -o fakesrv2 fakesrv2.c; cc -O2 -o holder2 holder2.c

Run (as root on the guest)

sh run.sh
# phase A: umount -f with no holder   -> clean (control)
# phase B: holder2 + umount           -> "Device busy" (control)
# phase C: umount -f                  -> ~13s retry loop, then panic:
#   unmount(/mnt/nfsx): ... N namecache refs, N mount refs still present
#   vflush: Warning, cannot destroy busy device vnode
#   NFS inodes: slab ... 1 objects were still allocated
#   malloc_uninit: 576 bytes of 'NFS inodes' still allocated on cpu 6
#   panic: unmount: dangling vnode
#   dounmount() at dounmount+0x87c
# (ssh session dies; capture is on the serial console / vm.sh log)

Expected outcome

Stock kernel: deterministic kernel panic (panic.txt). Patched kernel (fix.diff): phase C prints C_UMOUNT_FORCE_RC=1 (EBUSY), the guest stays up, the mount stays usable, and after the holder releases (kill holder2) umount -f succeeds normally.

VERDICT.md
↓ download raw

DF-3073 VERDICT β€” nfs_unmount: use-after-free + deterministic panic after failed forced vflush

Bottom line

REPRODUCED (impact: panic β€” deterministic local kernel DoS with a proven kernel use-after-free window immediately before the panic). On the stock INVARIANTS kernel, umount -f of an NFS mount while a referenced device vnode lives on the mount (held open by an unprivileged user via an SCM_RIGHTS-parked descriptor, device node supplied by the NFS server) makes nfs_unmount() free the nfsmount and destroy the nfsnode objcache with the device vnode's nfsnode still allocated, after which dounmount() panics unconditionally. The allocator's own NFS inodes: ... 1 objects were still allocated warning on the console is direct evidence of the lifetime violation; without INVARIANTS the same sequence silently leaves a freed nmp behind a live vnode (nfs_inactive/nfs_reclaim would then operate on freed memory: sys/vfs/nfs/nfs_node.c:383-387 and :439,485), but the panic("unmount: dangling vnode") at sys/kern/vfs_syscalls.c:1085-1086 fires microseconds later in the same syscall, so the realistic ceiling on this path is a reliable panic rather than a controllable corruption.

Root cause chain (all source-cited, all observed on the guest)

  1. dounmount() sets MNTK_UNMOUNTF and runs a 50Γ—250ms kill-retry loop (vfs_syscalls.c:839-972) that SIGINTs (retry 3) / SIGKILLs (retry 7) every process matching process_uses_mount() (:727-757). A descriptor parked in a unix-socket receive buffer belongs to no process, so nobody is killed; its f_nchandle keeps a namecache ref and a mount ref ("1 namecache refs, 1 mount refs still present" on the console).
  2. VFS_UNMOUNT β†’ nfs_unmount() β†’ vflush(mp, 1, FORCECLOSE). vflush_scan() clears FORCECLOSE for VCHR/VBLK vnodes (sys/kern/vfs_mount.c:1145-1146) and counts them busy (:1194) when VREFCNT>1 β€” the parked fp's vnode ref + the surviving ncp's ref give exactly that. Console: vflush: Warning, cannot destroy busy device vnode; vflush returns EBUSY.
  3. nfs_unmount() ignores the failure because FORCECLOSE was requested (sys/vfs/nfs/nfs_vfsops.c:1218-1224 "forced unmount we continue anyway") and completes the teardown: nfssvc_iod_stop1/2, nfs_disconnect, removal from nfs_mountq, then nfs_free_mount(nmp) (:1232-1243) β†’ kmalloc_destroy_obj(&nmp->nm_mnode) + kfree(nmp, M_NFS) (:1248-1262). The still-referenced device vnode keeps its nfsnode (allocated from that objcache) and its v_mount->mnt_data == freed nmp. Console: NFS inodes: slab ... 1 objects were still allocated + malloc_uninit: 576 bytes of 'NFS inodes' still allocated on cpu 6.
  4. Back in dounmount(): panic("unmount: dangling vnode") (vfs_syscalls.c:1085-1086) because the vnode never left mp->mnt_nvnodelist. Backtrace: dounmount+0x87c ← sys_unmount.

Why the holder must not be NFS-blocked (design detail)

First attempt (holder.c) blocked the holder inside a withheld READ RPC on a hard mount. That fails: nfs_sigintr() returns EINTR for every request as soon as MNTK_UNMOUNTF is set (sys/vfs/nfs/nfs_socket.c:2059-2066), the blocked process becomes killable, retry-7 SIGKILL closes its fds, namecache refs drop, vflush succeeds and umount -f returns 0 in ~2s (observed, run.log session 2). The SCM_RIGHTS variant (holder2.c) holds no NFS state at all β€” only the socket β€” and survives the entire kill-retry loop (12.5s, observed).

Reproduction

Baseline (stock kernel #0 Thu Jul 2 06:02:54 UTC 2026): see run.sh / run.log / panic.txt. Deterministic across the run (single decisive run after two control runs; the panic path is unconditional once vflush fails β€” no race involved).

Controls observed on the same setup: - umount -f with no holder: clean unmount (RC=0). - umount (no force) with holder: clean EBUSY, mount intact. - umount -f with NFS-blocked holder: holder killed, clean unmount (MNTK_UNMOUNTF aborts its RPC) β€” demonstrates the abort-everything semantics that the SCM_RIGHTS variant sidesteps.

Exploitability assessment (honest)

  • Deterministic: kernel panic, any user who can get a device-node fd parked on an NFS mount + a root umount -f.
  • The UAF window (freed nmp behind a live vnode) provably exists (the allocator reports the leaked-live object at destroy time), but the unconditional dounmount panic fires within the same syscall, in-line, before any other thread can act on the vnode (the vnode is VX-unlocked after the vflush scan, yet the panic check runs with only journal_remove_all_journals/mountlist_remove/vfs_rm_vnodeops in between). On this guest (UP-class INVARIANTS kernel) there is no realistic window to convert the free into controlled corruption before the panic; on an SMP box the theoretical window is the same few function calls. I therefore classify impact as panic (local DoS), not privesc.
  • Severity context: the unprivileged user only primes the state; the final trigger is root's umount -f β€” the documented remedy for a stale NFS mount, i.e. an action an admin is more likely to take when a hostile server is involved.

Fix validation

fix.diff (in this pack): nfs_unmount() returns the vflush error unconditionally and clears NFSMNT_FORCE on that path (the flag would otherwise permanently wedge the still-mounted filesystem β€” nfs_socket.c:200,1288).

Patched kernel (built in-guest: make nativekernel KERNCONF=X86_64_GENERIC after applying fix.diff to /usr/src): the exact PoC rerun yields umount -f β†’ EBUSY, no panic, guest stays up, mount intact; after the holder releases the parked descriptor, umount -f succeeds normally. Baseline panic vs patched EBUSY captured in run.fixed.log / fixed.console.txt.

Environment

See env.txt (guest uname, compiler, sysctls, listeners).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched kernel built in-guest (make nativekernel KERNCONF=X86_64_GENERIC) with fix.diff applied. Exact PoC rerun: umount -f returns EBUSY after the ~14s retry loop, console shows 'vfs refused to unmount, error 16' with NO objcache-destroy-with-live-objects warning and NO panic; guest stays up; after the holder releases the parked descriptor umount -f succeeds cleanly (RC=0). Baseline panic is gone.

['run.fixed.log: UMNTF_RC=1 on patched kernel (baseline: panic), mount intact, clean unmount after holder release', "fixed.console.txt: 'vflush: Warning, cannot destroy busy device vnode' followed by 'unmount: vfs refused to unmount, error 16' - no 'NFS inodes ... still allocated', no panic", 'fix.diff: the one-hunk fix (return error + clear NFSMNT_FORCE)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 5 23:42:21 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unprivileged user opens server-injected device node on an NFS mount -> parks fd in unix-socket SCM_RIGHTS receive buffer (no process 'uses' the mount; kill-retry loop finds nobody) -> root runs umount -f -> 50x250ms retry loop exhausts (namecache+mount refs held by the buffered fp) -> vflush(mp,1,FORCECLOSE) returns EBUSY because vflush_scan clears FORCECLOSE for VCHR and counts it busy -> nfs_unmount continues anyway: nfs_free_mount() kfrees nmp and destroys nm_mnode with the dev vnode's nfsnode still allocated (use-after-free condition; later nfs_inactive/nfs_reclaim on that vnode would deref freed nmp) -> dounmount panics 'unmount: dangling vnode'. Ceiling on this path: deterministic panic (the panic fires in the same syscall before the freed state can be acted on by another thread).

Evidence (decisive lines)

["panic.txt: console capture - 'vflush: Warning, cannot destroy busy device vnode' / 'NFS inodes: slab ... 1 objects were still allocated' / 'malloc_uninit: 576 bytes of NFS inodes still allocated' / 'panic: unmount: dangling vnode' / dounmount+0x87c <- sys_unmount", 'run.log: session 3 (decisive) - holder2 DEV_OPEN_OK/FD_PARKED as nobody, umount -f session dies at 23:26:19; sessions 1-2 are controls (clean umount -f without holder; NFS-blocked holder killed by MNTK_UNMOUNTF abort semantics)', "run.fixed.log + fixed.console.txt: patched kernel - same PoC gives UMNTF_RC=1 (EBUSY), guest alive, 'vfs refused to unmount, error 16', clean unmount after holder release", 'fakesrv2.c/holder2.c + build.sh/run.sh: exact reproducible sources and commands']

PoC changes

Rewrote the DF-3009 seed harness into a functioning NFSv3 server (fakesrv2.c): fixed a byte-swapped SunRPC record mark in send_record() (inherited from the DF-3009 seed - only its literal 0x80000000 mark was correct), added missing nfsstat words in ACCESS/FSSTAT/FSINFO/READDIR/READ replies, added LOOKUP serving a VCHR node with rdev 14:2 and an empty-dir READDIR(+). Replaced the seed's 'blocked holder' idea with holder2.c: a blocked-in-NFS holder cannot work because nfs_sigintr() aborts all RPCs once MNTK_UNMOUNTF is set; the working primitive is an SCM_RIGHTS-parked descriptor that no process owns.

Verified recommended fix

nfs_unmount(): on vflush() failure return the error unconditionally (also for MNT_FORCE) and clear NFSMNT_FORCE before returning - see fix.diff

Verdict

REPRODUCED on the stock INVARIANTS guest: nfs_unmount() ignores vflush() failure under MNT_FORCE and frees the nfsmount (destroying the nfsnode objcache) while a referenced VCHR vnode and its nfsnode are still alive - allocator warning 'NFS inodes: ... 1 objects were still allocated' - after which dounmount() panics unconditionally ('unmount: dangling vnode', dounmount+0x87c). The referenced device vnode is supplied by the NFS server (DF-2998 attribute surface) and held open by an unprivileged user via an SCM_RIGHTS-parked descriptor that dounmount's kill-retry loop cannot touch. Deterministic local kernel panic (root pulls the umount -f trigger); the UAF window provably exists but is panic-gated in-syscall, so realistic impact is DoS, not privesc. Fix validated: with nfs_unmount returning the vflush error and clearing NFSMNT_FORCE, the identical PoC yields EBUSY with the guest alive and the mount recoverable.