β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0767

nfs_lookitup returns uninitialized nfsnode pointer when server echoes parent filehandle β€” wild-pointer deref in create/mkdir/mknod/symlink

Summary

nfs_lookitup :3061 declares struct nfsnode *np UNINITIALIZED. :3087 NFS_CMPFH(dnp,nfhp,fhlen) branch: vref(dvp) newvp=dvp BUT never assigns np. :3127 *npp=np stores stack garbage. 4 callers nfs_create :1773 nfs_mknodrpc :1643 nfs_symlink :2259 nfs_mkdir :2351 all init np=NULL then do newvp=NFSTOV(np) derefs wild pointer. Compare nfs_lookup :1145 np=VTONFS(dvp) BEFORE CMPFH and nfs_nresolve :1055 same pattern β€” only nfs_lookitup forgot. Trigger: malicious/compromised NFSv3 server (AUTH_SYS no encryption trivially spoofable) replies LOOKUP with parent dir fh when client issues create/mkdir/mknod/symlink (gotvp=0 path server-controlled). Impact: panic (DoS) or with stack grooming controlled deref arbitrary kernel read/write. Fix: np=dnp in CMPFH branch.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0767 Β· 15 files
FileTypeDescriptionSize
nfs_mal_server.c trigger-source self-contained malicious NFSv3 server (rpcbind+MOUNTv3+NFSv3 loopback) that drives nfs_lookitup into the NFS_CMPFH branch 19.9 KB view raw
trigger.sh trigger-source unprivileged one-liner: mkdir $MNT/df0767_pwn 950 B view raw
build.sh build-script cc -O2 -Wall -o nfs_mal_server nfs_mal_server.c 151 B view raw
run.sh run-script orchestrates server start + mount + unprivileged trigger 1.6 KB view raw
fix.diff suggested-fix Fix B: return EEXIST in the NFS_CMPFH branch of nfs_lookitup (supersedes finding's np=dnp proposal). git apply-able, validated. 1.4 KB view raw
VERDICT.md verdict full analysis: mechanism, escalation assessment, fix before/after 11.9 KB ↓ raw
README.md readme human-facing reproduction instructions 3.7 KB ↓ raw
build.log build-log nfs_mal_server build output 595 B view raw
run.log run-log decisive unpatched-baseline run (panic) 1.9 KB view raw
panic.txt panic-signature fatal trap 9 GPF at nfs_mkdir+0x328 (from boot.log) 1.6 KB view raw
fix_build.log build-log full nativekernel build of the Fix B single-fix kernel (rc=0) 5.6 MB ↓ download
fix_run.log run-log patched-kernel runs #1/#2: EEXIST, no panic, guest up 1.5 KB view raw
env.txt environment uname, kern.version (#0 baseline + #1 patched), cc version 469 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human-facing reproduction instructions
↓ download raw

DF-0767 β€” PoC and fix evidence

nfs_lookitup returns an uninitialized nfsnode pointer when the server echoes the parent filehandle; nfs_create/nfs_mkdir/nfs_mknodrpc/ nfs_symlink then dereference the wild pointer.

Status: REPRODUCED (panic / local unprivileged DoS). Fix authored, built, and validated β€” see VERDICT.md and fix.diff.

What is here

file what
nfs_mal_server.c malicious NFSv3 server stub (the trigger)
trigger.sh the unprivileged mkdir that fires the bug
build.sh/run.sh exact build/run commands
fix.diff the validated one-hunk fix (returns EEXIST in the CMPFH branch)
VERDICT.md the full writeup (mechanism, escalation analysis, fix before/after)
panic.txt the fatal trap 9 panic signature (unpatched)
run.log / fix_run.log decisive unpatched (panic) and patched (EEXIST) runs
fix_build.log full single-fix kernel build log
manifest.json machine-readable artifact catalog

How to reproduce

# 1. build the malicious server (any user with cc)
sh build.sh                      # -> ./nfs_mal_server

# 2. admin pre-condition (root): start the malicious server and mount it.
#    This mirrors "an admin mounted an NFS share" β€” the realistic trigger
#    environment.
./nfs_mal_server &               # listens on 127.0.0.1:111 and :2049
mount_nfs -3 -T -o tcp,nfsv3 127.0.0.1:/export /mnt

# 3. unprivileged trigger (any non-root user).  On the UNPATCHED kernel
#    this panic's the guest immediately:
mkdir /mnt/df0767_pwn            # unpatched: panic; patched: "File exists"

Expected result

  • Unpatched audit-source kernel (6.5-DEVELOPMENT #0): kernel panic, Stopped at nfs_mkdir+0x328: cmpl $0x2,0xe8(%rdi) (fatal trap 9 general-protection-fault). Guest is down (DDB). Serial-console capture in panic.txt.
  • Single-fix kernel (Fix B, 6.5-DEVELOPMENT #1): mkdir returns File exists (EEXIST); guest stays up. No panic. Reproducible over fresh mount/remount cycles.

The bug in one paragraph

nfs_lookitup() (sys/vfs/nfs/nfs_vnops.c:3056) declares struct nfsnode *np uninitialized (line 3061). When the LOOKUP reply's filehandle equals the parent directory's filehandle, the NFS_CMPFH branch (line 3087) runs vref(dvp); newvp = dvp; but never assigns np; the epilogue (line 3127) then stores the still-garbage np into the caller's out-pointer. The create callers do newvp = NFSTOV(np) (lines 2354, 1776, 1646, 2262) and dereference the wild pointer. nfs_lookup() β€” the reference β€” avoids this by pre-setting np = VTONFS(dvp) at line 1145; nfs_lookitup() is the lone caller that forgot.

Threat model

A malicious, compromised, or MITM'd NFSv3 server (AUTH_SYS is cleartext and trivially spoofable) replies to a create's follow-up LOOKUP with the parent filehandle. Any local user who then issues mkdir/creat/mknod/ symlink on the mounted share panic's the kernel. Pre-condition: an admin has mounted the (malicious) share β€” ordinary for NFS-using deployments.

Fix

See fix.diff. The branch is only reachable when *npp == NULL (the update-existing case is handled by the preceding if (*npp)), i.e. only the create-style callers; treat the echoed filehandle as a name collision and return EEXIST. This both closes the uninitialized-use and avoids the vnode-lifecycle corruption that the finding's naive np = dnp proposal causes (double-unlock panic in kern_mkdir/vop_compat_nmkdir β€” verified by building and booting it).

VERDICT.md verdict full analysis: mechanism, escalation assessment, fix before/after
↓ download raw

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):

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):

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):

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):

} 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)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: the SAME unprivileged trigger (maxx mkdir /mnt/df0767_pwn against the malicious NFS server) that panic'd the unpatched 6.5-DEVELOPMENT #0 baseline at nfs_mkdir+0x328 (fatal trap 9 GPF, guest down) does NOT panic the single-fix #1 kernel -- mkdir returns EEXIST (File exists) and the guest stays up, reproducible over 2 fresh mount/remount cycles. Fix B (return EEXIST in the NFS_CMPFH branch of nfs_lookitup) closes the bug. Also built+booted the finding's proposed np=dnp fix and showed it panics a second way (lockmgr LK_RELEASE double-unlock in vput), which is why fix.diff supersedes that proposal.

