# 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:

```c
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`, `cat`s
  `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.

```diff
--- 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)

- `sys/vfs/msdosfs/msdosfs_fat.c:187` — reserved-cluster check (the
  missing `cn > pm_maxcluster` half).
- `sys/vfs/msdosfs/msdosfs_fat.c:203` — `bo >= bsize` guard (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_maxcluster` clamp
  (`clusters = ((FATsecs*DEV_BSIZE)/fatmult)*fatdiv; maxcluster=clusters-1`).
- `sys/vfs/msdosfs/msdosfs_vfsops.c:493,498` — FAT12 `pm_fatblocksize=1536`,
  `pm_fatblocksec=3`.
- `sys/vfs/msdosfs/msdosfsmount.h:115` — `FATOFS(pmp, cn) = cn*fatmult/fatdiv`.
