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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3207 Β· 14 files
FileTypeDescriptionSize
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:

  1. mount_null /tmp/t /mnt/a (root).
  2. Unprivileged process (test): open /mnt/a/f1, sendmsg() the fd over an AF_UNIX socketpair with SCM_RIGHTS, close it. The parked struct file keeps fp->f_nchandle.mount == nullfs mp (a mount ref) in the socket receive buffer, where process_uses_mount() (vfs_syscalls.c:727-757) cannot see it (it only scans fd tables and cwd/root/jail dirs) β€” so the umount -f SIGINT/SIGKILL scans pass it.
  3. Root: umount -f /mnt/a β€” exhausts 50x250ms retries (refs==2: ours is the invisible one), prints forcing unmount, runs VFS_UNMOUNT β†’ mnt_data = 0, kfree(xmp), returns 0. The mount structure stays allocated (leaked, freeok == 0).
  4. Unprivileged process: recvmsg() the fd back, call fstatfs(2): kern_fstatfs takes mp = 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_data before :262, dereferences after kfree) reads a freed 88-byte M_NULLFSMNT chunk. That malloc type is nullfs-private: reallocation content is only ever written by nullfs_mount (M_ZERO + two kernel pointers), so an attacker cannot plant controlled data; nullm_vfs resolves 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.c unprivileged trigger (park fd via SCM_RIGHTS, recover, fstatfs)
  • det_driver.sh root-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.diff interlock fix (vfs_busy/vfs_unbusy in 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)"
VERDICT.md
↓ download raw

DF-3207 VERDICT

status: reproduced / impact: panic (kernel DoS) / confidence: certain

Root cause (line-accurate)

  1. sys/vfs/nullfs/null_vfsops.c:261-267 β€” nullfs_unmount() frees the filesystem-private null_mount and NULLs mp->mnt_data with 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 under umount -f.

  2. sys/kern/vfs_syscalls.c:964-1006 β€” dounmount(MNT_FORCE): when the 50x250ms retry loop exhausts with mp->mnt_refs != 1, it prints "forcing unmount", sets freeok = 0 and still calls VFS_UNMOUNT (error remains 0). The struct mount is intentionally leaked (freeok == 0 skips the while (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.

  3. sys/kern/vfs_syscalls.c:727-757 β€” process_uses_mount() only scans fd tables and cwd/root/jail nhandles. A struct file parked in a socket receive buffer via SCM_RIGHTS keeps fp->f_nchandle.mount (mount ref) on the nullfs mount invisibly: the umount -f SIGINT/SIGKILL scans (retries 3 and 7, :931-947) never kill its owner.

  4. sys/kern/vfs_syscalls.c:1495-1512 β€” kern_fstatfs() takes mp = fp->f_nchandle.mount and calls VFS_STATFS(mp, ...) with no vfs_busy() / MNTK_UNMOUNT interlock. Same gap in kern_statfs() (:1428-1436) and sys_quotactl() (:1244-1247). Contrast kern_mountctl() (:1401: explicit MNTK_UNMOUNT check, "racing unmount" warning) and mountlist_scan() (sys/kern/vfs_mount.c:763: vfs_busy(mp, LK_NOWAIT)), which both do interlock.

  5. sys/vfs/nullfs/null_vfsops.c:298-310 β€” nullfs_statfs() executes VFS_STATFS(MOUNTTONULLMOUNT(mp)->nullm_vfs, ...); with mp->mnt_data == NULL that 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

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_testable
↓ fix.diffper-fix-DF-3207

Confirmed kernel references

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.