# DF-3154 — ext2_ei_csum_verify() memcmp over-read past the 128-byte inode slot

## The bug

`sys/vfs/ext2fs/ext2_csum.c:589`:

```c
static const struct ext2fs_dinode ei_zero;              /* 160 bytes */
...
if (!memcmp(ei, &ei_zero, sizeof(struct ext2fs_dinode)))
        return (0);
```

* `sizeof(struct ext2fs_dinode)` == **160** in this tree (last field
  `e2di_projid` ends at 156+4, `sys/vfs/ext2fs/ext2_dinode.h:127-159`).
* The on-disk inode slot is `EXT2_INODE_SIZE(fs)`, which mount validation
  (`ext2_vfsops.c:539-545`) allows to be **128** (power of two, ≥128).
* `ext2_vget()` passes `ei = bp->b_data + 128*ino_to_fsbo(ino)` of a
  **bsize (1024)** `bread()` buffer (`ext2_vfsops.c:1364-1374`).

Consequences for `isize == 128` (ipb = 8 per 1K block):

| victim slot | memcmp span within buffer | effect |
|---|---|---|
| idx 0..6 | `[idx*128, idx*128+160)` | **cross-inode read**: the first 32 bytes of the NEXT inode's slot silently decide the verdict |
| idx 7 (last) | `[896, 1056)` | **32 bytes read PAST `bp->b_data`** — kernel heap adjacent to the 1024-byte buffer allocation |

The memcmp only runs when the stored checksum mismatches
(`provided != calculated`, attacker-crafted), so the attacker controls
whether the check fires; the deciding bytes are outside the stat'd inode.

## Reproduce (host crafts, guest runs)

Host:
```
python3 craft_df3154.py /tmp/opencode/df3154     # mke2fs + debugfs + patching
```
Produces `df3154.{A_nonzero,A_zero,B}.ext2` (bsize=1024, isize=128,
metadata_csum, all inode generations set to 0x13579BDF — needed because
`ext2_vget()` randomizes `i_gen` for gen==0 inodes, `ext2_vfsops.c:1420`,
which would break every dirent/inode checksum against the image), plus
`.victims` (f03=15 slot 6, neighbor f04=16 slot 7, f12=24 slot 7).

Copy images + `run_baseline.sh` to the guest, then (root):
```
sh /root/run_baseline.sh
```
The script mounts one image per vn unit (buffer-cache identities must not
mix), all mounts before any stat, and stats as unprivileged `maxx`:

## Expected output (stock kernel) — observed 2026-09-07

```
stat f00 on A_zero img : OK /mnt/e2z/f00 ino=12 mode=-rw-r--r--
ls A_zero img          :       41
stat f03 on A_zero     : stat: /mnt/e2z/f03: stat: Bad file descriptor   <- memcmp==0, suppressed
stat f12 on B (vn2)    : stat: /mnt/e2b1/f12: stat: Bad file descriptor   <- 32 heap bytes (zero here)
stat f12 on B (vn3)    : stat: /mnt/e2b2/f12: stat: Bad file descriptor
stat f03 on A_nonzero  : stat: /mnt/e2a/f03: stat: Input/output error     <- memcmp!=0 -> EIO
```

`A_nonzero` and `A_zero` differ **only in the first 32 bytes of inode 16**
(the neighbor of victim inode 15, never stat'd) — yet `stat` of inode 15
flips EIO ↔ EBADF. Those bytes are outside inode 15's slot: the kernel
consumed them. For `B`, the deciding 32 bytes lie past the buffer — the
image contains no such bytes; on this heap layout they were zero twice
(suppression); DF-3049-style variance is layout-dependent.

The script's final `umount -f` of the EIO-poisoned mount panics the stock
kernel (see panic.txt — separate, cross-file bug: `ext2_vget()`'s
ei2i-error path leaves a devvp ref imbalance; `ext2_reclaim` →
`vrele(count==0)` assertion, `vfs_lock.c:354`).

## Deterministic harness (guest userspace, no kernel needed)

```
cc -O2 -o harness_df3154 harness.c && ./harness_df3154
```
Proves the address math: slot-6 memcmp spans `[768,928)` (next slot),
slot-7 spans `[896,1056)` → SIGSEGV at a guard page placed at buffer+1024.

## Fix (validated)

`fix.diff` bounds the memcmp by the slot size:
`imin(EXT2_INODE_SIZE(fs), sizeof(struct ext2fs_dinode))`.

Patched module, same test (`fix_run.log`):
`A_nonzero` → **EBADF** (was EIO), `A_zero` → EBADF — the outcome is now
independent of the neighbor's bytes; the out-of-slot consumption is gone.
(The subsequent `umount` panic becomes `unmount: dangling vnode` — the
degenerate zero-inode vget lifecycle bug, unrelated to this fix.)
