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)
PoC verification
Evidence pack
findings/poc/DF-3255 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 β 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)
- 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).
- nfs_unmount() defers the nfsmount free to the clientd ONLY for kerberos
mounts:
if (nmp->nm_flag & NFSMNT_KERB) nmp->nm_state |= NFSSTA_DISMNT;andif ((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. - 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
fixedfix.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)']
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
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).
No comments yet.