fhstatfs(2)/fhstatvfs(2) overwrite the vfs_getvfs()-held mount with vp->v_mount: nullfs pass-through FHTOVP causes mnt_hold underflow β KKASSERT panic on INVARIANTS, kfree() of a live struct mount (UAF) on release kernels
| Field | Value |
|---|---|
| ID | DF-2668 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-672 Operation on a Resource after Release |
| File | sys/kern/vfs_syscalls.c |
| Lines | 5074/5096 (fhstatfs), 5135/5149 (fhstatvfs) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
sys_fhstatfs() takes a held mount (vfs_getvfs, :5063; mount_hold at
vfs_mount.c:414-424) then executes mp = vp->v_mount; (:5074) and
mount_drop()s that pointer at :5096; nullfs_fhtovp() is a pass-through
returning a LOWER-filesystem vnode (null_vfsops.c:389-397), so
vp->v_mount != the held mount. The vfs_getvfs() hold leaks on the
nullfs mount while the live lower mount gets an unmatched mount_drop()
driving mnt_hold 1β0. sys_fhstatvfs() repeats the identical defect.
On INVARIANTS: panic: assertion "mp->mnt_refs == 0" failed in
mount_drop. On release kernels the same path kfree()s a live mount
still linked into the mountlist/fsid rb-tree with all its vnodes β
heap-corruption-grade UAF.
Threat model & preconditions
Root-only (SYSCAP_RESTRICTEDROOT) β not an unprivileged escalation; realistic victims are mountd/NFS tooling following the documented nullfs-export workflow (sys_getfh's own comment, :4761-4768).
Proof of concept
Reproduced as root (findings/poc/DF-2668/fhstatfs_hold.c):
mount -t null /tmp /tmp/nmtest; getfh(/tmp/nmtest/anchor_file);
fhstatfs(fh) β panic in mount_drop with sys_fhstatfs on the stack
(panic.txt). Fix validated on a rebuilt kernel: valid statfs returned,
no panic, nullfs unmounts cleanly.
Recommended fix
--- a/sys/kern/vfs_syscalls.c
+++ b/sys/kern/vfs_syscalls.c
@@ sys_fhstatfs()
if ((error = VFS_FHTOVP(mp, NULL, &fh.fh_fid, &vp)) != 0)
goto done;
- mp = vp->v_mount;
sp = &mp->mnt_stat;
@@ sys_fhstatvfs()
if ((error = VFS_FHTOVP(mp, NULL, &fh.fh_fid, &vp)))
goto done;
- mp = vp->v_mount;
sp = &mp->mnt_vstat;
(nullfs VFS_STATFS already passes through lower stats)
Timeline
- 2026-08-29 Discovered during pass-2 audit of vfs_syscalls.c (GLM 5.3); reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2668 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| fhstatfs_hold.c | β | 1.9 KB | view raw | |
| build.sh | β | 88 B | view raw | |
| setup_and_run.sh | β | 315 B | view raw | |
| run.log | β | 1.2 KB | view raw | |
| panic.txt | β | 530 B | view raw | |
| env.txt | β | 358 B | view raw | |
| fix.diff | β | 543 B | view raw | |
| run.patched.log | β | 640 B | view raw | |
| fix_validation.log | β | 2.5 KB | view raw | |
| VERDICT.md | β | 3.7 KB | β raw | |
| verdict.json | β | 3.6 KB | view raw | |
| manifest.json | β | 1.1 KB | view raw |
DF-2668 VERDICT β REPRODUCED (mount hold-count underflow β KKASSERT panic; live-mount UAF on release kernels)
Bottom line
fhstatfs(2) (and identically fhstatvfs(2)) on a file handle obtained
from a nullfs-covered path (an operation sys_getfh() explicitly
supports for NFS exports) makes sys_fhstatfs() call mount_drop() on the
lower filesystem's live struct mount without ever holding it. On the
stock INVARIANTS guest this is an immediate, 100%-reliable
panic: assertion "mp->mnt_refs == 0" failed in mount_drop with
sys_fhstatfs() on the stack (captured). On a release kernel the same path
executes kfree(mp, M_MOUNT) on a live mount β a use-after-free of
struct mount referenced by the mountlist, the fsid rb-tree, and every
vnode of that filesystem. The vfs_getvfs() hold on the nullfs mount is
leaked in the same call (permanent mount leak).
Why it happens (line-accurate)
sys_fhstatfs()(sys/kern/vfs_syscalls.c:5041) takes a held mount:mp = vfs_getvfs(&fh.fh_fsid)(:5063).vfs_getvfs()(sys/kern/vfs_mount.c:414-424) doesmount_hold(mp); the caller owes onemount_drop()(:393-405), which frees the mount whenmnt_holdhits 0 (withKKASSERT(mp->mnt_refs == 0)).VFS_FHTOVP(mp, ...)on a nullfs mount is a pass-through returning a vnode of the lower filesystem:nullfs_fhtovp()atsys/vfs/nullfs/null_vfsops.c:389-397callsVFS_FHTOVP(xmp->nullm_vfs, ...)and returns the lower vp, whosev_mountis the lower mount.sys_fhstatfs()then executesmp = vp->v_mount;(:5074): the held nullfs pointer is overwritten (its hold leaks), and the latermount_drop(mp)at:5096decrements the lower mount'smnt_holdwithout a matching hold. A live, mounted filesystem hasmnt_hold == 1(set inmount_init(), consumed only by the finaldounmount()drop atsys/kern/vfs_syscalls.c:1117), so the spurious drop drives it to 0 β assert/kfree.sys_fhstatvfs()repeats the same two mistakes at:5135and:5149. (sys_fhopen():4850/sys_fhstat():5021keep the held pointer throughout β correct.)- Confirmed end-to-end on the guest:
mount -t null /tmp /tmp/nmtest,getfh("/tmp/nmtest/anchor_file")(nullfs fsid reported:89630026,0000000b),fhstatfs(fh)β panic inmount_dropcalled fromsys_fhstatfs+0xd4.
Threat model
Triggering requires root (caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT)
at :5056), so this is not an unprivileged escalation. The realistic
victims are privileged daemons following the documented
"nullfs mounts can be explicitly exported" workflow (mountd/NFS), plus any
root tooling using fhstatfs on synthetic handles. The corruption itself
(live mount freed while fully linked into the mountlist) is
heap-corruption-grade on release kernels.
Fix validation
fix.diff deletes both mp = vp->v_mount; reassignments
(sys/kern/vfs_syscalls.c:5074, :5135) so the mount that was actually
held by vfs_getvfs() is the one statfs'd (nullfs VFS_STATFS already
passes through to the lower filesystem's stats) and dropped exactly once.
Applied to the guest's /usr/src together with DF-2667's fix, kernel
rebuilt (make nativekernel KERNCONF=X86_64_GENERIC, INVARIANTS),
rebooted:
- baseline (stock kernel): panic (above);
- patched kernel: identical setup, fhstatfs returns 0 with valid
f_mntonname/f_type, no panic, subsequent mount/unmount of the
nullfs and tmpfs lifecycle normal (run.patched.log,
fix_validation.log).
Artifacts
fhstatfs_hold.c (PoC), build.sh, setup_and_run.sh, run.log,
panic.txt, env.txt, fix.diff, run.patched.log, fix_validation.log,
verdict.json, manifest.json.
Fix verification
fixedPatched kernel: fhstatfs on the nullfs-covered handle succeeds with valid data, no KKASSERT, and the nullfs mount unmounts cleanly afterwards; mount hold bookkeeping correct.
['fix_validation.log', 'run.patched.log', 'fix.diff']
Confirmed kernel references
Detail
Evidence (decisive lines)
['run.log (getfh fsid + freeze)', 'panic.txt (mount_drop KKASSERT panic with sys_fhstatfs in backtrace)', 'fix_validation.log + run.patched.log (patched kernel: fhstatfs ok, clean unmount)', 'fix.diff']
PoC changes
PoC written fresh (no seed); raw syscalls getfh(161)/fhstatfs(297) with fhandle_t from sys/mount.h; anchor path passed as argv[1] after the first attempt used a path covered by the nullfs (getfh ENOENT) β fixed by pointing at the nullfs view of /tmp/anchor_file.
Verified recommended fix
Delete the 'mp = vp->v_mount;' reassignments in sys_fhstatfs()/sys_fhstatvfs() so the vfs_getvfs()-held mount is dropped exactly once (see fix.diff).
Verdict
Reproduced on the stock INVARIANTS guest as root: getfh() on a nullfs-covered path (explicitly supported for NFS exports), then fhstatfs() on that handle panics with 'panic: assertion "mp->mnt_refs == 0" failed in mount_drop' and the backtrace mount_drop() <- sys_fhstatfs() <- syscall2(). Root cause: sys_fhstatfs() takes a held mount from vfs_getvfs() (sys/kern/vfs_syscalls.c:5063) but overwrites the pointer with vp->v_mount (:5074) and mount_drop()s that at :5096; nullfs VFS_FHTOVP is a pass-through returning a LOWER-filesystem vnode (sys/vfs/nullfs/null_vfsops.c:389-397), so the vfs_getvfs hold leaks on the nullfs mount while the live lower mount receives an unmatched mount_drop() driving mnt_hold 1->0. On INVARIANTS kernels this trips the KKASSERT (captured); on release kernels the same path kfree()s a live struct mount β a use-after-free of a mount still linked into the mountlist/fsid rb-tree with all its vnodes. sys_fhstatvfs() has the identical defect at :5135/:5149. Root-only trigger, so impact ceiling is privileged-user-triggered kernel corruption, not unprivileged escalation. fix.diff deletes both reassignments; validated on a rebuilt kernel (#1): identical setup returns valid statfs data, no panic, and the nullfs unmounts cleanly afterwards (no leaked hold).
No comments yet.