BEFORE (unpatched #0): maxx mkdir -> 'Fatal trap 9: general protection fault while in kernel mode / Stopped at nfs_mkdir+0x328: cmpl $0x2,0xe8(%rdi) / db>', guest DOWN. AFTER (single-fix #1, Fix B): maxx mkdir -> 'mkdir: /mnt/df0767_pwn: File exists' (rc=1), guest UP, ALIVE_OK; second run (2 mkdirs) -> both 'File exists', guest UP, no panic in boot.log.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Jul 8 08:25:27 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 /boot/kernel/kernel = b663c4518622ac001198111c94b4ea3a85972521cffb3a9097eae52a9c7bf5e2)

Confirmed kernel references

Detail

Exploit chain

Not escalated to uid=0 -- valid hard blocker. The primitive is a wild-pointer dereference of np, a kernel-STACK local in nfs_lookitup's frame whose value is whatever residue occupied that slot. Unlike a heap object, the per-thread kernel stack is NOT a surface an unprivileged user can spray or groom, and the attacker-influenced inputs in the prior call chain (filename, MKDIR/RPC reply bytes parsed into nfs_mkdir's stack) do not deterministically alias np's exact stack slot without build-specific RE of the compiler's frame layout. Empirically every reproduction faulted in a NON-canonical address (trap-9 GPF, not page-fault), i.e. the residue was not even a dereferenceable pointer. On this permissive guest (no SMAP/SMEP/KASLR) a controlled np would in principle chain (point it at a forged userspace nfsnode/vnode, hijack a vnode op), but the precondition -- control of the stack-local np itself -- is precisely what is absent and not groomable. Honest impact ceiling: local unprivileged panic/DoS. No separate stack-info-leak primitive exists to lift np into a controlled value. (For memory-corruption primitive classification: bucket = kernel stack, no victim object/grooming possible.)

