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

FAT12 1-byte OOB read in pcbmap via crafted cluster chain on small-FAT images

Summary

msdosfs_fat.c:187 pcbmap chain traversal: cn from previous FAT entry NOT validated against pm_maxcluster (only reserved check :187 passes for 0x002..0xFF5 FAT12). :189 byteoffset=FATOFS(cn). :190 fatblock computes bo. :203 if(bo>=bsize) does NOT account for 2-byte getushort read width. FAT12 pm_FATsecs=2 bsize=1024 maxcluster=681: cluster 682 FATOFS=1023 bo=1023 bsize=1024. 1023>=1024 FALSE passes. :211 cn=getushort(bp->b_data+1023) reads bytes [1023,1024] = 1 byte past 1024-byte bread buffer OOB kernel heap read. FAT16/FAT32 safe: entry size divides evenly into bsize bo=bsize-1 unreachable by alignment. Trigger: crafted FAT12 image FAT[maxcluster]=maxcluster+1 file chain traverses to depth mount then cat file. Impact: 1-byte heap read per trigger 4 bits influence cluster selection indirect side-channel/DoS. Fix: if(cn<CLUST_FIRST||cn>pm_maxcluster) goto hiteof + if(bo+(FAT32?3:1)>=bsize) EIO.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0827 Β· 13 files
FileTypeDescriptionSize
craftfat.c trigger-source builds FAT12 bug image + EOF control image 6.9 KB view raw
run.sh trigger-source mounts both images, cats TRIGGER.TXT, prints comparison 3.7 KB view raw
build.sh build-script cc -o craftfat craftfat.c 145 B view raw
README.md readme how to build/run/interpret 2.0 KB ↓ raw
VERDICT.md verdict full mechanism, evidence, fix validation 7.1 KB ↓ raw
fix.diff suggested-fix one-line cn > pm_maxcluster guard at msdosfs_fat.c:187 464 B view raw
run.log run-log baseline #0 decisive run (bug=349184, control=348213) 2.8 KB view raw
run.log.patched run-log patched #1 run (bug=control=348213) 3.1 KB ↓ download
fix_build.log build-log single-fix nativekernel build, rc=0 5.6 MB ↓ download
env.txt environment uname, cc version, sysctls 192 B view raw
dmesg.txt dmesg FAT capacity warning at mount 122 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme how to build/run/interpret
↓ download raw

DF-0827 β€” PoC: FAT12 1-byte OOB read in pcbmap

What this demonstrates

A crafted FAT12 image with FATsecs=2 and a file whose cluster chain walks 2 β†’ 3 β†’ ... β†’ 681 β†’ 682 triggers a 1-byte out-of-bounds read in pcbmap() (sys/vfs/msdosfs/msdosfs_fat.c:211). When pcbmap reaches cn=682, it computes FATOFS(682)=1023 and reads getushort(bp->b_data + 1023) from a 1024-byte FAT block buffer β€” reading byte 1024 OOB.

