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

msdosfs_fhtovp feeds fully attacker-controlled NFS file-handle fields into deget/readep/deupdat: 31-byte kernel heap OOB read+write, arbitrary in-volume device-offset read/write via stat()/SETATTR, deterministic fatal kernel panic (3/3) and nfsd wedge β€” remotely reachable on any NFS-exported msdosfs, no crafted image needed

Summary

msdosfs_fhtovp (vfsops:856) passes the raw defid_dirclust/defid_dirofs bytes of an NFS file handle straight to deget() with zero validation. deget():363 calls readep(), which computes the direntry pointer as bp->b_data + (dirofs & pm_crbomask) (bptoep) while the block number detobn() ignores dirofs for non-root clusters - dirofs only slides the entry pointer within the buffer and dirclust selects any device block. DE_INTERNALIZE (:377) then copies 32 bytes into the denode (up to 31 past the buffer) and stat() returns those fields (va_size=de_FileSize); deupdat():453-463 does the reverse - *dirp = dir writes 32 externalized bytes at the same unbounded pointer on any SETATTR. VERIFIED stock INVARIANTS guest with unpriv ONC-RPC client: forged (0,32) GETATTR -> Fatal trap 12 at deget+0x2b5 in nfsd, 3/3; (3,511)/(0,511) -> 31 bytes of adjacent kernel heap leaked through stat; (3,0) -> planted file-content marker returned as st_size (arbitrary in-volume 32-byte read); fhopen/utimes -> 32-byte structured write persisted into an unrelated file's cluster (byte-exact on raw device); forged SETATTR (0,511) -> nfsd wedge. Requires an NFS-exported msdosfs mount (non-default admin config); locally the same deget path is root-gated fhstat/fhopen. Distinct from DF-3091 (pcbmap, crafted on-disk chains) - no crafted image needed. Fix validated (fid_len/cluster/dirofs bounds in fhtovp + deget refcnt-before-hashins, kernel #3): every probe EINVAL, panic/wedge gone, legit handles unaffected.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3157 Β· 18 files
FileTypeDescriptionSize
README.md β€” 4.4 KB ↓ raw
VERDICT.md β€” 6.4 KB ↓ raw
df3157.c β€” 11.4 KB view raw
fhtest.c β€” 1.9 KB view raw
mkfat.py β€” 4.8 KB view raw
setup.sh β€” 606 B view raw
build.sh β€” 134 B view raw
run.sh β€” 157 B view raw
build.log β€” 606 B view raw
run.log β€” 3.3 KB view raw
run.2.log β€” 2.2 KB view raw
panic.txt β€” 1.9 KB view raw
panic_capture.txt β€” 2.0 KB view raw
env.txt β€” 837 B view raw
fix.diff β€” 3.0 KB view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 4.4 KB view raw
manifest.sha β€” 236 B ↓ download

DF-3157 β€” forged msdosfs file handle β†’ OOB read/write in deget/readep/deupdat (NFS-reachable)

What this is

msdosfs_fhtovp() (sys/vfs/msdosfs/msdosfs_vfsops.c:848-863) passes the raw struct defid fields (defid_dirclust, defid_dirofs) of an NFS file handle β€” fully attacker-controlled opaque bytes β€” straight into deget() (sys/vfs/msdosfs/msdosfs_denode.c:236) with no validation. deget() β†’ readep() (msdosfs_lookup.c:880) computes the direntry location as bp->b_data + (dirofs & pm_crbomask) (bptoep), while the block number (detobn) ignores dirofs for non-root clusters. Results:

  1. 31-byte kernel heap OOB read whenever dirofs & (bpcluster-1) ∈ [bpcluster-31, bpcluster-1] (e.g. 511 for 512-byte clusters): DE_INTERNALIZE (denode.c:377 / denode.h:191-203) copies 32 bytes out of the buffer into the denode; the fields are handed to userspace by stat() (va_size = de_FileSize, msdosfs_vnops.c:250) β€” a repeatable kernel-memory info leak.
  2. Arbitrary in-volume 32-byte read via forged cluster numbers: detobn(dirclust) maps any dirclust to a device block in the data area; a forged handle turns stat() into a 32-byte reader at any device offset β‰₯ the data-area start (proven with the planted marker "ABCD" read back as st_size = 0x44434241).
  3. OOB write through deupdat() (msdosfs_denode.c:453-463): any SETATTR on a forged handle (utimes/chmod/truncate) externalizes the denode into the same unbounded bptoep slot (*dirp = dir, 32 bytes) β€” up to 31 bytes past kernel heap buffers, or a 32-byte structured write at any device offset (β‰₯ 9 attacker-chosen bytes: mtime/mdate/size/attributes).
  4. Deterministic kernel panic from an unprivileged RPC: a forged (dirclust=0, dirofs=32) GETATTR killed the stock INVARIANTS kernel in deget+0x2b5 (inlined DE_INTERNALIZE, page fault on an unmapped page) β€” reproduced 3/3 times in the nfsd server thread. A forged SETATTR (0,511) wedged the nfsd server (all subsequent RPCs time out).