Evidence (decisive lines)

UNPATCHED #0 baseline, unprivileged `mkdir /mnt/df0767_pwn` as maxx (uid 1001) -> kernel panic, ssh dies (rc=124), guest DOWN. Serial-console capture (dfbsd-qemu/boot.log): 'Fatal trap 9: general protection fault while in kernel mode / cpuid = 0 / instruction pointer = 0x8:0xffffffff8080f5d8 / current process = 870 / kernel: type 9 trap, code=0 / Stopped at nfs_mkdir+0x328: cmpl $0x2,0xe8(%rdi) / db>'. nfs_mkdir+0x328 == nfs_vnops.c:2355 `if (newvp->v_type != VDIR)` immediately after `newvp = NFSTOV(np)` consumed the uninitialized np. PATCHED #1 (Fix B): same trigger -> 'mkdir: /mnt/df0767_pwn: File exists' (EEXIST), guest UP, no panic; reproducible over 2 fresh mount/remount cycles.

PoC changes

Evidence pack shipped EMPTY (no PoC source) -- authored the entire trigger from scratch. nfs_mal_server.c: ~430-line self-contained malicious NFSv3 server (rpcbind + MOUNTv3 + NFSv3 over loopback) whose 'stateful echo-parent-fh after a create-class op' reply sequence is the heart of the trigger. trigger.sh: unprivileged one-liner (mkdir $MNT/foo). build.sh/run.sh: exact commands. fix.diff: authored Fix B (return EEXIST in CMPFH branch) which SUPERSEDES the finding's naive np=dnp proposal -- proved np=dnp trades the wild-deref panic for a double-unlock panic (lockmgr LK_RELEASE in vput via kern_mkdir/vop_compat_nmkdir) by building and booting it.

Verified recommended fix

In nfs_lookitup()'s NFS_CMPFH branch (sys/vfs/nfs/nfs_vnops.c:3087), instead of vref(dvp); newvp = dvp; (which leaves np uninitialized AND, if np were set to dnp, would return the directory as the create result and double-unlock), free the reply mbuf and return EEXIST. Rationale: this branch is only reachable when *npp==NULL (the create-style callers nfs_create/nfs_mkdir/nfs_mknodrpc/nfs_symlink); a correct server never echoes the parent fh on a post-create LOOKUP, so treating it as a name collision is both safe and what the callers already expect. SUPERSEDES the finding's 'np=dnp' proposal (built and verified panics a second way). Full git apply-able diff in findings/poc/DF-0767/fix.diff; applies + compiles + boots clean, validated before/after.

Verdict

REPRODUCED. nfs_lookitup() (sys/vfs/nfs/nfs_vnops.c:3056) declares struct nfsnode *np uninitialized (line 3061); in the NFS_CMPFH branch (line 3087, taken when a LOOKUP reply echoes the parent directory's filehandle) np is never assigned, yet the epilogue stores the still-garbage np into the caller's out-pointer via *npp = np (line 3127). The four create callers then do newvp = NFSTOV(np) (lines 2354/1776/1646/2262) and dereference the wild pointer. Confirmed by an unprivileged mkdir /mnt/foo (maxx, uid 1001) against a malicious NFSv3 server stub (findings/poc/DF-0767/nfs_mal_server.c) that replies MKDIR with no filehandle then echoes the parent fh on the follow-up LOOKUP: the unpatched #0 kernel panic'd at nfs_mkdir+0x328: cmpl $0x2,0xe8(%rdi) (fatal trap 9 GPF). The reference nfs_lookup() avoids this only because it pre-sets np=VTONFS(dvp) at line 1145; nfs_lookitup() is the lone caller that forgot. Realistic threat: a malicious/compromised/MITM'd NFSv3 server (AUTH_SYS cleartext) panic's any local user who issues create/mkdir/mknod/symlink on the mounted share.