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)
PoC verification
Evidence pack
findings/poc/DF-0827 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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:bugmode (default) βfat12_oob.img:FAT[681]=682(chain walks OOB).eofmode βfat12_eof.img:FAT[681]=0xff8(clean EOF control).build.shβcc -o craftfat craftfat.c.run.shβ mounts each image viavnconfig+mount_msdos,catsTRIGGER.TXT, prints a byte-count comparison and verdict.fix.diffβ one-line fix atmsdosfs_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.
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
- Attacker crafts a FAT12 image with
FATsecs=2, a file whose chain walks2 β 3 β ... β 681 β 682, and a file size that forcespcbmap(findcn=681)(>= 682 clusters worth of bytes). - Admin mounts the image (realistic: USB stick, downloaded disk image;
vfs.usermount=0by default so mount is a root action, but the file READ is the unprivileged trigger). cat(or any read past cluster 681) callspcbmap(dep, 681, ...). The loop walkscn=2..682. Ati=680withcn=682, the reserved check passes (682 < 0xff6), thebo >= bsizeguard passes (1023 < 1024), andgetushort(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<<8for 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
cnoutside the image, causingEIO; 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 (bugmode andeofcontrol).run.shβ mounts each viavnconfig+mount_msdos,catsTRIGGER.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-srcsnapshot): bug confirmed βcatreads 349184 bytes (OOB byte yieldscn=682, returns cluster 682's data). - Applied
fix.diffto in-guest/usr/src, built single-fix kernel (make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0), installed to/boot/kernel/kernel, rebooted to6.5-DEVELOPMENT #1(sha256d08bde5f...). - Patched
#1: bug image now behaves identically to control βcatreturns 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)
sys/vfs/msdosfs/msdosfs_fat.c:187β reserved-cluster check (the missingcn > pm_maxclusterhalf).sys/vfs/msdosfs/msdosfs_fat.c:203βbo >= bsizeguard (doesn't account for 2-byte read width; moot once the cn check is fixed).sys/vfs/msdosfs/msdosfs_fat.c:211βgetushort(bp->b_data + bo), the OOB read site.sys/vfs/msdosfs/msdosfs_vfsops.c:484-489βpm_maxclusterclamp (clusters = ((FATsecs*DEV_BSIZE)/fatmult)*fatdiv; maxcluster=clusters-1).sys/vfs/msdosfs/msdosfs_vfsops.c:493,498β FAT12pm_fatblocksize=1536,pm_fatblocksec=3.sys/vfs/msdosfs/msdosfsmount.h:115βFATOFS(pmp, cn) = cn*fatmult/fatdiv.
Fix verification
fixedVALIDATED: baseline bug image 349184 bytes (OOB); patched 348213 bytes (identical to control, hiteof).
BEFORE: control=348213, bug=349184. AFTER: control=348213, bug=348213.
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).
No comments yet.