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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2705 Β· 4 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
f = open("/q/f", O_CREAT|O_WRONLY); write up to the uid limit (charged, checks enforce).unlink("/q/f")while keepingfopen βvop_nremovesubtracts the entire size (VFS_ACCOUNT(..., -va.va_size)) and the inode's nlink drops to 0.- Keep writing through
fforever: every write'sdeltais checked against a uid usage that stays ~0 (0 + delta <= limitpasses for any single write that fits the limit once), anddo_accountingstays 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 passvq_write_okfor 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.
Recommended fix
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 β 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)
- Feature gate:
int vfs_quota_enabled = 0;withTUNABLE_INTandSYSCTL_INT (..., CTLFLAG_RD, ...)β sys/kern/vfs_quota.c:111-113. Boot-time only, default off. Everything below is conditional on the admin enabling it. - Charging skip: sys/kern/vfs_vopops.c:476-478 β
do_accountingonly whenva.va_nlink > 0; :495-497 charge gated ondo_accounting. - Check still runs: :489
vq_write_ok(mp, va.va_uid, va.va_gid, delta)β implementation sys/kern/vfs_quota.c:411-461 comparesspace + deltaagainst per-uid/gid/mount limits, wherespaceis the charged usage (vfs_stdaccount:150-171 is the only writer, called via VFS_ACCOUNT). - Removal refund: sys/kern/vfs_vopops.c:1665-1668 β on last-link remove the full
va.va_sizeis subtracted from usage. - 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.
- TOCTOU (secondary): concurrent writers on separate files both compute delta
against the same stale
spaceinsidevq_write_ok; the check-then-charge gap spans the entire write, so N concurrent writers can overshoot the limit by (N-1)Γlimit. - 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=1at 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_stdaccountcallsunode_insert/gnode_insert(sys/kern/vfs_quota.c:153-165) which dokmalloc(..., M_ZERO|M_WAITOK)whilemp->mnt_acct.ac_spinis 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_testableConfirmed 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.
No comments yet.