# DF-2550 — setfown() vnode lock+reference leak on VOP_GETATTR failure

**Severity:** Medium (local DoS / kernel panic)
**File:** `sys/kern/vfs_syscalls.c` — `setfown()`
**Status:** REPRODUCED (kernel panic) + FIX VALIDATED (panic gone on single-fix kernel)

## The bug

`setfown()` (called by `chown`/`fchown`/`fchownat`/`lchown`) acquires an
exclusive vnode lock and a reference with `vget(vp, LK_EXCLUSIVE)`, then reads
the old uid/gid/size via `VOP_GETATTR(vp)` for quota accounting.  If
`VOP_GETATTR` **fails**, the function returns immediately **without calling
`vput(vp)`** — permanently leaking the exclusive vnode lock **and** the
`vget`-added reference.

```c
// sys/kern/vfs_syscalls.c  (setfown, around line 3541)
	if ((error = vget(vp, LK_EXCLUSIVE)) == 0) {
		if ((error = VOP_GETATTR(vp, &vattr)) != 0)
			return error;            //  <-- BUG: no vput(vp); lock+ref leaked
		...
		error = VOP_SETATTR(vp, &vattr, td->td_ucred);
		vput(vp);                    //  only reached on the success path
	}
```

### Effect

The leaked exclusive lock means the caller's *very next* operation that
`vget()`s the same vnode deadlocks against its own leaked lock.  Because the
holder is the *same thread*, DragonFly's lockmgr detects the self-deadlock and
**panics**:

```
panic: lockmgr: locking against myself
vn_lock() at vn_lock+0xc0
vget()   at vget+0x3e
setfown() at setfown+0x39
sys_fchown() at sys_fchown+0x8b
```

i.e. an unprivileged `fchown()` on a vnode whose `VOP_GETATTR` can fail crashes
the kernel.  If the same path were hit by two *different* threads/processes, the
second would block forever instead of panicking (classic permanent DoS).

## Trigger (realistic)

`VOP_GETATTR` fails on a vnode whose backing filesystem reports an error.  The
classic unprivileged case is a **remote filesystem (NFS) whose server becomes
unavailable** (server crash / network partition / service stopped): the NFS
client's `getattr` RPC eventually errors (`nfs send error 61` / "not
responding"), so `setfown()`'s `VOP_GETATTR` returns a non-zero error and the
leak fires.

Sequence, all as the unprivileged user `maxx`:
1. `fchown(fd)` in a loop on an open fd into an NFS mount (`fd` is a normal
   user file descriptor — no privilege required).
2. The NFS server is stopped / becomes unreachable (environmental condition:
   server crash, partition, admin `service nfsd stop`).  This is the only
   precondition and it does **not** require the attacker to be root.
3. Once `VOP_GETATTR` starts failing, the loop's iteration N returns the error
   (the leak occurs), and iteration N+1 `vget()`s the leaked-locked vnode →
   **kernel panic** (or, from a different thread, permanent hang).

### What does NOT trigger it (and why)

The audit exhaustively checked the other ways to make `VOP_GETATTR` fail on this
guest and ruled them out (see `VERDICT.md` for full detail):
- **tmpfs / ufs / hammer2 getattr** read in-memory metadata and never fail.
- **`umount -f`** of a held mount *kills* every process holding an fd into it
  (`unmount_allproc_cb` SIGINT/SIGKILL in `dounmount`) before the vnode goes
  dead, so no live caller survives to observe a dead vnode.
- **`revoke(2)`** sets `FREVOKED` on the *file descriptor* (`fdrevoke`), so
  `fchown` fails at `holdvnode` before ever reaching `setfown`.
- **procfs** with a reaped target process: DragonFly defers proc-struct
  reclamation, so `pfs_pfind(pid)` keeps returning the (gone-from-`ps`) proc
  with a valid `p_ucred` and `VOP_GETATTR` keeps succeeding.
- **NFS attribute cache** would normally mask the failure (returns cached
  attrs); the PoC defeats it with `-o acregmin=0,acregmax=0`.

NFS server-death is therefore the demonstrated realistic trigger.

## Reproduce

```sh
# from the host repo root (guest already booted on the unpatched #0 kernel):
cd findings/poc/DF-2550
scp -F ../../../dfbsd-qemu/config trigger.c dfbsd-maxx:poc/DF-2550/   # (mkdir first)
ssh -F ../../../dfbsd-qemu/config dfbsd-maxx 'cd poc/DF-2550 && cc -o trigger trigger.c'
./run.sh                # sets up local NFS server+soft mount, runs trigger, kills nfsd
```

`run.sh` brings up a local NFS server (`rpcbind`/`mountd`/`nfsd`) as root,
mounts `127.0.0.1:/export` over UDP (`soft,-t 1,-x 1`, no attribute cache),
launches `maxx`'s `trigger` (an `fchown` poll-loop), kills the NFS server, and
waits.  **Expected on the unpatched kernel:** the guest panics
(`lockmgr: locking against myself`, `setfown`→`vget`→`vn_lock` in the trace) and
`vm.sh status` ⇒ down.  The panic signature is captured to `panic.txt`.

## Fix

Add `vput(vp)` on the `VOP_GETATTR`-failure early-return path (see `fix.diff`):

```c
		if ((error = VOP_GETATTR(vp, &vattr)) != 0) {
			vput(vp);
			return error;
		}
```

This releases the exclusive lock and reference acquired by `vget`, so the next
`vget` on the vnode no longer self-deadlocks.

### Fix validation

`verify_fix.sh` repeats the exact repro on a single-fix kernel built from
`fix.diff` (`6.5-DEVELOPMENT #1`).  Result: **no panic** — the `fchown` loop
returns the `VOP_GETATTR` error (`EINTR`) cleanly on iteration after iteration
(`rc=-1 errno=4`), the guest stays up, and the serial log shows no panic.
Baseline `#0` panicked at the identical point.  See `fix_run.log` /
`fix_build.log`.

## Files

- `trigger.c` — unprivileged `fchown` poll-loop trigger.
- `run.sh` — baseline repro driver (sets up NFS, kills server, captures panic).
- `verify_fix.sh` — fix-validation driver (same repro, expects guest to stay up).
- `fix.diff` — one-line `git apply`-able fix.
- `panic.txt` — kernel panic trace from the baseline repro.
- `run.log` / `trigger.out` — baseline run output (shows rc=0 → rc=-1 EINTR → panic).
- `fix_run.log` / `trigger_fix.out` — patched-kernel output (rc=-1, no panic, guest up).
- `fix_build.log` — full single-fix kernel build log.
- `env.txt`, `dmesg.txt` — guest environment + NFS kernel messages.
- `VERDICT.md`, `manifest.json` — narrative + machine catalog.
- `probe_*.c` — investigation probes that ruled out fifo/tmpfs/revoke/procfs paths.
