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

- `sys/vfs/ufs/ufs_dirhash.c:927-970` — `ufsdirhash_recycle`, no lock, frees victim's `dh_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_lookup` unlocked `dh_hash` reads + `DH_ENTRY` double-deref.
- `sys/vfs/ufs/ufs_dirhash.c:251-252` — `ufsdirhash_free` TAILQ_REMOVE, no global lock.
- `sys/vfs/ufs/ufs_dirhash.c:221` — `ufsdirhash_build` INSERT_TAIL, no global lock.
- `sys/vfs/ufs/dirhash.h:84-85` — `DH_ENTRY` macro = `dh->dh_hash[slot>>8][slot&255]`.
- `sys/vfs/ufs/ufs_dirhash.c` (whole file) — **zero** `lockmgr`/`mutex`/`spinlock` calls.
