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

nfs_inactive/nfs_reclaim discriminate the n_un3 union (sillyrename vs dir-cookie list) by the runtime-mutable v_type — a server-side VREG→VDIR retype of a sillyrenamed file makes reclaim walk the struct sillyrename as an nfsdmap list: kfree(M_NFSREQ block as M_NFSDIROFF), then kfree of the live s_cred ucred, then wild-pointer deref — deterministic kernel panic (verified 2/2), fix validated on rebuilt kernel

Summary

struct nfsnode overlays struct sillyrename *nf_silly and the nfsdmap cookie LIST in union n_un3 (nfsnode.h:130-133). nfs_inactive flushes the sillyrename only when v_type != VDIR (:392-397) and nfs_reclaim walks the cookie list only when v_type == VDIR (:466-473). But nfs_loadattrcache applies any server-claimed ftype to a live vnode via nfs_setvtype (nfs_subs.c:723-724), so a malicious/compromised/spoofing NFS server can retype a VREG node carrying a pending sillyrename to VDIR: the last close skips the sp flush (leaking sp, its crdup'd ucred, vref'd dvp) and reclaim interprets sp as the cookie list - kfree(sp, M_NFSDIROFF) on an M_NFSREQ object, then kfree of a live ucred as M_NFSDIROFF, then dp=*(void**)ucred = 0x1 -> page fault at 0x1, panic at nfs_reclaim+0x150. Distinct root cause from DF-2996: the sillyrename here is stored on the correct node through fully honest server replies; only a later GETATTR lies. VERIFIED 2/2 on stock guest via fake-server 'retype' mode (honest CREATE/RENAME/LOOKUP; only the post-silly-rename GETATTR answers DIR-typed attrs): [4] fstat type=DIRECTORY then Fatal trap 12 fault 0x1 'Stopped at nfs_reclaim+0x150'. Fix validated on rebuilt kernel (NSILLY flag discriminator): identical PoC survives, close()=0, SURVIVED, UNMOUNT_OK. Unpriv local user + malicious server; two wrong-object frees of live kernel objects execute before the fault (memcorrupt family); uid0 not realistically reachable through this walk (third pointer derives from ucred cr_ref).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3195 Β· 13 files
FileTypeDescriptionSize
README.md β€” 5.1 KB ↓ raw
VERDICT.md β€” 6.3 KB ↓ raw
fakesrv_retype.c β€” 20.2 KB view raw
trigger_retype.c β€” 2.7 KB view raw
build.sh β€” 85 B view raw
run.sh β€” 235 B view raw
run.log β€” 1.1 KB view raw
run.fixed.log β€” 503 B view raw
panic.txt β€” 718 B view raw
env.txt β€” 861 B view raw
build.log β€” 310 B view raw
fix.diff β€” 2.6 KB view raw
verdict.json β€” 5.5 KB view raw

DF-3195 — nfs_inactive/nfs_reclaim discriminate the n_un3 union (nf_silly vs nd_cook) by the runtime-mutable v_type → server-side VREG→VDIR retype turns a pending sillyrename into a cookie-list walk: mismatched/wild kfree(M_NFSDIROFF) chain → deterministic kernel panic (2/2)

What this is

struct nfsnode overlays struct sillyrename *nf_silly and LIST_HEAD(, nfsdmap) nd_cook in one union (n_un3, sys/vfs/nfs/nfsnode.h:130-133). nfs_inactive() and nfs_reclaim() pick which interpretation to use by testing vp->v_type:

/* nfs_inactive, sys/vfs/nfs/nfs_node.c:392-397 */
if (ap->a_vp->v_type != VDIR) {
        sp = np->n_sillyrename;
        np->n_sillyrename = NULL;
} else {
        sp = NULL;                  /* ← sillyrename flush SKIPPED */
}
...
/* nfs_reclaim, sys/vfs/nfs/nfs_node.c:466-473 */
if (vp->v_type == VDIR) {
        dp = np->n_cookies.lh_first;    /* ← walks sp as an nfsdmap list */
        while (dp) {
                dp2 = dp;
                dp = dp->ndm_list.le_next;
                kfree((caddr_t)dp2, M_NFSDIROFF);
        }
}

