mountlist_exists() provides no lifetime guarantee: stale vp->v_pfsmp used after nullfs unmount for VFS accounting and quota enforcement β wrong-mount EDQUOT/negative usage (deterministic), struct mount UAF window (racy)
| Field | Value |
|---|---|
| ID | DF-2774 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:N/I:H/A:N |
| CWE | CWE-367 TOCTOU (CWE-416 secondary) |
| File | sys/kern/vfs_mount.c |
| Lines | 692-708 (FIXME :689-691); consumers vfs_quota.c:421-433, vfs_vnops.c:325-326, vfs_vopops.c:487-496 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-31 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
Vnodes resolved through a nullfs mount carry a raw, unheld, never-cleared pointer vp->v_pfsmp to the nullfs struct mount. After that mount is unmounted, mountlist_exists() "validates" the stale pointer by pointer-comparison only β no reference, no identity check β exactly as its own FIXME admits. vq_vptomp() then feeds the stale pointer to VFS_ACCOUNT/vq_write_ok on the vn_open(O_TRUNC) path and every vop_write. Demonstrated deterministically on the guest: with a fresh mount reusing the freed M_MOUNT chunk address, an append through the unquota'd lower tmpfs fails with EDQUOT under an unrelated nullfs mount's 10-byte limit, and an O_TRUNC through the lower path drives that unrelated mount's usage to 2^64β8192. The concurrent-unmount variant is a struct mount UAF (vfs_stdaccount spin_lock + ac_bytes += attacker-influenced delta on freed memory), bounded in practice by M_MOUNT zone type-stability and DFly slab retaining freed-chunk contents. Default configs inert (vfs_quota_enabled defaults to 0). Trigger side unprivileged (open/truncate/write); arming side privileged mount/unmount activity that persistently poisons vnodes.
Threat model & preconditions
With vfs.quota_enabled=1 (boot tunable used by the vquota system): unprivileged writes on a nullfs-aliased lower filesystem are quota-checked and accounted against an arbitrary unrelated mount β quota limits enforced where none exist (EDQUOT DoS) or bypassed for the intended mount, and accounting corrupted. Racy variant dereferences freed struct mount memory. No uid0 route (accounting/integrity corruption + zone-type-stable UAF, not a control-flow hijack).
Proof of contest
Reproduced deterministically on the stock INVARIANTS guest
(findings/poc/DF-2774/): loader.conf quota tunable, tmpfs lower +
nullfs alias, alias-resolve, umount, remount (chunk address reuse),
vquota limit 10 on the new mount β append via /mnt/lower gets EDQUOT
(0 bytes) and : > /mnt/lower/victim drives the unrelated mount's
usage to 18446744073709543424; controls behave correctly. Fix
(mountlist_hold + per-mount generation cookie mirrored on the vnode,
held+cookie-checked vq_vptomp, mount_drop at both callers; 7-file
diff) built in-guest and validated: wrong-mount enforcement gone,
legitimate alias accounting preserved.
Recommended fix
See findings/poc/DF-2774/fix.diff (validated; git-apply --check clean).
Timeline
- 2026-08-31 Discovered during pass-2 audit of vfs_mount.c (GLM 5.3); deterministic wrong-mount quota corruption reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2774 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.0 KB | β raw | |
| VERDICT.md | β | 6.1 KB | β raw | |
| poc.sh | β | 2.5 KB | view raw | |
| build.sh | β | 310 B | view raw | |
| run.sh | β | 416 B | view raw | |
| run.log | β | 2.5 KB | view raw | |
| run.2.log | β | 1.9 KB | view raw | |
| run.fix.log | β | 2.4 KB | view raw | |
| build.log | β | 866 B | view raw | |
| env.txt | β | 374 B | view raw | |
| fix.diff | β | 5.4 KB | view raw | |
| verdict.json | β | 5.1 KB | view raw | |
| manifest.json | β | 1.5 KB | view raw |
DF-2774 β stale vp->v_pfsmp used via mountlist_exists() after nullfs unmount
What this proves
_cache_setvp() (sys/kern/vfs_cache.c:1386-1389) stores a raw, unheld,
never-cleared pointer vp->v_pfsmp = mp into the lower filesystem's
vnode whenever that vnode is resolved through a nullfs mount.
vq_vptomp() (sys/kern/vfs_quota.c:421-433) dereferences that stale pointer
after "validating" it with mountlist_exists() (sys/kern/vfs_mount.c:692-708),
which only does a pointer comparison walk of the mountlist under the shared
mountlist token β it takes no reference and provides no lifetime guarantee
(the source FIXME at vfs_mount.c:689-691 admits this).
Consequences:
1. After the nullfs mount is unmounted and its struct mount is kfree'd,
a fresh mount that reuses the same kfree'd chunk makes
mountlist_exists(stale) return 1 (the new, unrelated mount is on the
list at the same address).
2. vq_vptomp() then returns the stale pointer, and VFS accounting
(VFS_ACCOUNT at sys/kern/vfs_vnops.c:325, vq_write_ok at
sys/kern/vfs_vopops.c:487-492) charges / enforces against the wrong,
unrelated mount.
3. In the race window (exists() passes -> unmount completes -> kfree ->
use), VFS_ACCOUNT/vfs_stdaccount (sys/kern/vfs_quota.c:149-172)
operates on freed struct mount memory (stale mnt_op read,
spin_lock(&mp->mnt_acct.ac_spin) write, ac_bytes += delta with an
attacker-influenced delta). On this kernel's slab allocator the freed
chunk retains its contents, so the practical outcome is silent corruption
of freed-memory state / wrong-mount accounting rather than a wild write.
Deterministic user-visible manifestation (needs vfs.quota_enabled=1, a
boot tunable β default 0):
- extend a file through the lower mount whose vnode has a stale
v_pfsmp-> the write is quota-checked against the new, unrelated nullfs mount -> EDQUOT from a tmpfs that has no limits, or : > lowerfile(O_TRUNC) ->vfs_stdaccount(freed/wrong mp, uid, gid, -osize)-> the unrelated mount's reported usage goes negative.
Build
No compilation needed β pure shell against stock userland
(/sbin/mount_null, /sbin/vquota, dd, su).
Run
As root on the guest (the actor is unprivileged user qa):
./run.sh
Expected output (success criteria)
- CONTROL append through the lower mount with NO nullfs alive: succeeds.
vquota set limit /mnt/alias2 10, then the same append through the lower mount: fails with Disk quota exceeded (EDQUOT) β enforcement against the wrong mount (the stalev_pfsmpaddress now equals alias2'sstruct mount).vquota get usage /mnt/alias2reports negative bytes after an O_TRUNC through the lower mount (the-osizeaccount delta landed on alias2).
Environment
- DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS kernel)
vfs.quota_enabled=1added to /boot/loader.conf (tunable; sysctl is RD)- tmpfs lower mount, two sequential nullfs mounts (address reuse via M_MOUNT zone LIFO)
DF-2774 β VERDICT
Status: reproduced (deterministic, no race needed for the primary manifestation).
Impact: cross-mount VFS accounting corruption + wrong-mount quota enforcement
(EDQUOT) driven by a stale vp->v_pfsmp pointer that mountlist_exists()
fails to validate; a narrower concurrent-unmount variant is a use-after-free
of struct mount. No uid0 path; the memory-unsafety ceiling is bounded by
M_MOUNT-zone type-stability (see below). Requires non-default boot tunable
vfs.quota_enabled=1 for any user-visible effect (default 0 makes
VFS_ACCOUNT a no-op) plus nullfs stacking β hence Medium, not High.
Root cause (path:line)
sys/kern/vfs_cache.c:1386-1389β_cache_setvp(): when a vnode is resolved through a namecache entry owned by a nullfs mount, the lower filesystem's vnode is taggedvp->v_pfsmp = mp(the nullfsstruct mount). The pointer is raw: no reference, never cleared on unmount (onlybzeroof the whole vnode on reclaim clears it,vfs_lock.c:1200).sys/kern/vfs_syscalls.c:1040,1117βdounmount()removes the nullfs mount from the mountlist, waits formnt_refs, thenmount_drop()βkfree(mp, M_MOUNT)(sys/kern/vfs_mount.c:399-405).v_pfsmpis not anmnt_refs/mnt_holdreference, so nothing stops the free.sys/kern/vfs_mount.c:692-708βmountlist_exists()walks the mountlist comparing pointers only, under the shared mountlist token, and returns without taking any reference. The in-tree FIXME atsys/kern/vfs_mount.c:689-691admits the guarantee is absent.sys/kern/vfs_quota.c:421-433βvq_vptomp()uses that check as a lifetime guard and hands the stale pointer to: -sys/kern/vfs_vnops.c:325-326βvn_open()O_TRUNC path:VFS_ACCOUNT(mp, uid, gid, -osize); -sys/kern/vfs_vopops.c:487-496βvop_write():vq_write_ok(mp,β¦)(mount-wide/uid/gid limit enforcement) and post-writeVFS_ACCOUNT.sys/kern/vfs_quota.c:149-172βvfs_stdaccount()then doesspin_lock(&mp->mnt_acct.ac_spin),ac_bytes += delta(attacker- influenced 64-bit delta via write sizes / O_TRUNC sizes) and RB-tree walks on the (possibly freed)struct mount.
Two manifestations
(a) Deterministic type confusion (reproduced, see run.log):
after the nullfs mount is unmounted, mountlist_exists(stale) compares the
stale address against the current mountlist. A fresh mount_null
re-uses the just-freed M_MOUNT chunk (zone LIFO), so the stale pointer
compares equal to the new, unrelated mount and the guard passes.
Observed on stock kernel #0 with vfs.quota_enabled=1:
- TEST1: append through the lower tmpfs (which has no limits) failed
with
dd: stdout: Disc quota exceeded(EDQUOT, 0 bytes written) β the unrelated alias2 nullfs mount's 10-byte limit was enforced because the stalev_pfsmpnow aliases alias2'sstruct mount. - TEST2:
: > /mnt/lower/victim(O_TRUNC through the lower path) charged β8192 to alias2:vquota show /mnt/alias2βtotal: 18446744073709543424, limit = 10(2^64β8192). - Controls behaved correctly (append succeeds when no mount occupies the
stale address; charges then land on
vp->v_mount).
An unprivileged user observes/triggers both effects (the write/truncate is theirs); mounting/unmounting is privileged, but nullfs stacking with later unmount is normal admin/jail/automounter behavior β the poisoned vnodes persist after the admin action, and the confusion is armed deterministically by the next mount that recycles the chunk.
(b) Use-after-free race (analyzed, not raced on the guest): between
mountlist_exists() returning 1 and VFS_ACCOUNT/vq_write_ok
dereferencing mp, dounmount() can complete and kfree() the mount.
Reads: mp->mnt_op (mount.h:657-659), and with quota enabled,
vfs_stdaccount() writes the freed chunk (spin_lock, ac_bytes +=).
Practical ceiling on this kernel: DFly's slab allocator keeps freed chunk
contents intact (no freelist pointers embedded in the payload), quota RB
nodes are never freed (vfs_quota.c:142-146 "TODO"), and the M_MOUNT zone
serves only struct mount allocations β so the realistic outcome of a hit
is silent corruption of freed-mount state or the (a)-style confusion once
the chunk is re-allocated, not a wild write / RIP control. I therefore did
not chase uid0 from this primitive; the honest classification is
logic-corruption + UAF-read/limited-UAF-write.
Why the default config is unaffected
vfs_quota_enabled defaults to 0 (sys/kern/vfs_quota.c:112-115,
TUNABLE_INT). With it 0, VFS_ACCOUNT reduces to a NULL check of the
stale-but-intact mnt_op->vfs_account (still pointing at the live static
null_vfsops), i.e. a benign read of freed-but-intact memory.
Fix validation (fix.diff, kernel #1 built in-guest)
make -j6 nativekernel KERNCONF=X86_64_GENERIC (361s) + installkernel +
reboot; exact same poc.sh re-run (run.fix.log):
- TEST1 append now succeeds (
TEST1_RC=0, 4096 bytes transferred) β no EDQUOT from the unrelated mount. vquota show /mnt/alias2staystotal: 0, limit = 10β no stale charge.- The intended alias accounting still works (creation through alias charges the nullfs mount 4096; lower stays 0) β feature preserved.
fix.diff adds mountlist_hold() (a real-hold variant of
mountlist_exists(), sys/kern/vfs_mount.c), a unique mnt_cookie
generation per struct mount, v_pfsmp_cookie on struct vnode, and a
held+cookie-validated vq_vptomp() whose callers mount_drop() after
accounting (vfs_quota.c, vfs_vnops.c, vfs_vopops.c, vfs_cache.c).
Attempts / notes
4 guest runs: run2 (tooling: DFly dd/vquota syntax), run3 (ordering:
victim must be resolved through the alias first β an already-resolved
ncp never re-runs _cache_setvp), run4 = decisive baseline, fixrun =
patched. Guest dirtied (loader.conf tunable, user qa, patched kernel,
patched /usr/src) β reset via vm.sh reset with-src after evidence
collection. Baseline kernel: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2
06:02:54 UTC 2026; patched: #1 Sep 1 02:11:26 UTC 2026.
Fix verification
fixedBuilt fix.diff in-guest (make -j6 nativekernel, 362s), installed, rebooted into kernel #1, re-ran the identical poc.sh: baseline EDQUOT denial and negative-usage charge are gone (TEST1_RC=0, alias2 total: 0) while legitimate alias-path accounting still charges the nullfs mount 4096. Behavior change is exactly the bug disappearing.
['findings/poc/DF-2774/run.fix.log', 'findings/poc/DF-2774/build.log (head of in-guest build.log)', 'findings/poc/DF-2774/fix.diff']
Confirmed kernel references
Detail
Exploit chain
unpriv user file activity on a nullfs-aliased lower fs -> admin unmounts nullfs (v_pfsmp goes stale) -> next mount reuses the freed struct mount address -> mountlist_exists(stale)==1 -> vq_vptomp returns wrong mount -> vq_write_ok/VFS_ACCOUNT enforce/charge the wrong mount (demonstrated: EDQUOT on unquota'd tmpfs + negative usage). Race variant: unmount+free lands between exists() and use -> vfs_stdaccount writes freed mount (limited by zone type-stability).
Evidence (decisive lines)
["findings/poc/DF-2774/run.log β decisive baseline: 'dd: stdout: Disc quota exceeded' (TEST1) and 'total: 18446744073709543424, limit = 10' (TEST2)", "findings/poc/DF-2774/run.fix.log β patched kernel #1: TEST1_RC=0, alias2 stays 'total: 0, limit = 10'", 'findings/poc/DF-2774/fix.diff β 7-file git-apply-able fix (mountlist_hold, mnt_cookie, v_pfsmp_cookie, held vq_vptomp)', 'findings/poc/DF-2774/VERDICT.md β full narrative with path:line for every claim']
PoC changes
Seed sketch rewritten entirely: DFly dd rejects the '2>/dev/null'-style operand placement used in the draft and vquota syntax is 'limit/show' not 'set limit/get usage'; crucially the victim file must be created/resolved THROUGH the nullfs path first (an already-resolved ncp never re-runs _cache_setvp, so v_pfsmp is never set if the lower path resolves it first) β first two runs failed for those reasons.
Verified recommended fix
Replace the refcount-free mountlist_exists() probe in vq_vptomp() with mountlist_hold() (a real-hold lookup) plus a per-mount generation cookie (mnt_cookie/v_pfsmp_cookie) so a stale v_pfsmp can neither be used after free nor validate against a different mount that reused the address; callers mount_drop() after accounting.
Verdict
Reproduced deterministically on the stock INVARIANTS kernel with vfs.quota_enabled=1: vp->v_pfsmp is a raw, never-cleared pointer to the nullfs struct mount (vfs_cache.c:1386-1389); after unmount, mountlist_exists() (vfs_mount.c:692-708) validates it by pointer-compare only, so a fresh mount reusing the freed M_MOUNT chunk makes the guard pass and VFS accounting/enforcement operates on the wrong, unrelated mount β an append through the unquota'd lower tmpfs failed with EDQUOT under an unrelated mount's 10-byte limit, and an O_TRUNC through the lower path drove that mount's usage to 2^64-8192. A concurrent-unmount variant is a struct mount UAF (stale mnt_op read; vfs_stdaccount spin_lock + ac_bytes += delta on freed memory), bounded in practice by M_MOUNT zone type-stability. Default configs (quota_enabled=0) reduce VFS_ACCOUNT to a benign NULL check. fix.diff (mountlist_hold + per-mount cookie + held vq_vptomp) built as kernel #1 in-guest and eliminates both manifestations while preserving alias accounting; no uid0 path (logic-corruption class, not a control-flow primitive).
No comments yet.