# DF-0773 — NULL deref in `devfs_inode_to_vnode` (vn_lock on NULL vp)

**Verdict: REPRODUCED → FIX VALIDATED**
**Severity: Medium (CVSS AV:L/AC:L/**PR:H**/UI:N/S:U/C:N/I:N/A:H) — local DoS, requires root**

## The bug

`devfs_inode_to_vnode()` (sys/vfs/devfs/devfs_core.c:958-976) calls
`vn_lock(vp, ...)` **unconditionally** on the vnode pointer it just received
from the devfs core message dispatcher:

```c
958: struct vnode *
959: devfs_inode_to_vnode(struct mount *mp, ino_t target)
960: {
961:     struct vnode *vp = NULL;
...
970:     devfs_msg_send_sync(DEVFS_INODE_TO_VNODE, msg);
971:     vp = msg->mdv_ino.vp;
972:     vn_lock(vp, LK_EXCLUSIVE | LK_RETRY);   /* <-- no NULL check */
...
975:     return vp;
976: }
```

The dispatcher (devfs_core.c:1373-1377) sets `msg->mdv_ino.vp =
devfs_iterate_topology(...)`. `devfs_iterate_topology` (devfs_core.c:631-649)
returns NULL when no node matches the target inode, and the per-node callback
`devfs_inode_to_vnode_worker_callback` (devfs_core.c:2197-2214) returns NULL
for any node whose `d_dir.d_ino != target`. **So whenever `target` names no
devfs node, `vp` is NULL and line 972 dereferences it.**

`vn_lock()` → `vk_lock(vp,...)` → `lockmgr(&vp->v_lock,...)` with `vp==0x0`
page-faults at `&vp->v_lock ≈ 0` → `Fatal trap 12, fault VA=0x0`,
`lockmgr_exclusive+0x5d: orl (%r14),%esi` → DDB, kernel dead.

## Why callers don't save it

`devfs_vfs_fhtovp` (devfs_vfsops.c:202-221) and `devfs_vfs_vget`
(devfs_vfsops.c:244-256) **do** check `if (vp == NULL) return ENOENT;` —
but **after** the call, at lines 216-217 / 251-252. The panic happens inside
`devfs_inode_to_vnode` at line 972 before the caller ever sees the return
value, so the NULL check in the callers is dead code for the not-found case.

## Reachability / threat model

`VFS_FHTOVP` is reached through `sys_fhopen` (syscall 298),
`sys_fhstat` (478), `sys_fhstatfs` (297), `sys_fhstatvfs` (502). All four
are gated by `caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT)` — i.e. they
require root (sys/kern/vfs_syscalls.c:4832, 5013, 5063). This matches the
CVSS `PR:H` and the Medium severity: **a root user can panic the kernel** by
handing any of those syscalls a file handle whose `fid_ino` names no devfs
node. `vfs_vget` on devfs is otherwise only driven through NFS export
(setup also requires root), so the realistic ceiling is local root→kernel
DoS — no unprivileged path was identified, and the primitive is a fixed
NULL deref (no escalation possible).

## Reproduction (unpatched `#0` kernel)

`trigger.c` runs as root:

1. `getfh("/dev/null", &fh)` — obtains a *valid* devfs file handle
   (correct `fh_fsid` + correct `fid_gen == boottime.tv_sec`, so it passes
   the `devfs_vfs_fhtovp:211` gen check and reaches line 214).
2. Mutates `((struct devfs_fid *)&fh.fh_fid)->fid_ino` to `0xDEADBEEFCAFEBABE`
   — an inode no devfs node has, so `devfs_iterate_topology` returns NULL.
3. Calls `fhstat(&fh, &sb)` → `VFS_FHTOVP(devfs)` → `devfs_vfs_fhtovp` →
   `devfs_inode_to_vnode` → `vn_lock(NULL)` → panic.

**Observed on `6.5-DEVELOPMENT #0` (2026-07-02 build):**

