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

Global dquot hash/free-list manipulated with no lock - concurrent ufs_dqget cache-miss corrupts lists and panics

Summary

ufs_quota.c: ufs_dqhashtbl/ufs_dqfreelist/ufs_numdquot global across ALL mounts. ufs_dqget() :769 hash lookup :797 TAILQ_FIRST recycle :802 check :804 TAILQ_REMOVE :806 LIST_REMOVE :813 LIST_INSERT_HEAD - ALL without any token/mutex/spinlock. Only serialization is vn_lock(dqvp) :812 AFTER list mutation + per-quota-vnode not cross-mount. ufs_dqrele :890 TAILQ_INSERT_TAIL unlocked. ufs_dqflush :956 hash sweep unlocked. Cache-HIT path (:778 only removes from freelist when cnt==0) is self-protecting. Cache-MISS recycle path (:797-813) NOT: two missers on different CPUs both pass :802 check both TAILQ_REMOVE same entry = double-unlink list corruption. Reachable from unprivileged write() -> ufs_chkdq -> ufs_getinoquota -> ufs_dqget. Fix: lwkt_token around all hash/free-list mutations I/O outside token.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0847 Β· 13 files
FileTypeDescriptionSize
quota_unreachable.c trigger-source default-kernel reachability probe (the PoC): quotactl(Q_QUOTAON) -> EOPNOTSUPP proves ufs_dqget() is dead code 3.1 KB view raw
dq_race.c harness cross-mount root-driven race characterization harness for the options-QUOTA kernel 3.5 KB view raw
build.sh build-script cc -o quota_unreachable ... ; cc -O2 -pthread -o dq_race ... 393 B view raw
run.sh run-script ./quota_unreachable /mnt/q 534 B view raw
build.log build-log default-kernel PoC build output 94 B view raw
run.log run-log decisive default-kernel run (EOPNOTSUPP, x2) 931 B view raw
env.txt environment uname, cc, vfs.quota_enabled, config QUOTA count, nm symbol presence 1.4 KB view raw
characterization.log characterization options-QUOTA (non-default config) live-path + race + fix-validation record 4.3 KB view raw
fix.diff suggested-fix lwkt_token guarding all dquot hash/free-list mutation; git-apply-able; compiles into live options-QUOTA kernel 3.8 KB view raw
VERDICT.md verdict full narrative: why unreachable on default, why real-but-latent, fix validation 7.7 KB ↓ raw
README.md readme human-readable summary + reproduce instructions 5.0 KB ↓ 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 human-readable summary + reproduce instructions
↓ download raw

DF-0847 β€” Global dquot hash/free-list manipulated with no lock

Finding: sys/vfs/ufs/ufs_quota.c β€” ufs_dqhashtbl / ufs_dqfreelist / ufs_numdquot are global across all mounts and mutated (hash lookup, recycle, insert, remove, free-list insert) without any token/mutex/spinlock in ufs_dqget(), ufs_dqrele(), and ufs_dqflush(). Two concurrent quota operations could corrupt the lists / double-unlink / UAF.

Severity filed: Medium (CWE-362 race condition).

Verification outcome

NOT REPRODUCED on the default kernel; confirmed real-but-latent source bug.

The cited unlocked manipulation is dead code on the stock X86_64_GENERIC kernel:

  1. ufs_quotactl() early-returns EOPNOTSUPP unless the kernel was built with options QUOTA (sys/vfs/ufs/ufs_vfsops.c:77-78). X86_64_GENERIC does not set options QUOTA (0 occurrences β€” only VKERNEL64 and LINT64 carry it).
  2. Every in-kernel caller of ufs_getinoquota()/ufs_chkdq()/ufs_chkiq()/ ufs_dqrele() β€” the only paths that reach ufs_dqget() β€” is wrapped in #ifdef QUOTA (ffs_alloc.c:110/131/150/217/303, ffs_inode.c:176/195/448, ffs_balloc.c:490, ufs_vnops.c:290/545/568/589/1285/..., ufs_inode.c:78/113/150). On a non-QUOTA kernel these are compiled out.

So ufs_dqget()/ufs_dqrele()/ufs_dqflush() are present in the binary (ufs_quota.c is optional ffs, per sys/conf/files:1994, not optional quota) but have zero callers β€” unreachable.

Runtime proof

quota_unreachable calls quotactl(Q_QUOTAON) on a UFS mount and prints:

