# DF-0767 — VERDICT

**Title:** `nfs_lookitup` returns uninitialized `nfsnode` pointer when server
echoes parent filehandle — wild-pointer deref in create/mkdir/mknod/symlink.

**Verdict:** REPRODUCED (panic / DoS via wild-pointer dereference). Fix
AUTHORED, BUILT, and VALIDATED on a single-fix kernel — the panic is gone.

**Impact:** `panic` (local unprivileged denial-of-service; recoverable only
by reboot). See the "Escalation assessment" section for why this is not
`uid=0`.

---

## 1. The bug (root cause, line-accurate)

`nfs_lookitup()` declares its result nfsnode pointer **uninitialized**
(`sys/vfs/nfs/nfs_vnops.c:3061`):

```c
struct nfsnode *np, *dnp = VTONFS(dvp);   /* np is GARBAGE */
```

After a successful LOOKUP RPC, it dispatches on the returned filehandle
(`sys/vfs/nfs/nfs_vnops.c:3075-3098`):

```c
if (*npp) {                       /* update-existing case                 */
    np = *npp;  ...               /* np assigned                          */
} else if (NFS_CMPFH(dnp, nfhp, fhlen)) {   /* server echoed PARENT fh */
    vref(dvp);
    newvp = dvp;                  /* *** np is NEVER assigned here ***    */
} else {
    error = nfs_nget(..., &np, NULL);  /* np assigned                   */
    ...
}
```

Only the `NFS_CMPFH` branch fails to assign `np`. Then the function's
epilogue unconditionally stores the (still-uninitialized) `np` into the
caller's out-pointer (`sys/vfs/nfs/nfs_vnops.c:3117-3127`):

```c
if (npp && *npp == NULL) {
    if (error) { ... }
    else
        *npp = np;          /* stores stack garbage into caller's &np */
}
```

The four create-style callers all initialise `struct nfsnode *np = NULL;`
and then dereference the returned pointer through `NFSTOV(np)`:

| Caller        | line    | deref                                       |
|---------------|---------|---------------------------------------------|
| `nfs_mknodrpc`| 1643-46 | `newvp = NFSTOV(np);`                       |
| `nfs_create`  | 1773-76 | `newvp = NFSTOV(np);`                       |
| `nfs_symlink` | 2259-62 | `newvp = NFSTOV(np);`                       |
| `nfs_mkdir`   | 2351-54 | `newvp = NFSTOV(np); if (newvp->v_type...)` |

`NFSTOV(np)` is `((struct vnode *)(np)->n_vnode)` (`sys/vfs/nfs/nfsnode.h:168`)
— a read of `np->n_vnode` at a wild address. The reference implementation
`nfs_lookup()` avoids this only because it pre-sets `np = VTONFS(dvp)` at
line 1145, **before** its identical `NFS_CMPFH` branch at line 1234;
`nfs_lookitup()` is the one place that forgot.

The trigger condition is: the `NFS_CMPFH` branch is only reached when
`*npp == NULL` on entry (the first `if (*npp)` catches the update-existing
case). That is exactly the create-style callers above. They reach this code
when the server's MKDIR/CREATE/MKNOD/SYMLINK reply omits the new object's
filehandle (so the client must issue a follow-up LOOKUP to recover it), and
that follow-up LOOKUP then returns the *parent* directory's filehandle. A
malicious, compromised, or MITM'd NFSv3 server (AUTH_SYS is cleartext) can
trivially produce this.

---

## 2. Reproduction

The PoC is a self-contained, ~430-line malicious NFSv3 server
(`nfs_mal_server.c`) that speaks just enough rpcbind + MOUNTv3 + NFSv3 over
loopback to let the DragonFly NFS *client* mount an export and issue a
mkdir. It feeds the exact reply sequence that exercises the bug:

1. client `LOOKUP "foo"` (namei existence check) → reply `NFS3ERR_NOENT`
   (so the create proceeds).
2. client `MKDIR "foo"` → reply `NFS3_OK` with
   `post_op_fh3.handle_follows = 0` (⇒ `gotvp = 0`, forcing `nfs_lookitup`).
3. client `LOOKUP "foo"` (inside `nfs_lookitup`) → reply `NFS3_OK` with
   object filehandle == the parent directory's filehandle ⇒ `NFS_CMPFH`
   branch ⇒ `np` never assigned ⇒ `*npp = np` (garbage) ⇒ caller does
   `NFSTOV(np)` ⇒ wild dereference ⇒ panic.

**Pre-condition (realistic):** an administrator has mounted the malicious
server (`mount_nfs -3 -T ...`). This is the same precondition as "an admin
mounted an NFS share" — ordinary for any NFS-using deployment. The
destructive bit — the single `mkdir` — is issued **by the unprivileged
user `maxx` (uid 1001)**:

