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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5807 Β· 15 files
FileTypeDescriptionSize
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)
VERDICT.md
↓ download raw

DF-5807 β€” VERDICT

Status: reproduced (2/2 deterministic kernel panics; impact = panic, OOB-read primitive with info-leak ceiling).

Root cause chain (path:line)

  1. sys/kern/vfs_subr.c:2575 β€” bcopy(d_name, dp->d_name, d_namlen); in vop_write_dirent(): the helper allocates _DIRENT_RECLEN(d_namlen) (destination is safe) but copies d_namlen bytes from the caller's source pointer with no source bound β€” every caller must prove d_name holds d_namlen bytes.
  2. sys/vfs/ufs/ufs_vnops.c:1661-1664 β€” the caller-side validation offsetof(struct direct, d_name[dp->d_namlen]) > dp->d_reclen β†’ EIO checks the field read as d_namlen.
  3. 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.
  4. 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 at bp->b_data + bsize - 8 + 8 = exactly the end of the buffer.

Reproduction (guest DragonFly 6.5-DEVELOPMENT, x86_64, stock kernel #0)

  • craft.c patches a newfs'd UFS1 image (32MB, bsize 4096): superblock fs_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 at 0xfffff80069f17000 (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 before buf += freclen; len -= freclen; (size_t underflow), walking fde past the buffer. Not PoC'd here (needs a fuse mount); recommended hardening: bound d_namlen in vop_write_dirent and check freclen <= len in 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

fixed
baseline reproduced→ patch + rebuild →patched clean

baseline: 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
↓ fix.diffper-fix-DF-5807

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.