# DF-2627 VERDICT — hammer2_vop_readdir uninitialized-stack disclosure

**status: reproduced** — 63-bit kernel-stack disclosure to an unprivileged
local user, via the hammer2 directory cookie, on a failing `getdents(2)`.
Fix authored, built, and validated in-guest (leak eliminated, no regression).

## 1. Source-level confirmation (all lines verified in the read-only tree)

* `sys/vfs/hammer2/hammer2_vnops.c:601` — `hammer2_blockref_t bref;`
  declared **uninitialized**.
* `sys/vfs/hammer2/hammer2_vnops.c:688-692` — the first
  `hammer2_xop_collect()` in the loop; an error breaks out immediately with
  `bref` never assigned (assignment is only at :700,
  `hammer2_cluster_bref()`, after a successful collect).
* `sys/vfs/hammer2/hammer2_vnops.c:745-751` — `error != ENOENT` (here EDOM)
  takes the `else` at :749-750: `saveoff = bref.key &
  HAMMER2_DIRHASH_USERMSK;` → **read of uninitialized stack**.
* `sys/vfs/hammer2/hammer2_vnops.c:758` — `uio->uio_offset = saveoff &
  ~HAMMER2_DIRHASH_VISIBLE;` → stale stack bytes stored into the directory
  offset.  USERMSK (hammer2_disk.h:467) strips bit 63 → 63 bits disclosed.
* `sys/kern/vfs_syscalls.c:4644-4646` — `loff = auio.uio_offset =
  fp->f_offset;` … `error = VOP_READDIR_FP(...)` … **`fp->f_offset =
  auio.uio_offset;` executes BEFORE `if (error)` at :4647** → the poisoned
  offset is committed to the file even on the error return.
* Userspace readback: `lseek(fd, 0, SEEK_CUR)` returns `fp->f_offset`.

Error propagation for the chosen trigger (all verified):
`hammer2_chain_load_data` CRC failure sets `chain->error =
HAMMER2_ERROR_CHECK` (hammer2_chain.c:1070-1072);
`hammer2_chain_lookup` bails via the `parent->error` check
(hammer2_chain.c:2473-2476) when the CRC-broken indirect becomes the parent;
`hammer2_xop_readdir` feeds `NULL+CHECK` (hammer2_xops.c:213-223,242);
`hammer2_cluster_check` returns the fed error for the single-master cluster
(hammer2_cluster.c:471-477 keynull path, :533-536);
`hammer2_error_to_errno` maps CHECK→EDOM (hammer2.h:1322-1323).

## 2. Trigger construction

`mkbase2627.sh` (guest): PFS "testvol"; 12 sacrificial files fill the
4 direct blockref slots (HAMMER2_SET_COUNT=4, hammer2_disk.h:133), 40
payload files spill into 3 INDIRECT blocks; sacrificial files removed →
direct slots EMPTY on media (verified by the forger: slot0 type=0).

`forge_df2627.py` (host): volhdr walk (technique proven in DF-2616/17/18/20)
→ sroot inode → PFS inode → blockset; XOR 0xA5 into one 0x00 byte at
offset 0x678 of each indirect **data block**.  No ancestor block is edited
and no stored check is altered, so every parent CRC stays valid and
`mount` succeeds; the indirects simply fail their XXHASH64 verification on
resolution.  A read starting at offset 0 emits "." and ".." and then hits
the broken indirect on the FIRST collect — exactly the uninitialized path.

`ls /mnt/h2x` on the crafted image returns silently (getdents error), no
panic, no wedge (unmount clean) — the disclosure is silent.

## 3. Observed leak (stock INVARIANTS kernel #0, full logs in run.log/run2.log)

1355 samples (root + nobody; in-process, fork-per-sample, and four kernel-
stack priming modes: open/fstat, healthy-dir readdir, socket, pipe+writev):

| mode | who | leaked cookie (×count) |
|---|---|---|
| n (no prime) | root | 0x7ffff80116ad8c40 (155) |
| o (open+fstat prime) | root | 0x7ffff80116ad65c0 (150) |
| r (readdir prime) | root | 0x7ffff8008d23f1c0 (100) |
| s (socket prime) | root | 0x7ffff8008d225bc0 (150) |
| w (writev prime) | root | 0x7ffff8008d23e740 (150) |
| f (fork prime) | root+nobody | 0x0000000100000040 (250) |
| s (socket prime) | nobody | 0x7ffff80117a831c0 (100) |
| w (writev prime) | nobody | 0x7ffff80117a82ac0 (100) |

every call: `rc=-1 errno=33 (EDOM)`.  9 distinct values total; each is a
bit-63-stripped kernel pointer: `0xfffff800_8d2xxxxx`/`0xfffff801_16/17axxxxx`
(KVM — the same region the kernel prints in dmesg, e.g.
`hammer2_mount: hmp=0xfffff801192a0000`) and `0x00000001_00000040`
(direct map).  Values are deterministic per pre-history and change with the
pre-history — stale stack, not on-disk data (the on-disk cookies for this
image are name hashes of p0..p39, none of which appear; and a correct
implementation would leave the cookie at 2, which is what the fixed kernel
returns).

Control image (uncorrupted): `rc=1008 errno=0
cookie=0x7fffffffffffffff` — the documented end-of-dir marker
(vnops.c:745-748), deterministic, no error.  The contrast proves the cookie
channel is only poisoned on the error path.

## 4. Honest impact assessment

* 63 bits of kernel stack per failing getdents, repeatable at will,
  **unprivileged** (nobody verified), silent (no console message).
* Leaked content here was pointer-rich (kernel KVM pointers) — a genuine
  KASLR-aid / heap-layout oracle where KASLR or heap randomization exists.
  DragonFly currently ships without KASLR, so on today's default kernel this
  is primarily an info-leak primitive (kernel pointer disclosure and stale
  stack-word oracle), not directly an escalation.  Per the finding's
  Medium severity — correct.
* Not memory corruption: no write primitive, no panic observed (INVARIANTS
  kernel silent — nothing asserts an uninitialized stack read).
* DoS note: the underlying EDOM is genuine media corruption signaling; the
  bug is only the cookie content.

## 5. Fix validation (kernel #1, built from fix.diff, full logs in fix_*.log)

`patch -p1 < fix.diff` (git-apply-able against the repo tree), `make -j6
nativekernel && make installkernel`, reboot into kernel #1:

* Corrupt image: **1605/1605 samples** `rc=-1 errno=33
  cookie=0x0000000000000002` — EDOM preserved, cookie pinned to the last
  good offset (after "." and ".."), zero kernel pointers, zero variance.
  Leak eliminated.
* Healthy image: 40/40 files listed, `rc=1008`, end-of-dir cookie
  `0x7fffffffffffffff` — no behavioral regression.

fix_status: **fixed**.

## 6. Conclusion

The claimed path is real, line-accurate, and reproducible end-to-end:
uninitialized `bref.key` → `uio_offset` → `f_offset` (stored even on error)
→ `lseek()` readback.  Verified with 1355 baseline samples across privileged
and unprivileged callers and multiple stack pre-histories, and eliminated by
the two-line fix validated on a rebuilt kernel.
