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

vop_write quota accounting skips unlinked-open files (va_nlink==0) β†’ complete quota enforcement bypass when vfs.quota_enabled=1; concurrent-writer delta TOCTOU

Field Value
ID DF-2705
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L
CWE CWE-367 TOCTOU / accounting-integrity
File sys/kern/vfs_vopops.c
Lines 476-497 (refund side :1665-1668)
Area kern
Confidence likely
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

Charging (VFS_ACCOUNT) is gated on va_nlink>0 while the limit check runs against only the charged usage; vop_nremove refunds the whole size on last-link removal. Unlink an open fd (usage refunded, nlink=0) and keep writing through it: every delta check passes trivially and nothing is ever charged β†’ unbounded growth past any per-uid/gid/mount limit. Additionally, concurrent writers on separate files compute deltas against the same stale usage, overshooting limits by (Nβˆ’1)Γ—limit. Feature is default-off and boot-tunable only β€” hence Low. Also recorded for the vfs_quota.c audit (cross-ref): vfs_stdaccount/unode_insert kmalloc(M_WAITOK) while holding mp->mnt_acct.ac_spin at sys/kern/vfs_quota.c:153-165/:236-306 β€” blocking allocation under a spinlock.

Charge unlinked-open files (they still consume blocks until last close): drop the if (va.va_nlink > 0) gate at :476-478 (the last-link refund at :1665-1668 already balances); longer term, account on block allocation inside the FS.

