# DF-3172 VERDICT — reproduced (panic; controlled zero-write primitive; uid0 not achieved)

## Bottom line

`ffs_indirtrunc()`'s tail-zering `bzero()` (sys/vfs/ufs/ffs_inode.c:535-536)
computes its start index and length from `lastbn` (attacker-chosen through
the truncate length) and `NINDIR(fs)` (raw superblock data, never validated
at mount). On a filesystem whose superblock carries a crafted `fs_nindir`,
an unprivileged `ftruncate(2)` by the file's owner produces an
attacker-sized (up to ~16 GiB) and attacker-placed (start anywhere in a
~4 GiB window above the buffer, 4-byte granularity) kernel zero-write.
Proven on the stock INVARIANTS guest by two deterministic panics:

1. **Wild march (run A)** — `fs_nindir` 1024→512 (one int32 in the sb):
   shrink of a 560 GB sparse file to `Sigma'*4096+4096` =
   550,831,706,112 → `lastiblock[TRIPLE] == NINDIR³` → `last == NINDIR` →
   `bzero(&bap[NINDIR+1], (uint)-1 * sizeof(ufs_daddr_t))` = 16 GiB-4 march
   starting 2052 bytes into the 4096-byte indirect-block buffer:
   `Fatal trap 12, supervisor write data, page not present`,
   `Stopped at memset+0xd5: repe stosq %es:(%rdi)`, fault VA
   0xfffff80066de7000 (first non-resident page above the buffer), guest
   down at `db>`.

2. **Aimed write (run B)** — `fs_nindir = 2^28` + on-disk-crafted
   `di_size` (so no balloc is needed on the trigger path): shrink drives
   `lastbn = NINDIR-1026` at the SINGLE level → a **4100-byte zero at
   `b_data + 1,073,737,724` (+1.000 GiB)** → `panic: assertion "obj != NULL"
   failed in vm_object_hold_shared`, `rip = memset+0xf0` — the fault is in
   the bzero itself, at the aimed address (unmapped ⇒ fault; a mapped
   target would be silent corruption instead).

Bonus (same root cause, sibling sink): with `fs_nindir = 2^28`, growing the
file *through the kernel* first makes `ufs_getlbns` return
`in_off = (lbn-12) % 2^28` and `ffs_balloc` reads
`bap[268434431]` (ffs_balloc.c:297) = `b_data + 1 GiB` OOB →
`ffs_balloc+0x5e7` → vm_fault → `vm_object_hold_shared(obj==NULL)` panic.
The `ffs_inode.c:550-563` free loop has the same NINDIR-bound OOB-read
shape (reads then `ffs_blkfree()`s values past the buffer).

## Why the mount-time clamps do not save you

- `ffs_mountfs` checks only `fs_magic` and
  `sizeof(struct fs) <= fs_bsize <= MAXBSIZE` (ffs_vfsops.c:642-646);
  `fs_nindir` is adopted verbatim (ump->um_nindir, :729) and governs
  `NINDIR(fs)` everywhere (bmaparray levels, balloc offsets, indirtrunc
  bounds).
- The `fs_maxfilesize` clamp (:753-758, `min(2^30*bsize-1, 2^43-1)`) caps
  *lbns* at 2^30 — exactly the triple-indirect capacity `Sigma` of an
  *honest* NINDIR=bsize/4 filesystem ≥ 4096-byte blocks. It was verified
  numerically and empirically that an honest geometry is safe (an honest
  `fs_maxfilesize = Sigma*bsize-1` from newfs keeps `last <= NINDIR-1`,
  and the first PoC attempt via crafted `fs_maxfilesize` alone did NOT
  fire — the clamp neutralizes it for every bsize newfs can produce).
  The invariant collapses as soon as `fs_nindir` itself is a lie: with
  M=512 the tree capacity is 134,480,396 blocks while the clamp still
  permits lbns up to 2^30 — an 8x gap the attacker closes with the same
  truncate that the clamp was supposed to bound.

## uid0 push — what was tried, where it stops

