# DF-3154 — VERDICT

**REPRODUCED** (in-kernel behavioral proof + deterministic harness) —
Low-severity kernel OOB read / 1-bit oracle in `ext2_ei_csum_verify()`.

## Root cause (line-accurate)

`sys/vfs/ext2fs/ext2_csum.c:589` compares **160** bytes
(`sizeof(struct ext2fs_dinode)`, fields end at `e2di_projid+4`,
`ext2_dinode.h:127-159`) against a zero template, but the inode slot is
`EXT2_INODE_SIZE(fs) == 128` (mount-legal: `ext2_vfsops.c:539-545` accepts
any power of two in [128, bsize]; REV0 forces 128 at `:522-523`).
`ext2_vget()` (`ext2_vfsops.c:1364-1374`) and `ext2_reload_scan()`
(`:752-762`) call `ext2_ei2i()` → `ext2_ei_csum_verify()` with
`ei = bp->b_data + 128*ino_to_fsbo(ino)` of a **1024-byte** `bread()` buffer.

* victim at slot idx ≤ 6: the memcmp's last 32 bytes are the **next
  inode's on-disk slot** (cross-inode read inside the buffer);
* victim at slot idx 7 (last in block): the last 32 bytes are **past the
  `bp->b_data` allocation** — kernel heap.

The memcmp only executes when the stored inode checksum mismatches
(`ext2_csum.c:583`), i.e. exactly on attacker-corrupted images.

## How it was proven

1. **Deterministic harness** (`harness.c`, guest `cc`): transcribes the
   memcmp with the same offsets against a guard-page-backed buffer:
   slot-6 `[768,928)` returns nonzero iff the next slot's head is nonzero;
   slot-7 `[896,1056)` **SIGSEGVs at buffer+1024** — the 32-byte over-read
   is real (build.log/run captured in run.log header).

2. **In-kernel, unprivileged (uid=1001 maxx), stock kernel**
   (`run.log`, `craft_df3154.py`): two images differing ONLY in the first
   32 bytes of inode 16 (the neighbor of victim inode 15 — never stat'd):
   * `A_nonzero` (neighbor head 0xAA): `stat f03` → **EIO**
   * `A_zero`    (neighbor head 0x00): `stat f03` → **EBADF** (csum error
     suppressed by the zero-tail memcmp, vget succeeds on the mode-0 inode)
   The flip proves the kernel's EIO decision consumed bytes **outside the
   stat'd inode's 128-byte slot**. `B` (victim at last slot, deciding bytes
   entirely past the buffer): suppression observed (heap bytes happened to
   be zero on both vn units tried — variance is heap-layout-dependent, as
   with DF-3049/DF-0877).

3. **Crafting hurdles solved** (`poc_changes`): mke2fs leaves inode
   generations at 0; DFBSD's `ext2_vget()` **randomizes i_gen for gen==0
   inodes** (`ext2_vfsops.c:1420-1424`), which silently breaks every
   metadata_csum (dirent tails, inode csums) against stock e2fsprogs
   images — root of the initial all-EIO. The craft sets gen=0x13579BDF on
   all allocated inodes and recomputes (a) all inode checksums and (b) the
   root directory tail checksum, matching the kernel's seed/ino/gen chain.

## Impact

* **32-byte kernel-heap OOB read** (read-only) per mismatching
  last-in-block inode read; page-fault/panic only on unlucky adjacency.
* **1-bit all-zero oracle**: EIO-vs-accept reveals whether the 32 bytes
  past a chosen 1K buffer allocation were all zero — repeatable per inode,
  post-mount, by any unprivileged user with read access to the mount.
  (Needs a crafted image: unprivileged mount via `vfs.usermount`, or any
  attacker-supplied disk/NFS-exported image.)
* Cross-inode variant silently lets the *neighbor's* content decide
  whether corruption is reported — integrity-check weakening.

## Exploit chain

Not escalated (Low class): oracle/over-read only. No write primitive.

## Fix validation

`fix.diff`: `imin(EXT2_INODE_SIZE(fs), sizeof(struct ext2fs_dinode))`.
Module-level rebuild + reload, same test (`fix_run.log`):
* baseline: `A_nonzero`→EIO, `A_zero`→EBADF (variant-dependent)
* patched:  `A_nonzero`→**EBADF**, `A_zero`→EBADF (**variant-independent**;
  out-of-slot bytes no longer consumed)
The B case is unchanged (its deciding bytes were zero on this layout).

## Cross-file bugs surfaced during verification (NOT DF-3154, for the
orchestrator to file against their files)

1. **ext2_vget ei2i-error path refcount bug → umount panic (stock
   kernel)**: after `stat` of a checksum-mismatching inode, any later
   `umount` panics: `panic: assertion "(count & VREF_MASK) > 0" failed in
   vrele at vfs_lock.c:354` via `ext2_reclaim()`. Backtrace in panic.txt.
   (ext2_vfsops.c error path / ext2_reclaim devvp imbalance — DF-3064
   family, different site.)
2. **Zero-inode vget success path leaves dangling vnode →
   `panic: unmount: dangling vnode`** on umount (fix_run boot).
3. **`ext2_readdir` parses the metadata_csum dirent tail as an entry**
   (reclen reads 0 → EIO, `ext2_lookup.c:198-203`): every readdir on every
   valid Linux `metadata_csum` ext2 image fails; on RW mounts the same
   walk hits `ext2_dirbad` → **panic from a pristine image**
   (`ext2_lookup.c:777-780`, DF-3083 mechanism, valid-image trigger).
4. **`ext2_vget` i_gen randomization (ext2_vfsops.c:1420-1424) breaks all
   metadata_csum verification against gen-0 (stock e2fsprogs) images** —
   interop/correctness: entire fs EIOs.

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