Server attributes freely retype live vnodes in nfs_loadattrcache (VREG→VDIR observed live; VCHR/VBLK with server-chosen rdev via addaliasu + v_ops switch) — device-node injection and buffer-cache type confusion through every attribute load in this file
| Field | Value |
|---|---|
| ID | DF-2998 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-843 Type Confusion |
| File | sys/vfs/nfs/nfs_vnops.c |
| Lines | engine nfs_subs.c:718-731; call sites :2903, :3099-3101, :1420, :1071-1077 |
| Area | vfs/nfs |
| Confidence | likely |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
Every nfsm_loadattr/nfsm_postop_attr/nfsm_wcc_data call in nfs_vnops.c funnels into nfs_loadattrcache, which for an EXISTING vnode executes 'if (vp->v_type != vtyp) { nfs_setvtype(vp, vtyp); vp->v_ops = &...mnt_vn_spec_ops; addaliasu(vp, rmajor, rminor); }' with vtyp/rmajor/rminor straight from the wire. There is no 'only set type at creation' guard. OBSERVED LIVE during DF-2996 verification: a single LOOKUP reply with directory-typed attributes for the file's own file handle retyped an open VREG vnode to VDIR mid-life and turned its reclaim into the fatal cookie-walk. Beyond that vector, a malicious server can flip any file on its share to VCHR/VBLK with an arbitrary server-chosen major/minor, delivering device nodes with server-chosen mode/uid through ordinary attribute replies — nfsv3tov_type's &0x7 table bounds the enum, so the confusion is fully type-safe from the server's perspective. Malicious NFS server + local user (or root) touching the share: (a) retype VREG→VDIR/VCHR on live vnodes corrupts buffer-cache/vmio assumptions; (b) VCHR/VBLK injection with server-chosen rdev exposes local device access with server-chosen permissions. Not separately PoC'd beyond the live VREG→VDIR retype that served as DF-2996's crash enabler. Fix: only assign v_type at node creation (vp->v_type == VNON); on type change of an existing vnode, invalidate the node (n_attrstamp=0, NRMODIFIED, purge namecache) instead of retyping.
Timeline
- 2026-09-02 Discovered during pass-2 audit of nfs_vnops.c (GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2998 · 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | — | 3.8 KB | ↓ raw | |
| VERDICT.md | — | 7.4 KB | ↓ raw | |
| fakesrv2998.c | — | 15.8 KB | view raw | |
| trigger2998.c | — | 7.6 KB | view raw | |
| build.sh | — | 146 B | view raw | |
| build.log | — | 125 B | view raw | |
| run.sh | — | 242 B | view raw | |
| run.log | — | 3.2 KB | view raw | |
| fix_run.log | — | 2.0 KB | view raw | |
| fix_build.log | — | 478 B | view raw | |
| panic.bgetvp.txt | — | 604 B | view raw | |
| panic.removerpc.txt | — | 565 B | view raw | |
| run.test1b.log | — | 0 B | ↓ download | |
| fix.diff | — | 1.9 KB | view raw | |
| env.txt | — | 1.2 KB | view raw | |
| manifest.json | — | 1.6 KB | view raw | |
| verdict.json | — | 5.6 KB | view raw |
DF-2998 — Server attributes freely retype live vnodes in nfs_loadattrcache
Type-confusion engine: any attribute reply (GETATTR result, LOOKUP /
ACCESS / READDIR post-op attrs, …) applied to an existing NFS vnode
retypes it (nfs_setvtype + v_ops switch + addaliasu for VCHR/VBLK)
with no "only at creation" guard — sys/vfs/nfs/nfs_subs.c:723-733.
Threat model
Malicious (or compromised) NFSv3 server + a local user (unprivileged is enough) touching the share. The server flips the reported type of any file handle; after the client's attribute cache expires (mount options pin it to 1s in this PoC), the very next stat/getattr applies the lie to the LIVE vnode.
What it buys the attacker, demonstrated on the stock kernel:
- Live VREG→VDIR retype of an open file (fstat shows REGULAR → DIRECTORY on the same fd) — and a single vnode retypes repeatedly (REG→DIR→CHR→DIR).
- VCHR injection: a "file" becomes a visible character device with
server-chosen rdev/mode/uid (
addaliasu+mnt_vn_spec_ops). - Memory corruption, two independent deterministic panics:
-
nfs_removerpc+0x57GPF: a cookie-cached node left non-VDIR at inactive makesnfs_inactivereadn_sillyrename, which ALIASES the VDIR cookie-list head (n_un3union), and walk anfsdmapas astruct sillyrename. -panic: bgetvp - overlapping buffervianfs_getattrcache → nvtruncbuf: the VDIR→VREG retype itself sends directory buffer-cache state through the regular-file shrink path. (Third sink, already persisted under DF-2996: sillyrename + retype → reclaim cookie-walk panic.)
Honest limitation established on this DragonFly: injected VCHR nodes
are inert for device I/O — NFS special vnodes use
nfsv2_spec_vops (vop_stdnoread/vop_stdnowrite, nfs_vnops.c:162+)
and devfs associates devices by v_rdev pointer, which NFS never sets;
open succeeds as a stub, read returns EINVAL. The classic
"inject /dev/disk through NFS attrs" raw-device access of old BSDs is
structurally dead here; the injection remains a namespace/type spoof
(server-chosen st_mode/st_uid/rdev visible to local tooling).
Build (in the DragonFly guest, as root)
mkdir -p /root/df2998 cc -O -o fakesrv2998 fakesrv2998.c cc -O -o trigger2998 trigger2998.c # see build.sh / build.log
Run (stock kernel; TEST 2 panics — expect the ssh session to die)
sh run.sh # TEST 1 + 1b (retypes) then TEST 2 (panic)
or piecewise (as run.sh does):
(./fakesrv2998 > /tmp/fakesrv2998.log 2>&1 &)
mount_nfs -o port=2049,udp,acregmin=1,acregmax=1,acdirmin=1,acdirmax=1 \
127.0.0.1:/P /mnt
./trigger2998 retype /mnt $(stat -f '%Hr %Lr' /dev/vn0) # TEST 1
./trigger2998 dirreg /mnt ; umount /mnt # TEST 2 -> panic
Success criteria (stock kernel):
- TEST 1 prints
*** DF-2998 CONFIRMED: live VREG vnode retyped VDIR while fd open ***and*** ... device node injected ... ***. - TEST 2 prints the VDIR→VREG confirmation, then
umountpanics:panic: bgetvp - overlapping buffer(see panic.bgetvp.txt), or, if the node is non-VDIR at inactive without a stat first,Fatal trap 9 ... nfs_removerpc+0x57(see panic.removerpc.txt).
Fixed kernel (fix.diff, single-fix rebuild): all lies are refused
(fstat: RPC struct is bad = EBADRPC), no retype, no panic, umounts
clean, and a full honest-server lifecycle (create/write/read/ls/rm/
mkdir/readdir/umount) keeps working — see fix_run.log.
Rig
fakesrv2998.c — userland NFSv3 server (rpcbind :111, mountd :779,
nfs :2049 on 127.0.0.1) with a UDP control channel :7798 (LIE <fh>
<TYPE> [maj min], RESET) that flips the per-filehandle attribute
lie at runtime; trigger2998.c — drives/observes the retypes.
See manifest.json for artifacts and VERDICT.md for the full analysis.
DF-2998 VERDICT — nfs_loadattrcache retypes live vnodes from server attributes; engine verified live with two independent kernel panics; fix validated
Status: REPRODUCED (fresh verification on the stock kernel, 2026-10-12) — impact: kernel type confusion → deterministic panics (memory corruption class). Fix VALIDATED on a single-fix rebuilt kernel.
Root cause (line-accurate, current tree)
nfs_loadattrcache() — sys/vfs/nfs/nfs_subs.c:723-733:
np = VTONFS(vp);
if (vp->v_type != vtyp) { /* :723 — no VNON guard */
nfs_setvtype(vp, vtyp); /* flips v_type + vinitvmio */
if (vp->v_type == VFIFO) {
vp->v_ops = &vp->v_mount->mnt_vn_fifo_ops;
} else if (vp->v_type == VCHR || vp->v_type == VBLK) {
vp->v_ops = &vp->v_mount->mnt_vn_spec_ops;
addaliasu(vp, rmajor, rminor); /* server rdev */
} else {
vp->v_ops = &vp->v_mount->mnt_vn_use_ops;
}
np->n_mtime = mtime.tv_sec;
}
vtyp/rmajor/rminor come straight from the wire (nfsv3tov_type
bounds only the enum). There is no "only at creation" guard: an
EXISTING, typed, possibly open vnode is retyped, its v_ops switched,
and for VCHR/VBLK addaliasu() records the server-chosen device
numbers. Every attribute load in the NFS client funnels here (GETATTR,
LOOKUP/ACCESS/READDIR(PLUS) post-op attrs, WCC paths) — call sites in
nfs_vnops.c at :1071-1077 (read/write attr loads), :1420, :2903
(readdirplus), :3099-3101 (lookitup), etc.
What was demonstrated (stock kernel, fresh vm.sh reset with-src)
Rig: fakesrv2998 (userland NFSv3 server, loopback) + trigger2998
(driver). Mount with acregmin=1,acregmax=1,acdirmin=1,acdirmax=1
pins attribute-cache expiry to 1s so each lie lands deterministically
on the next stat. (With default cache settings the same happens after
up to 60s — the ancient-mtime clamp; observed in an earlier run where
a 12s sleep wasn't enough.)
- Live VREG→VDIR retype of an open file (
run.logTEST 1):fstat(fd)BEFORE:type=REGULAR; afterLIE file DIR+ sleep:type=DIRECTORY— the vnode behind an open, written-to file fd was retyped mid-life by a single attribute reply. Confirmed identically as unprivileged usermaxx(TEST 1b) — no privileges needed on the client side; the lying is entirely server-side. - Repeated retypes of one live vnode: REG→DIR→CHR→DIR, each
visible via fstat (
AFTER-DIR-LIE,AFTER-CHR-LIE,AFTER-DIR-RELIE). - VCHR injection:
AFTER-CHR-LIE: type=CHARDEVwith server-chosen mode/rdev (addaliasu+mnt_vn_spec_opsswitch, nfs_subs.c:728). Honest limitation:open()succeeds as a stub butread()returns EINVAL — NFS special vnodes usenfsv2_spec_vops(vop_stdnoread/vop_stdnowrite, sys/vfs/nfs/nfs_vnops.c:162-175) and devfs binds devices byv_rdevpointer (never set for NFS nodes;devfs_spec_openwould ENXIO on NULL). So no raw-device I/O through the injected node on this DragonFly — the injection is a namespace/type spoof (server-controlled st_mode/st_uid/st_rdev visible to stat/tooling), not the old-BSD device-access primitive. - Panic A —
nfs_removerpcGPF (panic.removerpc.txt): the getdents probe on the retyped-VDIR "file" caches nfsdmap cookie blocks on the node (nfs_getcookie(add=1), nfs_subs.c:1338-1356, via READDIR offsets > 4KiB). A later lie leaves the node VCHR at inactive time;nfs_inactive()gates the sillyrename branch onvp->v_type != VDIR(nfs_node.c:392-395) and readsnp->n_sillyrename— which ALIASES the cookie list head (n_un3union, nfsnode.h:125-133) — non-NULL, sonfs_removeit(sp)(nfs_vnops.c:1913) dereferences the nfsdmap asstruct sillyrename:sp->s_dvp=ndm_list.le_prev(pointer into the nfsnode) treated as a vnode →Fatal trap 9 ... Stopped at nfs_removerpc+0x57: movl (%rax),%eax. Then (if it survived)crfree(sp->s_cred),vrele(sp->s_dvp),kfree(sp, M_NFSREQ)(wrong zone) would follow. First observed live (unplanned) when umounting the TEST-1 leftover mount; deterministic. - Panic B — buffer-cache state confusion at retype time
(
panic.bgetvp.txt,run.logTEST 2): directorybig(>4KiB of dirents drained → cookies cached), thenLIE big REG+stat: applying the VDIR→VREG retype makesnfs_getattrcache()'s shrink path (nfs_subs.c:943-949:vap->va_type == VREG→nfs_meta_setsize→nvtruncbuf) feed the node's DIRECTORY buffer state to the regular-file truncate machinery →panic: bgetvp - overlapping bufferwith tracebgetvp ← getblk ← breadnx ← nvtruncbuf ← nfs_getattrcache. The panic fires inside the attribute-load machinery itself — no umount needed. - Third sink (already persisted as DF-2996's
typelie): retype VREG→VDIR + sillyrename storesspinto the cookie head → reclaim walk panics. That finding's fix handles the sillyrename half; the retype engine is this finding.
Exploitability assessment (honest)
Both new sinks are wild-pointer dereferences through
partially-attacker-shaped kernel heap objects (the nfsdmap carries
server-chosen 64-bit cookies in ndm_cookies; s_name/s_namelen
overlay them; s_dvp is a fixed kernel address inside the nfsnode —
KASLR-irrelevant). On this INVARIANTS guest they manifest as
deterministic panics; on production builds they are memory-corruption
primitives (fake-vnode v_ops/v_data derefs through nfsnode bytes
the server influences via file-handle and cookie values, wrong-zone
frees, ucred refcount corruption via crfree). Not pushed to a uid=0
chain within this run: the demonstrated primitives corrupt control
data adjacent to nfsnode/nfsdmap allocations; weaponizing would need
slab grooming of the M_NFSNODE neighborhood and a leak to place the
fake ops table — out of scope for a verify pass, and the finding's
severity (High, memcorrupt bucket) is already supported by the
deterministic panics plus the type-confusion primitives.
Fix validation (single-fix kernel, this run)
fix.diff: assign v_type only at creation (vp->v_type == VNON);
if a live vnode's type differs from the server's claim, invalidate the
attr cache (np->n_attrstamp = 0) and fail the load with EBADRPC so
callers treat the reply as bogus. Applied to a clean-source snapshot,
make nativekernel KERNCONF=X86_64_GENERIC + installkernel, reboot
(DragonFly 6.5-DEVELOPMENT #1: Mon Oct 12 12:33:28 UTC 2026):
- TEST 1 / TEST 1b (root + unprivileged): every lie now yields
fstat/stat FAILED: RPC struct is bad(EBADRPC) — no retype, no injection, no crash;BEFOREattrs still load (creation-time VNON assignment intact). - TEST 2:
AFTER-REG-LIE: stat FAILED: RPC struct is bad; node stays VDIR;umountclean (UMOUNT_RC=0), guest stays up — the bgetvp panic is gone. - Honest lifecycle on the fixed kernel: mount, write, read-back
(
HONEST-DATA-123), stat (REGULAR), mkdir, full 500-entry directory listing, rm, umount — all clean (fix_run.logtail).
Baseline reproduced / patched not reproduced → fix_status: fixed.
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
fixedSingle-fix kernel (fix.diff on clean /usr/src, nativekernel X86_64_GENERIC, installkernel, reboot): baseline reproduced live retypes (root+unpriv), device injection, and both panics; on the patched kernel every server lie is refused with EBADRPC ('RPC struct is bad'), no retype/injection occurs, TEST 1/1b/2 all pass without panic, umounts are clean, and an honest-server lifecycle (create, write, read-back, stat, mkdir, 500-entry readdir, rm, umount) is fully functional. Bad behavior gone; honest NFS unaffected.
fix_run.log (refusals + clean umounts + honest lifecycle); fix_build.log (build/install record); compare run.log (baseline retypes/panics)
Confirmed kernel references
Detail
Exploit chain
userland fake NFSv3 server (loopback) + mount as root -> unprivileged user opens file (honest VREG attrs, node typed VNON->VREG at creation) -> server flips attrs for the fh (LIE) -> after attribute-cache expiry the next stat/ACCESS/GETATTR applies them in nfs_loadattrcache -> live vnode retyped (v_type + v_ops + addaliasu) -> (A) getdents on the retyped-VDIR 'file' caches nfsdmap cookies, later leave node non-VDIR -> umount/inactive walks the nfsdmap as sillyrename -> nfs_removerpc wild deref -> panic; (B) drain a >4KiB dir (cookies cached), retype VDIR->VREG, stat -> nvtruncbuf on directory buffers -> bgetvp overlap -> panic
Evidence (decisive lines)
run.log (baseline: '*** live VREG vnode retyped VDIR while fd open ***', '*** device node injected ***', root+unpriv, TEST2 -> panic); panic.bgetvp.txt (bgetvp overlapping-buffer trace via nfs_getattrcache+nvtruncbuf); panic.removerpc.txt (GPF at nfs_removerpc+0x57); fix_run.log (all lies refused 'RPC struct is bad', no panic, clean umounts, honest lifecycle); VERDICT.md carries the full line-accurate analysis
PoC changes
pass-1 had no pack for DF-2998 (finding was filed from the DF-2996 typelie observation); this run built the full rig: new fakesrv2998.c (control-channel per-fh attribute lies, derived from DF-2996's fakesrv) and trigger2998.c (retype/dirreg/ctrl modes), run.sh with 1s attribute-cache mount options (stock cache timing delayed lies up to 60s via the ancient-mtime acregmax clamp)
Verified recommended fix
only assign v_type at creation (VNON); on server-type mismatch of a live vnode, invalidate the attr cache and fail the load with EBADRPC (see fix.diff)
Verdict
REPRODUCED on the stock kernel: a lying NFSv3 server retypes LIVE client vnodes through nfs_loadattrcache (nfs_subs.c:723-733, no VNON guard) — an open, written-to VREG file became a DIRECTORY and then a CHARDEV (server rdev) mid-life via single attribute replies, as root and as an unprivileged user; one vnode was retyped three times (REG->DIR->CHR->DIR). Two independent deterministic panics from the engine: (A) Fatal trap 9 GPF at nfs_removerpc+0x57 — a cookie-cached node left non-VDIR at inactive makes nfs_inactive read n_sillyrename (aliases the n_un3 cookie-list head, nfsnode.h:125-133) and walk an nfsdmap as struct sillyrename (wild fake-vnode deref, then crfree/vrele/wrong-zone kfree); (B) 'panic: bgetvp - overlapping buffer' via nfs_getattrcache->nvtruncbuf — the VDIR->VREG retype feeds directory buffer state through the regular-file shrink path (nfs_subs.c:943-949). Injected VCHR nodes are honestly inert for I/O on this DragonFly (nfsv2_spec_vops vop_stdnoread; devfs rdev pointer never set) — injection is a namespace/type spoof, not the old-BSD raw-device primitive. Fix validated on a single-fix rebuilt kernel: type assigned only at creation (VNON); live-type lies refused with EBADRPC, both panics gone, unmounts clean, full honest lifecycle works.
No comments yet.