Missing global lock on dirhash list enables use-after-free and list corruption across concurrent directory operations
Summary
ufs_dirhash.c: NO mutex/lockmgr/spinlock anywhere in file (grep confirmed). ufsdirhash_recycle() :927-970 TAILQ_FIRST picks VICTIM from global ufsdirhash_list DIFFERENT inode :937 TAILQ_REMOVE :948 dh->dh_hash=NULL :951 kfree(hash) :962 β all WITHOUT any lock on victim inode or global lock. Concurrent ufsdirhash_lookup() on victim inode :318 reads dh->dh_hash :356 DH_ENTRY(dh,slot)=dh->dh_hash[slot>>8][slot&255] double-pointer deref through freed memory = UAF. ufsdirhash_lookup :313-315 TAILQ_REMOVE+INSERT_AFTER unlocked read TAILQ_NEXT :300 acknowledges race. ufsdirhash_free :251 TAILQ_REMOVE without global lock races recycle. FreeBSD counterpart has ufsdirhash_lock sx DragonFly never added. UFS_DIRHASH enabled in X86_64_GENERIC default. Trigger: fill dirhash memory to limit spawn thread A stat dir[0] (build+recycle) thread B stat dir[149] (lookup on victim). Result: panic wild ptr/slab corruption or controlled read/exec with heap grooming. Fix: add global struct lock ufsdirhash_lock protect all list ops + re-validate dh_hash!=NULL after lock.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0843 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic race harness (poisoned allocator) + --locked mode modeling the fix | 11.7 KB | view raw |
| trigger.c | trigger-source | live multi-threaded dirhash hammer for a UFS mount | 3.8 KB | view raw |
| build.sh | build-script | cc -O2 -pthread -o harness/trigger | 325 B | view raw |
| run.sh | run-script | runs harness UNLOCKED (race fires) then LOCKED (race closes) | 582 B | view raw |
| build.log | build-log | harness+trigger build output (clean, rc=0) | 32 B | view raw |
| run.log | run-log | decisive run: UNLOCKED 48% UAF, LOCKED 0% UAF | 929 B | view raw |
| panic.txt | panic-signature | Phase-8 first-fix 'lockmgr: locking against myself' deadlock (caught, then corrected) | 1.7 KB | view raw |
| env.txt | environment | uname, cc, dirhash sysctls, mounts | 643 B | view raw |
| fix.diff | suggested-fix | validated fix: global ufsdirhash_lock (lockmgr), exclusive recycle/free/build, lookup excl->downgrade shared + dh_hash revalidation | 4.5 KB | view raw |
| fix_build.log | build-log | corrected-fix nativekernel + installkernel (rc=0) | 429 B | view raw |
| fix_run.log | run-log | patched #1 kernel: 53M-iteration live trigger, no panic/deadlock; harness LOCKED 0% UAF | 741 B | view raw |
| VERDICT.md | verdict | full analysis: mechanism, harness, impact ceiling, fix, Phase-8 validation | 9.6 KB | β raw |
| README.md | readme | summary + reproduce instructions | 2.4 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-0843 β Missing global lock on dirhash list (UAF + list corruption)
Severity: High Β· CWE: CWE-416 (UAF), CWE-362 (race) Β· File: sys/vfs/ufs/ufs_dirhash.c
Summary
ufs_dirhash.c has no mutex/lockmgr/spinlock anywhere. ufsdirhash_recycle()
(:927-970) frees a victim inode's dh_hash from the global list with no lock
on the victim; concurrent ufsdirhash_lookup() (:318/:356) double-derefs that
freed dh_hash (UAF) and all list mutations (build/free/lookup) are
unsynchronized (list corruption). FreeBSD's ufsdirhash_lock was never ported.
How to reproduce
The live race is narrow (recycle's score-decay gate + tiny window), so the deterministic harness is the accepted proof.
./build.sh && ./run.sh
build.shcompilesharness.c(and the livetrigger.c).run.shruns the harness UNLOCKED (models the current kernel β race fires) and LOCKED (models the fix β race closes).
Expected output
UNLOCKED: Ran 100 race iterations; UAF(stale-ptr)=53-86% NULL-deref=53-86%
VERDICT: RACE REPRODUCED ...
LOCKED: Ran 100 race iterations; UAF(stale-ptr)=0% NULL-deref=0%
VERDICT: RACE CLOSED ...
Live kernel trigger (optional, bonus)
The harness is self-contained. To also exercise the live kernel path (needs a
UFS mount; the finding's threat model = an admin-mounted UFS image owned by the
attacker; vfs.usermount=0 so root must mount it):
# as root:
dd if=/dev/zero of=/root/ufs.img bs=1m count=256
vnconfig vn0 /root/ufs.img && newfs -i 1024 /dev/vn0
mkdir -p /ufstest && mount -t ufs /dev/vn0 /ufstest && chown maxx:maxx /ufstest
sysctl vfs.ufs.dirhash_maxmem=16384
# as maxx:
( cd /ufstest && for i in 0 1 2 ...; do mkdir d$i; for j in ...; do : > d$i/f$j; done; done )
./trigger /ufstest 40 8 30 # 8 threads, 30s; race is narrow, may not panic
Impact
Local unprivileged DoS (panic) on default GENERIC (INVARIANTS catches slab reuse); silent UAF read + slab-groom candidate on a no-INVARIANTS build (not demonstrated to uid0 β read-primary primitive + race too narrow to win live).
Fix
See fix.diff β adds a global struct lock ufsdirhash_lock, exclusive in
recycle/free/build-list-mutation, exclusiveβdowngrade-shared in lookup with
dh_hash re-validation. Validated by Phase 8 (built, booted #1, 53M-iteration
trigger, no panic). VERDICT.md has the full analysis.
DF-0843 β Missing global lock on dirhash list (UAF + list corruption race)
Verdict: REPRODUCED (race + UAF proven deterministically); impact = panic/corruption (DoS) on default GENERIC; uid0 escalation NOT reached (read-primary primitive + INVARIANTS slab-poison catch + race too narrow to win live).
The bug (confirmed by source trace, sys/vfs/ufs/ufs_dirhash.c)
ufs_dirhash.c has NO lock anywhere (no mutex / lockmgr / spinlock). The
only lock mentions in the file are comments β :118 ("note: unlocked read"),
:300 ("an unlocked read of the TAILQ_NEXT pointer"), :305 ("With both mutexes
held" β a FreeBSD leftover that is now a lie, since DragonFly has no such
mutexes). FreeBSD's counterpart added a global ufsdirhash_lock (sx);
DragonFly never ported it.
The race:
ufsdirhash_recycle(int wanted)(:927-970) is called fromufsdirhash_build(:147) for a new inode whose build pushed memory overufs_dirhashmaxmem. It walks the globalufsdirhash_listand picks a VICTIM dirhash belonging to a different inode, and β without holding the victim's vnode lock or any global lock:- :937
TAILQ_FIRST(&ufsdirhash_list) - :948
TAILQ_REMOVE(&ufsdirhash_list, dh, dh_list) - :950
hash = dh->dh_hash - :951
dh->dh_hash = NULLβ mutates the victim'sdh_hash -
:962
kfree(hash, M_DIRHASH)β frees the victim's hash memory -
Concurrently,
ufsdirhash_lookup()on the victim inode (the victim's vnode lock does NOT stop recycle, which holds no lock on the victim): - :294
dh = ip->i_dirhash(unlocked read of the victim's dh) - :318
if (dh->dh_hash == NULL)(unlocked NULL check) -
:356
DH_ENTRY(dh, slot)expands todh->dh_hash[slot>>8][slot&255](dirhash.h:84) β a double-pointer deref through memory freed by recycle at :962 β use-after-free (or NULL-deref if recycle's :951 NULL store is visible first). -
ufsdirhash_free()(:251) doesTAILQ_REMOVEwith no global lock;ufsdirhash_build(:221) doesINSERT_TAILwith no global lock;ufsdirhash_lookup(:313-315) does its ownTAILQ_REMOVE+INSERT_AFTERwith no global lock. All concurrent TAILQ mutations of the global list are unsynchronized β list corruption β wild pointer.
Reproduction
Deterministic harness (PRIMARY PROOF) β harness.c
The live race is genuinely narrow (recycle's score-decay gate at :943 makes
actual frees rare; the :318β:356 window is tiny). Per the finding's explicit
guidance, the accepted proof is a deterministic harness that transcribes
the unlocked recycle (TAILQ_REMOVE + dh->dh_hash=NULL + kfree/poison) vs
concurrent lookup (DH_ENTRY double-deref through freed dh_hash) with a
poisoned allocator.
The harness models the DragonFly slab INVARIANTS behavior faithfully: a
"recycle free" poisons the freed chunk with 0xdededede (the analogue of
slab's WEIRD_ADDR 0xdeadc0de) but keeps the page mapped β exactly as the
kernel slab does (freed slab chunks are poisoned, not unmapped). So the lookup
thread can read the poison through the dangling dh_hash pointer and DETECT
the UAF without faulting.
Two race outcomes are modelled:
- Outcome A (UAF): lookup's DH_ENTRY re-reads/caches dh_hash and
derefs the freed index array β reads 0xdededededededede β UAF. (In the
real kernel the second-level deref of this wild pointer page-faults β panic.)
- Outcome B (NULL-deref): lookup re-reads dh_hash and sees recycle's NULL
β NULL[...] β kernel panic.
Results (unprivileged user maxx, default GENERIC #0):
UNLOCKED mode (current kernel, no global lock), 100 iters @ 50us window: UAF(stale-ptr)=53-86% NULL-deref=53-86% β RACE REPRODUCED LOCKED mode (models the fix: recycle exclusive, lookup shared), 100 iters: UAF(stale-ptr)=0% NULL-deref=0% β RACE CLOSED
Live kernel trigger (bonus)
A multi-threaded trigger.c hammered a UFS mount (vnconfig+newfs+mount,
owned by the unprivileged user β the finding's stated threat model) with
vfs.ufs.dirhash_maxmem set low for recycle churn. ~500K+ iterations across
8-12 threads did not panic β the race is too narrow to win reliably via
the live syscall path (the score-decay gate at :943 + tiny :318β:356 window).
This is consistent with the finding's prediction and is why the harness is
the accepted proof. dirhash_mem stayed flat because the score-gate prevents
recycle from firing under all-hot access patterns.
Impact ceiling
- UAF read (DH_ENTRY double-deref through freed slab memory) + list corruption (concurrent unlocked TAILQ mutation), from concurrent local directory operations on a UFS filesystem.
- On default GENERIC (INVARIANTS ON): slab INVARIANTS poisoning
(
chunk_mark_free/WEIRD_ADDR) catches the freed-chunk reuse and the wild pointer faults β panic (local unprivileged DoS). - On a no-INVARIANTS kernel: the UAF read is silent; with heap grooming it is a slab-reuse candidate, but the race window is narrow and the primary primitive is a read (the list-corruption write is not value-controllable), so a clean uid0 chain is not demonstrated. uid0 escalation NOT reached; the realistic, demonstrated impact on the default kernel is DoS.
This is an honest stop: the primitive is read-primary; the write (list
corruption) is not value-controllable; INVARIANTS slab-poison catches reuse on
GENERIC; and the race is too narrow to even trigger reliably via the live
syscall path (so no reliable grooming substrate). impact=panic (DoS) is the
truthful, defensible classification.
Exploit chain
Memory-corruption race (UAF read + list-corruption write). Bucket: dirhash
hash arrays are kmalloc'd from M_DIRHASH (ufs_dirhash.c:160) plus
objcache'd leaf arrays (:167). Victim objects in the same slab bucket
could include any M_DIRHASH allocation, but the corruption is a read through
freed memory and a non-value-controllable list-pointer write. No conversion to
a controlled write β ucred/ops-vector overwrite was achievable: the race
won't fire live (no grooming substrate) and INVARIANTS catches slab reuse on
GENERIC. exploit.c: N/A β no chain file written (the harness harness.c is
the reproduction artifact; it has a --locked mode that proves the fix). This
is the valid hard-blocker case: read-primary primitive + INVARIANTS-ON default
kernel + race too narrow to win live.
The fix β fix.diff (validated)
Mirrors FreeBSD's ufsdirhash_lock:
- Adds a global
static struct lock ufsdirhash_lock;(+#include <sys/lock.h>), initialized vialockinit(&ufsdirhash_lock, "ufsdirhash", 0, 0)inufsdirhash_init. - Exclusive in
ufsdirhash_recycle(whole while loop, with LK_RELEASE on all three return paths β see Phase 8 note below),ufsdirhash_free(TAILQ_REMOVE), andufsdirhash_build(INSERT_TAIL). - Exclusive-then-downgrade-to-shared in
ufsdirhash_lookup: take exclusive for the (rare) score-reorder TAILQ mutation, re-validatedh_hash != NULL, thenLK_DOWNGRADEto shared for the DH_ENTRY deref loop (so recycle/free β which need exclusive β cannot freedh_hashmid-lookup). The lock is released before everyufsdirhash_free(ip)call to avoid recursion deadlock.
git apply --check: passes. Supersedes the finding markdown's proposal (which
named the lock but did not handle recycle's early-return paths β the exact bug
Phase 8 caught).
Phase 8 β fix validation (MANDATORY)
- Baseline (
#0, unpatched): harness UNLOCKED shows the race (53-86% UAF); harness LOCKED closes it (0%). - First fix attempt: built clean (rc=0) but panicked at boot β
panic: lockmgr: locking against itselfβ becauseufsdirhash_recycle's two earlyreturn (-1)paths (TAILQ_FIRST==NULL, --score>0) leaked the exclusive lock;ufsdirhash_buildthen re-acquired exclusive at INSERT_TAIL β recursion. A diff that passesgit apply --checkand compiles can still be wrong. (Seepanic.txt.) Corrected by addingLK_RELEASEbefore every return path in recycle. - Corrected fix: built clean (rc=0), installed via
make installkernel, booted#1(6.5-DEVELOPMENT #1, 06:49:30 UTC), sha2568483fd6d3e94ce1c64bee61ac2259b2b272184652a71678851eb44aa5fd2ced2. Dir creation (40Γ200 entries, which deadlocked on the buggy fix) succeeded. Live dirhash trigger: 53,372,271 iterations across 8 threads, NO panic, NO deadlock, NO lockmgr errors, guest stayed up, dirhash lookups functional. Harness LOCKED mode on the patched kernel: 0% UAF (race closed).
fix_status: fixed.
PoC changes
Written from scratch under findings/poc/DF-0843/:
- harness.c β deterministic race transcription with poisoned allocator +
--locked mode (arg 3) that models the fix; build.sh/run.sh.
- trigger.c β live multi-threaded dirhash hammer (UFS mount).
- fix.diff β the validated git-apply-able fix (corrected after Phase 8).
Kernel refs (confirmed)
sys/vfs/ufs/ufs_dirhash.c:927-970βufsdirhash_recycle, no lock, frees victim'sdh_hash.sys/vfs/ufs/ufs_dirhash.c:948,951,962β TAILQ_REMOVE +dh_hash=NULL+kfree(hash).sys/vfs/ufs/ufs_dirhash.c:294,318,356βufsdirhash_lookupunlockeddh_hashreads +DH_ENTRYdouble-deref.sys/vfs/ufs/ufs_dirhash.c:251-252βufsdirhash_freeTAILQ_REMOVE, no global lock.sys/vfs/ufs/ufs_dirhash.c:221βufsdirhash_buildINSERT_TAIL, no global lock.sys/vfs/ufs/dirhash.h:84-85βDH_ENTRYmacro =dh->dh_hash[slot>>8][slot&255].sys/vfs/ufs/ufs_dirhash.c(whole file) β zerolockmgr/mutex/spinlockcalls.
Fix verification
fixedVALIDATED. The first fix.diff (recycle missing LK_RELEASE on its two early return paths) compiled and passed 'git apply --check' but PANICKED at boot with 'lockmgr: locking against myself' during dir creation -- Phase 8 caught it (proof in panic.txt). Corrected fix.diff (added LK_RELEASE before every recycle return) built clean (NK_DONE rc=0, INSTALL_DONE rc=0), booted #1, and on the patched kernel: dir creation (40x200 entries, which deadlocked on the buggy fix) succeeded, the live dirhash trigger ran 53,372,271 iterations across 8 threads with NO panic/deadlock/lockmgr errors and the guest stayed up, and the harness LOCKED mode showed 0% UAF. Before/after is clean: baseline #0 harness UNLOCKED=48% UAF (race open) vs patched #1 harness LOCKED=0% UAF (race closed).
baseline #0 harness UNLOCKED: UAF(stale-ptr)=48% NULL-deref=48% -- RACE REPRODUCED / patched #1 harness LOCKED: UAF(stale-ptr)=0% NULL-deref=0% -- RACE CLOSED / patched #1 live trigger: 53,372,271 iters, TRIGGER_RC=0, no panic, no lockmgr errors / (first-fix #1 boot: panic: lockmgr: locking against myself -- corrected)
Confirmed kernel references
Detail
Exploit chain
Memory-corruption race: UAF READ (DH_ENTRY double-deref through freed slab memory in M_DIRHASH) + non-value-controllable list-corruption write (concurrent unlocked TAILQ mutation). No uid0 chain developed or written (no exploit.c/chain.c): this is a valid Phase-6 hard-blocker case. (1) The primary primitive is a READ, not a controllable write -- the list-corruption write is a stale-pointer-value store, not attacker-shaped. (2) The race is too narrow to win live on the syscall path (~500K iterations, 8-12 threads, no trigger), so there is no reliable grooming substrate. (3) On default GENERIC (INVARIANTS ON) slab poisoning (WEIRD_ADDR 0xdeadc0de, chunk_mark_free/chunk_mark_allocated) catches the freed-chunk reuse before grooming can land -> panic. Realistic ceiling on the default kernel is local-unprivileged DoS (panic). The harness.c has a '--locked' mode (arg 3) modeling the fix that proves the race is closeable; the trigger.c is the live multi-threaded dirhash hammer (bonus).
Evidence (decisive lines)
harness UNLOCKED (current kernel): Ran 100 race iterations; UAF(stale-ptr)=48 (48.0%) NULL-deref=48 -- VERDICT: RACE REPRODUCED / harness LOCKED (models the fix): Ran 100 race iterations; UAF(stale-ptr)=0 (0.0%) NULL-deref=0 -- VERDICT: RACE CLOSED / Live trigger on #0 (8-12 threads, ~500K iters): no panic (race too narrow live, as predicted) / Phase-8 first-fix boot: panic: lockmgr: locking against myself (ufsdirhash_build -> recycle leaked lock -> build re-acquire) -- CAUGHT and corrected / Phase-8 corrected-fix #1: 53,372,271 dirhash iterations across 8 threads, no panic/deadlock/lockmgr errors
PoC changes
Written from scratch under findings/poc/DF-0843/: harness.c (deterministic race transcription with a poisoned allocator modeling INVARIANTS slab poison-on-free, + a '--locked' arg-3 mode that models the fix and proves the race closes); trigger.c (live multi-threaded dirhash hammer for a UFS mount); build.sh/run.sh; VERDICT.md; README.md; fix.diff; full logs. The harness went through 5 iterations (real-free SIGBUS -> inverted widening -> pool-exhaustion abort -> finalized with per-iteration pool reset and both UAF/NULL-deref outcome detection).
Verified recommended fix
Add a global 'static struct lock ufsdirhash_lock;' (+ #include
Verdict
REPRODUCED. The bug is real and confirmed by source trace: sys/vfs/ufs/ufs_dirhash.c has NO lock anywhere (the only 'lock' mentions are FreeBSD-leftover comments at :118/:300/:305 that are now lies). ufsdirhash_recycle() (:927-970) frees a VICTIM inode's dh_hash from the global ufsdirhash_list with no lock on the victim (:937 TAILQ_FIRST, :948 TAILQ_REMOVE, :951 dh->dh_hash=NULL, :962 kfree(hash)), while concurrent ufsdirhash_lookup() (:294 unlocked dh read, :318 unlocked NULL-check, :356 DH_ENTRY=dh->dh_hash[slot>>8][slot&255] double-deref per dirhash.h:84) derefs the freed memory = UAF, and all list mutations (build :221, free :251, lookup :313-315) are unsynchronized. The live race is genuinely narrow (recycle's score-decay gate at :943 + tiny :318->:356 window: ~500K live iterations did not panic), so per the finding's explicit guidance the accepted proof is a deterministic harness transcribing recycle (kfree/poison dh_hash) vs lookup (DH_ENTRY double-deref) with a poisoned allocator modeling INVARIANTS slab poison. The harness reproduces the race at 48-86% (UAF stale-ptr read + NULL-deref outcomes) UNLOCKED and closes it at 0% when modeling the fix.
No comments yet.