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

nfs_clientd() use-after-free and double-free of the nfsmount on non-KERB NFS mounts: nfs_unmount() frees nmp (and the EINTR path's own dounmount frees both nmp and mp) while the clientd loop keeps executing against the freed memory β€” reproduced kernel double-free panic 2/2

Summary

sys_nfssvc() enters nfs_clientd() for ANY mount passed to nfssvc(NFSSVC_MNTD) - there is no NFSMNT_KERB check - and nfs_clientd() then loops forever re-reading nmp->nm_state every hz/3 while tsleeping on &nmp->nm_authstr. But nfs_unmount() defers the nfsmount free to the clientd only for KERB mounts: for a non-KERB mount it calls nfs_free_mount(nmp) inside VFS_UNMOUNT, leaving the looping clientd on freed memory. Worse, on PCATCH EINTR the clientd itself calls dounmount(nmp->nm_mountp): that dounmount frees nmp (non-KERB) AND mount_drop()s the struct mount, then returns control straight into the loop condition - a read of freed nm_state. When the freed/reused chunk reads with NFSSTA_DISMNT set - deterministic with debug.use_weird_array=1 (slab poison bit 23); probabilistic via chunk reuse at defaults - the teardown walks the dead nm_uidlruhead (wild LIST/TAILQ_REMOVE/kfree) and calls nfs_free_mount(nmp) a SECOND time: kernel double free. Repeated signals re-execute dounmount() through the stale nm_mountp. VERIFIED 2/2 on stock INVARIANTS guest (loopback NFS, nfssvc(NFSSVC_MNTD) child + SIGTERM, and external-umount variant): 'panic: memory chunk ... is already free!'. Privileged trigger (nfssvc SYSCAP_RESTRICTEDROOT) - kernel integrity/robustness against privileged misuse (delegated roots/jails/caps): a root-triggerable heap-corruption primitive. Fix validated in-guest (delegate free to clientd whenever NFSSTA_MNTD + EINTR break on DISMNT): both variants clean.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3255 Β· 13 files
FileTypeDescriptionSize
poc.c β€” 3.8 KB view raw
setup.sh β€” 408 B view raw
build.sh β€” 77 B view raw
run.sh β€” 151 B view raw
build.log β€” 253 B view raw
run.log β€” 1.1 KB view raw
run.2.log β€” 1.6 KB view raw
panic.txt β€” 1.3 KB view raw
fix.log β€” 2.0 KB view raw
fix.diff β€” 2.1 KB view raw
env.txt β€” 965 B view raw
VERDICT.md β€” 5.0 KB ↓ raw
README.md β€” 3.5 KB ↓ raw

DF-3255 β€” nfs_clientd() use-after-free / double-free on non-KERB NFS mounts

Kernel: DragonFly 6.5-DEVELOPMENT x86_64, stock INVARIANTS kernel #0 (X86_64_GENERIC, built Thu Jul 2 06:02:54 UTC 2026).

WHAT

sys/vfs/nfs/nfs_kerb.c nfs_clientd() keeps looping (and tearing down) against the struct nfsmount AFTER it has been freed:

  • The kernel does NOT restrict nfssvc(NFSSVC_MNTD) to kerberos mounts (sys/vfs/nfs/nfs_syscalls.c:160-187 β€” no NFSMNT_KERB check), so a clientd can be started on ANY nfs mount.
  • But nfs_unmount() only defers the nfsmount free to the clientd for KERB mounts: sys/vfs/nfs/nfs_vfsops.c:1230,1242-1244. For a non-KERB mount it kfree()s nmp itself, while nfs_clientd()'s loop (sys/vfs/nfs/nfs_kerb.c:106) is still running in another thread.
  • Two trigger variants, both reproduced: "sig" β€” SIGTERM the daemon: the PCATCH tsleep returns EINTR and nfs_clientd ITSELF calls dounmount(nmp->nm_mountp,0,0) (nfs_kerb.c:126); the non-KERB unmount frees nmp (and mount_drop() frees mp); control returns into the loop which re-reads freed nm_state and runs the teardown (nfs_kerb.c:133-138) on freed memory. "umount" β€” umount(2) by any other thread while the daemon sleeps; the loop wakes <= hz/3 later on freed nmp.
  • Teardown on freed memory ends in nfs_free_mount(nmp) a SECOND time (nfs_kerb.c:138) β†’ kernel double free; the INVARIANTS slab checker catches it: "panic: memory chunk ... is already free!".

Privilege gate: nfssvc(2) requires SYSCAP_RESTRICTEDROOT (nfs_syscalls.c:144) and mount(2)/unmount(2) require root (or vfs.usermount=1) β€” trigger is privileged; this is a robustness/memory- integrity finding, not an unpriv->root escalation.

REPRODUCE (on the DragonFly guest, as root)

# one-time: loopback NFS server + mount (non-kerb) sh setup.sh # crash knob: poison kfree'd memory so the freed nm_state reads with # NFSSTA_DISMNT (bit 23) set and the teardown path runs deterministically sysctl debug.use_weird_array=1 cc -O0 -g -o poc poc.c ./poc /mnt/nfspoc sig # variant 1: SIGTERM self-dounmount ./poc /mnt/nfspoc umount # variant 2: external unmount (re-mount first)

EXPECTED

Baseline (stock kernel): within ~1 s of the trigger the kernel panics with

panic: memory chunk 0xfffff8008971f880 is already free! chunk_mark_free() at chunk_mark_free+0xae slab_cleanup() at slab_cleanup+0xbb slotimer_callback() ... softclock_handler()

(the slab INVARIANTS double-free checker; see panic.txt). Control run (mount + sysctl + umount with NO nfssvc clientd) stays up β€” the clientd is the discriminating factor.

With fix.diff applied (kernel rebuilt in-guest): both variants complete cleanly, the daemon exits, the mount unmounts, guest stays up, no panic (see VERDICT.md).

FILES

poc.c minimal reproduction (struct nfsd_cargs layout verbatim from sys/vfs/nfs/nfs.h:215-224; nfssvc = syscall 155) setup.sh loopback NFS server + mount setup build.sh build the PoC run.sh exact decisive run (variant "sig") panic.txt verbatim serial-console panic (run 1) run.log / run.2.log / control.log full runtime output fix.diff git-apply-able fix (nfs_vfsops.c + nfs_kerb.c) fix.log fix-validation transcript (build + both variants clean) VERDICT.md full narrative manifest.json / verdict.json

VERDICT.md
↓ download raw

VERDICT β€” DF-3255

FINDING: nfs_clientd() use-after-free / double-free of the nfsmount on non-KERB NFS mounts. sys/vfs/nfs/nfs_kerb.c:106-141 (loop + teardown), :126 (self-dounmount); free at sys/vfs/nfs/nfs_vfsops.c:1242-1244.

VERDICT: REPRODUCED (2/2 trigger variants + clean discriminating control), impact = panic (kernel double free / memory corruption primitive), root-gated trigger.

ROOT CAUSE (path:line)

  1. sys_nfssvc() enters nfs_clientd() for ANY mount passed to nfssvc(NFSSVC_MNTD) β€” there is no NFSMNT_KERB check (sys/vfs/nfs/nfs_syscalls.c:160-187). The flag is simply recorded (NFSSTA_MNTD, :186) and nfs_clientd() runs (:187) sleeping in its "nqnfstimr" loop on &nmp->nm_authstr (sys/vfs/nfs/nfs_kerb.c:123-124), re-reading nmp->nm_state every hz/3 (:106).
  2. nfs_unmount() defers the nfsmount free to the clientd ONLY for kerberos mounts: if (nmp->nm_flag & NFSMNT_KERB) nmp->nm_state |= NFSSTA_DISMNT; and if ((nmp->nm_flag & NFSMNT_KERB) == 0) nfs_free_mount(nmp); (sys/vfs/nfs/nfs_vfsops.c:1230, 1242-1244). For a non-KERB mount with a clientd running, VFS_UNMOUNT kfree()s nmp while nfs_clientd() still references it β€” and nfs_clientd() itself DRIVES the unmount on EINTR: if (error == EINTR || error == ERESTART) (void) dounmount(nmp->nm_mountp, 0, 0); (nfs_kerb.c:125-126). dounmount() also mount_drop()s and kfree()s the struct mount, so nmp->nm_mountp is stale immediately after.
  3. Control returns from dounmount() straight into the loop condition (:106) β€” a read of freed memory. When the freed/reused chunk reads with NFSSTA_DISMNT (0x00800000) set (deterministic with debug.use_weird_array=1: the INVARIANTS slab poison WEIRD_ADDR 0xdeadc0de has bit 23 set β€” sys/kern/kern_slaballoc.c:191,232-234; or probabilistic via chunk reuse), the loop falls out and executes the teardown (nfs_kerb.c:133-138): TAILQ/LIST_REMOVE walk over the dead nm_uidlruhead and nfs_free_mount(nmp) a SECOND time β†’ kernel double free. Repeated signals re-execute dounmount(nmp->nm_mountp) on freed pointers.

REPRODUCTION

Baseline stock INVARIANTS kernel #0 (X86_64_GENERIC), loopback NFS (127.0.0.1:/tmp on /mnt/nfspoc, non-kerb), debug.use_weird_array=1: * variant "sig" β†’ panic: memory chunk 0xfffff8008971f880 is already free! (chunk_mark_free/slab_cleanup/slotimer) * variant "umount" β†’ same panic (cpuid 1); child's nfssvc returned 0 first β€” the ENTIRE teardown ran on the freed nfsmount and returned before the slab checker fired. * control (identical setup, NO clientd) β†’ no panic, guest stays up. See run.log, run.2.log, panic.txt.

EXPLOIT CHAIN (why it stops at panic)

nfssvc(2) is gated by caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT) (sys/vfs/nfs/nfs_syscalls.c:144) and mounting/unmounting requires root (vfs.usermount=0 by default). The trigger is therefore privileged; there is no unprivileged→root escalation path through this bug. What the primitive gives a privileged caller is kernel heap corruption: post-free execution of nfs_free_mount() (crfree/kfree/destroy_obj through a reused chunk) and a LIST_REMOVE walk over attacker-influenceable reused memory — i.e., the classic slab-groom double-free, relevant for integrity (zero-trust/containers, jailed roots, capability-delegated subroots) but not for unpriv→uid0.

