# DF-0918 — NULL-deref panic via dead/replied race in fuse_ipc_wait early-return

**Verdict: REPRODUCED (NULL-ohd primitive confirmed by deterministic harness +
line-by-line code-path trace + live path-exercised run); fix VALIDATED
(patched fuse.ko compiles clean `-Werror`, loads, FUSE subsystem mounts/
unmounts/serves 3.6M+ stat() calls with no regression; model-level
before/after proves the dead-recheck closes the window).**

Impact on default GENERIC (`#0`, INVARIANTS ON): **panic / DoS** when the
race is won — `fuse_ipc.c:275` `KKASSERT(ohd)` fires on the NULL
`fip->reply.buf` (or, INVARIANTS OFF, `:276` `ohd->error` NULL-derefs).
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 DoS**, not
an unprivileged escalation (same hard blocker as DF-0915/0917).

## The bug (line-by-line)

`fuse_ipc_wait()` (`sys/vfs/fuse/fuse_ipc.c:158-204`) has three early-return
paths that test the `replied` flag (`fip->done`) and `return 0` (success)
**WITHOUT re-checking the mount's `dead` flag** and **WITHOUT verifying that
`fip->reply.buf` was actually populated**:

```c
158: static int
159: fuse_ipc_wait(struct fuse_ipc *fip)
160: {
161:     int error, retry = 0;
162:
163:     if (fuse_test_dead(fip->fmp)) {          // dead check #1 (safe)
164:         KKASSERT(!fuse_ipc_test_replied(fip));
165:         fuse_ipc_set_replied(fip);
166:         return ENOTCONN;
167:     }
168:
169:     if (fuse_ipc_test_replied(fip))           // <<< BUG: returns 0
170:         return 0;                             //     without dead recheck
171: again:
172:     tsleep_interlock(fip, 0);
173:     if (fuse_ipc_test_replied(fip))           // <<< BUG: returns 0
174:         return 0;                             //     without dead recheck
...
198:     if (fuse_test_dead(fip->fmp)) {          // dead check #2 (safe)
199:         KKASSERT(fuse_ipc_test_replied(fip));
200:         return ENOTCONN;
201:     }
202:
203:     return 0;
204: }
```

`fuse_device_clear()` (`sys/vfs/fuse/fuse_device.c:99-116`) — invoked by
the daemon reader (`fuse_device_read:137`) when it wakes and observes `dead`
during teardown — walks `fmp->reply_head` and for every pending `fip` calls
`fuse_ipc_test_and_set_replied(fip)` + `wakeup(fip)` **WITHOUT assigning
`fip->reply.buf`** (only `fuse_device_write:fuse_device.c:205` populates the
reply). `fip->reply.buf` therefore stays NULL (set to NULL by
`fuse_ipc_get:fuse_ipc.c:104`). `fuse_mount_kill`
(`fuse_vfsops.c:65-76`) has **ALREADY** set `dead=1` by the time
`fuse_device_clear` runs (it's `fuse_mount_kill`'s `wakeup(fmp)` that woke
the daemon reader).

### The race

A tx waiter (`fuse_ipc_tx:fuse_ipc.c:246` → `fuse_ipc_wait`) that has:

1. passed the line-163 dead check (dead was 0 then — the kill hasn't
   happened yet), and
2. is between line 169 and the line-198 post-tsleep dead recheck

…can observe `replied==1` at line 169 or 173 (set underneath it by
`fuse_device_clear`) and `return 0`. Control returns to `fuse_ipc_tx`:

```c
266:     error = fuse_ipc_wait(fip);       // returns 0
...
274:     ohd = fuse_out(fip);              // == fip->reply.buf == NULL
275:     KKASSERT(ohd);                    // PANIC (INVARIANTS ON, default GENERIC)
              // or, INVARIANTS OFF:
276:     error = ohd->error;               // NULL deref -> trap 0xc/0xe
```

The `goto-again` loop at `:171`/`:184` re-runs `:172-173` up to 6 times per
`fuse_ipc_wait` call, widening the early-return window 6× — but the window
between `tsleep_interlock` (`:172`) and the replied check (`:173`) is still
only a few instructions (~nanoseconds).

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

The race requires the tx waiter to be between `:163` and `:198` (or more
narrowly between `:172` and `:173`) at the precise instant
`fuse_device_clear` runs during teardown — a nanoseconds-wide window against
a one-shot teardown. `fuse_device_clear` runs once per mount lifecycle (when
the daemon reader wakes and sees `dead`). Per-attempt hit probability is
~window/period ≈ 5ns / (5s tsleep × 6 retries) ≈ 1.7×10⁻¹⁰; even at
thousands of `fuse_ipc_tx` per second per child, winning it live needs hours
to days of attempts. This is exactly the "live race too narrow →
deterministic code-level harness reproducing the early-return logic" case
(option (b) in the playbook), matching the DF-0917 precedent.

**`harness.c`** models both paths with pthreads and forces the worst-case
interleaving with two barriers placed at the kernel's race point:
- UNFIXED build → **`NULL-ohd PRIMITIVE CONFIRMED`**: the tx waiter observes
  `replied=1` (set by the teardown thread) and returns 0 with
  `fip->reply.buf == NULL`; `fuse_out(fip)` yields NULL, which would fire
  `KKASSERT(ohd)` at `fuse_ipc.c:275`. Deterministic across 3/3 runs.