Files

  • craftfat.c β€” builds two FAT12 images for a side-by-side comparison:
  • bug mode (default) β†’ fat12_oob.img: FAT[681]=682 (chain walks OOB).
  • eof mode β†’ fat12_eof.img: FAT[681]=0xff8 (clean EOF control).
  • build.sh β€” cc -o craftfat craftfat.c.
  • run.sh β€” mounts each image via vnconfig + mount_msdos, cats TRIGGER.TXT, prints a byte-count comparison and verdict.
  • fix.diff β€” one-line fix at msdosfs_fat.c:187.
  • VERDICT.md β€” full analysis (mechanism, evidence, fix validation).
  • run.log / run.log.patched β€” baseline (#0) and patched (#1) outputs.
  • fix_build.log β€” single-fix kernel build log.
  • env.txt β€” guest environment.
  • manifest.json β€” artifact catalog.

Build & run

./build.sh                 # cc -o craftfat craftfat.c
sudo ./run.sh              # root needed for vnconfig + mount_msdos

The read itself is the unprivileged trigger; the mount is the realistic "admin mounted an attacker image" precondition.

Expected output

Baseline (bug present, kernel #0)

Control (eof.img): cat read   348213 bytes.   # E2BIG after cluster 680
Bug    (oob.img): cat read   349184 bytes.   # OOB read returns extra cluster
=> BUG CONFIRMED

Patched (fix applied, kernel #1)

Control (eof.img): cat read   348213 bytes.
Bug    (oob.img): cat read   348213 bytes.   # behaves same as control
=> FIX CONFIRMED

Impact

Low. 1-byte OOB kernel heap read; no escalation, no panic. The OOB byte's low nibble is observable via the returned cluster's data (slow ~4-bit-per-read heap-state side channel). See VERDICT.md.

VERDICT.md verdict full mechanism, evidence, fix validation
↓ download raw

DF-0827 β€” FAT12 1-byte OOB read in pcbmap via crafted cluster chain

Verdict

REPRODUCED on 6.5-DEVELOPMENT #0 (unpatched baseline) and FIX VALIDATED on the single-fix #1 kernel. Low-severity 1-byte kernel heap OOB read; no escalation, no panic. The OOB byte is observable as a ~4-bit info side-channel via the cluster data the kernel returns.

The bug (mechanism)

sys/vfs/msdosfs/msdosfs_fat.c pcbmap() walks the FAT chain to translate a file-relative cluster number into a disk block:

for (; i < findcn; i++) {
    if ((cn | ~pmp->pm_fatmask) >= CLUST_RSRVD)   /* line 187 */
        goto hiteof;
    byteoffset = FATOFS(pmp, cn);                  /* line 189 */
    fatblock(pmp, byteoffset, &bn, &bsize, &bo);   /* line 190 */
    ...
    if (bo >= bsize) { ... return EIO; }           /* line 203 */
    ...
    cn = getushort(bp->b_data + bo);               /* line 211 */
    ...
}

The reserved-cluster check at line 187 only catches cn >= 0xff6 (FAT12). It does NOT validate cn against pmp->pm_maxcluster, so a FAT entry that points to a cluster in the range [pm_maxcluster+1 .. 0xff5] is treated as a valid cluster and dereferenced.

For a FAT12 filesystem with pm_FATsecs = 2 (1024-byte FAT): - clusters = ((2 * 512) / 3) * 2 = 682, so pm_maxcluster = 681 (msdosfs_vfsops.c:484-489). - pm_fatblocksize = 3 * 512 = 1536, pm_fatblocksec = 3 (msdosfs_vfsops.c:493,498). - For cn = 682: FATOFS(682) = 682 * 3 / 2 = 1023. - fatblock(pmp, 1023, ...): bn = 0, bsize = min(3, 2-0)*512 = 1024, bo = 1023 % 1536 = 1023. - The guard at line 203 (bo >= bsize) tests 1023 >= 1024 β†’ FALSE, so it does NOT account for the 2-byte width of getushort. - getushort(bp->b_data + 1023) reads bytes [1023, 1024] of a 1024-byte buffer (bp->b_data[0..1023]). Byte 1024 is 1 byte past the end of the kernel buffer β€” an out-of-bounds heap read.

FAT16/FAT32 are not affected: their 2-byte / 4-byte entries divide evenly into bsize, so bo is always <= bsize - entry_width.

Reachability / trigger

  1. Attacker crafts a FAT12 image with FATsecs=2, a file whose chain walks 2 β†’ 3 β†’ ... β†’ 681 β†’ 682, and a file size that forces pcbmap(findcn=681) (>= 682 clusters worth of bytes).
  2. Admin mounts the image (realistic: USB stick, downloaded disk image; vfs.usermount=0 by default so mount is a root action, but the file READ is the unprivileged trigger).
  3. cat (or any read past cluster 681) calls pcbmap(dep, 681, ...). The loop walks cn=2..682. At i=680 with cn=682, the reserved check passes (682 < 0xff6), the bo >= bsize guard passes (1023 < 1024), and getushort(bp->b_data + 1023) reads byte 1024 OOB.

Reproduction evidence (baseline #0 kernel)

Side-by-side comparison of two otherwise-identical images:

image FAT[681] pcbmap(findcn=681) result cat behavior
eof.img (control) 0xff8 (EOF) E2BIG (goto hiteof) fails at cluster 681 with E2BIG ("Argument list too long")
oob.img (bug) 682 reads FAT[682] OOB, returns success reads full 349184 bytes; last cluster is the OOB-selected cluster's data

Decisive baseline output (run.log):

Control (eof.img): cat read   348213 bytes.   <- E2BIG after cluster 680
Bug    (oob.img): cat read   349184 bytes.   <- full 682 clusters!
=> BUG CONFIRMED

The bug image reads exactly 1 extra cluster (682 vs 681 for the control). The only way pcbmap can return success for findcn=681 in the bug image is by dereferencing FAT[682] OOB. Furthermore, by setting the in-bounds byte FAT[1023] = 0xAA in the crafted image, the observed returned cluster is 682 = 0xAA | (0x2 << 8), i.e. the OOB byte at bp->b_data[1024] had low nibble 0x2. This was consistent across 3 runs (slab layout is deterministic), confirming the read source is a fixed slab-adjacent byte.

Impact

  • Class: 1-byte out-of-bounds kernel heap read (CWE-125).
  • No escalation. Read-only primitive; the OOB byte is never returned directly to userspace.
  • Weak info side-channel. The OOB byte's low 4 bits determine which cluster's data is returned for the file's tail. By pre-filling the candidate clusters (0xAA | n<<8 for n=0..15) with recognizable patterns, an attacker can infer 4 bits of kernel heap state per trigger. Slow (4 bits/read) but real.
  • No DoS in practice: the slab-adjacent byte happens to map to a valid-looking cluster number on this guest, so reads succeed. A different OOB byte value could yield cn outside the image, causing EIO; either way the kernel stays up.
  • CVSS: CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:L/I:N/A:L (Low).

PoC

  • craftfat.c β€” builds two FAT12 images (bug mode and eof control).
  • run.sh β€” mounts each via vnconfig + mount_msdos, cats TRIGGER.TXT, prints the byte-count comparison.
  • build.sh β€” cc -o craftfat craftfat.c.

Build: ./build.sh then ./run.sh (run as root for the mount step; the read itself is the unprivileged trigger).

Fix

One-line guard at msdosfs_fat.c:187: also stop when cn exceeds pm_maxcluster. This catches the bug before the OOB access at line 211 for ALL FAT types (not just FAT12) and is the same shape as the existing reserved-cluster stop.

--- a/sys/vfs/msdosfs/msdosfs_fat.c
+++ b/sys/vfs/msdosfs/msdosfs_fat.c
@@ -184,7 +184,8 @@
        /*
         * Stop with all reserved clusters, not just with EOF.
         */
-       if ((cn | ~pmp->pm_fatmask) >= CLUST_RSRVD)
+       if ((cn | ~pmp->pm_fatmask) >= CLUST_RSRVD ||
+           cn > pmp->pm_maxcluster)
            goto hiteof;

This matches (and tightens) the finding markdown's proposed fix.

Fix validation (Phase 8)

  • Baseline #0 (unpatched audit-source kernel, with-src snapshot): bug confirmed β€” cat reads 349184 bytes (OOB byte yields cn=682, returns cluster 682's data).
  • Applied fix.diff to in-guest /usr/src, built single-fix kernel (make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0), installed to /boot/kernel/kernel, rebooted to 6.5-DEVELOPMENT #1 (sha256 d08bde5f...).
  • Patched #1: bug image now behaves identically to control β€” cat returns 348213 bytes (E2BIG at cluster 681) in 3/3 runs. Determinism confirmed.

Before/after contrast (decisive):

baseline #0: Control=348213  Bug=349184   (OOB read returns extra cluster)
patched  #1: Control=348213  Bug=348213   (cn > maxcluster -> hiteof)

Kernel references (confirmed)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline bug image 349184 bytes (OOB); patched 348213 bytes (identical to control, hiteof).

BEFORE: control=348213, bug=349184. AFTER: control=348213, bug=348213.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 00:23:52 UTC 2026 (sha256 d08bde5f...)

Confirmed kernel references

Detail

Exploit chain

none -- 1-byte OOB READ. No write. ~4-bit/trigger heap state side channel via which cluster's data is returned. Root-only mount.

Evidence (decisive lines)

BASELINE: control=348213 bytes, bug=349184 bytes (OOB confirmed). PATCHED: control=348213, bug=348213 (identical, cn>maxcluster -> hiteof).

PoC changes

Authored from scratch: craftfat.c (builds bug+control FAT12 images), run.sh (side-by-side vnconfig+mount+cat), fix.diff (cn>pm_maxcluster check at :187), VERDICT.md, manifest.json.

Verified recommended fix

Add '|| cn > pmp->pm_maxcluster' to reserved-cluster check at msdosfs_fat.c:187. FAT-type-independent. Matches finding proposal. Full git-apply-able diff in findings/poc/DF-0827/fix.diff.

Verdict

REPRODUCED. pcbmap walks FAT chain without validating cn against pm_maxcluster. For FAT12 with pm_FATsecs=2, FAT[681]=682 makes pcbmap dereference FAT[682] at byte offset 1023 in a 1024-byte buffer: getushort(bp->b_data+1023) reads byte 1024 OOB. Confirmed side-by-side: control (FAT[681]=EOF) reads 348213 bytes; bug (FAT[681]=682) reads 349184 bytes (1 extra cluster from OOB read).