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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0846 Β· 14 files
FileTypeDescriptionSize
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
README.md readme summary, reachability, build/run
↓ download 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.

VERDICT.md verdict full narrative: mechanism, reachability proof (disasm), 3-kernel comparison, fix validation
↓ download raw

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().

  1. Trigger setup β€” quotactl(ufs_mount, QCMD(Q_QUOTAON, USRQUOTA), 0, path) reaches ufs_quotaon() only when the kernel was built with options QUOTA (dispatcher ufs_vfsops.c:77). The dispatcher also requires caps_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.

  2. 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 calls vn_close() on the stored quota vnode (ufs_quota.c:519) and crfree() on ump->um_cred[type] (ufs_quota.c:521). When the same quota file is re-supplied (*vpp == vp) β€” e.g. Q_QUOTAON issued twice for the same path β€” quotaoff is 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 previous um_cred[type] is orphaned (its cr_ref is never decremented β†’ crfree/kfree never fires). The author flagged this with an /* XXX release duplicate vp if *vpp == vp? */ note at ufs_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.

  1. Effect β€” slow kernel memory leak (M_CRED zone 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)

sys/vfs/ufs/ufs_vfsops.c:

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 own XXX note); not a security-critical issue.
  • The fix.diff is the authoritative verified fix. It applies cleanly with git apply -p1 and patch -p1, compiles into a QUOTA kernel, and eliminates the demonstrated leak.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 01:52:53 UTC 2026 (X86_64_QUOTA, sha256 fc362811...)

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