FIX (validated)

fix.diff β€” sys/vfs/nfs/nfs_vfsops.c: nfs_unmount() delegates the nfsmount free to nfs_clientd() whenever NFSSTA_MNTD indicates a clientd is (or was) active (not just KERB), sets NFSSTA_DISMNT + wakeup() as its LAST action on nmp, and frees only when no clientd can be looping; this also fixes the pre-existing leak of kerb nfsmounts whose clientd never ran. sys/vfs/nfs/nfs_kerb.c: the EINTR path breaks out before touching nm_mountp if NFSSTA_DISMNT is already set (external unmount already tore the mount down). Validated in-guest: kernel #1 rebuilt (make -j6 nativekernel, BUILD_RC=0, 0 errors), installed, booted; BOTH variants re-run cleanly (no panic, single-free teardown, child exits, guest up); control unchanged. See fix.log.

RESIDUAL (documented, much narrower than the bug fixed): the clientd call still does not hold a mount reference across its lifetime; a fully-correct fix would vfs_hold(mp) at nfs_clientd() entry / vfs_drop at exit. If a clientd exits via the ENEEDAUTH return and userland never re-enters, the delegated free never happens (leak, no UAF) β€” same as the historical KERB behavior.

CONFIDENCE: certain (2/2 panic reproductions, clean control, fix validated).

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 in-guest /usr/src; make -j6 nativekernel BUILD_RC=0 (0 errors); installkernel + reboot into kernel #1; exact PoC re-run: variant sig clean (child exits, no panic), variant umount clean (child returns 0 via single-free teardown), control unchanged; guest stayed up both times. Baseline #0 panicked on both variants.

