# DF-2550 — VERDICT

**Verdict: REPRODUCED (kernel panic / local DoS). FIX VALIDATED (panic eliminated on the single-fix kernel).**

## Root cause (confirmed line-by-line)

`setfown()` in `sys/kern/vfs_syscalls.c` (around line 3528) is invoked by
`sys_chown`/`sys_lchown`/`sys_fchown`/`sys_fchownat` to change a vnode's owner.
It does:

```c
3541:	if ((error = vget(vp, LK_EXCLUSIVE)) == 0) {        // lock + reference
3542:		if ((error = VOP_GETATTR(vp, &vattr)) != 0)
3543:			return error;                                // BUG: no vput(vp)
...
3551:		error = VOP_SETATTR(vp, &vattr, td->td_ucred);
3552:		vput(vp);                                       // only on success path
3553:	}
```

`vget(vp, LK_EXCLUSIVE)` (`sys/kern/vfs_lock.c:571`) increments `v_refcnt` and
acquires the vnode's exclusive lock.  The matching release is `vput(vp)`
(unlock + drop ref).  On the `VOP_GETATTR`-failure path (line 3543) the function
returns **without `vput`**, so:

* the vnode is left **exclusively locked** (by the calling thread), and
* the `vget`-added **reference is leaked**.

## Primitive → effect

The leaked exclusive lock means the *same thread's* next operation that
`vget()`s the vnode tries to take `LK_EXCLUSIVE` on a lock it already holds.
DragonFly's lockmgr detects this self-deadlock and panics:

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

If the second access came from a *different* thread/process, the result would be
a permanent block (DoS) rather than a panic.  Either way it is an unprivileged
denial of service: the caller only needs an open fd on the vnode, and the
trigger condition is simply a `VOP_GETATTR` that can return an error.

## Trigger path (how `VOP_GETATTR` is made to fail, realistically)

`VOP_GETATTR` fails on a vnode whose backing filesystem returns an error.  The
demonstrated, realistic, unprivileged case is **NFS server unavailability**
(server crash / network partition / `service nfsd stop`).  The NFS client's
`getattr` RPC eventually errors:

```
kernel: nfs server 127.0.0.1:/export: not responding
kernel: nfs send error 61 for server 127.0.0.1:/export
```

so `setfown()`'s `VOP_GETATTR` returns non-zero and the leak fires.

### Reproduction evidence (baseline kernel `6.5-DEVELOPMENT #0`, INVARIANTS ON)

`trigger` (run as unprivileged `maxx`, uid 1001) opens `/mnt/nfs/f` on a
soft-mounted local NFS export and calls `fchown(fd,-1,-1)` in a loop.  After the
NFS server is killed:

```
[trigger] iter 17: fchown rc=0 errno=0 (ok)          # getattr still ok (cached/server-up)
[trigger] iter 18: fchown rc=-1 errno=4 (Interrupted system call)   # VOP_GETATTR FAILED -> LEAK (no vput)
# iter 19: fchown -> vget -> vn_lock on the leaked lock -> PANIC
panic: lockmgr: locking against myself    (trace: sys_fchown -> setfown -> vget -> vn_lock)
```

The guest goes down (`vm.sh status` ⇒ down); the panic is in `boot.log`
(captured to `panic.txt`).

### Precondition realism

* The attacker is **unprivileged** (`maxx`, not in `wheel`); only a normal fd
  into an NFS mount is needed.
* The trigger condition — the NFS server becoming unreachable — is an ordinary
  environmental event (server crash, network partition, admin stopping the
  service, removable network).  No root cooperation is required of the attacker.
* The default `X86_64_GENERIC` kernel (INVARIANTS ON) is used; the panic is not
  an INVARIANTS-only artifact (it is a `lockmgr` self-deadlock, which fires
  regardless of INVARIANTS).

## Why other failing-getattr paths were ruled out (thoroughness)

| Path | Result | Why it does not trigger here |
|------|--------|------------------------------|
| FIFO on hammer2 root | getattr succeeds | `hammer2_fifo_vops` overrides `.vop_getattr = hammer2_vop_getattr` (the `vop_ebadf` in the base `fifo_vnode_vops` is not used for hammer2 FIFOs). |
| tmpfs / ufs / hammer2 regular getattr | never fails | they read in-memory metadata and unconditionally `return 0`. |
| `umount -f` of a held mount | holder is killed | `dounmount()`'s `unmount_allproc_cb` (vfs_syscalls.c ~922-947) SIGINT/SIGKILLs every process with an fd into the mount before vnodes go dead; no live caller survives to observe a dead vnode. |
| `revoke(2)` on an owned file | fd itself revoked | `vrevoke`→`fdrevoke` marks the *file descriptor* `FREVOKED`; `fchown` fails at `holdvnode` before reaching `setfown`. |
| procfs, reaped target proc | getattr still succeeds | DragonFly defers proc-struct reclamation; `pfs_pfind(pid)` keeps returning the (gone-from-`ps`) proc with a valid `p_ucred` for many seconds, so `procfs_getattr` never takes its `ENOENT` branch. |
| NFS attribute cache | masks the failure | defeats with `-o acregmin=0,acregmax=0` so every getattr issues a real RPC. |

NFS server-death is therefore the canonical reachable failing-getattr trigger.

## Exploit chain

This is a **denial-of-service / panic** primitive, not a memory-corruption
primitive — there is no `uid=0` chain.  The leaked object is a vnode lock (not a
slab object), so the impact ceiling is **local kernel panic / permanent hang**
(DoS).  No escalation primitive is derivable; this is honestly reported as a DoS.

## Fix (validated)

`fix.diff` adds the missing `vput(vp)` on the early-return path:

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

### Validation (single-fix kernel `6.5-DEVELOPMENT #1`)

Built from `fix.diff` only (`make -j6 nativekernel KERNCONF=X86_64_GENERIC`),
installed over `/boot/kernel/kernel`, rebooted, `kern.version` ⇒ `#1`.  The
identical repro (`verify_fix.sh`) now:

```
[trigger] iter 17: fchown rc=0 errno=0 (ok)
[trigger] iter 18: fchown rc=-1 errno=4 (Interrupted system call)   # getattr fails, vput releases lock -> no leak
[trigger] iter 19: fchown rc=-1 errno=4 (Interrupted system call)   # loop KEEPS GOING, no panic
```

Guest stays **UP**; serial log has **no panic**.  Contrast with the baseline,
which panicked at iteration 19.  `fix_status = fixed`.

## PoC changes

The PoC directory was authored from scratch (it was empty on arrival):
`trigger.c` (unprivileged `fchown` poll-loop), `run.sh` (NFS repro driver),
`verify_fix.sh` (fix-validation driver), `fix.diff`, plus investigation probes
(`probe_fifo.c`, `probe_procfs2.c`, `probe_kill.c`) that document the ruled-out
paths.
