# DF-3232 — Unvalidated `fs_fragshift` scales the FFS block-bitmap byte index
up to 8× past the cylinder-group bitmap → OOB read AND single-byte `0x00`/`0xff`
OOB write in buffer-cache memory from one unprivileged `ftruncate()`
(`sys/vfs/ufs/ffs_subr.c`)

## What

The block-bitmap helpers in `ffs_subr.c` index the cylinder-group free-block
bitmap (one bit per fragment ⇒ `fpg / fs_frag` bytes) with a *block* number
derived by shifting a fragment number down by the superblock's `fs_fragshift`:

```c
/* fs.h:518 */  fragstoblks(fs, frags)  ==  frags >> fs->fs_fragshift
/* ffs_alloc.c:1538 */ blkno = fragstoblks(fs, bno);   /* bno = dtogd(freed blk) */
/* ffs_alloc.c:1539 */ ffs_isfreeblock(fs, blksfree, blkno);   /* OOB READ  */
/* ffs_alloc.c:1544 */ ffs_setblock(fs, blksfree, blkno);      /* OOB WRITE */
```

`ffs_mountfs` validates only `fs_magic` and the `fs_bsize` range
(`ffs_vfsops.c:642-646`) and `ffs_oldfscompat` (`ffs_vfsops.c:796-818`)
never touches `fs_fragshift`, so an image with

```
fs_frag     = 8      (valid — the switch() in every bitmap helper is happy)
fs_fragshift= 0      (should be 3: log2(fs_frag))
fs_fpg      = 131072 (so dtogd(bno) == bno keeps the forged block in cg0)
```

mounts cleanly, and every bitmap index becomes **8× too large**: with
`fs_frag == 8` the bitmap holds one byte per 8 frags, but `h = bno >> 0 = bno`
addresses one byte per *frag* — `cp[bno]` walks off the end of the bitmap and
off the end of the `fs_cgsize`-byte `bread()`'d cg buffer.

Sinks in `sys/vfs/ufs/ffs_subr.c` (all reached from ordinary file operations
on the crafted mount):

| sink | line | op |
|---|---|---|
| `ffs_isblock`     | 227/230/233/236 | OOB read (alloc path :1137) |
| `ffs_isfreeblock` | 250             | OOB read (free path :1539) |
| `ffs_clrblock`    | 270             | OOB write `cp[h] = 0` (alloc path :1209) |
| `ffs_setblock`    | 294             | OOB write `cp[h] = 0xff` (free path :1544) |

The freed/allocated block number `bno` is attacker-chosen (a file's
`di_db[]` on the image), so the OOB **offset is attacker-chosen** up to
`fs_fpg` bytes past the bitmap start — with `fs_fpg = 131072` that is up to
~123 KB past `b_data`, i.e. across multiple 64-KB buffer-cache KVA slots
into *other buffers' data*. Write value is a full byte: `0xff` (setblock),
`0x00` (clrblock case 8).

## Preconditions

* crafted FFS image (any unprivileged user can prepare it);
* mount by root (`vfs.usermount=0` default) — or fully unprivileged with
  `vfs.usermount=1` (same profile as DF-0894/0903/3190/3212);
* trigger is one syscall by any user with write permission on one file.

## PoC (verified on the stock INVARIANTS guest, X86_64_GENERIC #0)

`sbtool2` builds a stock 128 MB `newfs -b 8192 -f 1024` image, creates
`victim` (one 8 K block), then offline patches the superblock
(`fs_fragshift 3→0`, `fs_fpg→131072`, softdep flag cleared) and points
`victim`'s `di_db[0]` at a chosen fragment `BNO`.  Root mounts; **maxx
(uid 1001)** runs `trigger` = `open()+ftruncate(0)+sync()`.

Real geometry (dumped by `sbtool2 info`): cg buffer `fs_cgsize = 8192`,
`cg_freeoff = 1628`, bitmap = `fpg/8 = 5632` bytes, cg0 block at byte 24576.

| run | BNO | cp[BNO] lands at | result |
|---|---|---|---|
| run.log  | 16000 | b_data+17628 (+9.4 KB past 8 KB buffer) | **Fatal trap 12, supervisor READ, page not present** — `Stopped at ffs_isfreeblock+0x19: cmpb $0,(%rsi,%rdx,1)`; fault VA − 0x44DC (=1628+16000) is page-aligned b_data. Guest down. |
| panic2   | 6000  | bitmap+368 (mapped, nonzero cluster-map byte) | **`panic: ffs_blkfree: freeing free block` — dev=vn0, block=6000** (OOB read returned live kernel data); stack `ffs_truncate→ffs_blkfree→ffs_blkfree_cg`. |
| run.3.log| 5648  | bitmap+16 (mapped, zero cluster-sum byte)   | **silent OOB WRITE**: trigger survives, clean unmount, byte at image offset 31852 (cg0+freeoff+5648) flipped `00 → ff` on disk. |

Control: identical image/trigger with stock `fs_fragshift` — survives,
truncates to 0, clean unmount (`run.control.log`).

## Build / run

```
# guest, as root:
sh /root/df3232/build.sh          # cc sbtool2.c trigger.c
sh /root/df3232/setup.sh 16000    # build+poison image, mount crafted RW
su -m maxx -c "/tmp/trig3232 /mnt/victim"     # → fatal trap (run 1)
# variants: setup.sh 6000 → panic 'freeing free block'
#           setup.sh pick → auto-picks a zero byte → silent 0xff write
```

## Impact

* attacker-offset single-byte `0x00`/`0xff` write into buffer-cache memory
  (groomable: with BNO past 64 KB the write crosses into neighbouring
  buffers' mapped data — same KVA layout DF-3190 documented), and
* attacker-offset OOB read used as a value oracle (`freeing free block`
  panic ⇔ byte ≠ 0), and
* two deterministic unprivileged reboot-equivalent panics.

No `uid=0` chain was completed: the write value is constrained to one byte
(0x00/0xff) and the victim buffer population at the target KVA slot is not
steerable enough on this configuration to reach a credential-bearing object
reliably — documented as the hard blocker (see VERDICT.md).

## Fix

Validate the shift/mask geometry at mount (see `fix.diff`): reject unless
`fs_frag ∈ {1,2,4,8}`, `(1 << fs_fragshift) == fs_frag`,
`fs_bsize == fs_fsize << fs_fragshift`, `fs_bmask == fs_bsize-1`,
`fs_fmask == fs_fsize-1` and the 64-bit masks are the proper complements.
This also closes the mask-craft family (DF-3190/3212/0903) at the same gate.