```
# root: admin pre-condition
mount_nfs -3 -T -o tcp,nfsv3 127.0.0.1:/export /mnt
# maxx (uid 1001): the actual trigger
mkdir /mnt/df0767_pwn     # -> kernel panic
```

### Result on the unpatched audit-source kernel (#0)

```
DragonFly 6.5-DEVELOPMENT #0: Thu Jul  2 06:02:54 UTC 2026

$ id
uid=1001(maxx) gid=1001(maxx) groups=1001(maxx)
$ mkdir /mnt/df0767_pwn
<<kernel panics; ssh dies>>
```

Serial-console panic signature (`dfbsd-qemu/boot.log`):

```
Fatal trap 9: general protection fault while in kernel mode
cpuid = 0; lapic id = 0
instruction pointer = 0x8:0xffffffff8080f5d8
stack pointer            = 0x10:0xfffff801182df428
frame pointer            = 0x10:0xfffff801182df5e8
current process = 870
kernel: type 9 trap, code=0
Stopped at      nfs_mkdir+0x328:        cmpl    $0x2,0xe8(%rdi)
db>
```

`nfs_mkdir+0x328` is `if (newvp->v_type != VDIR)` (line 2355) immediately
after `newvp = NFSTOV(np)` (line 2354) consumed the uninitialized `np`.
`cmpl $0x2,0xe8(%rdi)` reads `v_type` at struct vnode offset 0xe8; the GPF
(trap 9, not page-fault 12) fires because the wild `newvp` is a
non-canonical x86-64 address derived from garbage `np->n_vnode`. **This is
the bug.**

---

## 3. Escalation assessment (why `panic`, not `uid=0`)