quotactl(Q_QUOTAON, "/mnt/q", uid=0, "/mnt/q/quota.user") -> rc=-1 errno=45 (Operation not supported)
PATH DEAD: EOPNOTSUPP => ufs_quotactl() #ifndef-QUOTA early-return at
           sys/vfs/ufs/ufs_vfsops.c:77-78. `options QUOTA` is absent from
           X86_64_GENERIC, so every caller of ufs_dqget() is compiled out.
           => DF-0847 unreachable on this (default) kernel.

The source-level bug is REAL (latent)

Even though unreachable on the default kernel, the concurrency defect is genuine. Reading ufs_quota.c:

  • ufs_dqget() (ufs_quota.c:769-866): LIST_FOREACH hash walk, TAILQ_REMOVE free-list dequeue (hit path :779 and recycle path :804), LIST_REMOVE hash removal (:806), LIST_INSERT_HEAD publish (:813) β€” no lock.
  • ufs_dqrele() (ufs_quota.c:890): TAILQ_INSERT_TAIL free-list enqueue β€” no lock.
  • ufs_dqflush() (ufs_quota.c:956-966): full hash-table sweep with LIST_REMOVE per matching entry β€” no lock.

The only serialization the original code has is vn_lock(dqvp) (:812), which is per quota-file vnode (per-mount-per-type) and is taken after the list mutation β€” it does nothing to protect the global lists against two CPUs on different mounts (or two missers on the same mount).

dq_race.c is a root-driven characterization harness (calls Q_GETQUOTA with cycling uids from N threads) used to exercise the race on a custom options QUOTA kernel. Note: even on an options QUOTA kernel, an unprivileged user can only trigger a cache-miss for their own uid (a single dquot via file create/write), giving a negligible race window; driving the race needs the privileged Q_GETQUOTA-for-arbitrary-uid path. So even where the code is live, unprivileged exploitation is not realistic.

How to reproduce (default-kernel reachability probe)

Root setup (once) β€” create a UFS mount with quota files:

ssh dfbsd                          # root
dd if=/dev/zero of=/root/q.img bs=1m count=64
vnconfig -c -s labels vn0 /root/q.img
newfs /dev/vn0s0
mkdir -p /mnt/q
mount -o userquota,groupquota /dev/vn0s0 /mnt/q
touch /mnt/q/quota.user /mnt/q/quota.group
chmod 1777 /mnt/q

Then as the unprivileged user:

ssh dfbsd-maxx                     # maxx (uid 1001)
cd poc/DF-0847 && ./build.sh && ./run.sh /mnt/q
# expect: errno=45 (EOPNOTSUPP)  => PATH DEAD

Fix

fix.diff adds a single lwkt_token ufs_dq_token (mirroring ext2_ihash_token in sys/vfs/ext2fs/ext2_ihash.c) and wraps every hash/free-list mutation in lwkt_gettoken/lwkt_reltoken. The token is held only across list manipulation and is released around sleeping calls (kmalloc(M_WAITOK), vn_lock, VOP_READ) so it cannot deadlock. This closes the race on any kernel that enables options QUOTA.

Files

file purpose
quota_unreachable.c default-kernel reachability probe (the PoC)
dq_race.c root-driven race characterization harness (options-QUOTA only)
build.sh/run.sh exact build/run
fix.diff the lwkt_token fix (git-apply-able)
VERDICT.md full narrative + before/after
manifest.json artifact catalog
VERDICT.md verdict full narrative: why unreachable on default, why real-but-latent, fix validation
↓ download raw

DF-0847 β€” VERDICT

Status: NOT REPRODUCED on the default kernel. (Real-but-latent source-level concurrency defect; the cited code path is dead on X86_64_GENERIC.)

The claim

sys/vfs/ufs/ufs_quota.c: the global dquot hash table (ufs_dqhashtbl), free list (ufs_dqfreelist), and counter (ufs_numdquot) are mutated without any lock in ufs_dqget()/ufs_dqrele()/ufs_dqflush(). Concurrent quota operations could double-unlink / corrupt the lists β†’ UAF / panic.

Why it does NOT reproduce on the default kernel

