# DF-0769 — `hammer_vop_readdir` OOB kernel-heap read / info leak via forged direntry `data_len`

## Verdict
**REPRODUCED.** On the GENERIC (INVARIANTS-ON) kernel the bug manifests as a kernel
**panic** (local DoS) at `hammer_vnops.c:1728`. On a production (INVARIANTS-OFF)
kernel the same code path performs a **~49 KB out-of-bounds kernel-heap read**
disclosed to userspace via `getdirentries` (info leak). The **fix.diff** runtime
bounds check is validated: the panic is gone on a single-fix `#1` kernel.

## Root cause (confirmed line-by-line)

`hammer_vop_readdir` (`sys/vfs/hammer/hammer_vnops.c`) reads each directory entry
B-tree leaf and, trusting the on-disk `int32_t data_len`
(`hammer_btree.h:175`, sourced from untrusted B-tree metadata):

- **vnops.c:1728** — `KKASSERT(cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF);`
  This assertion is the **only** guard. It is a **no-op on production kernels**
  (compiled out without `INVARIANTS`). `HAMMER_ENTRY_NAME_OFF` = 16
  (`hammer_disk.h:963`, `offsetof(hammer_direntry_data, name[0])`).
- **vnops.c:1737** — `cursor.leaf->data_len - HAMMER_ENTRY_NAME_OFF` is passed
  as the `d_namlen` argument. The expression is computed in `int`, then
  **implicitly narrowed to `uint16_t`** by `vop_write_dirent`'s prototype
  (`vfs_subr.c:2560`). For a forged `data_len = 8`: `8 - 16 = -8` →
  `(uint16_t)-8 = 65528`.
- **vfs_subr.c:2575** — `bcopy(d_name, dp->d_name, d_namlen)` reads `65528`
  bytes starting at `cursor.data->entry.name`, which lives inside a 16 KB
  `hammer_buffer->ondisk`. The name begins at offset 16 in that buffer, so the
  bcopy reads `16384 − 16 = 16368` valid bytes then **49160 bytes past the
  buffer** = OOB kernel-heap read.
- **vfs_subr.c:2577** — `uiomove(dp, len, uio)` copies the whole dirent
  (`_DIRENT_RECLEN(65528) = 65552` bytes, including the OOB-read data) to the
  user `getdirentries` buffer = **info leak**.

### Why the CRC gate does not stop it
`hammer_crc_test_leaf` (`hammer_crc.h:293`) computes the CRC over
`leaf->data_len` bytes of the data (`hammer_crc.h:274`). For a forged
`data_len = 8` the CRC covers only the first 8 bytes (the `obj_id`). An attacker
pre-computes `data_crc = iscsi_crc32(direntry_data[0:8])`, so the CRC test
**passes** — yet the consumer at vnops.c:1737 uses `data_len − 16`, which is
**decoupled from the CRC'd length**. The containing B-tree node CRC
(`hammer_crc_get_btree`, `hammer_crc.h:224`) must also be recomputed since
`data_len` lives inside the node; the patcher does both. This was verified
empirically: the patcher's self-check (`ok=1`) confirms the userspace
`iscsi_crc32` matches the kernel's, and the mount proceeded cleanly with no
"CRC FAILED" message.

## Reproduction

### GENERIC (INVARIANTS-ON) manifestation — panic (this guest's `#0` baseline)

1. **Image crafting** (`craft_img.c`, links the kernel's own
   `sys/libkern/icrc32.c` for userspace): a 1 GB `newfs_hammer` image is
   populated with two entries, unmounted, then binary-patched. The patcher scans
   every 4 KB B-tree leaf node, finds each `HAMMER_RECTYPE_DIRENTRY` (0x0011)
   leaf element, sets `data_len = 8`, recomputes `data_crc =
   iscsi_crc32(data[0:8])`, and recomputes the node CRC.
2. **Mount** (root precondition — acceptable per the threat model: an admin
   mounts a crafted filesystem image): `vnconfig -c vn0 img && mount -t hammer
   -o nohistory /dev/vn0 /mnt`.
3. **Trigger** (unprivileged): `ls /mnt` → `getdirentries` → `hammer_vop_readdir`
   → `KKASSERT(8 > 16)` fails.

**Panic signature** (from `dfbsd-qemu/boot.log`, full block in `panic.txt`):
```
panic: assertion "cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF" failed in hammer_vop_readdir at /usr/src/sys/vfs/hammer/hammer_vnops.c:1728
hammer_vop_readdir() at hammer_vop_readdir+0x294
vop_readdir() at vop_readdir+0x6b
kern_getdirentries() at kern_getdirentries+0xdc
sys_getdirentries() at sys_getdirentries+0x24
syscall2() at syscall2+0x11e
Stopped at Debugger+0x7c
```
The stack names `hammer_vop_readdir` at `vnops.c:1728` reached via the
unprivileged `getdirentries` syscall — exactly the cited path.

