# DF-0917 — Use-after-free on fuse_ipc during tx completion

**Verdict: REPRODUCED (UAF pattern confirmed by deterministic harness +
line-by-line code-path trace); fix VALIDATED (patched fuse.ko compiles clean,
loads, FUSE subsystem functions with no regression; model-level before/after
proves the refcount-hold closes the window).**

Impact on default GENERIC (`#0`, INVARIANTS ON): **panic / memory corruption
(DoS)** when the race is won — a 16-byte write (`struct fuse_buf` = kernel
pointer + length) into a freed `fuse_ipc` slab slot plus a pointer
dereference (`fuse_in(fip)` reads the freed `request.buf`) and a 4-byte atomic
on `fip->done`. Reachability is **root-only** on default config (`/dev/fuse`
is `root:operator 0660`, `mount("fuse")` needs `caps_priv_check(
SYSCAP_NOMOUNT_FUSE)` → `uid==0`), so this is a **root→kernel UAF**, not an
unprivileged→root escalation (same hard blocker as DF-0915).

## The bug (line-by-line)

The daemon's `/dev/fuse` **write** completion (`sys/vfs/fuse/fuse_device.c`,
`fuse_device_write`) and **read** completion (`fuse_device_read`) both:

1. take `ipc_lock`, find `fip` in `reply_head`/`request_head`, `TAILQ_REMOVE`
   it, and **release `ipc_lock`** — `fuse_device.c:197` (write),
   `fuse_device.c:149` (read);
2. then dereference `fip` **with no reference held**:
   - write path: `fip->reply = fb` (`:205`), `fuse_in(fip)` = `fip->request.buf`
     (`:206`), `fuse_ipc_test_and_set_replied(fip)` = atomic on `fip->done`
     (`:219`);
   - read path: `fuse_in_size(fip)` = `fip->request.len` (`:153`),
     `fuse_in(fip)` (`:156`), `fip->sent` atomic (`:158`).

The only reference on `fip` is the originator's (`fuse_ipc_get` sets
`refcnt=1`, `fuse_ipc.c:98`; nobody else `refcount_acquire`s). The originator
blocks in `fuse_ipc_wait` (`fuse_ipc.c:158`). On timeout (7 × `5*hz` = ~35s,
`fuse_ipc.c:175,183`) the waiter runs `fuse_ipc_remove` + `fuse_ipc_set_replied`
(`:186-187`) and returns `ETIMEDOUT` (`:188`); `fuse_ipc_tx` then calls
`fuse_ipc_put(fip)` (`:270`) which drops `refcnt 1→0` and **frees fip**
(`fuse_buf_free` ×2 + `objcache_put`, `fuse_ipc.c:112-116`). If that free
lands while the device path is still mid-access (between its `mtx_unlock` and
its last `fip` deref), every one of those derefs is a **use-after-free**.