But v_type of a live NFS vnode is mutable at runtime: nfs_loadattrcache() applies any server-claimed ftype to an existing vnode (if (vp->v_type != vtyp) nfs_setvtype(vp, vtyp) …, sys/vfs/nfs/nfs_subs.c:723-724 β€” the DF-2998 retyping behavior). A malicious/compromised/spoofing server (AUTH_SYS over UDP has no integrity) can therefore retype a VREG node that carries a pending sillyrename to VDIR, after which the last close executes:

  1. nfs_inactive(): v_type == VDIR β†’ the struct sillyrename (allocated M_NFSREQ in nfs_sillyrename, sys/vfs/nfs/nfs_vnops.c:3015) is never flushed β€” sp, sp->s_cred (a crdup'd ucred) and the vref'd sp->s_dvp all leak.
  2. NREMOVED is set β†’ vrecycle β†’ nfs_reclaim(): v_type == VDIR β†’ the cookie teardown interprets the union: dp = np->n_cookies.lh_first is sp. Loop iteration 1: kfree(sp, M_NFSDIROFF) β€” an M_NFSREQ object freed as M_NFSDIROFF. Iteration 2: dp = *(void **)sp = sp->s_cred (a live struct ucred *) β†’ kfree(ucred, M_NFSDIROFF). Iteration 3: dp = *(void **)ucred = {u_int cr_ref = 1; padding} β‰ˆ 0x1 β†’ movq (%rdi),%r12 page-faults at 0x1 β†’ panic (nfs_reclaim+0x150).

This is distinct from DF-2996 (vnops consuming an unchecked lookitup result): here every step up to the retype is honest β€” the sillyrename is validly stored on the correct node, and the DF-2996 vnops fix (validating the lookitup result and its type) does not prevent a later GETATTR from retyping the node. The defect is purely the union discrimination by mutable type in nfs_node.c.

Trigger: any unprivileged local user unlink()ing an open file on a mounted NFS share whose server then answers any attribute-bearing RPC for that file with directory-typed attributes.

PoC files

  • fakesrv_retype.c β€” DF-2996's fake NFSv3 server + a retype mode: everything honest (CREATE/RENAME/LOOKUP all truthful, the silly-rename LOOKUP replies the file's own fh with honest REG attrs), EXCEPT GETATTR(fh_file) replies DIR-typed attributes once the silly-rename completed (the "late retype").
  • trigger_retype.c β€” open(O_CREAT) + unlink() (valid sp stored on the VREG node) β†’ sleep 16 s (attr-cache expiry) β†’ fstat(fd) (RPC GETATTR β†’ live retype, printed as type = DIRECTORY) β†’ close(fd) (VREF_FINALIZE β†’ VOP_INACTIVE β†’ vrecycle β†’ VOP_RECLAIM β†’ wild walk).

Build (in-guest)

cd /root/df3195
cc -O2 -o fakesrv_retype fakesrv_retype.c
cc -O2 -o trigger_retype trigger_retype.c

Run (baseline, stock kernel)

(./fakesrv_retype retype > /dev/console 2>&1 &)
sleep 1
mount_nfs -o port=2049,udp 127.0.0.1:/P /mnt
rm -f /mnt/f
./trigger_retype > /dev/console 2>&1

Expected (baseline β€” verified 2/2, deterministic)

[1] open(/mnt/f) = fd 3
[2] unlink() = 0  (silly-rename pending: valid sp on VREG node)
[3] sleeping 16s for attr-cache expiry...
[4] fstat(fd): server-claimed type = DIRECTORY (live retype applied)
[5] close(fd) -> VOP_INACTIVE (sp flush skipped) + vrecycle -> VOP_RECLAIM ...
Fatal user address access from kernel mode from trigger_retype
Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0x1
Stopped at      nfs_reclaim+0x150:      movq    (%rdi),%r12