The primitive is a wild-pointer *dereference* of a value the attacker does
**not** control: `np` is a kernel-stack local in `nfs_lookitup`'s frame, and
its value is whatever stack residue happened to be at that slot when the
frame was laid down. Unlike a heap object, the kernel stack **cannot be
sprayed or groomed** by an unprivileged user — stack residue is dictated by
the prior call chain (syscall trap frame → namei → `nfs_mkdir` → the MKDIR
RPC blocking in `nfsm_request` → `nfs_lookitup`), and although some of those
frames carry attacker-influenced inputs (the filename, RPC reply bytes
parsed into the `info` struct on `nfs_mkdir`'s stack), none of them lands
deterministically on `np`'s exact stack slot without build-specific reverse
engineering of the compiler's frame layout. The empirical evidence agrees:
every reproduction faulted in a *non-canonical* address (trap 9 GPF), i.e.
the residue was not even a dereferenceable pointer, let alone one shaped to
hit an attacker-placed object. On this guest (no SMAP/SMEP/KASLR) a
controlled `np` would in principle be chainable — point it at a forged
`nfsnode`/`vnode` in userspace, hijack a vnode op — but the precondition
(control of the stack-local `np` itself) is precisely what is missing, and
the kernel stack is not a surface an unprivileged syscall can shape.

This is a valid hard blocker (Phase 6): the corrupt value lives on a
non-groomable surface (the per-thread kernel stack) and is not
attacker-controlled, so there is no escalation chain to develop. Honest
impact ceiling: **local unprivileged DoS / panic**. (A separate, unrelated
stack-info-leak primitive would change this assessment, but none exists
here.)

---

## 4. The fix (authored, validated — SUPERSEDES the finding's proposal)

### Finding's proposal
> "Fix: `np = dnp` in CMPFH branch."

### Why that is insufficient (verified by building + booting it)
`np = dnp` makes `*npp = dnp`, so the create callers do
`newvp = NFSTOV(dnp) = dvp` (the parent directory vnode). Returning `dvp`
as the "created" object violates the VOP-create vnode lifecycle: the compat
wrapper `vop_compat_nmkdir()` (`sys/kern/vfs_default.c:440-456`) does
`vn_unlock(dvp); vrele(dvp);` after `VOP_OLD_MKDIR` returns, and then
`kern_mkdir()` (`sys/kern/vfs_syscalls.c:4527-4528`) does `vput(vp=dvp)` —
a second unlock of an already-unlocked vnode. I built and booted exactly
this fix and it panic'd with:

```
panic: lockmgr: LK_RELEASE: no lock held
lockmgr_release() at lockmgr_release+0x11a
vput() at vput+0x11
kern_mkdir() at kern_mkdir+0x10c
sys_mkdir() at sys_mkdir+0x51
```

So the finding's one-line proposal trades the wild-deref panic for a
double-unlock panic. It is **correct that `np` must be made safe**, but the
proper cure is to not return the directory as the create result at all.

### The validated fix (Fix B)
The `NFS_CMPFH` branch is only reachable when `*npp == NULL` (the first
`if (*npp)` branch already handles the update-existing case) — i.e.
exclusively the create-style callers that asked for a *new* nfsnode. A
correct server never echoes the parent fh on a post-create LOOKUP. Treat
the echoed handle as a name collision and return `EEXIST`, which all four
create callers already handle gracefully (no `NFSTOV(np)`, no `vput`):

```c
} else if (NFS_CMPFH(dnp, nfhp, fhlen)) {
    /*
     * The server echoed the parent directory's filehandle for this
     * lookup.  This branch is only reached when *npp == NULL (the
     * first 'if (*npp)' handles the update-existing case), i.e. the
     * create-style callers (nfs_create / nfs_mkdir / nfs_mknodrpc /
     * nfs_symlink) that asked nfs_lookitup() to allocate a NEW
     * nfsnode.  Returning the directory vnode as the "new" object
     * would corrupt those callers' vnode lock/ref accounting, and the
     * local 'np' being left uninitialized here would (bug) cause the
     * trailing '*npp = np' to store stack garbage which the caller
     * dereferences via NFSTOV(np).  Treat the echoed handle as a
     * collision so the create callers see EEXIST instead of panicking.
     * A correct server never triggers this path (a post-create LOOKUP
     * returns the new object's own filehandle, not the parent's).
     */
    m_freem(info.mrep);
    info.mrep = NULL;
    return (EEXIST);
} else {
```

Full `git apply`-able diff in `fix.diff`. It applies cleanly and compiles
cleanly with `make -j6 nativekernel KERNCONF=X86_64_GENERIC`.

### Fix validation (Phase 8, before/after)

| kernel                              | trigger `mkdir /mnt/df0767_pwn` as maxx   |
|-------------------------------------|-------------------------------------------|
| unpatched `#0` (audit baseline)     | **panic** `nfs_mkdir+0x328 cmpl $0x2,0xe8(%rdi)` (trap 9 GPF); guest down |
| single-fix `#1` (Fix B applied)     | **no panic**; `mkdir: File exists` (EEXIST); guest up; reproducible over 2 fresh mount/remount cycles |

Both kernels were built from the same `with-src` source tree; only the
single `nfs_vnops.c` hunk differs. `fix_baseline_reproduced=1`,
`fix_patched_reproduced=0`. The fix closes the bug.

---

## 5. PoC changes (what the runner wrote)

The finding's evidence pack shipped with no PoC source, so the runner
authored the entire trigger from scratch:

- `nfs_mal_server.c` — self-contained malicious NFSv3 server (rpcbind +
  MOUNTv3 + NFSv3 over loopback). The "stateful echo-parent-fh after a
  create" behaviour is the heart of the trigger.
- `trigger.sh` — the unprivileged one-liner (`mkdir $MNT/df0767_pwn`).
- `build.sh`, `run.sh` — exact build/run commands.
- `fix.diff` — Fix B (supersedes the finding's `np=dnp` proposal).
- `panic.txt`, `run.log`, `fix_run.log`, `fix_build.log`, `build.log`,
  `env.txt`, `manifest.json`.

---

## 6. Kernel references (confirmed during verification)

- `sys/vfs/nfs/nfs_vnops.c:3061` — `struct nfsnode *np` declared uninitialized.
- `sys/vfs/nfs/nfs_vnops.c:3087` — `NFS_CMPFH` branch: `np` never assigned.
- `sys/vfs/nfs/nfs_vnops.c:3117-3127` — `*npp = np` stores the garbage.
- `sys/vfs/nfs/nfs_vnops.c:2354-2355` — `nfs_mkdir`: `newvp = NFSTOV(np); if (newvp->v_type != VDIR)` (the faulting deref).
- `sys/vfs/nfs/nfs_vnops.c:1773-1776` (`nfs_create`), `1643-1646` (`nfs_mknodrpc`), `2259-2262` (`nfs_symlink`) — the other three affected callers.
- `sys/vfs/nfs/nfsnode.h:168` — `NFSTOV(np)` macro (`(struct vnode *)(np)->n_vnode`).
- `sys/vfs/nfs/nfs.h:105` — `NFS_CMPFH` macro.
- `sys/vfs/nfs/nfsm_subs.c:394-436` — `nfsm_mtofh` (parses `handle_follows`; `gotvp=0` forces `nfs_lookitup`).
- `sys/kern/vfs_default.c:440-456` — `vop_compat_nmkdir` (explains why the naive `np=dnp` fix double-unlocks).