Threat: any host permitted by an NFS export of an msdosfs filesystem (FAT/FAT32 USB sticks, shares) with any AUTH_SYS credential β€” no crafted on-disk image needed. Locally, the identical deget path is reachable through fhstat(2)/fhopen(2) (root-gated in DF, unprivileged via NFS).

Files

  • mkfat.py β€” deterministic FAT16 test image (512 B clusters; SUB at cluster 2, VICTIM.DAT at cluster 3 with the size-marker "ABCD" at entry offset +28).
  • df3157.c β€” unprivileged raw ONC-RPC client (mountd GETFH bypassed; fh forged from the mount fsid). Modes: mountdump, null, getattr <clust> <dirofs> [n], setattr <clust> <dirofs> <unix_sec>.
  • fhtest.c β€” local fhstat/fhopen harness driving the same VFS_FHTOVP β†’ deget path (diagnostic; root-gated).
  • setup.sh β€” guest setup: vnconfig, mount, exports, rpcbind/mountd/nfsd.
  • hammer.c β€” DF-3158 hammer (see that pack).
  • panic_capture.txt β€” serial-console capture of baseline panic #3.
  • fix.diff β€” validated two-part fix (fh bounds validation + deget refcnt ordering; the latter belongs to DF-3158).

Reproduce (baseline, stock kernel)

# host: build image + push
python3 mkfat.py && gzip -9 fat16.img
scp fat16.img.gz df3157.c fhtest.c setup.sh dfbsd-maxx:/tmp/
# guest root:
cd /tmp && gunzip -f fat16.img.gz && sh setup.sh
cc -O2 -o /tmp/df3157 df3157.c && cc -O2 -o /tmp/fhtest fhtest.c

# guest UNPRIVILEGED (maxx), baseline kernel:
DF3157_SRCPORT=800 DF3157_FSID0=0x1e110807 DF3157_FSID1=3 \
    /tmp/df3157 getattr 3 0      # -> GETATTR size=1145258561 (0x44434241 "ABCD") : arbitrary in-volume read
DF3157_SRCPORT=800 ... /tmp/df3157 getattr 3 511    # -> size/mtime = kernel heap garbage : 31B OOB read leak
DF3157_SRCPORT=800 ... /tmp/df3157 getattr 0 32     # -> kernel panic (deget+0x2b5, DE_INTERNALIZE), guest down
# write: guest root, local harness (same deget path):
/tmp/fhtest 3 0 write ; sync      # 32-byte externalized dirent lands in VICTIM.DAT data cluster
hexdump -C /dev/vn0 | ...         # bytes 11,22..25 of victim cluster replaced

Note on the fsid seed: DF masks f_fsid for non-root callers, so the local harness seeds it via env (obtained once as root). A network client does not need this β€” mountd hands the full fh of the export root to any permitted client (that is how the attack obtains fsid+format in the real threat model). All forged-handle RPCs themselves run unprivileged.

VERDICT.md
↓ download raw

DF-3157 VERDICT β€” reproduced (kernel panic + OOB read + arbitrary-offset R/W via forged fh)

Bottom line

