⬢ DragonFlyBSD Kernel Audit
← triage · dashboard
DF-2998

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2998 · 17 files
FileTypeDescriptionSize
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
README.md
↓ download 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:

  1. 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).
  2. VCHR injection: a "file" becomes a visible character device with server-chosen rdev/mode/uid (addaliasu + mnt_vn_spec_ops).
  3. Memory corruption, two independent deterministic panics: - nfs_removerpc+0x57 GPF: a cookie-cached node left non-VDIR at inactive makes nfs_inactive read n_sillyrename, which ALIASES the VDIR cookie-list head (n_un3 union), and walk a nfsdmap as a struct sillyrename. - panic: bgetvp - overlapping buffer via nfs_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 umount panics: 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.

VERDICT.md
↓ download raw

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

  1. Live VREG→VDIR retype of an open file (run.log TEST 1): fstat(fd) BEFORE: type=REGULAR; after LIE 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 user maxx (TEST 1b) — no privileges needed on the client side; the lying is entirely server-side.
  2. Repeated retypes of one live vnode: REG→DIR→CHR→DIR, each visible via fstat (AFTER-DIR-LIE, AFTER-CHR-LIE, AFTER-DIR-RELIE).
  3. VCHR injection: AFTER-CHR-LIE: type=CHARDEV with server-chosen mode/rdev (addaliasu + mnt_vn_spec_ops switch, nfs_subs.c:728). Honest limitation: open() succeeds as a stub but read() returns EINVAL — NFS special vnodes use nfsv2_spec_vops (vop_stdnoread/vop_stdnowrite, sys/vfs/nfs/nfs_vnops.c:162-175) and devfs binds devices by v_rdev pointer (never set for NFS nodes; devfs_spec_open would 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.
  4. Panic A — nfs_removerpc GPF (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 on vp->v_type != VDIR (nfs_node.c:392-395) and reads np->n_sillyrename — which ALIASES the cookie list head (n_un3 union, nfsnode.h:125-133) — non-NULL, so nfs_removeit(sp) (nfs_vnops.c:1913) dereferences the nfsdmap as struct 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.
  5. Panic B — buffer-cache state confusion at retype time (panic.bgetvp.txt, run.log TEST 2): directory big (>4KiB of dirents drained → cookies cached), then LIE big REG + stat: applying the VDIR→VREG retype makes nfs_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 buffer with trace bgetvp ← getblk ← breadnx ← nvtruncbuf ← nfs_getattrcache. The panic fires inside the attribute-load machinery itself — no umount needed.
  6. Third sink (already persisted as DF-2996's typelie): retype VREG→VDIR + sillyrename stores sp into 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; BEFORE attrs still load (creation-time VNON assignment intact).
  • TEST 2: AFTER-REG-LIE: stat FAILED: RPC struct is bad; node stays VDIR; umount clean (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.log tail).

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-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)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Oct 12 12:33:28 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

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.