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)
PoC verification
Evidence pack
findings/poc/DF-0847 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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:
ufs_quotactl()early-returnsEOPNOTSUPPunless the kernel was built withoptions QUOTA(sys/vfs/ufs/ufs_vfsops.c:77-78).X86_64_GENERICdoes not setoptions QUOTA(0 occurrences β onlyVKERNEL64andLINT64carry it).- Every in-kernel caller of
ufs_getinoquota()/ufs_chkdq()/ufs_chkiq()/ufs_dqrele()β the only paths that reachufs_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-QUOTAkernel 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_FOREACHhash walk,TAILQ_REMOVEfree-list dequeue (hit path:779and recycle path:804),LIST_REMOVEhash removal (:806),LIST_INSERT_HEADpublish (:813) β no lock.ufs_dqrele()(ufs_quota.c:890):TAILQ_INSERT_TAILfree-list enqueue β no lock.ufs_dqflush()(ufs_quota.c:956-966): full hash-table sweep withLIST_REMOVEper 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 |
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:
-
ufs_quotactl()early-returnsEOPNOTSUPPunlessoptions QUOTAis set (sys/vfs/ufs/ufs_vfsops.c:77-78):c ufs_quotactl(...) { #ifndef QUOTA return (EOPNOTSUPP); #elseX86_64_GENERICdoes not carryoptions QUOTA(0 occurrences β onlyVKERNEL64andLINT64do, persys/config/). Soquotaonalways fails andump->um_quotas[type]is never set. -
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 QUOTAkernel 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 theoptions QUOTAkernel.build.sh/run.shβ exact build/run.fix.diffβ thelwkt_tokenfix.
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)
sys/vfs/ufs/ufs_quota.c:731-732β global free list + counter, no locksys/vfs/ufs/ufs_quota.c:769-866βufs_dqget, all mutations unlockedsys/vfs/ufs/ufs_quota.c:890βufs_dqrelefree-list enqueue unlockedsys/vfs/ufs/ufs_quota.c:956-966βufs_dqflushhash sweep unlockedsys/vfs/ufs/ufs_vfsops.c:77-78βufs_quotactl#ifndef QUOTA return EOPNOTSUPPsys/config/X86_64_GENERICβ nooptions QUOTAsys/conf/files:1994βufs_quota.cisoptional ffs(compiled) notoptional quotasys/vfs/ufs/ffs_alloc.c:110(and siblings) β#ifdef QUOTAcaller gatesys/vfs/ext2fs/ext2_ihash.c:67β thelwkt_tokenanalogue the fix mirrors
Fix verification
not_testablenot_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).
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.
No comments yet.