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)
PoC verification
Evidence pack
findings/poc/DF-3195 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
nfs_inactive():v_type == VDIRβ thestruct sillyrename(allocatedM_NFSREQinnfs_sillyrename, sys/vfs/nfs/nfs_vnops.c:3015) is never flushed βsp,sp->s_cred(acrdup'd ucred) and thevref'dsp->s_dvpall leak.NREMOVEDis set βvrecycleβnfs_reclaim():v_type == VDIRβ the cookie teardown interprets the union:dp = np->n_cookies.lh_firstissp. Loop iteration 1:kfree(sp, M_NFSDIROFF)β anM_NFSREQobject freed asM_NFSDIROFF. Iteration 2:dp = *(void **)sp = sp->s_cred(a livestruct ucred *) βkfree(ucred, M_NFSDIROFF). Iteration 3:dp = *(void **)ucred={u_int cr_ref = 1; padding}β 0x1 βmovq (%rdi),%r12page-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 + aretypemode: everything honest (CREATE/RENAME/LOOKUP all truthful, the silly-rename LOOKUP replies the file's own fh with honest REG attrs), EXCEPTGETATTR(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 astype = 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).
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):
unlink()of an open file βnfs_sillyrename()(sys/vfs/nfs/nfs_vnops.c:2992-3041): the rename RPC and the confirmingnfs_lookitup(".nfsXXX")are answered HONESTLY by the server (moderetype= control mode for everything except the late GETATTR), so a perfectly validsp(kmalloc'd M_NFSREQ at :3015,crdup'd s_cred,vref'd s_dvp) is stored:np->n_sillyrename = sp(:3040);NREMOVED+VREF_FINALIZEset (: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.)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.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_firstis sp β iter1:kfree(sp, M_NFSDIROFF);dp = sp->s_crediter2:kfree(ucred, M_NFSDIROFF);dp = *(void**)ucred = 0x1iter3:movq (%rdi),%r12faults 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 bitNUNUSED0400x0040) as the union discriminator: set at the sp store site (nfs_vnops.c:3041), honored and cleared innfs_inactive(flush regardless of current type), checked innfs_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-runaborted the&&chain), so that kernel contained only the DF-3194 hunks. The identicalnfs_reclaim+0x150offset 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
fixedfix.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.
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.
No comments yet.