The unlocked manipulation is dead code on the stock X86_64_GENERIC kernel. Two compile-time gates close the path:

  1. ufs_quotactl() early-returns EOPNOTSUPP unless options QUOTA is set (sys/vfs/ufs/ufs_vfsops.c:77-78): c ufs_quotactl(...) { #ifndef QUOTA return (EOPNOTSUPP); #else X86_64_GENERIC does not carry options QUOTA (0 occurrences β€” only VKERNEL64 and LINT64 do, per sys/config/). So quotaon always fails and ump->um_quotas[type] is never set.

  2. Every in-kernel caller of ufs_dqget() is #ifdef QUOTA-gated, so the internal write()/creat()/chown() paths never reach it: - sys/vfs/ufs/ffs_alloc.c:110,131,150,217,303 (ufs_chkdq) - sys/vfs/ufs/ffs_inode.c:176,195,448 (ufs_chkdq/ufs_getinoquota) - sys/vfs/ufs/ffs_balloc.c:490 (ufs_chkdq) - sys/vfs/ufs/ufs_vnops.c:290,545,568,589,1285,1301,1319,1330,2027,... - sys/vfs/ufs/ufs_inode.c:78,113,150

Even a hypothetical direct caller would hit the guard inside ufs_dqget() itself (ufs_quota.c:762-765): dqvp = ump->um_quotas[type]; if (dqvp == NULLVP ...) return (EINVAL); β€” early-returns before any list mutation.

ufs_quota.c is compiled whenever FFS is (sys/conf/files:1994: vfs/ufs/ufs_quota.c optional ffs, not optional quota), so the functions exist in the binary (nm /boot/kernel/kernel shows ufs_dqget, ufs_dqrele, ufs_quotaon) β€” but with zero callers. Unreachable.

Runtime proof

quota_unreachable calls quotactl(Q_QUOTAON) on a UFS mount as the unprivileged maxx user:

quotactl(Q_QUOTAON, "/mnt/q", uid=0, "/mnt/q/quota.user") -> rc=-1 errno=45 (Operation not supported)
PATH DEAD: EOPNOTSUPP => ufs_quotactl() #ifndef-QUOTA early-return at
           sys/vfs/ufs/ufs_vfsops.c:77-78.

The source-level bug is REAL (latent)

Although unreachable on the default kernel, the concurrency defect is genuine and would be live on any kernel built with options QUOTA. Reading ufs_quota.c (master DEV, unpatched):

Site Operation Lock
ufs_dqget:770 LIST_FOREACH hash walk none
ufs_dqget:779 TAILQ_REMOVE free-list (hit path) none
ufs_dqget:804 TAILQ_REMOVE free-list (recycle) none
ufs_dqget:806 LIST_REMOVE hash (recycle) none
ufs_dqget:813 LIST_INSERT_HEAD publish none
ufs_dqget:841 LIST_REMOVE hash (error path) none
ufs_dqrele:890 TAILQ_INSERT_TAIL free-list none
ufs_dqflush:956-966 full hash sweep + LIST_REMOVE per entry none

The only serialization is vn_lock(dqvp) (:812), which is per quota-file vnode and is acquired after the list mutation β€” it cannot protect the global lists against two CPUs on different mounts. This matches the ext2_ihash_token / msdosfs / nfs analogue in the tree, all of which use an lwkt_token to guard their global hash tables β€” ufs_quota.c is the odd one out with no token at all.

Characterization on an options QUOTA kernel (non-default config)

To prove the bug isn't theoretical, a kernel was built with options QUOTA added to X86_64_GENERIC. On it, quotaon succeeds and ufs_dqget() becomes reachable. dq_race.c (root-driven, N threads calling Q_GETQUOTA with cycling uids β†’ repeated cache-miss recycle + free-list races) was used to exercise the unlocked lists.

Note on unprivileged reachability: even with options QUOTA, an unprivileged user can only trigger a dquot cache-miss for their own uid (via file create/write β€” a single dquot), giving a negligible race window. Driving the race requires the privileged Q_GETQUOTA-for-arbitrary-uid path (caps_priv_check RESTRICTEDROOT, ufs_vfsops.c:123). So the realistic unprivileged impact ceiling is negligible; the bug is a privileged-DoS / latent corruption on a non-default config.

This characterization is labelled non-default config per the audit's bright-line rule and does not affect the default-kernel verdict.

Exploit-chain / escalation assessment

No escalation chain was developed, because:

  • On the default kernel the primitive is unreachable (dead code) β€” there is nothing to escalate. This is a valid hard blocker (the cited vulnerable code path is dead at runtime on the default kernel AND on the default config).
  • On an options QUOTA kernel the primitive is a concurrent-list- corruption race whose unprivileged reachability is negligible (single own-uid cache entry). Realistically it is a privileged-DoS, not an unprivileged privesc. Spending 15–30 grooming attempts would not change the unprivileged-reachability verdict, which is the gating property.

Honest impact: none on the default kernel; latent concurrency defect (privileged-DoS ceiling) on a non-default options QUOTA kernel.

PoC changes

The finding shipped with no on-disk PoC folder (findings/poc/DF-0847/ did not exist). I authored the full evidence pack:

  • quota_unreachable.c β€” default-kernel reachability probe (the PoC).
  • dq_race.c β€” root-driven race characterization harness for the options QUOTA kernel.
  • build.sh / run.sh β€” exact build/run.
  • fix.diff β€” the lwkt_token fix.

Fix

fix.diff adds static struct lwkt_token ufs_dq_token (mirroring ext2_ihash_token in sys/vfs/ext2fs/ext2_ihash.c) and wraps every hash / free-list mutation in ufs_dqget/ufs_dqrele/ufs_dqflush in lwkt_gettoken/lwkt_reltoken. The token is held only across list manipulation and is explicitly released around sleeping calls (kmalloc(M_WAITOK), vn_lock, VOP_READ) so it cannot deadlock or serialize I/O. It compiles cleanly and git apply --check passes against the audit tree.

This supersedes the finding's one-line proposal ("lwkt_token around all hash/free-list mutations, I/O outside token") by implementing exactly that with correct token-scope boundaries around the two sleeping call sites in ufs_dqget.

Fix validation

fix_status: not_testable. The race-triggering PoC cannot run on the default guest because the path is latent (options QUOTA off). I validated that fix.diff (a) git apply --check passes against the read-only sys/ tree, (b) compiles into a kernel (built alongside the options QUOTA characterization kernel β€” no new warnings/errors), and (c) by source tracing closes every cited unlocked mutation site. On the default kernel the path is dead both before and after the fix, so there is no runtime before/after to observe.

Kernel references (confirmed during verification)

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

not_testable at the runtime level. The race-triggering PoC cannot produce an observable effect on the default guest: the cited path is dead code there (options QUOTA off -- quotactl returns EOPNOTSUPP). On a custom options-QUOTA kernel the path is live, but the race never panicked even on the UNFIXED kernel (cross-mount harness, 47.6M Q_GETQUOTA iterations -- AC:H, silent corruption with no INVARIANTS guarding the dquot list), so there is no runtime 'before' marker to contrast a 'after' against. I therefore validated the fix by the strongest means available for a latent/unreachable PoC: (a) git apply --check -p1 fix.diff against the read-only sys/ tree PASSES; (b) fix.diff applied cleanly (all 11 hunks) to the in-guest options-QUOTA source; (c) a kernel with fix.diff applied BUILT successfully (rc=0, kernel.stripped = valid 15.7MB ELF); (d) the ufs_dq_token symbol is present in the built kernel.debug (ffffffff81114880 d ufs_dq_token) confirming the fix is compiled in; (e) source-trace confirms every cited unlocked mutation site is now inside a gettoken/reltoken pair while no sleeping call is held under the token. The fix closes the cited race on any kernel that enables options QUOTA.

Apply+compile validation (no runtime contrast possible -- path latent):
+ git apply --check -p1 fix.diff  => APPLY_CHECK_OK
+ patch -p1 --forward < fix.diff   => 'Hunk #9..#11 succeeded' (all 11 hunks applied)
+ make -j6 nativekernel (options QUOTA + fix) => === NK_DONE rc=0 ===
+ nm kernel.debug | grep ufs_dq_token => ffffffff81114880 d ufs_dq_token  (fix compiled in)
+ source: ufs_quota.c:786 lwkt_gettoken(&ufs_dq_token); :799/:815/:824/:843/:872 lwkt_reltoken; :920/:988 get/reltoken around dqrele insert + dqflush sweep.
Baseline (unfixed) on default #0 kernel: quotactl(Q_QUOTAON) -> errno=45 EOPNOTSUPP (path dead, nothing to fix-contrast at runtime).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Jul 11 12:37:31 UTC 2026 (custom X86_64_GENERIC + options QUOTA, with fix.diff applied and rebuilt at 13:01:02)

Confirmed kernel references

Detail

Exploit chain

none -- not a memory-corruption primitive that is reachable on the default target. On the default X86_64_GENERIC kernel the cited unlocked list manipulation is unreachable dead code (no escalation possible -- nothing to corrupt). On a non-default options QUOTA kernel the primitive is a concurrent-list-corruption race, but: (a) it is a non-default-config result (per the bright-line rule, not a default-GENERIC escalation); (b) even there, an unprivileged user can only trigger a cache-miss for their OWN uid (file create/write -> ufs_getinoquota uses ip->i_uid) -- a single cached dquot with a negligible race window -- so driving the race requires the privileged Q_GETQUOTA-for-arbitrary-uid path (caps_priv_check RESTRICTEDROOT, ufs_vfsops.c:123); (c) I additionally discovered that vfs_quotactl() holds the per-mount mnt_token (UFS is not MNTK_MPSAFE), so the race is only cross-mount, narrowing it further. Valid hard blocker (dead code on default kernel/config + non-default-config-only + non-unprivileged-reachable); no escalation chain developed because the gating property -- unprivileged reachability -- does not hold. Characterization of the live (options-QUOTA) path is recorded in findings/poc/DF-0847/characterization.log and dq_race.c.

Evidence (decisive lines)

Decisive default-kernel run (unprivileged maxx, /mnt/q is a UFS mount with quota files):
quotactl(Q_QUOTAON, "/mnt/q", uid=0, "/mnt/q/quota.user") -> rc=-1 errno=45 (Operation not supported)
PATH DEAD: EOPNOTSUPP => ufs_quotactl() #ifndef-QUOTA early-return at sys/vfs/ufs/ufs_vfsops.c:77-78.
(repeat, identical.) Cross-mount race harness on a custom options-QUOTA kernel: survived 47.6M Q_GETQUOTA iterations with no panic (race real but narrow/silent -- consistent with AC:H).

PoC changes

The finding shipped with NO on-disk PoC folder (findings/poc/DF-0847/ did not exist). I authored the complete evidence pack: quota_unreachable.c (default-kernel reachability probe -- the PoC), dq_race.c (cross-mount root-driven race harness for the options-QUOTA characterization, including the insight that the race is cross-mount only because vfs_quotactl holds the per-mount mnt_token), build.sh/run.sh, README.md, VERDICT.md, characterization.log, fix.diff, and manifest.json.

Verified recommended fix

Add a static lwkt_token ufs_dq_token (mirroring ext2_ihash_token in sys/vfs/ext2fs/ext2_ihash.c:67) and wrap every hash/free-list mutation in ufs_dqget()/ufs_dqrele()/ufs_dqflush() (ufs_quota.c:779,804,806,813,841,890,956-966) in lwkt_gettoken/lwkt_reltoken, releasing the token around sleeping calls (kmalloc M_WAITOK, vn_lock, VOP_READ) so it cannot deadlock or serialize I/O. This SUPERSIDES the finding's one-line proposal by implementing exactly that with correct token-scope boundaries around the two sleeping call sites in ufs_dqget. The full git-apply-able diff is in findings/poc/DF-0847/fix.diff (git apply --check passes; it compiles into a live options-QUOTA kernel with rc=0 and the ufs_dq_token symbol present).

Verdict

NOT REPRODUCED on the default kernel. The cited unlocked manipulation of the global UFS dquot hash/free-list in ufs_dqget()/ufs_dqrele()/ufs_dqflush() (ufs_quota.c:769-866,890,956-966) is DEAD CODE on the stock X86_64_GENERIC kernel. Two compile-time gates close the path: (1) ufs_quotactl() early-returns EOPNOTSUPP unless options QUOTA is set (ufs_vfsops.c:77-78), and X86_64_GENERIC does NOT carry options QUOTA (0 occurrences -- only VKERNEL64/LINT64 do); (2) every in-kernel caller of ufs_getinoquota()/ufs_chkdq()/ufs_chkiq()/ufs_dqrele() -- the only paths into ufs_dqget() -- is wrapped in #ifdef QUOTA (ffs_alloc.c:110/131/150/217/303, ffs_inode.c:176/195/448, ffs_balloc.c:490, ufs_vnops.c:290/545/568/589/..., ufs_inode.c:78/113/150) and is compiled out. ufs_quota.c IS compiled (optional ffs, sys/conf/files:1994 -- nm confirms ufs_dqget/ufs_chkdq/ufs_dqrele/ufs_quotaon are defined) but has ZERO callers. Runtime proof: quotactl(Q_QUOTAON) on a UFS mount returns errno=45 (EOPNOTSUPP) -- 'PATH DEAD' -- run as unprivileged maxx and as root, deterministic across repeats. I also built a custom options QUOTA kernel to confirm the bug is REAL-but-LATENT (not a phantom): on it quotactl(Q_QUOTAON) succeeds (PATH LIVE) and the unlocked code becomes reachable, but the race is genuinely narrow (CVSS AC:H) -- a cross-mount harness driving 47.6M Q_GETQUOTA iterations produced no panic (no INVARIANTS guard the dquot TAILQ/LIST, so corruption is silent), and unprivileged reachability is negligible (single own-uid cache entry). This is the valid hard blocker: the vulnerable code path is dead at runtime on the default kernel AND the default config.