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

mountmsdosfs() never bounds BPB volume geometry by the backing device size (FreeBSD's mediasize check dropped β€” in-tree XXX admits it): crafted FAT32 image wires attacker-chosen kernel heap (128 MiB/mount demonstrated, up to 512 MiB) and pins a 38 s non-SIGKILL-able FAT scan per mount

Summary

The only size sanity check on the BPB (:432-437) is a 64-bit overflow test that can never trigger; the comment '/* XXX FreeBSD also checks media size in above */' (:434) marks the missing device-size bound FreeBSD enforces. pm_maxcluster is derived purely from BPB numbers (:463-464), clamped only by the FAT capacity the attacker also controls via 32-bit bpbBigFATsecs (:484-490), sized into kmalloc(M_WAITOK) pm_inusemap (:565) and fully scanned by fillinusemap (:578, fat.c:884). A 9 GiB sparse image claiming a 1.5 TiB FAT32 volume mounted rc=0 in 38 s with 128 MiB wired, df reporting 512 GiB, a second concurrent mount adding another 128 MiB, and SIGKILL of the mounting process unable to stop the kernel work or prevent the mount landing. Unprivileged whenever vfs.usermount=1 with an accessible device; otherwise malicious-image/root. VERIFIED stock INVARIANTS guest: craftfat32 (9 GiB sparse, HugeSectors=0xC0000000, BigFATsecs=0x00800000) -> rc=0 in 38s, MSDOSFS_FAT 128M wired, kill -9 mid-scan does not prevent the mount; FreeBSD rejects the same image instantly. Fix validated (dev_dpsize bound): patched kernel rejects in 0s EINVAL, legit FAT32/FAT16 mount RW unchanged.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3128 Β· 14 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 5.2 KB ↓ raw
craftfat32.c β€” 4.7 KB view raw
craftvar.c β€” 2.6 KB view raw
build.sh β€” 85 B view raw
run.sh β€” 109 B view raw
build.log β€” 9 B view raw
run.log β€” 2.4 KB view raw
run.fixed2.log β€” 756 B view raw
fix_build.log β€” 678 B view raw
env.txt β€” 374 B view raw
fix.diff β€” 1.1 KB view raw
manifest.json β€” 1.7 KB view raw
verdict.json β€” 4.0 KB view raw

DF-3128 β€” msdosfs mount accepts BPB geometry far beyond the backing device (dropped FreeBSD media-size check) β†’ mount-time kernel memory exhaustion + non-interruptible FAT scan

  • File: sys/vfs/msdosfs/msdosfs_vfsops.c
  • Lines: 432-437 (missing check, in-tree comment admits it), 463-464, 484-490, 565-566, 578-579
  • Severity: Medium (local DoS; unprivileged when vfs.usermount=1, otherwise malicious-image/root)
  • Bucket: base:vfs (DoS)
  • Verdict: reproduced (impact dos), fix validated (fixed)

Reproduce

Guest: DragonFly 6.5-DEVELOPMENT stock #0 kernel (INVARIANTS), 4 GB RAM.

# on the audit host
dfbsd-qemu/vm.sh up                      # guest up, clean
# copy craftfat32.c build.sh run.sh into /root/df3128/ (ssh -F dfbsd-qemu/config dfbsd)
# in the guest
cd /root/df3128 && sh build.sh           # cc -O2 -o craftfat32 craftfat32.c
sh run.sh                                # ~90 s total

Expected (vulnerable, stock kernel)

  1. TEST 1: mount_msdos -m 644 /dev/vn3 /mnt/DF-3128a returns rc=0 after ~38 s of non-interruptible kernel work β€” the image claims a 1.5 TiB FAT32 volume (HugeSectors=0xC0000000) with a 4 GiB FAT (BigFATsecs=0x00800000) on a 9 GiB sparse device. FreeBSD rejects this image instantly (> cp->provider->mediasize /* past end of vol */); DragonFly has no such check β€” the in-tree comment /* XXX FreeBSD also checks media size in above */ (msdosfs_vfsops.c:434) admits the omission.
  2. df on the mount reports 536,870,912 1K-blocks (512 GiB) free β€” statfs blocks come from pm_maxcluster+1 (2^30 clusters Γ— 512 B) derived entirely from the forged BPB, not from the device.
  3. vmstat -m shows MSDOSFS_FAT 1 128M β€” the pm_inusemap kmalloc(M_WAITOK) (vfsops.c:565) is attacker-sized via BigFATsecs.
  4. TEST 2: SIGKILL of mount_msdos 3 s into the mount does not stop it: the kernel finishes the 38 s fillinusemap() scan and the mount lands anyway (SIGKILL is only acted on at the syscall boundary).
  5. TEST 3: a second concurrent mount of the same image adds another 128 MiB (MSDOSFS_FAT 2 256M). BigFATsecs is 32-bit: up to 512 MiB wired per mount (2^32 clusters) before the uint32 pm_HugeSectors store truncates; N concurrent mounts Γ— attacker-chosen size bounded only by kmem.

Fixed kernel (fix.diff: bound pm_HugeSectors * pm_BlkPerSec by dev_dpsize(dev))

  • Crafted image: mount rc=71 (EINVAL) elapsed=0s, zero MSDOSFS_FAT allocation.
  • Regression: newfs_msdos -F 32 -c 1 (FAT32, HugeSectors == device size) and newfs_msdos (FAT16) both mount read-write normally (run.fixed2.log).

Artifacts

  • craftfat32.c β€” image crafter (FAT[0] media-ID word 0x0FFFFFF8 required by fillinusemap's FAT[0]-vs-bpbMedia check, fat.c:933-938)
  • build.sh / build.log β€” guest compile
  • run.sh / run.log β€” baseline run on stock #0 (decisive evidence)
  • run.fixed2.log β€” patched kernel #2: attack rejected + FAT32/FAT16 regression OK
  • fix_build.log β€” nativekernel build/install tails (full logs were in-guest)
  • env.txt β€” guest uname/sysctl/vmstat baseline
  • fix.diff β€” git-apply-able patch (also applies to the repo sys/ tree)
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-3128 β€” VERDICT

Status: reproduced (impact: dos, confidence: certain). Fix validated (fixed).

Root cause

mountmsdosfs() (sys/vfs/msdosfs/msdosfs_vfsops.c) validates BPB fields for internal consistency but never bounds the described volume by the size of the backing device:

  • vfsops.c:432-437 β€” the only size sanity check is an overflow test that can never trigger in 64-bit arithmetic ((off_t)pm_HugeSectors * pm_BytesPerSec ≀ 2^32Β·2^15 = 2^47). The comment on line 434 β€” XXX FreeBSD also checks media size in above β€” marks exactly the check DragonFly dropped. FreeBSD (msdosfs_vfsops.c ~667) rejects HugeSectors * BytesPerSec > mediasize with /* past end of vol */.
  • vfsops.c:463-464 β€” pm_maxcluster = (pm_HugeSectors - pm_firstcluster)/SecPerClust + 1 is derived purely from BPB numbers.
  • vfsops.c:484-490 β€” the only clamp is the FAT-capacity clamp (clusters = FATsecs*512*fatdiv/fatmult), which the attacker also controls via bpbBigFATsecs (FAT32, 32-bit).
  • vfsops.c:565-566 β€” pm_inusemap = kmalloc(howmany(pm_maxcluster+1,32)*4, M_WAITOK) β€” attacker-sized, wired for the mount's lifetime.
  • vfsops.c:578-579 / msdosfs_fat.c:884-955 β€” fillinusemap() scans every cluster 0..pm_maxcluster, reading the whole FAT: minutes of kernel time on a forged-but-sparse image.

Related census note: pm_HugeSectors is a 16-bit/32-bit-store alias (pm_bpb.bpbHugeSectors, uint32_t), so pm_HugeSectors *= pm_BlkPerSec (:439) truncates β€” harmless here because everything downstream is derived from the truncated value consistently.

Threat model

Mounting a crafted filesystem image: a malicious USB stick / disk image mounted by an operator (root), or β€” with vfs.usermount=1 β€” any unprivileged user against a device node they can open (e.g. their own md/vn). Guest default vfs.usermount: 0. Each crafted mount:

  1. consumes attacker-chosen wired kernel heap (128 MiB demonstrated; up to 512 MiB per mount given the 32-bit BigFATsecs and 32-bit HugeSectors store, i.e. 2^32 clusters Γ— 1 bit + 1 bit/32 slack),
  2. pins the mounting thread (and the mount path) for the attacker-chosen scan duration β€” 38 s demonstrated with a 4 GiB sparse FAT; the work is not interruptible by SIGKILL (proven: mount completed after the caller was killed),
  3. stacks linearly with concurrent mounts (256 MiB for two demonstrated),
  4. reports bogus statfs (512 GiB "free" on a 9 GiB device) β€” amusing but secondary.

Reproduction (stock kernel #0, full log: run.log)

./craftfat32 /root/DF-3128a.img       # 9 GiB sparse, claims 1.5 TiB FAT32
vnconfig -c -s labels vn3 /root/DF-3128a.img
mount_msdos -m 644 /dev/vn3 /mnt/DF-3128a   # rc=0, 38 s
df -k /mnt/DF-3128a    # 536870912 1K-blocks
vmstat -m | grep -i msdos   # MSDOSFS_FAT 1 128M (2 256M with a second mount)

Decisive lines in run.log: mount rc=0, wall time 38s, /dev/vn3 536870912 1 536870911 0%, MSDOSFS_FAT 1 128M, and TEST 2's post-SIGKILL mount output showing both vn3 and vn1 mounted (the killed mount finished anyway) with MSDOSFS_FAT 2 256M.

Fix validation (kernel #2, full log: run.fixed2.log)

fix.diff adds the missing bound, in DEV_BSIZE-block units matching DragonFly's dev_dpsize() convention (vm_swap.c treats it as a block count; vn.c:562 divides by secsize):

devsize = dev_dpsize(dev);  /* DEV_BSIZE blocks, 0 if unknown */
...
if ((off_t)pmp->pm_HugeSectors * pmp->pm_BytesPerSec <
    pmp->pm_HugeSectors /* overflow */ ||
    (devsize > 0 &&
     (off_t)pmp->pm_HugeSectors * pmp->pm_BlkPerSec > devsize)
    /* past end of volume */) { EINVAL }
  • make -j6 nativekernel KERNCONF=X86_64_GENERIC BUILD_RC=0, installkernel INSTALL_RC=0, reboot into #2.
  • Crafted image: mount rc=71 (EINVAL) elapsed=0s, 0 mounts, MSDOSFS_FAT 0 β€” attack gone.
  • Regression: newfs_msdos -F 32 -c 1 (legit FAT32, HugeSectors == device) mounts rc=0, file create/read/delete OK; newfs_msdos (FAT16) mounts rc=0, RW OK.
  • First fix iteration compared HugeSectors*BytesPerSec (bytes) against dev_dpsize (blocks) and wrongly rejected legit images β€” caught by the regression test, corrected to blocks. (Units gotcha documented for upstream.)

Baseline-vs-patched table:

kernel crafted 1.5 TiB image legit FAT32 legit FAT16
stock #0 rc=0, 38 s, 128 MiB wired, 512 GiB in df mounts mounts
fixed #2 rc=71 in 0 s, no alloc mounts, RW OK mounts, RW OK

Non-goals / notes

  • Secondary observation: SIGKILL-interrupted mounts can leave one leaked msdosfsmount (MSDOS_mount inUse +1) β€” a much smaller, separate error-path leak; noted, not part of this finding's fix.
  • No memory corruption: all FAT/inusemap indexing stays consistent with the (forged) geometry; the damage is resource exhaustion + unkillable kernel work + statfs fiction.
  • Guest left clean: vm.sh reset with-src after validation (stock #0).

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

Patched kernel #2 rejects the crafted image instantly (EINVAL, 0 s, no MSDOSFS_FAT allocation, no mount) while legit newfs_msdos FAT32 (HugeSectors == device size) and FAT16 images mount read-write normally; baseline reproduced on stock #0 immediately before.

findings/poc/DF-3128/run.fixed2.log (PATCHED_RC=71 elapsed=0s; GOOD32_RC=0; GOOD16_RC=0; RW_OK/RW16_OK writes); fix_build.log (BUILD_RC=0, INSTALL_RC=0); fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #2: Sun Sep 6 22:40:16 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

craft sparse image with FAT32 BPB (RootDirEnts=0, FSVers=0, 16-bit FATsecs=0, BigFATsecs=X, HugeSectors >> device; FAT[0]=0x0FFFFFF8 to pass fillinusemap's media check) -> vnconfig -> mount_msdos -> kernel kmalloc(X128 bytes) M_WAITOK + X1024-entry scan; repeat per mount to exhaust kmem / pin CPUs; unprivileged only when vfs.usermount=1

Evidence (decisive lines)

["run.log: 'mount rc=0, wall time 38s'; df '/dev/vn3 536870912 1 536870911'; vmstat 'MSDOSFS_FAT 1 128M' then '2 256M' after second mount; TEST 2 shows both mounts present after SIGKILL of mount_msdos", 'run.fixed2.log: PATCHED_RC=71 elapsed=0s with MSDOSFS_FAT 0; GOOD32_RC=0 and GOOD16_RC=0 with RW writes', 'fix_build.log: nativekernel BUILD_RC=0, installkernel INSTALL_RC=0', "sys/vfs/msdosfs/msdosfs_vfsops.c:434 comment 'XXX FreeBSD also checks media size in above'"]

PoC changes

seed sketch had wrong FAT[0] media-ID word (0x0FFFFF08; correct value per fat.c:933-938 is (FAT32_MASK & 0xffffff00)|0xF8 = 0x0FFFFFF8 - misfixed nibble caused silent EINVAL from fillinusemap); vn device nodes exist only for vn0-vn3 on the guest; root login shell is csh so all remote commands run via /bin/sh; craftvar.c added for field bisection

Verified recommended fix

In mountmsdosfs(), capture devsize = dev_dpsize(dev) (DEV_BSIZE blocks) after VOP_OPEN and reject when pm_HugeSectors * pm_BlkPerSec > devsize (past end of volume), matching FreeBSD's mediasize check.

Verdict

mountmsdosfs() drops FreeBSD's media-size bound on the BPB (in-tree comment at msdosfs_vfsops.c:434 admits it). A 9 GiB sparse image claiming a 1.5 TiB FAT32 volume (HugeSectors=0xC0000000, 4 GiB FAT via BigFATsecs) mounts with rc=0 after 38 s of non-interruptible kernel FAT scanning, wiring an attacker-chosen 128 MiB pm_inusemap per mount (up to 512 MiB given the 32-bit fields), stacking linearly with concurrent mounts (256 MiB demonstrated), surviving SIGKILL of the mounting process, and publishing 512 GiB of statfs blocks. FreeBSD rejects the same image instantly. Local DoS via crafted image (root, or any user with vfs.usermount=1 and an accessible device).