nullfs_unmount() frees mnt_data while callers without unmount interlock can still reach it: kern_statfs/kern_fstatfs/sys_quotactl dereference a dead nullfs mount β deterministic NULL-deref panic (kernel DoS) triggered by an unprivileged user around umount -f; race-window UAF read of the freed null_mount
Summary
nullfs_unmount (:261-267) unconditionally zeroes mp->mnt_data and kfrees the null_mount. dounmount(MNT_FORCE) is permitted to call VFS_UNMOUNT with outstanding mount references once its 50x250ms retry loop exhausts (vfs_syscalls.c:964-1006), and the umount -f kill-scan only scans fd tables/cwd - a struct file parked in a socket receive buffer via SCM_RIGHTS keeps an invisible mount ref and survives. kern_fstatfs/kern_statfs/sys_quotactl then call VFS_STATFS/VFS_QUOTACTL on the dead mount with no vfs_busy/MNTK_UNMOUNT interlock (unlike kern_mountctl and mountlist_scan); nullfs_statfs:308/nullfs_quotactl:294/nullfs_checkexp:341 execute MOUNTTONULLMOUNT(mp)->nullm_vfs - a load from kernel VA 0 after the zeroing, or a UAF read of the freed 88-byte chunk in the pure race variant. VERIFIED deterministic chain on guest: root mounts nullfs; unpriv parks fp via SCM_RIGHTS over socketpair; umount -f exhausts retry loop ('forcing unmount'), mnt_data=0; unpriv recvmsg() recovers fd, fstatfs(2) -> Fatal trap 12 fault VA 0x0 'Stopped at nullfs_statfs+0x32', guest lost to DDB. Escalation ceiling honestly DoS: NULL-deref read + non-sprayable freed-chunk read, no write primitive, no uid0 route. Fix validated on rebuilt kernel (vfs_busy LK_NOWAIT interlock around the three VFS call sites): patched returns EBUSY, no panic, guest up across 4 rounds. With vfs.usermount=1 the whole chain incl. umount -f runs unprivileged.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3207 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.5 KB | β raw | |
| VERDICT.md | β | 6.2 KB | β raw | |
| ghost.c | β | 3.9 KB | view raw | |
| det_driver.sh | β | 1.2 KB | view raw | |
| spam.c | β | 2.4 KB | view raw | |
| driver.sh | β | 520 B | view raw | |
| fix.diff | β | 2.0 KB | view raw | |
| panic.txt | β | 2.1 KB | view raw | |
| fix_run.log | β | 345 B | view raw | |
| fix_run2.log | β | 1.1 KB | view raw | |
| build.log | β | 215 B | view raw | |
| env.txt | β | 613 B | view raw | |
| SHA256SUMS | β | 189 B | β download | |
| verdict.json | β | 5.3 KB | view raw |
DF-3207 β nullfs VFS ops race forced unmount: mnt_data NULL-deref / UAF
What
kern_statfs() (sys/kern/vfs_syscalls.c:1428-1436), kern_fstatfs()
(:1495-1512) and sys_quotactl() (:1244-1247) call
VFS_STATFS/VFS_QUOTACTL on a mount pointer obtained from an nchandle or
fp->f_nchandle without vfs_busy() or an MNTK_UNMOUNT interlock
(unlike kern_mountctl() at :1401 and mountlist_scan() at
sys/kern/vfs_mount.c:763, which both interlock).
Under umount -f, dounmount() is allowed to proceed to VFS_UNMOUNT
while mount references are still outstanding: after its 50x250ms retry
loop exhausts it just sets freeok = 0 and calls VFS_UNMOUNT anyway
(sys/kern/vfs_syscalls.c:964-1006). nullfs_unmount()
(sys/vfs/nullfs/null_vfsops.c:261-267) then does
xmp = (void *)mp->mnt_data;
mp->mnt_data = 0;
...
kfree(xmp, M_NULLFSMNT);
unconditionally. Any VFS op that reaches the nullfs mount after that
dereferences MOUNTTONULLMOUNT(mp)->nullm_vfs
(nullfs_statfs at null_vfsops.c:308, nullfs_quotactl at :294,
nullfs_checkexp at :341) β a read from kernel VA 0 (deterministic
panic) or a use-after-free read of the freed null_mount.
A mount reference pins only struct mount, never mnt_data. This is the
gap the nullfs pass-2 note ("every VOP caller holds nch + mount ref") missed.
Deterministic trigger (verified)
The retry-loop race is hard to win (an in-flight statfs holds the mount
ref that blocks dounmount from proceeding until the statfs finishes),
but the exhausted-force path makes it deterministic with a mount
reference that is invisible to the unmount kill-scan:
mount_null /tmp/t /mnt/a(root).- Unprivileged process (
test): open/mnt/a/f1,sendmsg()the fd over anAF_UNIXsocketpair withSCM_RIGHTS, close it. The parkedstruct filekeepsfp->f_nchandle.mount== nullfs mp (a mount ref) in the socket receive buffer, whereprocess_uses_mount()(vfs_syscalls.c:727-757) cannot see it (it only scans fd tables and cwd/root/jail dirs) β so theumount -fSIGINT/SIGKILL scans pass it. - Root:
umount -f /mnt/aβ exhausts 50x250ms retries (refs==2: ours is the invisible one), printsforcing unmount, runsVFS_UNMOUNTβmnt_data = 0,kfree(xmp), returns 0. The mount structure stays allocated (leaked,freeok == 0). - Unprivileged process:
recvmsg()the fd back, callfstatfs(2):kern_fstatfstakesmp = fp->f_nchandle.mount(dead mount),VFS_STATFSβnullfs_statfsβMOUNTTONULLMOUNT(mp)->nullm_vfsβ read at VA 0 β fatal trap 12, kernel panic.
Baseline result (stock kernel #0):
Fatal user address access from kernel mode from ghost at ffffffff807105e2 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x0 current process = 6405 (ghost, uid=test) Stopped at nullfs_statfs+0x32: movq (%rax),%rdi
(see panic.txt; guest sat in DDB, ssh dead, vm.sh status => down)
With vfs.usermount=1 (non-default) an unprivileged user owning the mount
can run the umount -f step too, making the whole chain unprivileged.
The in-flight race variant (statfs/quotactl straddling step 3) is the same
bug with a tighter window.
Ceiling
- Deterministic outcome is the NULL-deref panic (kernel DoS).
- The UAF-read variant (thread loads
mnt_databefore:262, dereferences afterkfree) reads a freed 88-byteM_NULLFSMNTchunk. That malloc type is nullfs-private: reallocation content is only ever written bynullfs_mount(M_ZERO + two kernel pointers), so an attacker cannot plant controlled data;nullm_vfsresolves to a valid mount or garbage β panic. No path to uid=0 was found; the primitive is not a controlled write. Classified DoS/panic, not privesc.
Files
ghost.cunprivileged trigger (park fd via SCM_RIGHTS, recover, fstatfs)det_driver.shroot-side driver (mount_null / umount -f / signal)spam.c,driver.shβ brute-force race variant (2400 rounds, no hit; the retry loop self-serializes against in-flight calls β see VERDICT.md)fix.diffinterlock fix (vfs_busy/vfs_unbusyin kern_statfs / kern_fstatfs / sys_quotactl)panic.txt,fix_run.log,fix_run2.log,env.txt
Reproduce
# in guest, as root: /root/det_driver.sh 1 # round 1: parked; umount -f (takes ~13s)... # round 1: umount rc=0; firing fstatfs # -> stock kernel: Fatal trap 12, Stopped at nullfs_statfs+0x32 # -> patched kernel: "fstatfs returned errno=16 (no panic)"
DF-3207 VERDICT
status: reproduced / impact: panic (kernel DoS) / confidence: certain
Root cause (line-accurate)
-
sys/vfs/nullfs/null_vfsops.c:261-267βnullfs_unmount()frees the filesystem-privatenull_mountand NULLsmp->mnt_datawith no protection for late callers. This is by itself fine if the vfs core guarantees no VFS op can be in flight or arrive later β it doesn't underumount -f. -
sys/kern/vfs_syscalls.c:964-1006βdounmount(MNT_FORCE): when the 50x250ms retry loop exhausts withmp->mnt_refs != 1, it prints "forcing unmount", setsfreeok = 0and still calls VFS_UNMOUNT (error remains 0). Thestruct mountis intentionally leaked (freeok == 0skips thewhile (mp->mnt_refs > 0)wait at:1107-1121) β the kernel's own contract is that the leaked mount may still be referenced, yet filesystem-private data is already freed. -
sys/kern/vfs_syscalls.c:727-757βprocess_uses_mount()only scans fd tables and cwd/root/jail nhandles. Astruct fileparked in a socket receive buffer viaSCM_RIGHTSkeepsfp->f_nchandle.mount(mount ref) on the nullfs mount invisibly: theumount -fSIGINT/SIGKILL scans (retries 3 and 7,:931-947) never kill its owner. -
sys/kern/vfs_syscalls.c:1495-1512βkern_fstatfs()takesmp = fp->f_nchandle.mountand callsVFS_STATFS(mp, ...)with novfs_busy()/MNTK_UNMOUNTinterlock. Same gap inkern_statfs()(:1428-1436) andsys_quotactl()(:1244-1247). Contrastkern_mountctl()(:1401: explicitMNTK_UNMOUNTcheck, "racing unmount" warning) andmountlist_scan()(sys/kern/vfs_mount.c:763:vfs_busy(mp, LK_NOWAIT)), which both do interlock. -
sys/vfs/nullfs/null_vfsops.c:298-310βnullfs_statfs()executesVFS_STATFS(MOUNTTONULLMOUNT(mp)->nullm_vfs, ...); withmp->mnt_data == NULLthat is a load from kernel VA 0.
How it was reproduced (guest, DragonFly 6.5-DEVELOPMENT #0, 6 CPUs)
Deterministic chain (see README): mount_null as root; unpriv user test
parks an fd on /mnt/a/f1 in a socketpair via SCM_RIGHTS; root
umount -f /mnt/a exhausts its retry loop (dmesg: "0 namecache refs,
1 mount refs still present" -> "forcing unmount") and frees null_mount;
unpriv user recovers the fd with recvmsg() and calls fstatfs(2).
Serial console (panic.txt):
Fatal user address access from kernel mode from ghost at ffffffff807105e2 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x0 code segment = ... current process = 6405 Stopped at nullfs_statfs+0x32: movq (%rax),%rdi
nullfs_statfs+0x32 is exactly the MOUNTTONULLMOUNT(mp)->nullm_vfs
load with %rax == 0 (mnt_data NULLed by nullfs_unmount:262).
ssh died, vm.sh status => down (DDB prompt db>).
The brute-force race variant (spam.c + driver.sh, 2400
mount/umount- -f rounds with 12 unpriv statfs/fstatfs/quotactl threads)
did NOT hit β and the source explains why: an in-flight statfs holds the
mount ref that keeps dounmount's retry loop spinning until the statfs
completes; the straddle window requires a thread stalled >= 12.5s inside
a ~50ns window. The exhausted-force path (above) removes the race
entirely. Both variants are the same missing interlock.
Why not uid=0
The memory-unsafety primitive is (a) a NULL-deref read (deterministic,
panic) and (b) a read of a freed 88-byte M_NULLFSMNT chunk (race
variant). M_NULLFSMNT is a nullfs-private malloc type: the only code
that allocates it is nullfs_mount:120 (M_ZERO, then two kernel
pointers). An attacker cannot spray controlled content into a freed
chunk of that type (mount_null requires root unless vfs.usermount=1,
and even then the content is M_ZERO + kernel pointers). The stale
nullm_vfs read resolves to a valid mount or garbage β worst case a
call through a garbage vfsops pointer (panic), not a controlled call.
No write primitive, no forge -> no escalation chain. Honest ceiling:
kernel panic / local DoS. (With vfs.usermount=1, an unprivileged user
who owns the nullfs mount can execute the entire chain including
umount -f β still a panic, not privesc.)
Fix validation (mandatory, done)
fix.diff (authored against the read-only sys/; applied only inside
the guest's /usr/src copy): adds vfs_busy(mp, LK_NOWAIT) /
vfs_unbusy(mp) interlocks around the VFS calls in kern_statfs,
kern_fstatfs, sys_quotactl β mirroring mountlist_scan(). When
MNTK_UNMOUNT is set (i.e. after/before a completed or in-progress
unmount) the syscalls fail with EBUSY instead of dereferencing freed
mnt_data; the shared mnt_lock excludes dounmount's exclusive hold
for in-flight calls.
- Baseline: stock kernel #0 -> panic (above), guest down. (fix_baseline_reproduced = 1)
- Patched:
make -j6 nativekernel KERNCONF=X86_64_GENERIC(#1, 2026-09-08), same exact PoC, 4 rounds:fstatfs returned errno=16 (EBUSY), guest up, mount list clean. (fix_patched_reproduced = 0, fix_status = fixed)
Deeper alternative (not needed for this PoC, noted for upstream):
nullfs ops could additionally treat mp->mnt_data == NULL as EINVAL, and
dounmount could refuse MNT_FORCE when refs belong to non-process
holders (socket-buffered fps / stacked mounts) β but the syscall-side
interlock is the root-cause fix matching existing kernel idiom.
Kernel references
- sys/vfs/nullfs/null_vfsops.c:261-267 (unmount frees mnt_data)
- sys/vfs/nullfs/null_vfsops.c:294, 308, 341 (unguarded MOUNTTONULLMOUNT derefs)
- sys/kern/vfs_syscalls.c:888-1006 (retry loop + forced VFS_UNMOUNT with refs>1)
- sys/kern/vfs_syscalls.c:727-757 (kill-scan blind to sockbuf-held fps)
- sys/kern/vfs_syscalls.c:1244-1247, 1428-1436, 1495-1512 (missing interlock)
- sys/kern/vfs_syscalls.c:1401 (mountctl's existing check β the model)
- sys/kern/vfs_mount.c:271-305 (vfs_busy / MNTK_UNMOUNT interlock)
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
not_testableConfirmed kernel references
- sys/vfs/nullfs/null_vfsops.c:261-267
- sys/vfs/nullfs/null_vfsops.c:294
- sys/vfs/nullfs/null_vfsops.c:308
- sys/vfs/nullfs/null_vfsops.c:341
- sys/kern/vfs_syscalls.c:888-1006
- sys/kern/vfs_syscalls.c:727-757
- sys/kern/vfs_syscalls.c:1244-1247
- sys/kern/vfs_syscalls.c:1428-1436
- sys/kern/vfs_syscalls.c:1495-1512
- sys/kern/vfs_syscalls.c:1401
- sys/kern/vfs_mount.c:271-305
Detail
Exploit chain
1) root: mount_null /tmp/t /mnt/a; 2) unpriv: open /mnt/a/f1, sendmsg(SCM_RIGHTS) over socketpair, close fd (fp parked in sockbuf holds mount ref invisible to umount -f kill-scan); 3) root: umount -f /mnt/a -> 50x250ms retry loop exhausts ('1 mount refs still present'/'forcing unmount'), VFS_UNMOUNT runs -> mp->mnt_data=0, kfree(null_mount), mount struct leaked but alive; 4) unpriv: recvmsg() recovers fd, fstatfs(fd) -> kern_fstatfs takes dead mp from fp->f_nchandle.mount -> nullfs_statfs loads MOUNTTONULLMOUNT(mp)->nullm_vfs from VA 0 -> fatal trap 12, guest in DDB, ssh dead. No escalation: no write primitive, freed chunk content not attacker-controllable.
Evidence (decisive lines)
["panic.txt lines 40-59: 'Fatal trap 12: page fault while in kernel mode', 'fault virtual address = 0x0', 'current process = 6405', 'Stopped at nullfs_statfs+0x32: movq (%rax),%rdi'", "panic.txt lines 36-38: 'unmount(/mnt/a): ... 1 mount refs still present' -> 'forcing unmount' (retry loop exhausted, VFS_UNMOUNT with refs>1)", "fix_run.log / fix_run2.log: same PoC on patched kernel #1 -> 'fstatfs returned errno=16 (no panic)', guest up, 4 rounds", 'ghost.c: SCM_RIGHTS park + recvmsg + fstatfs trigger', 'fix.diff: vfs_busy(LK_NOWAIT)/vfs_unbusy interlocks in kern_statfs/kern_fstatfs/sys_quotactl (git apply --check clean)']
PoC changes
Seed concept only; wrote trigger from scratch. First deterministic attempt had a userland bug (recvmsg on the sending socketpair end sv[0] instead of sv[1] -> blocked in sbwait); fixed and re-run -> immediate panic. Brute-force race variant (spam.c, 2400 mount/umount -f rounds) never hit because dounmount's retry loop self-serializes against in-flight callers holding mount refs; the exhausted-force path with an SCM_RIGHTS-invisible mount ref makes it deterministic.
Verified recommended fix
Add vfs_busy(mp, LK_NOWAIT)/vfs_unbusy(mp) interlocks around VFS_STATFS/VFS_QUOTACTL in kern_statfs, kern_fstatfs and sys_quotactl (mirroring mountlist_scan), so umount -f cannot free mnt_data under a caller; optionally guard nullfs ops with mnt_data==NULL -> EINVAL as defense in depth.
Verdict
Deterministic kernel panic reproduced as unprivileged user 'test': nullfs_unmount (null_vfsops.c:261-267) frees mnt_data during umount -f (dounmount proceeds after its retry loop exhausts, vfs_syscalls.c:964-1006), while kern_statfs/kern_fstatfs/sys_quotactl (vfs_syscalls.c:1428/1495/1244) call into the filesystem with no vfs_busy/MNTK_UNMOUNT interlock. A struct file parked in a socket receive buffer via SCM_RIGHTS keeps an invisible mount ref through the force-unmount kill-scan (process_uses_mount scans only fd tables/cwd, :727-757); recovering the fd afterwards and calling fstatfs(2) executes MOUNTTONULLMOUNT(mp)->nullm_vfs (nullfs_statfs, :308) with mnt_data==NULL -> read at VA 0 -> 'Fatal trap 12 ... Stopped at nullfs_statfs+0x32'. UAF-read variant exists in the race window but M_NULLFSMNT is a nullfs-private malloc type with non-attacker-controlled content, so the honest ceiling is kernel DoS/panic, not uid0 (with vfs.usermount=1 the entire chain including umount -f runs unprivileged). Fix (vfs_busy/vfs_unbusy interlocks in the three syscalls) validated by in-guest nativekernel rebuild: baseline panics, patched returns EBUSY with guest up.
No comments yet.