['fix.log (full transcript)', 'fix.diff (sha256 70b683729bf367fce7c2e381a3975f556c696cbc5e644c8f39d89ccabc03916e)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Wed Sep 9 16:33:56 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root: mount loopback nfs -> fork; child syscall(155, NFSSVC_MNTD, dir) enters nfs_clientd and tsleeps; parent SIGTERM (or umount) -> nfs_unmount nfs_free_mount(nmp) kfree while clientd loops -> clientd re-reads freed nm_state (poison 0xdeadc0de has NFSSTA_DISMNT bit set) -> falls out of loop -> TAILQ/LIST_REMOVE walk over dead nm_uidlruhead -> nfs_free_mount(nmp) AGAIN -> slab double-free panic. Escalation to uid0: not applicable (PR:H trigger); impact is privileged-callable kernel heap corruption.

Evidence (decisive lines)

["panic.txt: 'panic: memory chunk 0xfffff8008971f880 is already free!' chunk_mark_free/slab_cleanup trace", "run.2.log: variant umount β€” 'child: nfssvc returned 0' BEFORE the panic (full post-free teardown execution)", 'run.log + run.2.log control section: no-clientd control stays up', 'fix.log: patched kernel #1 β€” both variants clean, guest up', 'VERDICT.md: full path:line narrative']

PoC changes

Added (unmount) and (waitpid/WNOHANG) to the seed layout; struct nfsd_cargs replicated verbatim from sys/vfs/nfs/nfs.h:215-224 with NFSKERBKEY_T=u_char[2]; nfssvc hardcoded as syscall 155; added variant selector (sig/umount) and a stuck-child fallback that re-signals to re-exercise the EINTR dounmount path.

Verified recommended fix

nfs_unmount: delegate the nfsmount free to nfs_clientd whenever NFSSTA_MNTD is set (set NFSSTA_DISMNT + wakeup as the LAST nmp action); nfs_clientd EINTR path: break before touching nm_mountp if DISMNT already set

Verdict

REPRODUCED 2/2 (variants 'sig' self-dounmount and 'umount' external) on the stock INVARIANTS guest: nfs_clientd() executes its entire teardown against a kfree()'d struct nfsmount and calls nfs_free_mount(nmp) a second time -> 'panic: memory chunk 0x... is already free!' from the INVARIANTS slab double-free checker (chunk_mark_free/slab_cleanup). Discriminating control (mount+poison+umount with NO nfssvc clientd) stays up. Root-gated trigger (nfssvc requires SYSCAP_RESTRICTEDROOT, nfs_syscalls.c:144) so no unpriv->uid0 chain exists; the primitive is a root-triggerable kernel double-free/wild-LIST_REMOVE. fix.diff validated in-guest: kernel #1 rebuilt and booted, both PoC variants run clean (single-free teardown, child exits, guest up).