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