The write primitive is **zero-only**, **forward-marching**, and anchored at
`bp->b_data`, which lives in the buffer KVA submap
(platform/pc64/x86_64/machdep.c:499-505: kernel_map ⊃ clean_map ⊃
buffer_map, `nbuf*MAXBSIZE` ≈ 838 MB on this guest, 12634 buffers;
kern/vfs_bio.c:638 assigns each struct buf a 64K window of which only the
buffer's own pages are mapped).

- The march from run A dies at the first non-resident page (observed:
  within one page above the 4096-byte buffer). Victims before that point
  are **buffer-cache page images** (cached file data/metadata), not kernel
  objects: zeroing them can only *clear* bits — it cannot set ISUID or
  forge a credential; at worst it corrupts written-back filesystem state.
- `struct proc`/`ucred` live in kernel_map kmem slab allocations that are
  *not* contiguous with the resident buffer windows; a march that survives
  the arena would have to cross unmapped pager_map/clean_map reservations
  first (it cannot — it faults at the first hole).
- The **aimed** variant (start anywhere in a 4 GiB window above b_data,
  arbitrary length) is the exploitation direction: with no KASLR the guest
  layout is boot-deterministic, so a two-boot campaign (probe boot leaks
  b_data from the fault VA, target boot zeroes a sprayed ucred at
  `b_data + delta` and a child process polls `geteuid()==0`) is
  *theoretically* viable. It was not completed: (a) locating one's own
  ucred needs a disclosure companion — the only candidate here is the
  OOB-read→`ffs_blkfree`→cg-bitmap oracle, whose values are fsblock-cast
  int32s and whose INVARIANTS checks abort the run long before a usable
  sample; (b) each probe costs a full boot cycle; (c) an aimed zero that
  crosses live kernel objects between the anchor and the ucred zeroes
  spinlocks/list pointers first, so the race window closes before
  `exec` of a root shell can complete. Documented as a hard blocker for
  this primitive shape; impact recorded as `panic` (deterministic DoS +
  controlled kernel heap corruption window), severity High under the
  crafted-media threat model (same class as DF-3035/DF-3078).

## Fix validation (fix.diff)

- Baseline (stock #0): run A panic + run B aimed panic (above).
- Patched (#1, `make nativekernel` in-guest, 0 warnings):
  - T1 regression: legit `newfs -b 4096` mount; unpriv user extends sparse
    file to 560,000,000,000 then truncates to 550,831,706,112 → returns 0,
    `stat` size exact, guest up.
  - T2: run-A craft (fs_nindir 1024→512) → `mount_ufs: /dev/vn0 on
    /mnt/poc: incorrect super block` (EINVAL), guest up.
  - T3: run-B craft (fs_nindir = 2^28) → same EINVAL rejection, guest up.
- fix.diff = mount-time `fs_nindir == fs_bsize/sizeof(ufs_daddr_t)` check
  (mirrors newfs/mkfs.c:316 exactly, cannot reject honest media) +
  `fs_maxfilesize` capped at the representable `Sigma*bsize-1` (same
  formula as mkfs.c:332-335) + defense-in-depth clamp of `last` to
  `NINDIR(fs)-1` in `ffs_indirtrunc` (also protects MNT_RELOAD geometry
  re-adoption à la DF-3078).

## Kernel references

- sys/vfs/ufs/ffs_inode.c:535-536 — the bzero sink (start+len from media)
- sys/vfs/ufs/ffs_inode.c:311-314 — lastblock/lastiblock from length
- sys/vfs/ufs/ffs_inode.c:157-158 — only length validation (fs_maxfilesize)
- sys/vfs/ufs/ffs_inode.c:550-563 — NINDIR-bounded OOB read loop + blkfree
- sys/vfs/ufs/ffs_vfsops.c:642-646 — insufficient superblock validation
- sys/vfs/ufs/ffs_vfsops.c:729 — um_nindir adopted raw
- sys/vfs/ufs/ffs_vfsops.c:753-758 — maxfilesize clamp (invariant needs honest nindir)
- sys/vfs/ufs/ufs_bmap.c:315 — in_off = (bn/blockcnt) % MNINDIR
- sys/vfs/ufs/ffs_balloc.c:297 — sibling OOB read sink
- sys/vfs/ufs/ufs_types.h:41 — ufs_daddr_t is int32 (NINDIR = bsize/4)

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