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)
PoC verification
Evidence pack
findings/poc/DF-3157 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
- 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 bystat()(va_size = de_FileSize, msdosfs_vnops.c:250) β a repeatable kernel-memory info leak. - Arbitrary in-volume 32-byte read via forged cluster numbers:
detobn(dirclust)maps anydirclustto a device block in the data area; a forged handle turnsstat()into a 32-byte reader at any device offset β₯ the data-area start (proven with the planted marker "ABCD" read back asst_size = 0x44434241). - OOB write through
deupdat()(msdosfs_denode.c:453-463): any SETATTR on a forged handle (utimes/chmod/truncate) externalizes the denode into the same unboundedbptoepslot (*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). - Deterministic kernel panic from an unprivileged RPC: a forged
(dirclust=0, dirofs=32)GETATTR killed the stock INVARIANTS kernel indeget+0x2b5(inlinedDE_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β localfhstat/fhopenharness driving the sameVFS_FHTOVP β degetpath (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.
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)
- 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. - 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 exactlyNFSX_V3FH=28on the wire (nfsm_srvmtofh, sys/vfs/nfs/nfsm_subs.c:934-965). - Marker read (
getattr 3 0, localfhstat(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 printdeget(): "." entry at clust 100 != 63222proving the internalization). - OOB read (
fhstat(3,511)): succeeds silently;st_mtime = 315360000 (0x12CE0FC0),st_sizegarbage β 31 bytes past the 512-byte buffer internalized into the denode and returned by stat. - Kernel panic (
df3157 getattr 0 32as unprivileged user, via localhost nfsd): Fatal trap 12 indeget+0x2b5β disassembly of the guest's own kernel.debug shows the faulting instruction is the inlinedDE_INTERNALIZE(movq (%rax),%rcx= deName copy) with %rax = thebptoep-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 showsdscheck(vn0): b_bcount 1 is not on a sector boundaryβ a malformed bread sizing artifact of the same unvalidated input. - Arbitrary-offset write (
fhtest 3 0 write= fhopen forged fd + futimes+fchmod β msdosfs_setattr β deupdat β*dirp = dir): aftersync, raw block 274 (VICTIM.DAT data) shows byte 114bβ6b(attributes) and bytes 22-25 = the exact FAT encoding of the chosen mtime; readback through a fresh forged fhstat returnsmtime=1599999998(the set second). 9+ attacker-chosen bytes at any device offset. - 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+32land on an unmapped page) is pinned to the DE_INTERNALIZE read via disassembly but not traced to the allocation site; thedscheck b_bcount 1console 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_testableConfirmed 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.
No comments yet.