### Production (INVARIANTS-OFF) manifestation — ~49 KB info leak

`harness.c` transcribes vnops.c:1737's length arithmetic and
vfs_subr.c:2575's `bcopy` verbatim against a poisoned 16 KB "hammer_buffer"
plus adjacent sentinel "heap". Output (deterministic):
```
forged data_len = 8  =>  d_namlen (uint16_t) = 65528
bcopy(d_name, dp->d_name, 65528): valid in buffer = 16368, OOB read = 49160 bytes
=> ~49160-byte kernel-heap INFO LEAK via unprivileged getdirentries
```
This is the **production ceiling**: on INVARIANTS-OFF kernels the KKASSERT is
absent, the bcopy runs, and `uiomove` ships the OOB bytes to userland. The leak
exposes neighbouring slab/page data — kernel pointers (KASLR defeat, though KASLR
is OFF on this guest regardless), `struct ucred *` / `struct file *` pointers,
and other `kmalloc` bucket contents. This is a **read-only** primitive (the bcopy
writes into a freshly `kmalloc`'d dirent that is immediately `kfree`'d after
`uiomove`); it does not corrupt kernel state, so no escalation chain applies
(valid hard blocker: read-only primitive).

## Impact

- **GENERIC (default kernel, INVARIANTS ON):** local **panic / DoS**. Trigger:
  unprivileged `getdirentries`/`ls` on a directory of a mounted crafted HAMMER v1
  image. Mount is the only root precondition.
- **Production (INVARIANTS OFF):** ~49 KB **kernel-heap info leak** per
  `getdirentries` call, repeatable, exposing kernel pointers and credential
  structures. Read-only — no `uid=0` chain.

HAMMER v1 is shipped as a loadable module (`hammer.ko`, auto-loaded on mount);
`mount_hammer` / `newfs_hammer` are in `/sbin`. No exotic config required.

## Threat model / preconditions

- An admin has mounted (or made mountable) a crafted HAMMER v1 filesystem image
  and made its directory readable (acceptable precondition, consistent with the
  audit's realism test). `vfs.usermount` is OFF, so the mount itself is root; the
  *trigger* (`getdirentries`) is fully unprivileged.
- Alternatively a physically malicious disk / filesystem image presented to the
  kernel produces the same effect on mount + first directory read.

## Exploit chain
None — **read-only info-leak / panic** primitive. No write, no corruption, so
there is no privilege-escalation chain to develop (valid hard blocker per Phase 6:
"the primitive is genuinely read-only"). The production ceiling is the ~49 KB
kernel-heap disclosure (KASLR defeat + credential-pointer leak).

## PoC changes (what was built from scratch)
The finding folder had no PoC; everything was authored during verification:
- `craft_img.c` — HAMMER image forger (locates direntry leaves, sets
  `data_len=8`, recomputes leaf `data_crc` + node crc via the kernel's own
  `iscsi_crc32`). Self-validates its CRC against the on-disk values before patching.
- `icrc32.c` — verbatim copy of `sys/libkern/icrc32.c` (userspace-buildable CRC32C).
- `harness.c` — deterministic transcription of vnops.c:1728-1738 +
  vfs_subr.c:2560-2577 proving the 49160-byte OOB read (production ceiling).
- `build.sh`, `run.sh` — exact build/run commands.
- `fix.diff` — runtime bounds check (see below).

## Recommended fix (`fix.diff`)
Add a runtime bounds check **before** the KKASSERT and the :1737 arithmetic:
```c
if (cursor.leaf->data_len <= HAMMER_ENTRY_NAME_OFF ||
    cursor.leaf->data_len > HAMMER_ENTRY_NAME_OFF + NAME_MAX) {
    error = EIO;
    break;
}
```
This rejects forged/impossible direntry `data_len` values with `EIO`, preventing
both the underflow and the OOB read. The `KKASSERT` is retained as
defense-in-depth (now provably-true after the guard). **Matches** the finding
markdown's `## Recommended fix` proposal. Validated by Phase 8 (below).

## Phase 8 — fix validation (single-fix kernel)

- **Baseline `#0`** (`with-src`, INVARIANTS ON, unpatched): `ls /mnt` on the
  crafted image → **panic** at `hammer_vnops.c:1728` (guest wedged in DDB).
- **Single-fix `#1`** (same tree + `fix.diff` only, `kern.version` =
  `6.5-DEVELOPMENT #1: Sun Jul  5 11:11:26 UTC 2026`,
  `sha256 = 56bd513b...`): identical crafted image, identical `ls /mnt` →
  **clean return** (`total 0`, `LS_RC=0`), **no panic**, guest healthy across 3
  runs. `stat /mnt` succeeds.

The fix closes the bug: the forged direntry is rejected with `EIO` before
reaching the KKASSERT/underflow, on both GENERIC and production kernels.
See `fix_build.log` (kernel build `rc=0`) and `fix_run.log` (post-fix readdir,
no panic).