msdosfs_fhtovp β†’ deget accept fully attacker-chosen (dirclust, dirofs) from an NFS file handle with no validation, and readep/bptoep/DE_INTERNALIZE/DE_EXTERNALIZE trust them for kernel pointer arithmetic. Verified on the stock INVARIANTS guest (kernel #0, 2026-07-02): deterministic fatal kernel page fault in deget from an unprivileged forged NFS GETATTR (3/3 runs), a silent 31-byte heap OOB read exfiltrated through stat(), an arbitrary in-volume 32-byte read (planted-marker proof), a persisted arbitrary-offset 32-byte write with attacker-chosen bytes, and an nfsd wedge from a forged SETATTR. The two-part fix (fh bounds validation in msdosfs_fhtovp + de_refcnt ordering in deget, the latter for DF-3158) was built as kernel #3 and re-validated: every OOB/out-of-range probe returns EINVAL, the panic is gone, nfsd stays healthy, and legitimate in-range handles keep working.

How it was reproduced (baseline, stock kernel #0)

  1. Deterministic FAT16 image (mkfat.py): 512 B/cluster, FAT16, root dir at sector 257 (16 sectors), data from sector 273; SUB = cluster 2, VICTIM.DAT = cluster 3 whose first 32 bytes double as a fake dirent with size field "ABCD" at +28. Guest: vnconfig vn0, mount -t msdos, /etc/exports β†’ /fat -network 127.0.0.0/mask 255.255.255.0 -maproot=0, rpcbind + mountd + nfsd.
  2. fh layout learned from mountd GETFH (privileged src port): 28 bytes = fsid(8) + struct defid{len=12,pad,dirclust,dirofs} at offset 8; DF nfsd requires exactly NFSX_V3FH=28 on the wire (nfsm_srvmtofh, sys/vfs/nfs/nfsm_subs.c:934-965).
  3. Marker read (getattr 3 0, local fhstat(3,0)): st_size = 1145258561 = 0x44434241 = "ABCD" β€” stat() returning file-content bytes at a forged location (arbitrary in-volume 32-byte read; also verified with cluster 100 free-space filler and the "."-fixup kernel print deget(): "." entry at clust 100 != 63222 proving the internalization).
  4. OOB read (fhstat(3,511)): succeeds silently; st_mtime = 315360000 (0x12CE0FC0), st_size garbage β€” 31 bytes past the 512-byte buffer internalized into the denode and returned by stat.
  5. Kernel panic (df3157 getattr 0 32 as unprivileged user, via localhost nfsd): Fatal trap 12 in deget+0x2b5 β€” disassembly of the guest's own kernel.debug shows the faulting instruction is the inlined DE_INTERNALIZE (movq (%rax),%rcx = deName copy) with %rax = the bptoep-computed direntry pointer on an unmapped page. current process = the nfsd server thread. Reproduced 3/3 (captures: crash 1 & 2 in run logs, crash 3 in panic_capture.txt). One session also shows dscheck(vn0): b_bcount 1 is not on a sector boundary β€” a malformed bread sizing artifact of the same unvalidated input.
  6. Arbitrary-offset write (fhtest 3 0 write = fhopen forged fd + futimes+fchmod → msdosfs_setattr → deupdat → *dirp = dir): after sync, raw block 274 (VICTIM.DAT data) shows byte 11 4b→6b (attributes) and bytes 22-25 = the exact FAT encoding of the chosen mtime; readback through a fresh forged fhstat returns mtime=1599999998 (the set second). 9+ attacker-chosen bytes at any device offset.
  7. nfsd wedge (df3157 setattr 0 511): the nfsd server thread stops answering (subsequent NULL/GETATTR time out; guest otherwise alive).

Unprivileged position: the RPC client runs as uid 1001 with a spoofable AUTH_SYS credential; only the fsid seed was obtained root-assisted for the local harness (a network client gets it from mountd GETFH β€” that is the actual remote attack path; noted in README).

Fix validation (kernel #3, 2026-09-07 09:32:17, guest-built)

fix.diff adds to msdosfs_fhtovp: fid length check, cluster-range check (CLUST_FIRST..pm_maxcluster), root-area bounds for FAT12/16, and a uniform "entry must not cross its buffer" bound ((dirofs & crbomask) ≀ bpcluster-32) β€” plus the DF-3158 de_refcnt ordering change in deget. Post-fix matrix (same probes):

probe baseline (kernel #0) patched (kernel #3)
fhstat(3,0) marker size=0x44434241 leak still works β€” in-range by design (residual, see below)
fhstat(3,511)/(3,481) OOB read garbage exfil EINVAL
fhstat(0,511)/(0,481) OOB read garbage exfil EINVAL
fhstat(0,8192) beyond root (offset probe) EINVAL
fhstat(4294967295,0) cluster range (offset probe) EINVAL
NFS GETATTR(0,32) fatal panic 3/3 clean ESTALE, guest up
NFS SETATTR(0,511) nfsd wedge no wedge; nfsd still serving (NULL OK)
fhopen+setattr write 32-byte persisted at forged offset in-range only; OOB write EINVAL

Residual (documented, matches the validation level upstream BSDs use): a forged handle naming a valid cluster and a fitting offset still lets stat() disclose the first 32 bytes of any in-volume cluster β€” FAT clusters carry no type tags, so a handle cannot be told "this cluster is file data"; the fix removes every out-of-bounds and out-of-range case, which is where the memory-safety violation lived.

Not fully root-caused (honesty notes)

  • The nfsd-context ESTALE for cluster-mode forged keys (while the identical local fhstat path succeeds) was observed pre- and post-fix and not root-caused; it does not affect the demonstrated primitives (the local path proves them end-to-end; the NFS path demonstrably reaches the same deget code β€” it panicked there three times).
  • The precise micro-mechanism of the (0,32) panic (which buffer placement makes bptoep+32 land on an unmapped page) is pinned to the DE_INTERNALIZE read via disassembly but not traced to the allocation site; the dscheck b_bcount 1 console line from the same input family corroborates malformed-size buffers on this path.

uid=0 escalation: not pursued β€” the guest's buffer-cache heap layout makes reliable grooming from these 31-byte windows a research project; the panic + arbitrary-offset R/W + leak are sufficient to establish the memory-safety violation.

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

not_testable
↓ fix.diffper-fix-DF-3157

Confirmed kernel references

Detail

Exploit chain

NFS-exported msdosfs -> client learns fsid+fh format via mountd GETFH (standard client privilege) -> forged 28-byte fh with chosen (dirclust, dirofs) -> NFSPROC3_GETATTR -> deget/readep OOB or arbitrary-offset read -> stat()/fattr3 exfil (leak); NFSPROC3_SETATTR (utimes/chmod/truncate) -> msdosfs_setattr -> deupdat -> *dirp = dir 32-byte write at the same unbounded location (heap OOB write / arbitrary device-offset corruption); dirofs=511-family -> fatal page fault in DE_INTERNALIZE (panic), dirofs beyond buffer -> nfsd wedge. Local root-gated equivalents via fhstat(2)/fhopen(2).

Evidence (decisive lines)

["findings/poc/DF-3157/run.log -- marker read st_size=1145258561 (0x44434241 'ABCD'), OOB read garbage (mtime=0x12CE0FC0), persisted 32-byte write into VICTIM.DAT cluster (raw-block hexdump, bytes 11/22-25 changed to exact FAT-encoded futimes value)", 'findings/poc/DF-3157/panic.txt + panic_capture.txt -- Fatal trap 12 at deget+0x2b5 (inlined DE_INTERNALIZE, movq (%rax),%rcx), current process = nfsd server thread, 3/3 deterministic', 'findings/poc/DF-3157/run.2.log -- patched-kernel matrix: (3,481)/(3,511)/(0,481)/(0,511)/(0,8192)/(4294967295,0) all EINVAL; NFS (0,32) clean ESTALE instead of panic; nfsd healthy after (0,511) SETATTR']

PoC changes

Seed sketch did not exist; written from scratch. Fixes vs initial draft: RPC reply parser (reply_stat/verf offsets), GETPORT null-cred verifier words, fh must be exactly NFSX_V3FH=28 with defid at offset 8 (nfsm_srvmtofh), mountd rejects unprivileged ports so fh is forged from the mount fsid (env-seeded locally; mountd-supplied remotely), status-first GETATTR reply parsing, bounds-checked XDR reads after a client segfault.

Verified recommended fix

Validate fh in msdosfs_fhtovp (fid_len, cluster range CLUST_FIRST..pm_maxcluster, FAT12/16 root-area bounds, and uniform (dirofs & crbomask) <= bpcluster-32) -- validated in fix.diff

Verdict

Stock INVARIANTS kernel: an unprivileged forged NFS file handle (unvalidated defid_dirclust/defid_dirofs through msdosfs_fhtovp -> deget -> readep/bptoep -> DE_INTERNALIZE/DE_EXTERNALIZE) yields (a) a deterministic fatal kernel page fault in deget from a single GETATTR (3/3, nfsd thread), (b) a silent 31-byte heap OOB read exfiltrated via stat() fields, (c) an arbitrary in-volume 32-byte read proven with a planted marker returned as st_size, (d) a persisted 32-byte structured write at a forged device offset with >=9 attacker-chosen bytes (utimes+chmod -> deupdat), and (e) an nfsd wedge from a forged SETATTR. Patched kernel (fix.diff: fh bounds validation) rejects every OOB/out-of-range probe with EINVAL, the panic and wedge are gone, and legitimate in-range handles still work; the residual in-range cluster-content disclosure via stat is inherent to untyped FAT clusters and matches upstream validation level. uid=0 escalation not pursued (buffer-cache heap grooming research project); panic + arbitrary-offset R/W + leak fully establish the memory-safety violation.