# DF-3120 VERDICT — unsynchronized UFS quotaon/quotaoff state machine

## Bottom line

**REPRODUCED (kernel panic, race double-execution of `ufs_quotaoff`)**
on a DragonFly 6.5-DEVELOPMENT x86_64 kernel built with `options QUOTA`
(stock `X86_64_GENERIC` has no `options QUOTA`, so the surface is compiled
out of the default kernel — `ufs_quotactl` returns `EOPNOTSUPP`,
`sys/vfs/ufs/ufs_vfsops.c:77-78`). With the QUOTA option the race
panics the kernel within ~45 s of hammering; the fix serializes
quotaon/quotaoff per-mount and survives the identical hammer.

The corruption is gated behind `caps_priv_check(SYSCAP_NOQUOTA_WR)`
(both racing threads must be quota-admin-capable, normally root), so this
is a Medium "privileged-admin → kernel memory corruption/panic" finding,
not an unprivileged escalation. There is no unprivileged trigger path:
every entry into `ufs_quotaon`/`ufs_quotaoff` is via
`quotactl(2)` (privilege-checked) or unmount (`ffs_flushfiles`,
`ffs_vfsops.c:891`, serialized against quotactl by `MNTK_UNMOUNT` /
`vfs_busy`). The realistic impact ceiling is a capability-delegated quota
manager (jail/helper holding only `SYSCAP_NOQUOTA_WR`) converting its
narrow privilege into kernel UAF → host takeover.

## Root cause (path:line)

* `sys/vfs/ufs/ufs_quota.c:495-531` — `ufs_quotaoff()`: entry test
  `if ((qvp = ump->um_quotas[type]) == NULLVP) return (0);` at `:503`,
  `QTF_CLOSING` set at `:505` but **never tested here** — the only reader
  is `ufs_dqget` at `:762`. Teardown: `ufs_dqflush(qvp)` `:517`,
  `vn_close(qvp)` `:519`, `um_quotas[type] = NULLVP` `:520`,
  `crfree(um_cred[type])` `:521`, `um_cred[type] = NOCRED` `:522`.
* `sys/vfs/ufs/ufs_quota.c:412-475` — `ufs_quotaon()`: state swap
  `if (*vpp != vp) ufs_quotaoff(mp, type)` at `:437-438`, `*vpp = vp`
  `:442`, `um_cred[type] = crhold(cred)` `:448` — same absence of locks.
* `sys/vfs/ufs/ufs_vfsops.c:141` — `vfs_busy(mp, LK_NOWAIT)` is a shared
  pin against unmount, not mutual exclusion between quotactls.
* Consequence: N threads passing `:503` together execute the teardown N
  times → double `vn_close` (vnode refcount/opencount underflow),
  `crfree(NOCRED)` with `NOCRED == (struct ucred *)0`
  (`sys/sys/ucred.h:82`) → NULL write-deref in `crfree`
  (`sys/kern/kern_prot.c`, `cr->cr_ref` at +0x10),
  stale-tail `dqrele→dqsync` seeing `um_quotas[type]` cleared →
  `panic("dqsync: file")` `ufs_quota.c:909` / `VOP_WRITE` on a closed
  quota file, and clobbering of a concurrent re-quotaon's live
  `um_quotas[type]`/`um_cred[type]` pointers (leak + later panic).

## Reproduction timeline (all on dfbsd-qemu guest, 6 vCPU)

1. **Stock kernel**: `quotactl(Q_QUOTAON)` = `EOPNOTSUPP` (no QUOTA
   option in `X86_64_GENERIC`). Built `X86_64_QUOTA`
   (`cp X86_64_GENERIC + options QUOTA`) with `make nativekernel`.
2. **Instrumented kernel** (diag counters only, no semantic change):
   PoC with a *shared* control path never overlapped (1.5M quotactls,
   0 window entries — the shared namecache entry of the single ctrl
   file serialized the syscalls by accident). PoC v3 with *distinct*
   control paths per thread (`/qtmnt/ctrl0..5`): panic in <60 s —
   `panic.diag.txt`:
   `DF3120-DIAG: race window entered (in-flight=1)` immediately followed
   by `Fatal trap 12 ... fault virtual address = 0x0`,
   `Stopped at crfree+0xc: cmpq $0,(%rdi)` — the predicted
   `crfree(NOCRED)` NULL-deref at `ufs_quota.c:521`.
3. **Clean baseline kernel** (pristine `ufs_quota.c`, only `options
   QUOTA` added, INVARIANTS on): identical PoC panicked in ≤45 s —
   `panic.txt`:
   `panic: VOP_STDCLOSE: BAD OPENCOUNT` with stack
   `vop_stdclose ← vop_close ← vn_close ← ufs_quotaoff ← ufs_quotactl`
   — the double-`vn_close(qvp)` manifestation of the same race
   (`ufs_quota.c:519` executed twice).
4. **Fix v1** (`fix.diff`, `um_quota_lock` around quotaon/quotaoff):
   no panic, but my own validation caught a fix deadlock — quotaon
   originally took `um_quota_lock` while still holding the quota-file
   vp lock returned by `vn_open` (before `vn_unlock(vp)`), inverting
   against quotaoff's `um_quota_lock → vget()` order. All quotactl
   threads stuck in `D` state, wchan `ncplk`. Fixed by unlocking the vp
   before taking the lock (documented in `fix.diff` comment).
5. **Fix v2** (final `fix.diff`): identical PoC, 300 s + repeat runs:
   guest up, no panic, mount healthy (see run logs). Baseline panicked
   ≤45 s; patched survived every run — behavior change demonstrated.

## Exploitability assessment

Memory-corruption class, but both racing threads must pass
`SYSCAP_NOQUOTA_WR`; no unprivileged entry exists. Escalation ceiling:
a delegated quota-capable process (not uid 0) double-closing the quota
vnode → refcount desync → premature vnode recycle while
`um_quotas[type]`/dquot cache still reference it → UAF on later
`dqsync` I/O. Not pursued to uid=0 because the entry gate already
requires the privilege the escalation would grant (root-vs-root has no
boundary to cross); the panic is the honest demonstrated impact.

## Fix

`fix.diff` — per-mount `um_quota_lock` (lockmgr) serializing
`ufs_quotaon`/`ufs_quotaoff` (`ufs_quotaoff` wraps a
`ufs_quotaoff_locked` body so quotaon's internal swap-call does not
self-deadlock); `lockinit` in `ffs_mountfs`; `vn_unlock(vp)` before
taking the lock in quotaon (deadlock note). Validated on-guest.

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