The freeing path is real and was exercised live: a daemon that delays its
`FUSE_GETATTR` reply by 35s causes the originator's `stat()` to return
`ETIMEDOUT` (so `fuse_ipc_put` ran and freed `fip`), and the daemon's
subsequent `write()` for that unique returns `ENOMSG`/`w=-1` (fip already
removed from `reply_head` by the timed-out waiter's `fuse_ipc_remove`). The
UAF arm — daemon finds fip in `reply_head` *and then* the waiter frees it
before `:205` — is the same code path with the waiter losing the lock race by
a few microseconds rather than winning it.

## Why the live race is hard (and why a deterministic harness is the proof)

The UAF requires the originator's *final* `5*hz` tsleep to return
`EWOULDBLOCK` at the precise instant the daemon's `write()` syscall holds fip
between lock-drop (`:197`) and set_replied (`:219`) — a sub-microsecond window
against a 35-second period. The per-attempt hit probability is ~window/period
≈ 1µs/35s ≈ 3×10⁻⁸; winning it live needs many thousands of 35-second
attempts (hours). This is exactly the "live race too narrow → deterministic
code-level harness reproducing the drop-lock-then-access-fip logic" case
(option (b) in the playbook).

**`harness.c`** models both paths with pthreads and forces the worst
interleaving with two barriers placed at the kernel's lock-drop point:
- UNFIXED build → **`UAF CONFIRMED`**: the device thread observes the
  `0xdeadc0de` poison the timeout/free thread wrote into the freed fip
  (mirrors INVARIANTS `WEIRD_ADDR` slab poisoning), proving it dereferenced
  freed memory. Deterministic across 3/3 runs.
- `-DFIXED` build → **`NO UAF`**: the device thread's `refcount_acquire`
  (mirroring the fix) makes `refcnt=2`, so the waiter's put (`2→1`) cannot
  free fip mid-access. Deterministic across 3/3 runs.

## Threat model / reachability / Phase 6 escalation

- `/dev/fuse` is `crw-rw---- root:operator`; `mount("fuse",...)` requires
  `caps_priv_check(SYSCAP_NOMOUNT_FUSE)` → `uid==0` (`fuse_vfsops.c:155`,
  `kern_caps.c:311`). `vfs.usermount=0` on this guest and `maxx` (uid 1001)
  is **not** in `operator`. So the FUSE daemon — which authors the timing
  that opens the race — **must be started by root** on default GENERIC.
- This is a **root→kernel UAF** (hardening gap + DoS) on default config. It
  is **not** an unprivileged→root escalation: the valid hard blocker
  "the write is reachable only from an already-root context" applies (the
  daemon that opens the race window runs as root; root→kernel is game-over by
  definition; there is no privilege boundary for the daemon to cross for
  itself). An unprivileged *consumer* (`maxx`) can drive a `stat()`/`read()`
  on a root-mounted FUSE fs, but `maxx` cannot shape the race timing or the
  freed-slot content, so he cannot escalate himself.
- **Conditional escalation** (NOT default config): IF an admin set
  `vfs.usermount=1` AND added the user to `operator`, an unprivileged user
  could run the daemon, win the race, and reclaim the freed `fuse_ipc` slot
  with controlled content. `fuse_ipc` is a dedicated `objcache` (`M_FUSE_IPC`,
  `fuse_ipc.c:34,316-318`) backed by `kmalloc`; on GENERIC the
  `WEIRD_ADDR`/`chunk_mark_*` INVARIANTS checks in `kern_slaballoc.c` would
  very likely catch the cross-free corruption and panic before a clean
  `uid0`. On a non-default INVARIANTS-OFF build the primitive (16-byte
  controlled write of a `fuse_buf` into the recycled slot + a ptr read +
  4-byte atomic) would be directly weaponizable against a same-bucket victim
  (e.g. another `fuse_ipc`, or a `kmalloc`-same-size object holding a
  function pointer / `ucred *`). These are documented as conditional /
  non-default, not default-GENERIC.

**Outcome:** primitive fully characterized (16-byte write + ptr-read + 4-byte
atomic UAF into a freed `fuse_ipc` slab slot); escalation on default GENERIC
blocked by the root-only-FUSE hard blocker (root authors the race; no unpriv
boundary to cross). Impact on default GENERIC = **panic / corruption (DoS)**
from a root-started (or conditionally-unprivileged) malicious/slow daemon.

## The fix (`fix.diff`)

Hold a reference on `fip` across the lock-drop window in **both**
`fuse_device_write` and `fuse_device_read`: `refcount_acquire(&fip->refcnt)`
under `ipc_lock` (before the unlock), and `fuse_ipc_put(fip)` after the last
`fip` access. Four hunks:

- `fuse_device.c:149` — read path: acquire after `TAILQ_REMOVE` from
  `request_head`;
- `fuse_device.c:162` — read path: `fuse_ipc_put(fip)` before `return error`;
- `fuse_device.c:196` — write path: acquire after `TAILQ_REMOVE` from
  `reply_head`;
- `fuse_device.c:225` — write path: `fuse_ipc_put(fip)` before `return error`.

The early `return ENOMSG` (write, `fip==NULL`) and the read-path
`mtxsleep`-error returns sit *before* any ref/acccess, so they need no put.
With the fix, the originator's timeout `fuse_ipc_put` drops `refcnt 2→1` (not
`1→0`), so fip cannot be freed while the device path still dereferences it;
the device path's own final `fuse_ipc_put` then performs the last drop. This
**matches** the finding's recommended fix ("refcount_acquire under ipc_lock
before unlock").

## Fix validation (Phase 8)

- **Applies clean:** `patch -p1 --forward < fix.diff` → all 4 hunks at
  `:146/:159/:193/:222`.
- **Builds clean:** module-only `cd /usr/src/sys/vfs/fuse && make` →
  `fuse.ko` produced, `-Werror`, no warnings (`fix_build.log`).
- **Loads clean:** `kldload fuse` → `RC=0`, `/dev/fuse` appears
  (`sha256 fuse.ko = 2eff54d3…`).
- **No regression (live):** on the patched module, `fused0917 2` mounts FUSE,
  handles INIT/STATFS/LOOKUP/GETATTR/OPEN, the delayed GETATTRs time out
  (`stat()=ETIMEDOUT`, daemon `write()=ENOMSG`) exactly as on the unpatched
  module, and the guest stays up (`fix_run.log`). The fix changes nothing
  observable when the race is not won — it only closes the UAF window — so
  identical live behavior before/after is the correct expectation.
- **Model-level before/after:** `harness` (UNFIXED) → `UAF CONFIRMED`;
  `harness_fixed` (`-DFIXED`) → `NO UAF` (ref held across window). Proves the
  refcount-hold closes the window deterministically.

`fix_status = fixed`: the patched `fuse.ko` compiles, loads, and the FUSE
subsystem functions correctly; the model-level before/after demonstrates the
window is closed.

## How to reproduce

```
# unprivileged deterministic models (the primary proof):
ssh dfbsd-maxx 'cd poc/DF-0917 && sh build.sh && ./harness && ./harness_fixed'
# live race attempt on the real kernel (root only):
ssh dfbsd 'kldload fuse && mkdir -p /mnt/fuse && cd /home/maxx/poc/DF-0917 && ./fused0917 2'
# fix build + install + re-test (module-only):
ssh dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff && cd sys/vfs/fuse && make && cp /usr/obj/usr/src/sys/vfs/fuse/fuse.ko /boot/kernel/fuse.ko && kldload fuse'
```
