# DF-2627 — hammer2_vop_readdir uninitialized-stack disclosure via directory cookie

## What this is

`hammer2_vop_readdir()` (`sys/vfs/hammer2/hammer2_vnops.c:601`) declares
`hammer2_blockref_t bref;` **uninitialized**.  `bref` is only assigned inside
the collect loop after a *successful* `hammer2_xop_collect()` (vnops.c:700).
When the **first** collect returns an error (break at vnops.c:688-692),
vnops.c:750 reads `bref.key` from stale kernel stack into `saveoff` and
vnops.c:758 stores it into `uio->uio_offset`.  `kern_getdirentries()`
(`sys/kern/vfs_syscalls.c:4645-4646`) then copies `auio.uio_offset` into
`fp->f_offset` **even on the error return** (the store at :4646 precedes the
`if (error)` at :4647), so an unprivileged user reads 63 bits of stale kernel
stack back with `lseek(fd, 0, SEEK_CUR)` after a failing `getdents(2)`.

## Trigger

A hammer2 directory whose entry scan fails on the **first** xop collect:
a PFS-root directory blockset with EMPTY direct slots whose INDIRECT blocks
fail their XXHASH64 check (one flipped byte in each indirect data block).
Chain resolution sets `chain->error = HAMMER2_ERROR_CHECK`
(hammer2_chain.c:1070-1072); the lookup's `parent->error` bail
(hammer2_chain.c:2473-2476) fails the first lookup; the xop feeds
`NULL+CHECK`; `hammer2_cluster_check` (hammer2_cluster.c:471-477,533-536)
returns it; `hammer2_error_to_errno` maps CHECK→**EDOM**; the first collect
errors out with `bref` never assigned.

Such an image is produced by `forge_df2627.py` from `base2627.img` (built by
`mkbase2627.sh`: 12 sacrificial files fill the 4 direct slots, 40 payload
files spill into 3 indirect blocks, sacrificial files are removed so the
direct slots are EMPTY on disk).  Only bytes inside the indirect data blocks
are flipped — volhdr/sroot/PFS-inode blocks and all stored checks are
untouched, so `mount` succeeds normally (vfsops.c:1428 only rejects a
CRC-broken PFS *inode*, not broken children).

Real-world reachability: any hammer2 media corruption (bad block, CRC error,
torn write) hitting a directory's indirect block turns every `getdents()` on
that directory into a disclosure; no crafted image needed.

## Build

```
sh build.sh        # base image in guest (root), forge variant on host,
                   # compile leak_df2627.c in guest, push images back
```

## Run (guest, root for the mount; the LEAK itself is unprivileged)

```
vnconfig -c vn0 /root/poc/df2627/craft2627.img
mount -t hammer2 /dev/vn0@testvol /mnt/h2x
chmod 755 /mnt/h2x
sh /root/poc/df2627/run_guest.sh     # battery 1: root + nobody, 505 samples
sh /root/poc/df2627/run_guest2.sh    # battery 2: priming modes, 850 samples
```

## Expected output (stock INVARIANTS kernel #0 — VERIFIED)

```
sample N rc=-1 errno=33 cookie=0x7ffff80116ad8c40     <- kernel KVM pointer
sample N rc=-1 errno=33 cookie=0x7ffff8008d225bc0     <- kernel KVM pointer
sample N rc=-1 errno=33 cookie=0x0000000100000040     <- direct-map pointer
```

* **1355 samples** across 7 modes (in-process, fork-per-sample, open/fstat
  prime, healthy-readdir prime, socket prime, writev prime, and the same as
  unprivileged user `nobody`).
* **9 distinct leaked values**, every one a bit-63-masked kernel pointer
  (`0xfffff800_8d2xxxxx` / `0xfffff801_16axxxxx` / `0xfffff801_17axxxxx`
  KVM, `0x00000001_00000040` direct map).  The kernel itself prints KVM
  pointers of the same shape in `dmesg` (`hammer2_mount: hmp=0xfffff801192a0000`).
* The value is deterministic per syscall pre-history and CHANGES with the
  prehistory (7 modes → 9 values) — the signature of stale stack, not of any
  on-disk or computed quantity.
* Control (uncorrupted image): `rc=1008 errno=0 cookie=0x7fffffffffffffff`
  (the documented end-of-dir marker) — the cookie channel behaves normally.
* No panic, no wedge; the mount unmounts cleanly afterwards.

## Fix validation (kernel #1, this fix.diff)

`patch -p1 < fix.diff && make -j6 nativekernel && make installkernel`, reboot:

* Corrupt image: **1605/1605 samples** → `rc=-1 errno=33
  cookie=0x0000000000000002` — EDOM still reported, cookie pinned at the
  last good offset, **zero kernel pointers, zero variance**.
* Healthy image: still lists all 40 files (`rc=1008`, end-of-dir cookie
  unchanged) — no regression.

## Files

* `forge_df2627.py` — image forger (host python3)
* `mkbase2627.sh` — base image builder (guest, root)
* `leak_df2627.c` — leak sampler (getdents + lseek cookie readback)
* `run_guest.sh`, `run_guest2.sh` — leak batteries
* `fix_validate.sh` — post-fix re-run + control
* `run.log`, `run2.log` — full untrimmed baseline output (stock kernel)
* `fix_run.log`, `fix_run2.log` — full untrimmed fixed-kernel output
* `fix_build.log`, `fix_install.log` — kernel rebuild/install logs
* `leak_sample.txt` — ALL raw cookie values, 1355 samples
* `env.txt` — guest environment
* `fix.diff` — the verified fix
* `manifest.json`, `verdict.json`
