vop_write_dirent() performs an unbounded source bcopy; UFS OFSFMT readdir passes an unvalidated namlen β crafted old-format FFS image reads up to 255 bytes past the buffer-cache buffer
Summary
vop_write_dirent bcopy()s d_namlen bytes from the caller's buffer with no source bound; in ufs_readdir the OFSFMT (old-format, mnt_maxsymlinklen<=0) branch validates dp->d_namlen (the old 16-bit namlen MSB) but passes dp->d_type (the real LSB namlen, <=255) into the call. A crafted UFS1 image (one int32 superblock patch) with a final 8-byte dirent claiming namlen 0x00FF makes getdents(2) copy 255 bytes starting exactly at bp->b_data+bufsize. Guest-reproduced: stock kernel Fatal trap 12 at memcpy+0x19 (first qword past the buffer), guest down 2/2; patched kernel rejects the record EIO, no panic. Secondary same-class caller: fuse_vnops.c:1071-1086 (daemon-controlled freclen, size_t underflow walks past the ipc buffer). Crafted/removable image mounted by admin (or vfs.usermount=1); read primitive - kernel heap disclosure is the ceiling.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5807 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.5 KB | β raw | |
| SHA256SUMS | β | 233 B | β download | |
| VERDICT.md | β | 3.9 KB | β raw | |
| build.sh | β | 221 B | view raw | |
| craft.c | β | 3.8 KB | view raw | |
| fix.diff | β | 1.5 KB | view raw | |
| getd.c | β | 1.6 KB | view raw | |
| panic.txt | β | 2.3 KB | view raw | |
| panic2.txt | β | 2.1 KB | view raw | |
| run.log | β | 48 B | view raw | |
| run.sh | β | 552 B | view raw | |
| run1_prerun.log | β | 113 B | view raw | |
| run_fixed.log | β | 92 B | view raw | |
| setup.log | β | 337 B | view raw | |
| verdict.json | β | 3.3 KB | view raw |
DF-5807 β vop_write_dirent() unbounded source bcopy; UFS OFSFMT readdir passes unvalidated namlen (OOB kernel read)
What
vop_write_dirent() (sys/kern/vfs_subr.c:2560-2582) trusts the caller's
d_namlen for a bcopy(d_name, dp->d_name, d_namlen) from the
caller-supplied source buffer β the helper performs no bound on the
SOURCE. The UFS readdir caller validates the wrong field for
old-format (OFSFMT) filesystems: ufs_vnops.c:1661-1664 checks
offsetof(struct direct, d_name[dp->d_namlen]) β but in the old 4.4
on-disk layout the real 16-bit namlen's LSB is read as dp->d_type
(byte 6) while dp->d_namlen (byte 7) is the MSB. The OFSFMT emit at
ufs_vnops.c:1673-1676 then swaps the fields into the call
(..., dp->d_namlen, dp->d_type, ... β d_type param gets the MSB,
d_namlen param gets the REAL LSB namlen). A crafted old-format FFS
image therefore supplies a name length up to 255 in an 8-byte record,
and the bcopy reads up to 255 bytes past the on-disk record β from
beyond the buffer-cache buffer (kernel heap OOB read).
OFSFMT is mnt_maxsymlinklen <= 0 (ufs_vnops.c:117), i.e. any
filesystem image whose superblock says fs_maxsymlinklen == 0
(patched with one int32 write β old-format FFS is mountable by design).
Build
cc -O2 -I/usr/src/sys -I/usr/src/sys/vfs/ufs -o craft craft.c # on guest cc -O2 -D__BSD_VISIBLE=1 -o getd getd.c # on guest
Run
(root, one-time setup)
dd if=/dev/zero of=/root/f1.img bs=1m count=32 vnconfig -c vn2 /root/f1.img newfs -b 4096 -f 512 /dev/vn2 vnconfig -u vn2 /tmp/craft /root/f1.img vnconfig -c vn2 /root/f1.img mkdir -p /mnt/f1 && mount -t ufs -o ro /dev/vn2 /mnt/f1
(unprivileged trigger)
/tmp/getd /mnt/f1
Expected
Stock kernel: Fatal trap 12 page fault in memcpy+0x19
(Stopped at memcpy: movq (%rsi),%rdx) β the 255-byte copy starts
exactly at bp->b_data + b_bufsize and runs into unmapped kernel VA.
Deterministic (2/2 fresh-snapshot runs). The bcopy source begins at
the end of the directory block, so all 255 "name" bytes are kernel
memory from beyond the buffer β with adjacent KVM mapped this is a
~255-byte-per-record kernel heap disclosure via getdents(2).
Patched kernel (fix.diff): record rejected with EIO, getdirentries returns n=24 (first record only), no panic, guest stays up.
Files
- craft.c β image patcher (fs_maxsymlinklen=0 + crafted dir block)
- getd.c β unprivileged getdirentries trigger/hexdump
- fix.diff β ufs_vnops.c validation fix (validated on guest)
DF-5807 β VERDICT
Status: reproduced (2/2 deterministic kernel panics; impact = panic, OOB-read primitive with info-leak ceiling).
Root cause chain (path:line)
sys/kern/vfs_subr.c:2575βbcopy(d_name, dp->d_name, d_namlen);invop_write_dirent(): the helper allocates_DIRENT_RECLEN(d_namlen)(destination is safe) but copiesd_namlenbytes from the caller's source pointer with no source bound β every caller must proved_nameholdsd_namlenbytes.sys/vfs/ufs/ufs_vnops.c:1661-1664β the caller-side validationoffsetof(struct direct, d_name[dp->d_namlen]) > dp->d_reclen β EIOchecks the field read asd_namlen.sys/vfs/ufs/ufs_vnops.c:1673-1676β but the OFSFMT (old-format) emit swaps the two bytes into the call:vop_write_dirent(&error, uio, dp->d_ino, dp->d_namlen, dp->d_type, dp->d_name)(params:..., uint8_t d_type, uint16_t d_namlen, ...), because in the pre-4.4Lite dirent layout bytes 6..7 are a native-endian 16-bit namlen: byte 6 = real namlen (LSB), byte 7 = MSB. The validation checked the MSB (byte 7); the bcopy uses the LSB (byte 6), up to 255.- Trigger: any UFS1 image with
fs_maxsymlinklen = 0(OFSFMT,ufs_vnops.c:117) whose directory block contains a final record{d_ino, d_reclen=8, namlen16=0x00FF}. The 255-byte copy starts atbp->b_data + bsize - 8 + 8= exactly the end of the buffer.
Reproduction (guest DragonFly 6.5-DEVELOPMENT, x86_64, stock kernel #0)
craft.cpatches a newfs'd UFS1 image (32MB, bsize 4096): superblockfs_maxsymlinklen 60β0; root dir block (db[0]=1040 β file offset 532480) = record0 {reclen=4088, namlen16=0} + record1@4088 {reclen=8, namlen16=0x00FF}; root i_size bumped 512β4096.getd.c(run as uid 1001):getdirentries("/mnt/f1", 64KB).- Run 1 (fresh boot):
Fatal trap 12: page fault while in kernel mode, fault virtual address = 0xfffff80059357000, Stopped at memcpy+0x19: movq (%rsi),%rdxβ guest down (see panic1.txt). - Run 2 (fresh
vm.sh reset with-src, fresh image, fresh mount): identical signature at0xfffff80069f17000(see panic2.txt).
The fault address is page-granular and equals the first qword of the 255-byte source range: proof the read is outside the buffer mapping. When the adjacent KVM is mapped (other buffer-cache allocations), the same primitive returns up to 255 bytes of adjacent kernel heap per crafted record to userspace (info leak) β on this guest the neighbor page was unmapped, so the observable manifestation is the panic.
Fix validation
Applied fix.diff (ufs_vnops.c: validate on_disk_namlen, the length
actually passed) in the guest /usr/src, make quickkernel &&
make installkernel, rebooted into #1: Tue Oct 13 01:22:48:
- Same crafted image, same mount, same trigger:
getdirentries n=24; dirent @0: fileno=2 namlen=0 reclen=24; GETD-RC=0; SURVIVED-NOPANIC(df5807_fixed.log). The crafted record is now rejected (offsetof(d_name[255]) > reclen 8 β EIO).
Notes
- Secondary affected caller (same helper, weaker precondition):
sys/vfs/fuse/fuse_vnops.c:1071-1086βfreclen = FUSE_DIRENT_SIZE(fde)(daemon-controlled) is never checked against the remaining ipc buffer length beforebuf += freclen; len -= freclen;(size_t underflow), walkingfdepast the buffer. Not PoC'd here (needs a fuse mount); recommended hardening: boundd_namleninvop_write_direntand checkfreclen <= lenin fuse_readdir. - Not reachable via hammer/hammer2/tmpfs/devfs/dirfs callers (their namelens are bounded by construction β checked each call site).
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
fixedbaseline: 2/2 deterministic Fatal trap 12 in memcpy on stock kernel; after applying fix.diff, rebuilding and rebooting (kernel #1 01:22:48), the identical PoC returns n=24 with the crafted record rejected (EIO) and the guest stays up (run_fixed.log)
run_fixed.log; fix.diff; VERDICT.md
Confirmed kernel references
Detail
Exploit chain
craft UFS1 image (patch fs_maxsymlinklen=0; root dir block last record namlen16=0x00FF, reclen=8) -> root mounts image (crafted-removable-media / usermount threat model) -> unprivileged getdirentries() -> vop_write_dirent bcopy reads 255 bytes past bp->b_data+bufsize -> OOB read: panic when neighbor page unmapped (demonstrated), else heap disclosure in dirent name field
Evidence (decisive lines)
panic.txt/panic2.txt (Fatal trap 12, Stopped at memcpy+0x19, fault addr == first qword past the buffer, two fresh-snapshot runs); craft.c/getd.c (rig); run_fixed.log (patched kernel: n=24, SURVIVED-NOPANIC); fix.diff (validated)
PoC changes
seed sketch rewritten entirely: crafted-image rig (UFS1 superblock+inode+dirent patcher using guest kernel headers) plus getdirentries dumper; fixed a reclen offset bug in the initial crafter (bsize-6 -> bsize-4)
Verified recommended fix
ufs_vnops.c: validate the namlen actually passed (on_disk_namlen = OFSFMT ? dp->d_type : dp->d_namlen) against d_reclen before vop_write_dirent; see fix.diff (validated on guest)
Verdict
vop_write_dirent() (sys/kern/vfs_subr.c:2575) bcopy()s d_namlen bytes from the caller's source with no source bound; the UFS OFSFMT readdir path validates dp->d_namlen (the old-format namlen MSB) but passes dp->d_type (the real LSB namlen, up to 255) to the helper. A crafted old-format FFS image (fs_maxsymlinklen=0) with a final 8-byte dirent claiming namlen 0x00FF makes getdents(2) copy 255 bytes starting exactly at the end of the buffer-cache buffer. Reproduced 2/2 deterministically as a kernel page fault in memcpy on the stock kernel; with adjacent KVM mapped the same primitive is a per-record kernel-heap disclosure to unprivileged readers of a mounted crafted image. Fixed kernel (fix.diff applied in-guest, quickkernel+installkernel+reboot) rejects the record with EIO - no panic.
No comments yet.