- `-DFIXED` build → **`NO NULL-deref`**: the dead-recheck-after-replied
  (mirroring the fix) converts the teardown reply into `ENOTCONN`, so
  `fuse_ipc_tx:274` is never reached. Deterministic across 3/3 runs.

**Live path-exercised run** (`fused0918`): a real FUSE daemon + mount +
`stat()` storm + force-unmount. Confirms (a) the FUSE subsystem works on
default GENERIC, (b) `fuse_ipc_wait` is genuinely reached at runtime
(dmesg shows `fuse_init`/`fuse_mount`; `fuse_ipc_wait` timeouts fire when
the daemon delays replies), (c) the `fuse_mount_kill` →
`fuse_device_clear` teardown path is exercised (force-unmount triggers it).
3 mounts created/destroyed, 6.5M+ `stat()` calls (= `fuse_ipc_tx`es) driven,
no panic (the nanosecond race did not fire in this short window — expected).

## 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 DoS / NULL-deref panic** (hardening gap) on default
  config. It is **not** an unprivileged→root escalation: the primitive is a
  **NULL pointer dereference at a fixed offset** — there is no
  attacker-controlled write, no slab corruption, no function-pointer
  overwrite. The only effect is a kernel panic (DoS). The valid hard
  blocker "the primitive is genuinely read-only-equivalent (no write, no
  corruption; just a deref of a NULL pointer at a known offset)" applies:
  there is no chain to develop. No `uid=0` is derivable from a NULL-deref
  panic.
- **Conditional reachability** (NOT default config): IF an admin set
  `vfs.usermount=1` AND added the user to `operator`, an unprivileged user
  could run the daemon and win the race — but the impact is still only a
  DoS (panic), not escalation. Documented as conditional, not default.

**Outcome:** primitive fully characterized (NULL-deref / `KKASSERT(ohd)`
panic via the early-return-without-dead-recheck race); escalation impossible
(NULL-deref at fixed offset = pure DoS). Impact on default GENERIC =
**panic / DoS** from a root-started (or conditionally-unprivileged) malicious
daemon racing mount-teardown against an in-flight `fuse_ipc_tx`.

## The fix (`fix.diff`)

Convert the two bare `return 0;` at `fuse_ipc.c:170` and `:174` (the early
`replied` returns) into `goto done;`, and add a `done:` label immediately
before the existing post-tsleep dead-recheck at `:198`. This routes both
early-return paths through the dead-recheck, which returns `ENOTCONN` if
the mount is dead (as it is when `fuse_device_clear` set `replied`). The
caller (`fuse_ipc_tx:268`) treats `ENOTCONN` as an error and never reaches
the `ohd = fuse_out(fip)` deref at `:274`.

```diff
@@ -167,11 +167,11 @@
 	}
 
 	if (fuse_ipc_test_replied(fip))
-		return 0;
+		goto done;
 again:
 	tsleep_interlock(fip, 0);
 	if (fuse_ipc_test_replied(fip))
-		return 0;
+		goto done;
 	error = tsleep(fip, PINTERLOCKED, "ftxp", 5 * hz);
@@ -195,6 +195,7 @@
 		return error;
 	}
 
+done:
 	if (fuse_test_dead(fip->fmp)) {
 		KKASSERT(fuse_ipc_test_replied(fip));
 		return ENOTCONN;
```

This is a **minimal, targeted** change (3 lines added/changed) that reuses
the existing dead-recheck logic rather than duplicating it. It **matches**
the finding's recommended fix ("recheck dead after replied in early
returns").

## Fix validation (Phase 8)

- **Applies clean:** `patch -p1 --forward < fix.diff` → both hunks at
  `:170/:174` (goto done) and `:198` (done: label).
- **Builds clean:** module-only `cd /usr/src/sys/vfs/fuse && make` →
  `fuse.ko` produced, `-Werror`, no warnings (`fix_build.log`).
  `sha256 = 4d03e5093cfb9c78fb43350b745f6fa6d801ecc7ea06a8f1371ad01defee64ae`.
- **Loads clean:** `kldload fuse` → RC=0, `/dev/fuse` appears,
  `kldstat -v` shows `fuse` registered.
- **No regression (live):** on the patched module, `fused0918 2 1` mounts
  FUSE, handles INIT/STATFS/LOOKUP/GETATTR/OPEN/DESTROY, drives 3.6M+
  `stat()` calls, survives 2 force-unmount teardowns (each runs
  `fuse_mount_kill` → `fuse_device_clear`), and the guest stays up
  (`fix_run.log`). The fix changes nothing observable when the race is not
  won — it only closes the NULL-ohd window — so identical live behavior
  before/after is the correct expectation.
- **Model-level before/after:** `harness` (UNFIXED) → `NULL-ohd PRIMITIVE
  CONFIRMED`; `harness_fixed` (`-DFIXED`) → `NO NULL-deref` (dead-recheck
  converts teardown reply to `ENOTCONN`). Proves the fix 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-0918 && sh build.sh && ./harness && ./harness_fixed'
# live path-exercised run on the real kernel (root only):
ssh dfbsd 'kldload fuse && mkdir -p /mnt/df918 && cd /root/poc918 && cc -O2 -pthread -o fused0918 fused0918.c && ./fused0918 3 1'
# fix build + install + re-test (module-only):
ssh dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff && cd sys/vfs/fuse && make && cp fuse.ko /boot/kernel/fuse.ko && kldload fuse'
```
