Credential and vnode-reference leak when Q_QUOTAON re-enables quotas on same vnode
Summary
ufs_quota.c:437 if(*vpp!=vp) ufs_quotaoff(mp,type) - when SAME quota file re-supplied *vpp==vp quotaoff SKIPPED. :425 vn_open took extra vnode ref never vn_closed. :448 ump->um_cred[type]=crhold(cred) overwrites credential without prior crfree. Each call leaks one struct ucred + one vnode ref. v_refcnt signed int after 2^31 calls wraps negative = premature vnode reclaim/UAF risk. Author flagged XXX comment :443. Trigger: root Q_QUOTAON on already-active quota file path. Fix: vn_close duplicate ref + crfree existing cred before overwrite when *vpp==vp.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0846 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| quotaon_leak.c | trigger-source | Q_QUOTAON same-path leak demonstrator (forks children w/ distinct creds to expose refcount leak as cred-zone Count growth) | 5.5 KB | view raw |
| build.sh | build-script | cc -Wall -O2 -o quotaon_leak quotaon_leak.c | 274 B | view raw |
| run.sh | run-script | ./quotaon_leak [children] [iters_per_child] (root, on a QUOTA kernel against /boot ufs) | 512 B | view raw |
| README.md | readme | summary, reachability, build/run | 2.9 KB | β raw |
| VERDICT.md | verdict | full narrative: mechanism, reachability proof (disasm), 3-kernel comparison, fix validation | 7.2 KB | β raw |
| fix.diff | suggested-fix | git-apply-able unified diff: vn_close duplicate ref + crfree stale cred when *vpp==vp | 1.1 KB | view raw |
| generic_baseline_run.log | run-log | default GENERIC #0: quotactl -> EOPNOTSUPP (unreachable) | 266 B | view raw |
| baseline_quota_run.log | run-log | unpatched X86_64_QUOTA #0 (sha 69940976): cred delta +60 = LEAK CONFIRMED | 590 B | view raw |
| fix_run.log | run-log | patched X86_64_QUOTA #1 (sha fc362811): cred delta 0 = no leak (x2 runs) | 576 B | view raw |
| quota_kernel_build.log | build-log | full nativekernel X86_64_QUOTA build output (unpatched baseline, rc=0) | 5.6 MB | β download |
| fix_build.log | build-log | full nativekernel X86_64_QUOTA build output (with fix.diff applied, rc=0) | 5.6 MB | β download |
| env.txt | environment | uname, mounts, QUOTA option presence, vmstat -m cred idle baseline | 712 B | view raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0846 β Credential + vnode-reference leak when Q_QUOTAON re-enables quotas on the same vnode
Summary
ufs_quotaon() (sys/vfs/ufs/ufs_quota.c) leaks one struct ucred and one
vnode reference every time Q_QUOTAON is issued for an already-active
quota file (i.e. the supplied path resolves to the same vnode already stored
in ump->um_quotas[type]).
Root cause, line by line:
- ufs_quota.c:425 β vn_open(&nd, NULL, FREAD|FWRITE, 0) takes a fresh
vnode reference on the quota file.
- ufs_quota.c:437 β if (*vpp != vp) ufs_quotaoff(mp, type); β quotaoff
is the only place that calls vn_close() on the stored quota vnode and
crfree() on ump->um_cred[type]. When the same vnode is re-supplied
(*vpp == vp) quotaoff is skipped, so neither the duplicate vnode
ref nor the old credential is released.
- ufs_quota.c:448 β ump->um_cred[type] = crhold(cred); overwrites the
credential pointer unconditionally β the previous um_cred[type] is now
leaked (its cr_ref is never decremented, so crfree/kfree never
fires).
- The author left an /* XXX release duplicate vp if *vpp == vp? */ note at
ufs_quota.c:443.
Each such call therefore leaks 1 Γ struct ucred (M_CRED zone) + 1 Γ vnode
reference. After 2Β³ΒΉ calls the signed v_refcnt could wrap negative,
risking a premature vnode reclaim / UAF β but that is a theoretical ceiling;
in practice this is a slow memory leak.
Reachability (the key fact for this audit)
The entire Q_QUOTAON dispatch in ufs_quotactl() (sys/vfs/ufs/ufs_vfsops.c:77)
is inside #else of #ifndef QUOTA. Without options QUOTA, ufs_quotactl
is a stub that returns EOPNOTSUPP immediately:
#ifndef QUOTA
return (EOPNOTSUPP);
#else
... case Q_QUOTAON: error = ufs_quotaon(cred, mp, type, arg); ...
#endif
The default X86_64_GENERIC config does not include options QUOTA
(only LINT64 does β sys/config/LINT64:510). Disassembly of the running
GENERIC kernel confirms ufs_quotactl is the two-instruction stub
mov $0x2d,%eax ; retq (EOPNOTSUPP = 45 = 0x2d). So on the stock kernel
quotactl(Q_QUOTAON) returns EOPNOTSUPP and ufs_quotaon() is never
reached.
Additional precondition (even on a QUOTA kernel): the dispatcher requires
caps_priv_check(cred, SYSCAP_NOQUOTA_WR) (ufs_vfsops.c:104) β i.e.
root. So this is a root-only resource leak, not a privilege boundary
crossing.
Reproduction / build / run
./build.sh # cc -Wall -O2 -o quotaon_leak quotaon_leak.c sudo ./run.sh 2000 # on a QUOTA-enabled kernel; prints cred-zone delta
On the default GENERIC kernel the PoC exits with code 3 and prints the
EOPNOTSUPP explanation. On a kernel built with options QUOTA the cred
zone count grows by ~iters.
See VERDICT.md for the full trace + reachability proof and fix_validation/
for the QUOTA-kernel before/after demonstration.
DF-0846 β Verdict
Verdict: REPRODUCED (on a options QUOTA kernel); UNREACHABLE on the default X86_64_GENERIC kernel.
The credential + vnode-reference leak described by the finding is a genuine
code defect in ufs_quotaon(), but the vulnerable code path is dead code
on the stock audit kernel: the Q_QUOTAON dispatch in ufs_quotactl() is
#else-gated behind options QUOTA, which X86_64_GENERIC does not include.
On the default kernel quotactl(Q_QUOTAON) returns EOPNOTSUPP and
ufs_quotaon() is never reached. The bug was therefore demonstrated on a
purpose-built X86_64_QUOTA kernel (= GENERIC + options QUOTA), and the
authored fix.diff was validated before/after on that same kernel.
Mechanism (trigger β primitive β effect)
File: sys/vfs/ufs/ufs_quota.c, ufs_quotaon().
-
Trigger setup β
quotactl(ufs_mount, QCMD(Q_QUOTAON, USRQUOTA), 0, path)reachesufs_quotaon()only when the kernel was built withoptions QUOTA(dispatcherufs_vfsops.c:77). The dispatcher also requirescaps_priv_check(cred, SYSCAP_NOQUOTA_WR)(ufs_vfsops.c:104), i.e. root. So even on a QUOTA kernel this is a root-only path β not a privilege-boundary crossing. -
The leak β
ufs_quotaon(): -ufs_quota.c:425βvn_open(&nd, NULL, FREAD|FWRITE, 0)takes a fresh vnode reference on the quota file. -ufs_quota.c:437βif (*vpp != vp) ufs_quotaoff(mp, type);.ufs_quotaoff()is the only place that callsvn_close()on the stored quota vnode (ufs_quota.c:519) andcrfree()onump->um_cred[type](ufs_quota.c:521). When the same quota file is re-supplied (*vpp == vp) β e.g.Q_QUOTAONissued twice for the same path βquotaoffis skipped, so neither the duplicate vnode reference nor the old credential is released. -ufs_quota.c:448βump->um_cred[type] = crhold(cred);overwrites the credential pointer unconditionally; the previousum_cred[type]is orphaned (itscr_refis never decremented βcrfree/kfreenever fires). The author flagged this with an/* XXX release duplicate vp if *vpp == vp? */note atufs_quota.c:443.
Each such call leaks 1 Γ credential reference + 1 Γ vnode
reference. After ~2Β³ΒΉ calls the signed v_refcnt could wrap negative
(theoretical UAF ceiling); in practice this is a slow, unbounded memory /
refcount leak that can be driven by an unprivileged-triggerable... no β
only by root.
- Effect β slow kernel memory leak (
M_CREDzone growth + ever-growing vnode refcount) when root repeatedly re-enables quotas on an already-active quota file. DoS-via-exhaustion ceiling, not RCE/privesc.
Why it is unreachable on the default kernel (the key fact)
int ufs_quotactl(struct mount *mp, int cmds, uid_t uid, caddr_t arg, struct ucred *cred)
{
#ifndef QUOTA
return (EOPNOTSUPP); /* <-- the entire dispatch is compiled out */
#else
...
case Q_QUOTAON: error = ufs_quotaon(cred, mp, type, arg);
...
#endif
}
sys/config/X86_64_GENERIC does not contain options QUOTA (only
sys/config/LINT64:510 does). The compiled ufs_quotactl in the running
6.5-DEVELOPMENT #0 GENERIC kernel is literally the two-instruction stub:
ffffffff80917820 <ufs_quotactl>: ffffffff80917820: b8 2d 00 00 00 mov $0x2d,%eax ; EOPNOTSUPP = 45 = 0x2d ffffffff80917825: c3 retq
Behaviourally confirmed: quotactl(Q_QUOTAON) on the stock kernel returns
errno=45 (Operation not supported) immediately. The file
ufs_quota.c is linked (optional ffs) so the ufs_quotaon symbol is
present, but its sole caller is inside the excluded #else, so it is dead
code on GENERIC.
Reproduction (three kernels compared)
The PoC forks N children that each obtain a distinct cred (via
setresgid β crcopy), re-issue Q_QUOTAON on the same already-active
quota file M times, then exit. Each child's cred gets its cr_ref pinned by
M leaked references, so it can never be freed β the cred struct leaks
permanently and the vmstat -m "cred" zone Count grows by ~N.
| Kernel | build | sha256 (kernel) | PoC result (60 children Γ 30 iters) |
|---|---|---|---|
default X86_64_GENERIC |
stock #0 |
β | quotactl β EOPNOTSUPP; ufs_quotaon never reached |
X86_64_QUOTA unpatched |
#0 |
69940976β¦52e491 |
cred zone 9 β 69, delta +60 = LEAK CONFIRMED |
X86_64_QUOTA patched (fix.diff) |
#1 |
fc362811β¦3b2982 |
cred zone 8 β 8, delta 0 = no leak (Γ2 runs) |
Decisive before/after:
- baseline (unpatched QUOTA): cred delta : 60 VERDICT: LEAK CONFIRMED
- patched QUOTA: cred delta : 0 VERDICT: no leak (fix in effect)
The / root filesystem is hammer2 on this guest, so the PoC targets the
ufs mount /boot (/dev/vbd0s1a on /boot (ufs, local)) and creates the
quota file at /boot/quota.user.
Escalation
None applicable. This is a refcount / resource leak, not a memory-
corruption primitive (no attacker-shaped write, no UAF at trigger time β the
reference count only grows, it does not wrap on a realistic call budget).
There is no primitive to convert to control-flow hijack. The realistic impact
ceiling is a slow kernel-memory exhaustion DoS, and only when root repeatedly
re-enables quotas on the same file on a options QUOTA kernel.
The fix (fix.diff)
Minimal, targeted change to ufs_quotaon() at ufs_quota.c:437. When
*vpp == vp (same vnode re-supplied), release the duplicate vnode reference
taken by vn_open() and drop the previously-saved credential before
overwriting it β mirroring exactly what ufs_quotaoff() does for the
*vpp != vp branch:
if (*vpp != vp) {
ufs_quotaoff(mp, type);
} else {
vn_close(vp, FREAD|FWRITE, NULL); /* release duplicate vn_open ref */
if (ump->um_cred[type] != NOCRED)
crfree(ump->um_cred[type]); /* release stale stored cred */
}
The reference retained by ump->um_quotas[type] keeps vp alive across the
vn_close(), so the subsequent vsetflags(vp, VSYSTEM) / *vpp = vp remain
safe. Validated before/after on the X86_64_QUOTA kernel (table above): the
leak (+60) is completely gone (delta 0).
PoC changes
The supplied PoC scaffolding for DF-0846 did not exist in the repo (the
finding markdown/poc dir were missing); this run authored the full evidence
pack from scratch: quotaon_leak.c (the demonstrator), build.sh, run.sh,
README.md, this VERDICT.md, fix.diff, the three kernel logs, and
manifest.json. The PoC targets the ufs mount /boot (the guest's / is
hammer2) and uses distinct per-child creds to make the refcount leak visible
as vmstat -m "cred" zone Count growth.
Notes for maintainers
- The leak is real but low impact: root-only, and only on kernels built
with
options QUOTA(not the default). Worth fixing for correctness / hygiene (and to retire the author's ownXXXnote); not a security-critical issue. - The
fix.diffis the authoritative verified fix. It applies cleanly withgit apply -p1andpatch -p1, compiles into aQUOTAkernel, and eliminates the demonstrated leak.
Fix verification
fixedVALIDATED on QUOTA kernel: baseline cred delta +60; patched delta 0 x2 runs.
BEFORE (QUOTA #0): cred delta +60 LEAK. AFTER (QUOTA #1): cred delta 0 x2.
Confirmed kernel references
Detail
Exploit chain
none -- refcount/resource leak. Root-only, non-default kernel. No write primitive.
Evidence (decisive lines)
QUOTA kernel baseline: cred delta +60 (LEAK). QUOTA kernel patched: cred delta 0. GENERIC: EOPNOTSUPP.
PoC changes
Authored from scratch: quotaon_leak.c (forks N children with distinct creds, same-path Q_QUOTAON loop), build.sh, run.sh, fix.diff (vn_close + crfree on same-vnode path), VERDICT.md, manifest.json.
Verified recommended fix
In ufs_quotaon:437 when *vpp==vp, call vn_close(vp,...) to release duplicate ref + crfree old um_cred before crhold. Matches finding proposal. Full git-apply-able diff in findings/poc/DF-0846/fix.diff.
Verdict
REPRODUCED (on X86_64_QUOTA kernel); UNREACHABLE on default GENERIC. ufs_quotaon:425 vn_open takes vnode ref; :437 same-vnode skip doesn't release it; :448 crhold overwrites stored cred without crfree. 60 children x 30 iters -> cred zone +60. Default GENERIC: quotactl returns EOPNOTSUPP (options QUOTA absent).
No comments yet.