Timeline

  • 2026-08-30 Discovered during pass-2 audit of vfs_vopops.c (GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2705 Β· 4 files
FileTypeDescriptionSize
README.md β€” 3.8 KB ↓ raw
VERDICT.md β€” 2.6 KB ↓ raw
verdict.json β€” 2.5 KB view raw
manifest.json β€” 836 B view raw

DF-2705 β€” vop_write quota accounting skips unlinked-open files β†’ complete quota enforcement bypass (when vfs.quota_enabled=1)

File: sys/kern/vfs_vopops.c (pass 2, GLM 5.3) Severity: Low (feature default-off, boot-tunable only) β€” Confidence: certain (logic), conditional on config

What

The generic write-path quota glue in vop_write() (sys/kern/vfs_vopops.c:470-497):

if (vfs_quota_enabled && (vp->v_type == VREG)) {
        ...
        size_before = va.va_size;
        /* this file may already have been removed */
        if (va.va_nlink > 0)
                do_accounting = 1;                      /* :477-478 */
        ...
        if (!vq_write_ok(mp, va.va_uid, va.va_gid, delta)) {   /* :489 check */
                error = EDQUOT;
                goto done;
        }
}
DO_OPS(ops, error, &ap, vop_write);
if ((error == 0) && do_accounting) {                    /* :495-497 */
        VFS_ACCOUNT(mp, va.va_uid, va.va_gid, size_after - size_before);
}

Charging (VFS_ACCOUNT β†’ vfs_stdaccount, sys/kern/vfs_quota.c:150-171) is skipped whenever va_nlink == 0, but the limit check (vq_write_ok) still runs against a usage counter that will never be charged for this file's growth. Combined with vop_nremove() (sys/kern/vfs_vopops.c:1665-1668), which subtracts the whole file size from usage when the last link is removed, the enforcement is fully bypassable:

  1. f = open("/q/f", O_CREAT|O_WRONLY); write up to the uid limit (charged, checks enforce).
  2. unlink("/q/f") while keeping f open β€” vop_nremove subtracts the entire size (VFS_ACCOUNT(..., -va.va_size)) and the inode's nlink drops to 0.
  3. Keep writing through f forever: every write's delta is checked against a uid usage that stays ~0 (0 + delta <= limit passes for any single write that fits the limit once), and do_accounting stays 0, so nothing is ever charged. β†’ unbounded growth on a quota-limited mount.

Secondary weaknesses in the same glue (same lines):

  • Concurrent-writer TOCTOU: size_before/uid/gid are read pre-write; two concurrent writers on two files both pass vq_write_ok for deltas computed against the same stale uid usage, then both charge β€” usage can exceed the limit by up to (threads-1) Γ— limit.
  • uid/gid snapshot TOCTOU (chown between check and charge charges the wrong uid).

All of it is gated on vfs_quota_enabled (sys/kern/vfs_quota.c:111-113): default 0, CTLFLAG_RD sysctl β€” settable only via the vfs.quota_enabled boot tunable, so the whole class is conditional on a non-default admin configuration. The feature is a fairness/accounting mechanism (vquotactl limits), not a hard security boundary; impact is disk-exhaustion across users on mounts where it was enabled.

Charge unlinked-open files too (they still consume blocks until the last close) β€” i.e. drop the va_nlink > 0 condition, or charge nlink==0 files against a separate "unlinked usage" bucket; and re-read size/uid after the write for the delta (or account inside the FS on block allocation, which is the accurate basis).

--- a/sys/kern/vfs_vopops.c
+++ b/sys/kern/vfs_vopops.c
@@ -474,9 +474,7 @@
        size_before = va.va_size;
-       /* this file may already have been removed */
-       if (va.va_nlink > 0)
-           do_accounting = 1;
+       do_accounting = 1;  /* unlinked-open files still consume space */

(the symmetric subtraction in vop_nremove already handles last-link removal)

Reproduction status

Not executed on the guest (Phase V skipped): demonstrating requires booting the guest with the non-default vfs.quota_enabled=1 boot tunable plus vquota/ vquotactl limit setup β€” a configuration change to the shared baseline image β€” for a Low-severity accounting bypass. The logic chain above is complete and each line was verified against source in this pass.

VERDICT.md
↓ download raw

VERDICT β€” DF-2705

status: untested (Phase V deliberately skipped: Low severity; reproduction requires reconfiguring the shared guest's boot tunables + vquota limit setup, disproportionate for an accounting-bypass finding).

Complete logic chain (all lines verified this pass)

  1. Feature gate: int vfs_quota_enabled = 0; with TUNABLE_INT and SYSCTL_INT (..., CTLFLAG_RD, ...) β€” sys/kern/vfs_quota.c:111-113. Boot-time only, default off. Everything below is conditional on the admin enabling it.
  2. Charging skip: sys/kern/vfs_vopops.c:476-478 β€” do_accounting only when va.va_nlink > 0; :495-497 charge gated on do_accounting.
  3. Check still runs: :489 vq_write_ok(mp, va.va_uid, va.va_gid, delta) β€” implementation sys/kern/vfs_quota.c:411-461 compares space + delta against per-uid/gid/mount limits, where space is the charged usage (vfs_stdaccount :150-171 is the only writer, called via VFS_ACCOUNT).
  4. Removal refund: sys/kern/vfs_vopops.c:1665-1668 β€” on last-link remove the full va.va_size is subtracted from usage.
  5. Bypass: unlink an open fd (usage drops to ~0, nlink becomes 0) β†’ subsequent writes through the fd are checked against usage that never grows (their own growth is never charged) and each individual write's delta only has to fit the limit once β†’ unbounded growth past any per-uid/mount limit.
  6. TOCTOU (secondary): concurrent writers on separate files both compute delta against the same stale space inside vq_write_ok; the check-then-charge gap spans the entire write, so N concurrent writers can overshoot the limit by (N-1)Γ—limit.
  7. Cross-refs surveyed for completeness: ftruncate/chown are accounted at the syscall layer (sys/kern/vfs_syscalls.c:3560-3561, 4048, 4124; sys/kern/vfs_vnops.c:326) β€” the nlink==0 skip in vop_write is the gap.

Honest classification

  • Certain as a code-level logic flaw; exploitable only where an admin enabled vfs.quota_enabled=1 at boot.
  • Impact ceiling: quota enforcement bypass β†’ disk exhaustion on the quota'd mount (multi-user DoS); no memory-safety consequence (VFS_ACCOUNT with this delta feeds int64 counters only).
  • Cross-file note for the orchestrator (belongs to the vfs_quota.c row, not this finding): vfs_stdaccount calls unode_insert/gnode_insert (sys/kern/vfs_quota.c:153-165) which do kmalloc(..., M_ZERO|M_WAITOK) while mp->mnt_acct.ac_spin is held β€” blocking allocation under a spinlock; same pattern in cmd_set_usage_all/cmd_set_limit_uid/gid (:236-306). Recommend a separate finding against sys/kern/vfs_quota.c.

Fix verification

not_testable
per-fix-DF-2705

Confirmed kernel references

Detail

Exploit chain

open+write to limit -> unlink while open (usage refunded, nlink=0) -> keep writing through fd: checks pass trivially, charges never happen -> unbounded growth on quota-limited mount

Evidence (decisive lines)

['sys/kern/vfs_vopops.c:476-478 do_accounting gated on va_nlink>0', 'sys/kern/vfs_vopops.c:489 vq_write_ok check vs charged usage', 'sys/kern/vfs_vopops.c:495-497 charge skipped when do_accounting==0', 'sys/kern/vfs_vopops.c:1665-1668 last-link removal refunds full size', 'sys/kern/vfs_quota.c:111-113 vfs_quota_enabled default 0, boot tunable only', 'findings/poc/DF-2705/VERDICT.md full chain + cross-refs']

PoC changes

no PoC built: needs non-default boot tunable + vquota setup on the shared guest; Low severity

Verified recommended fix

Charge unlinked-open files (drop the va_nlink>0 gate on do_accounting in vop_write) or account on block allocation inside the FS; refunds in vop_nremove already balance.

Verdict

vop_write's quota glue (sys/kern/vfs_vopops.c:476-478, 489, 495-497) never charges VFS_ACCOUNT usage for files with va_nlink==0, while vop_nremove (:1665-1668) refunds the entire size on last-link removal and vq_write_ok checks against only the charged usage β€” so unlinking an open fd then writing unbounded completely bypasses per-uid/gid/mount quota limits (and N concurrent writers can TOCTOU-overshoot by (N-1)x limit). Certain as logic, but the whole mechanism is gated on vfs_quota_enabled (sys/kern/vfs_quota.c:111-113), default 0, boot-tunable only (CTLFLAG_RD) β€” non-default admin config. Phase V skipped: reproducing requires booting the shared guest with vfs.quota_enabled=1 and vquota limit setup for a Low accounting-bypass; every line of the chain was verified statically.