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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0843 Β· 15 files
FileTypeDescriptionSize
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
README.md readme summary + reproduce instructions
↓ download 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.sh compiles harness.c (and the live trigger.c).
  • run.sh runs 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.

VERDICT.md verdict full analysis: mechanism, harness, impact ceiling, fix, Phase-8 validation
↓ download raw

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 from ufsdirhash_build (:147) for a new inode whose build pushed memory over ufs_dirhashmaxmem. It walks the global ufsdirhash_list and 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's dh_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 to dh->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) does TAILQ_REMOVE with no global lock; ufsdirhash_build (:221) does INSERT_TAIL with no global lock; ufsdirhash_lookup (:313-315) does its own TAILQ_REMOVE+INSERT_AFTER with 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:

  1. Adds a global static struct lock ufsdirhash_lock; (+ #include <sys/lock.h>), initialized via lockinit(&ufsdirhash_lock, "ufsdirhash", 0, 0) in ufsdirhash_init.
  2. Exclusive in ufsdirhash_recycle (whole while loop, with LK_RELEASE on all three return paths β€” see Phase 8 note below), ufsdirhash_free (TAILQ_REMOVE), and ufsdirhash_build (INSERT_TAIL).
  3. Exclusive-then-downgrade-to-shared in ufsdirhash_lookup: take exclusive for the (rare) score-reorder TAILQ mutation, re-validate dh_hash != NULL, then LK_DOWNGRADE to shared for the DH_ENTRY deref loop (so recycle/free β€” which need exclusive β€” cannot free dh_hash mid-lookup). The lock is released before every ufsdirhash_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 β€” because ufsdirhash_recycle's two early return (-1) paths (TAILQ_FIRST==NULL, --score>0) leaked the exclusive lock; ufsdirhash_build then re-acquired exclusive at INSERT_TAIL β†’ recursion. A diff that passes git apply --check and compiles can still be wrong. (See panic.txt.) Corrected by adding LK_RELEASE before 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), sha256 8483fd6d3e94ce1c64bee61ac2259b2b272184652a71678851eb44aa5fd2ced2. 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)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Jul 6 06:49:30 UTC 2026 (sha256 /boot/kernel/kernel = 8483fd6d3e94ce1c64bee61ac2259b2b272184652a71678851eb44aa5fd2ced2)

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 ) to sys/vfs/ufs/ufs_dirhash.c, lockinit in ufsdirhash_init; acquire it EXCLUSIVE in ufsdirhash_recycle (whole while loop, with LK_RELEASE on ALL THREE return paths -- the two early 'return (-1)' at :938/:944 plus the success path), ufsdirhash_free (TAILQ_REMOVE), and ufsdirhash_build (INSERT_TAIL); acquire EXCLUSIVE in ufsdirhash_lookup for the score-reorder, re-validate dh_hash!=NULL, then LK_DOWNGRADE to SHARED for the DH_ENTRY deref loop, releasing before every ufsdirhash_free() call. Mirrors FreeBSD's ufsdirhash_lock (sx). SUPERSEDES the finding proposal (which named the lock but missed recycle's early-return lock leaks -- the exact deadlock Phase 8 caught). Full git-apply-able diff in findings/poc/DF-0843/fix.diff.

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.