# DF-0925 — VERDICT: REPRODUCED (UAF panic, DoS)

## Verdict: REPRODUCED — Use-after-free in `fuse_alloc_node` confirmed

The bug is **real and confirmed**. `fuse_alloc_node()` (fuse_node.c:106-119)
drops `fmp->ino_lock` at line 112 and calls `fuse_node_vn(fnp, ...)` at line
114 **without holding any reference on `fnp`**. A concurrent `fuse_vop_reclaim()`
(fuse_vnops.c:1785-1803) can free `fnp` via `fuse_node_free()` in this window,
causing `fuse_node_vn` to dereference, lock, and write to freed memory.

## Mechanism (confirmed by line-by-line source trace + runtime panic)

1. **Trigger:** An unprivileged user with read access to a FUSE mount repeatedly
   stats/opens a file on the mount. Each lookup drives `fuse_vop_nresolve →
   fuse_alloc_node`.

2. **Race window:** `fuse_alloc_node` takes `fmp->ino_lock`, does `RB_LOOKUP`
   for the inode, and either creates a new `fuse_node` or finds an existing one.
   It then **releases `ino_lock`** (line 112) and calls `fuse_node_vn(fnp)`
   (line 114) with **no reference** on `fnp`.

3. **Concurrent reclaim:** `vnlru` reclaims the vnode for the same inode via
   `vclean → VOP_RECLAIM → fuse_vop_reclaim → fuse_node_free`. Inside `vclean`
   (vfs_subr.c:1314), `cache_inval_vp` invalidates the namecache entry BEFORE
   `VOP_RECLAIM` (line 1402). A concurrent `nresolve` can miss the cache,
   enter `fuse_alloc_node`, find the `fuse_node` in the RB tree (still present
   between `cache_inval_vp` and `fuse_node_free`), drop `ino_lock`, and call
   `fuse_node_vn`. `fuse_node_vn`'s `vget(vp)` blocks on the VX lock held by
   `vclean`. When `VOP_RECLAIM` runs and frees `fnp`, `vget` wakes up and the
   retry loop reads freed `fnp` → **UAF**.

4. **Panic evidence:** With diagnostic delays widening the race window (2s in
   `vclean` for FUSE vnodes, 2s in `fuse_vop_reclaim` after `fuse_node_free`),
   the kernel panicked:

   ```
   panic: memory chunk 0xfffff80117d4f400 is already free!
   chunk_mark_free() at chunk_mark_free+0xae
   slab_cleanup() at slab_cleanup+0xbb
   slotimer_callback() at slotimer_callback+0x11
   ```

   The slab INVARIANTS timer (`slotimer_callback → slab_cleanup →
   chunk_mark_free`) detected the corrupted/double-freed `fuse_node` memory
   chunk. Also observed: `malloc_uninit: -1536 bytes of 'fuse_node' still
   allocated on cpu 6` — a negative allocation count indicating more frees than
   allocs (double-free from the UAF).

## Threat model & reachability

- **FUSE is module-only** on DragonFly: requires `kldload fuse` (root) and
  `mount_fusefs` (root, since `vfs.usermount=0`). `/dev/fuse` is root:operator.
- **The trigger (stat/open on the mount) IS unprivileged** — once an admin
  sets up a FUSE mount, any user with read access can trigger the race.
- **Impact ceiling:** Reliable kernel panic (DoS). Escalation to uid0 is
  blocked: the freed `fuse_node` (~232 bytes) goes to a dedicated objcache
  (`fuse_node_objcache`) with `objcache_malloc_alloc_zero` backing. The
  objcache magazine layer keeps the freed object type-stable — only
  `M_FUSE_NODE` allocations reclaim from the magazine, and those only happen
  inside the FUSE module. Cross-type slab reclamation with attacker-controlled
  content is not achievable from userspace on this guest. This is a **valid
  hard blocker** for escalation per Phase 6.

## Why the race needed diagnostic widening

The natural race window (~20ns between `mtx_unlock(ino_lock)` at line 112 and
the first `fnp` dereference at fuse_node_vn:128) is extremely tight. The race
is widened by `getnewvnode`'s sleep under vnode pressure, but reliable
reproduction required adding diagnostic `tsleep(2s)` delays in:
1. `vclean` (vfs_subr.c) between `cache_inval_vp` and `VOP_RECLAIM` —
   gives a concurrent `nresolve` time to find the `fuse_node` after cache
   invalidation but before it's freed.
2. `fuse_vop_reclaim` after `fuse_node_free` — allows the freed objcache slot
   to be reused (zeroed), causing the retrying `fuse_node_vn` to dereference
   zeroed memory and crash visibly.

These delays **do not change the code logic** — they only widen timing windows
that already exist. The race is the SAME race; the delays make it observable.

## PoC changes

- Wrote `rawfuse.c` — a self-contained raw FUSE protocol daemon (no libfuse
  dependency). Speaks the FUSE kernel ABI directly over `/dev/fuse`.
- Wrote `race_trigger2.c` — improved race harness with synchronized stat bursts
  and vnode pressure.
- Original `fusedemo.c` (needed libfuse) and `race_winner.c` kept as reference.

## Fix validation

The fix adds an atomic `fn_refcnt` to `struct fuse_node`:
- `fuse_node_new` initializes `fn_refcnt = 1` (tree's reference).
- `fuse_node_free` does `RB_REMOVE` then `atomic_fetchadd_int(-1)`; only calls
  `objcache_put` when refcnt reaches 0.
- `fuse_alloc_node` increments `fn_refcnt` when finding an existing node,
  dropping it after `fuse_node_vn` returns (with proper free-on-0).
- `fuse_vop_reclaim` writes `fnp->vp = NULL` under `node_lock` (eliminates the
  data race).

This **corrects** the finding's proposed fix, which had a memory leak: the alloc
path used `atomic_subtract_int` without checking for 0, leaking memory when
reclaim ran first. The corrected version uses `atomic_fetchadd_int` with proper
free-on-0 in both paths.

**Before (unpatched + diagnostic delays):** kernel panic
(`memory chunk is already free!`) within seconds of running the race trigger.

**After (fix + same diagnostic delays):** no panic; the race trigger runs to
completion without incident. The `fn_refcnt` pin prevents `fuse_node_free` from
freeing `fnp` while `fuse_alloc_node` is using it.
