# DF-0777: hammer2_vop_readdir leaks kernel heap via unvalidated on-disk name_len/namlen

## Verdict: REPRODUCED + FIXED

**Impact:** Info leak (OOB read past directory-entry data into adjacent kernel
buffer content). Medium severity. Mount-time parsing threat model (attacker
crafts a hammer2 image; admin mounts it).

## Mechanism

`hammer2_vop_readdir` (`sys/vfs/hammer2/hammer2_vnops.c:683-776`) reads on-disk
directory entries via `hammer2_xop_collect` and passes their name length directly
to `vop_write_dirent` without validation:

### INODE branch (lines 702-711)
```c
ripdata = &hammer2_xop_gdata(&xop->head)->ipdata;
r = vop_write_dirent(&error, uio, ..., ripdata->meta.name_len, ripdata->filename);
```
`ripdata->meta.name_len` is a `uint16_t` from on-disk inode data (max 65535).
`ripdata->filename` is a 256-byte (`HAMMER2_INODE_MAXNAME`) field. `vop_write_dirent`
calls `bcopy(d_name, dp->d_name, d_namlen)` — with `name_len > 256`, this reads
past the filename field into adjacent inode data and the 64KB dio buffer.

### DIRENT branch (lines 718-731)
```c
namlen = bref.embed.dirent.namlen;
if (namlen <= sizeof(bref.check.buf)) {
    dname = bref.check.buf;    // 64-byte inline buffer
} else {
    dname = hammer2_xop_gdata(&xop->head)->buf;  // chain data buffer
}
r = vop_write_dirent(&error, uio, ..., namlen, dname);
```
`bref.embed.dirent.namlen` is a `uint16_t` from the on-disk blockref. For
`namlen > 64` with `data_off != 0`, `dname` points to a data block buffer
(typically 1024 bytes within a 64KB dio buffer). With `namlen = 65535`, the
`bcopy` reads 65535 bytes from the buffer, leaking adjacent on-disk content.

The only existing validation (`KKASSERT(name_len < HAMMER2_INODE_MAXNAME)` at
`hammer2_inode.c:1078,1340`) is at **create time** and compiled out in production
kernels. Nothing validates at **read time**.

## Threat model
An attacker crafts a hammer2 filesystem image with forged CRC (or CHECK_NONE
blockrefs). An admin mounts it. Any unprivileged user who does `getdents`/`readdir`
on the mounted filesystem receives the leaked data in their dirent buffer.

## Reproduction

### Image crafting (`craft_img.py`)
1. Create a valid hammer2 image with `newfs_hammer2` + files
2. Add a long-filename file (>64 chars, so DIRENT has `data_off != 0`)
3. Modify the DIRENT's `namlen` to 65535
4. Add a fake INODE entry (with visible key) pointing to a crafted inode with
   `name_len = 4096`
5. Set `CHECK_NONE` on all blockrefs in the path (bypass CRC)
6. Recompute volume header CRC-32C cascade

### Trigger (`readdir_leak.c`)
Mount the corrupted image, then `getdents` with a 128KB buffer.

### Observed (unpatched kernel #0)
```
entry 5: d_namlen=65535, reclen=65552 — LEAK (DIRENT path, ~64KB leaked)
entry 6: d_namlen=4096, reclen=4120 — LEAK (INODE path, ~3840 bytes leaked)
```

### After fix (patched kernel #1)
```
Only 5 legitimate entries returned.
Kernel logs: "ignoring dirent with bogus namlen 65535"
             "ignoring inode with bogus name_len 4096"
No leak.
```

## Fix
The fix (`fix.diff`) validates `name_len`/`namlen` against `HAMMER2_INODE_MAXNAME`
(256) before calling `vop_write_dirent`. Entries with bogus lengths are skipped
(with a kernel warning). See `fix.diff` for the git-apply-able diff.

## Files
- `craft_img.py` — image corruption tool
- `readdir_leak.c` — getdents-based trigger
- `build.sh` / `run.sh` — repro scripts
- `run.log` — unpatched kernel output (leak observed)
- `fix_run.log` — patched kernel output (no leak)
- `fix_build.log` — single-fix kernel build log
- `fix.diff` — the fix
- `env.txt` — guest environment
