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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2774 Β· 13 files
FileTypeDescriptionSize
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)

  1. CONTROL append through the lower mount with NO nullfs alive: succeeds.
  2. 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 stale v_pfsmp address now equals alias2's struct mount).
  3. vquota get usage /mnt/alias2 reports negative bytes after an O_TRUNC through the lower mount (the -osize account delta landed on alias2).

Environment

  • DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS kernel)
  • vfs.quota_enabled=1 added to /boot/loader.conf (tunable; sysctl is RD)
  • tmpfs lower mount, two sequential nullfs mounts (address reuse via M_MOUNT zone LIFO)
VERDICT.md
↓ download raw

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)

  1. 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 tagged vp->v_pfsmp = mp (the nullfs struct mount). The pointer is raw: no reference, never cleared on unmount (only bzero of the whole vnode on reclaim clears it, vfs_lock.c:1200).
  2. sys/kern/vfs_syscalls.c:1040,1117 β€” dounmount() removes the nullfs mount from the mountlist, waits for mnt_refs, then mount_drop() β†’ kfree(mp, M_MOUNT) (sys/kern/vfs_mount.c:399-405). v_pfsmp is not an mnt_refs/mnt_hold reference, so nothing stops the free.
  3. 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 at sys/kern/vfs_mount.c:689-691 admits the guarantee is absent.
  4. 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-write VFS_ACCOUNT.
  5. sys/kern/vfs_quota.c:149-172 β€” vfs_stdaccount() then does spin_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 stale v_pfsmp now aliases alias2's struct 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/alias2 stays total: 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Built 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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 1 02:11:26 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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).