(the 0x1 is sp->s_cred's cr_ref=1 + padding read as the next nfsdmap * β€” after TWO wrong-object frees already executed: sp (M_NFSREQ) and the live ucred.)

Expected (with fix.diff)

The late GETATTR retype may still be applied (that's DF-2998's side), but nfs_inactive flushes the sillyrename regardless of the current type (NSILLY flag), the REMOVE RPC fires, and nfs_reclaim never walks the union as cookies: no panic, guest stays up, trigger prints [6] close() = 0. Verified on the rebuilt kernel (run.fixed.log).

Fix

fix.diff: add NSILLY (reusing the free NUNUSED040 bit) as the union discriminator β€” set at the sp store site, honored (and cleared) in nfs_inactive, checked in nfs_reclaim (cookie walk only when not NSILLY; unreachable safety-net free otherwise).

VERDICT.md
↓ download raw

DF-3195 VERDICT β€” REPRODUCED (panic; deterministic; 2/2 baseline) β€” FIXED (validated on rebuilt kernel)

Bottom line

nfs_inactive() and nfs_reclaim() in sys/vfs/nfs/nfs_node.c decide how to interpret the n_un3 union of struct nfsnode (struct sillyrename *nf_silly vs LIST_HEAD(, nfsdmap) nd_cook, sys/vfs/nfs/nfsnode.h:130-133) by testing vp->v_type. The type of a live NFS vnode is mutable at runtime β€” nfs_loadattrcache() applies any server-claimed ftype to an existing vnode (nfs_setvtype, sys/vfs/nfs/nfs_subs.c:723-724; DF-2998's retyping behavior) β€” so a malicious server can flip a VREG node carrying a pending sillyrename to VDIR and the next reclaim executes the directory-cookie teardown on the sillyrename pointer: kfree(sp /* M_NFSREQ */, M_NFSDIROFF), then dp = sp->s_cred β†’ kfree(live ucred, M_NFSDIROFF), then dp = *(void**)ucred = 0x1 (cr_ref=1+padding) β†’ page fault at 0x1.

Reproduced: yes β€” deterministic, 2/2, stock INVARIANTS kernel. Impact: panic (with two wrong-object frees executed before the fault β€” a type-confused teardown in the memcorrupt family). Exploit ceiling: the first two frees are size-correct in DragonFly's slab allocator (block headers, not the malloc type, determine placement), so they are "only" type/accounting mismatches plus a premature free of a live ucred; the third dereference chases cr_ref-derived garbage (0x00000001) and faults. Pushing to uid=0 would require the first qword of a freed object in the walk to be attacker-shaped β€” s_cred's layout (cr_ref/padding) is not attacker-controlled β€” so uid0 escalation is not realistically reachable through THIS walk; the deterministic outcome is a remote-triggerable (server) / local-assisted (unpriv unlink) kernel panic.

How it was reproduced (baseline, stock kernel #0 Thu Jul 2 06:02:54)

(./fakesrv_retype retype > /dev/console 2>&1 &)     # loopback NFSv3 server
mount_nfs -o port=2049,udp 127.0.0.1:/P /mnt
./trigger_retype > /dev/console 2>&1

Console (full text in run.log / panic.txt):

[1] open(/mnt/f) = fd 3
[2] unlink() = 0  (silly-rename pending: valid sp on VREG node)
[3] sleeping 16s for attr-cache expiry...
[4] fstat(fd): server-claimed type = DIRECTORY (live retype applied)
[5] close(fd) -> VOP_INACTIVE (sp flush skipped) + vrecycle -> VOP_RECLAIM ...
Fatal user address access from kernel mode from trigger_retype
Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0x1
Stopped at      nfs_reclaim+0x150:      movq    (%rdi),%r12

Walk-through of the exact chain (every step source-verified):

  1. unlink() of an open file β†’ nfs_sillyrename() (sys/vfs/nfs/nfs_vnops.c:2992-3041): the rename RPC and the confirming nfs_lookitup(".nfsXXX") are answered HONESTLY by the server (mode retype = control mode for everything except the late GETATTR), so a perfectly valid sp (kmalloc'd M_NFSREQ at :3015, crdup'd s_cred, vref'd s_dvp) is stored: np->n_sillyrename = sp (:3040); NREMOVED + VREF_FINALIZE set (:2999-3002). Node is VREG. (This is what distinguishes DF-3195 from DF-2996: no unchecked lookitup result, no lying fh, no vnops bug involved at all.)
  2. sleep(16) lets the attribute cache go stale; fstat(fd) β†’ GETATTR RPC β†’ server replies DIR-typed fattr for the file's own fh β†’ nfs_loadattrcache β†’ vp->v_type != vtyp β†’ nfs_setvtype(vp, VDIR) (nfs_subs.c:723-724). Trigger prints [4] ... DIRECTORY.
  3. close(fd) β†’ 1β†’0 with VREF_FINALIZE β†’ vnode_terminate() β†’ VOP_INACTIVE β†’ nfs_inactive (nfs_node.c:392): v_type == VDIR β†’ sp flush SKIPPED β†’ NREMOVED β†’ vrecycle β†’ vgone_vx β†’ VOP_RECLAIM β†’ nfs_reclaim (nfs_node.c:466): v_type == VDIR β†’ dp = np->n_cookies.lh_first is sp β†’ iter1: kfree(sp, M_NFSDIROFF); dp = sp->s_cred iter2: kfree(ucred, M_NFSDIROFF); dp = *(void**)ucred = 0x1 iter3: movq (%rdi),%r12 faults at 0x1 β†’ panic at nfs_reclaim+0x150.

Two independent baseline runs panicked identically (run.log = capture run; first run same signature in the initial ssh session).

Fix validation (mandatory: memory-corruption class)

fix.diff (applied in-guest to /usr/src; make -j6 nativekernel KERNCONF=X86_64_GENERIC RC=0 β€” nfs_node.c compile confirmed in build log; make installkernel RC=0; rebooted into #1: Tue Sep 8 08:54:52 UTC 2026):

  • adds NSILLY (reusing free flag bit NUNUSED040 0x0040) as the union discriminator: set at the sp store site (nfs_vnops.c:3041), honored and cleared in nfs_inactive (flush regardless of current type), checked in nfs_reclaim (cookie walk only when not NSILLY; safety-net free otherwise).

Baseline vs patched (identical PoC, mode retype):

kernel outcome
stock #0 Jul 2 panic at nfs_reclaim+0x150, fault 0x1 (2/2)
patched #1 Sep 8 08:54 [4] retype still applied β†’ [6] close() = 0,
SURVIVED rc=0, UNMOUNT_OK, guest healthy (run.fixed.log)

The patched run still shows [4] ... DIRECTORY β€” i.e. the server-side retype (DF-2998, separate finding) is NOT what the fix blocks; the fix makes nfs_node.c's teardown immune to the type flip. fix_status: fixed.

Honesty notes

  • An intermediate "fixed" run still panicked β€” root cause: the combined patch application silently failed (interactive patch --dry-run aborted the && chain), so that kernel contained only the DF-3194 hunks. The identical nfs_reclaim+0x150 offset across kernels exposed it. The final validation kernel had all hunks applied and verified by grep (NSILLY present in nfsnode.h / nfs_node.c / nfs_vnops.c) before build.
  • The fake server's startup banner ("mode=err") is a stale print from the DF-2996 lineage; the actual mode logic is retype (proven by [4]).
  • Requires a malicious/compromised NFS server (or an on-path spoofer β€” AUTH_SYS/UDP replies carry no integrity); the local-user part (open+unlink+fstat+close) is entirely unprivileged.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied in-guest to /usr/src (verified by grep before build: NSILLY present in nfsnode.h/nfs_node.c/nfs_vnops.c), kernel rebuilt with make -j6 nativekernel KERNCONF=X86_64_GENERIC (RC=0, nfs_node.c compile confirmed in build log), installed and booted. Identical PoC: baseline panicked 2/2 at nfs_reclaim+0x150 fault 0x1; patched kernel prints [6] close()=0, SURVIVED rc=0, UNMOUNT_OK and stays healthy, while [4] proves the server retype is still applied (the fix is in the union discrimination, not in blocking the retype).

findings/poc/DF-3195/run.fixed.log (patched run transcript from serial console); fix.diff (applied diff); VERDICT.md 'Fix validation' section; note in VERDICT.md about the intermediate build that silently lacked the patch (identical nfs_reclaim+0x150 offset exposed it) and was discarded.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 8 08:54:41 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unprivileged user: open(O_CREAT) + unlink() on mounted NFS share -> nfs_sillyrename stores valid sp on VREG node (all server replies honest so far) -> sleep past attrcache timeout -> fstat(fd) -> server GETATTR reply claims ftype=DIR -> nfs_setvtype(VDIR) on live node -> close(fd) -> VREF_FINALIZE -> VOP_INACTIVE skips sp flush (v_type==VDIR branch, nfs_node.c:392) + NREMOVED vrecycle -> VOP_RECLAIM cookie-walk on sp (nfs_node.c:466-473): kfree(sp, M_NFSDIROFF) -> dp=sp->s_cred -> kfree(ucred, M_NFSDIROFF) -> dp=(void*)ucred=0x1 -> page fault panic at nfs_reclaim+0x150. Escalation ceiling: the walk's third pointer derives from ucred cr_ref (not attacker-shaped), so the realistic outcome is the panic plus two premature/wrong-type frees of live kernel objects (sp, ucred); uid0 not reached.

Evidence (decisive lines)

['run.log: full serial-console capture - steps [1]-[5] then Fatal trap 12, fault 0x1, Stopped at nfs_reclaim+0x150 movq (%rdi),%r12', 'panic.txt: panic block from the capture run', 'run.fixed.log: same PoC on fix-validation kernel #1 Sep 8 08:54:41 - [4] retype still applied, [6] close()=0, SURVIVED rc=0, UNMOUNT_OK', 'VERDICT.md: full chain with path:line for every step, fix-diff design, honesty notes (incl. the intermediate patch-application failure)', "fakesrv_retype.c mode 'retype': honest silly-rename lifecycle, only the post-rename GETATTR(fh_file) lies with DIR attrs"]

PoC changes

Extended DF-2996's fake NFSv3 server with a 'retype' mode (honest everything; GETATTR of the file fh answers DIR-typed attrs once the silly-rename completed) and wrote trigger_retype.c (open+unlink -> 16s sleep for attrcache expiry -> fstat to force the GETATTR RPC -> close). PoC output routed to /dev/console so evidence survives the panic.

Verified recommended fix

Discriminate the n_un3 union with an NSILLY flag bit (0x0040, currently NUNUSED040) set at sp-store time, honored/cleared in nfs_inactive and checked in nfs_reclaim, instead of testing the mutable vp->v_type (see fix.diff).

Verdict

Deterministic kernel panic reproduced 2/2 on the stock INVARIANTS kernel: nfs_inactive() and nfs_reclaim() discriminate the n_un3 union (struct sillyrename * vs nfsdmap cookie LIST_HEAD) by the runtime-mutable vp->v_type, so a server-side VREG->VDIR retype (nfs_loadattrcache -> nfs_setvtype, nfs_subs.c:723-724) of a node with a pending sillyrename makes the last close skip the sp flush and makes reclaim walk sp as a cookie list: kfree(sp M_NFSREQ-as-M_NFSDIROFF), then kfree of the live s_cred ucred as M_NFSDIROFF, then a wild deref of (void*)ucred (=0x1 from cr_ref) -> Fatal trap 12 at nfs_reclaim+0x150, fault address 0x1. Every step is source-verified and the whole chain is driven by unprivileged local syscalls (open+unlink, sleep, fstat, close) against a malicious server. Fix validated on a rebuilt kernel: identical trigger survives ([6] close()=0, clean unmount) while the retype is still applied.