```
[+] getfh("/dev/null") OK
[+] original devfs_fid: len=16 pad=0 gen=1783630069 ino=175
[+] mutated  devfs_fid: len=16 pad=0 gen=1783630069 ino=16045690984503098046
[*] calling fhstat() with bogus devfs fid_ino -> expect panic...
(hang — ssh torn down, run_root times out)

== dfbsd-qemu/boot.log ==
Fatal user address access from kernel mode from trigger at ffffffff80647bad
Fatal trap 12: page fault while in kernel mode
fault virtual address    = 0x0
instruction pointer      = 0x8:0xffffffff80647bad
Stopped at      lockmgr_exclusive+0x5d: orl     (%r14),%esi
db>
```

The panic signature is exactly the predicted `vn_lock(NULL) →
lockmgr(&vp->v_lock)` NULL deref.

## Escalation

None. A NULL deref at a fixed kernel offset is a pure DoS — there is no
controllable corruption, no primitive to groom. This is a hard blocker for
escalation (read: there is nothing to escalate). Realistic impact ceiling:
local root→kernel panic / DoS.

## Fix

`fix.diff` adds the missing NULL guard at the actual fault site
(devfs_core.c:972). Minimal, targeted at the root cause; both VFS callers
already translate `vp==NULL` into `ENOENT`:

```diff
@@ -969,7 +969,14 @@
 	msg->mdv_ino.ino = target;
 	devfs_msg_send_sync(DEVFS_INODE_TO_VNODE, msg);
 	vp = msg->mdv_ino.vp;
-	vn_lock(vp, LK_EXCLUSIVE | LK_RETRY);
+	/*
+	 * The dispatcher may set mdv_ino.vp to NULL when no devfs node
+	 * matches `target` (devfs_iterate_topology returns NULL).
+	 * Guard vn_lock to avoid a NULL-deref panic; callers already
+	 * translate vp==NULL into ENOENT.
+	 */
+	if (vp != NULL)
+		vn_lock(vp, LK_EXCLUSIVE | LK_RETRY);
 	devfs_msg_put(msg);
```

This matches the finding's `## Recommended fix` (`if(vp!=NULL)
vn_lock(vp,...)`) and adds an explanatory comment.

## Fix validation (Phase 8)

Built a **single-fix kernel** from the `with-src` baseline + this one diff
(`make -j6 nativekernel KERNCONF=X86_64_GENERIC`, warm obj, ~5 min, rc=0),
installed `kernel.stripped` → `/boot/kernel/kernel` + `kernel.debug`, rebooted
into `6.5-DEVELOPMENT #1 root@dfbsd:/usr/obj/... 2026-07-09 21:10:29`,
re-ran the **identical** PoC:

**BEFORE (unpatched #0):** panic `Fatal trap 12 fault VA=0x0
lockmgr_exclusive+0x5d`, guest dead.

**AFTER (single-fix #1):**
```
[+] mutated  devfs_fid: len=16 pad=0 gen=1783631817 ino=16045690984503098046
[*] calling fhstat() with bogus devfs fid_ino -> expect panic...
[-] fhstat returned rc=-1 errno=2 (No such file or directory) — NOT vulnerable?
TRIGGER_RC=1
```

`fhstat` now returns `ENOENT` (the post-call NULL check at
`devfs_vfs_fhtovp:216-217` finally fires), no panic, guest stays up. Re-ran
twice for determinism — identical. **fix_status: fixed.**

## Files

| file | role |
|---|---|
| `trigger.c` | minimal PoC: getfh + mutate fid_ino + fhstat |
| `build.sh` / `run.sh` | exact build/run commands |
| `fix.diff` | git-apply-able fix (NULL guard at devfs_core.c:972) |
| `build.log` | (none separately — compiled in one shot; see fix_build.log) |
| `run.log` | full baseline reproduction output + panic signature |
| `panic.txt` | panic excerpt from boot.log |
| `boot_baseline.txt` | boot.log before the panic run |
| `fix_build.log` | full single-fix kernel build output (rc=0) |
| `fix_run.log` | full patched-kernel run output (ENOENT, no panic) |
| `env.txt` | guest environment |
| `manifest.json` | machine-readable catalog |
