# DF-2562 — hammer2 readdir OOB heap read via unchecked on-media name length

## Verdict: REPRODUCED (info-leak / OOB read), FIX VALIDATED

The bug is **real and confirmed**. An unprivileged user calling `readdir` (getdents)
on a hammer2 mount backed by a malicious filesystem image triggers a **kernel heap
out-of-bounds read** of up to ~64 KB past the DIRENT data block. The over-read bytes
are copied into a `struct dirent` and returned to userspace.

## Mechanism

`hammer2_vop_readdir` (`sys/vfs/hammer2/hammer2_vnops.c`) iterates directory
entries via the hammer2 xop machinery. For each chain it extracts the on-media
name length and passes it **unchecked** to `vop_write_dirent()`:

### DIRENT branch (the one triggered by this PoC)

```c
// hammer2_vnops.c:723
namlen = bref.embed.dirent.namlen;      // attacker-controlled, uint16
if (namlen <= sizeof(bref.check.buf))   // 64 bytes
    dname = bref.check.buf;
else
    dname = hammer2_xop_gdata(&xop->head)->buf;  // 1024-byte data block
// hammer2_vnops.c:729
r = vop_write_dirent(&error, uio, ..., namlen, dname);
```

`vop_write_dirent` (`sys/kern/vfs_subr.c:2575`):
```c
bcopy(d_name, dp->d_name, d_namlen);   // OOB if d_namlen > source size
```

With `namlen = 0xFFFF` (forged on-media), `dname` points to a **1024-byte**
DIRENT data block (`HAMMER2_ALLOC_MIN`), but `bcopy` copies **65535 bytes** —
reading 64511 bytes past the block into adjacent kernel heap. The result is
`uiomove`'d to the user buffer.

### INODE branch (same class of bug, not separately triggered)

```c
// hammer2_vnops.c:710
r = vop_write_dirent(&error, uio, ...,
    ripdata->meta.name_len,    // attacker-controlled, uint16
    ripdata->filename);         // 256-byte fixed array (HAMMER2_INODE_MAXNAME)
```

`name_len > 256` would over-read past `filename[]` into the inode blockset/data
union and beyond the 1024-byte inode_data. (The INODE branch is not reachable
via normal readdir because inode chains have keys without `HAMMER2_DIRHASH_VISIBLE`
set; but the code path is identical and vulnerable.)

## Reproduction

### Image forging

The PoC forges a hammer2 filesystem image:

1. Create a fresh 64 MB hammer2 image with `newfs_hammer2`.
2. Mount, create `testdir/`, add files including one with a **>64-byte name**
   (`AAAA...AAAA.txt`, 76 bytes) to force a DIRENT with a separate 1024-byte
   data block (names ≤ 64 bytes embed the name in `bref.check.buf`).
3. Unmount.
4. **forge.c** patches `bref.embed.dirent.namlen` from 76 to 65535 in the
   on-disk DIRENT blockref. Because this changes the parent inode_data,
   the full CRC chain must be recomputed:
   - testdir inode_data → INDIRECT block → DATA PFS root → SUPROOT → volume header.
   - Inode/indirect CRCs are **XXH64** (seed `0x4d617474446c6c6e`).
   - Volume header CRCs are **CRC32C** (3 regions: sect0, sect1, full VH).
5. Re-mount the forged image.

### PoC trigger (unprivileged)

`poc.c` calls `getdents(2)` with a **128 KB buffer** (must exceed
`_DIRENT_RECLEN(65535) ≈ 65552`). The kernel returns the forged entry's
dirent record containing 65535 bytes of `d_name` — 76 bytes of real filename
plus 65459 bytes over-read from kernel heap.

### Evidence

**Unpatched kernel (#0):**
```
getdents returned 65688 (errno=0 ok)
```
The 65688-byte response includes:
- "." (24 B), ".." (24 B), "second_file_padding.txt" (40 B)
- **forged entry** (65552 B): d_namlen=0xFFFF, d_name = 76 B filename + 65476 B OOB-read kernel heap
- "AAAABBBB...txt" (48 B)

On this quiet guest the OOB-read bytes are zero (the adjacent buffer-cache
pages happen to be zeroed), but the read crossed 64511 bytes past the
1024-byte source buffer — the primitive is real.

**Fixed kernel (#1):**
```
getdents returned 136 (errno=0 ok)
```
Only 136 bytes returned (".", "..", and two legitimate entries). The forged
entry is rejected. dmesg shows:
```
hammer2_readdir: ignoring dirent with corrupt namlen 65535
```

## Impact classification

- **Class**: kernel heap OOB read → information disclosure (info-leak).
- **Impact ceiling**: Up to ~64 KB of kernel heap memory disclosed per readdir
  call. On a busy system the adjacent pages contain other filesystem data,
  slab objects, or metadata. Repeated calls can scan large regions of kernel
  heap.
- **No write primitive**: this bug is read-only. Escalation to `uid=0`
  requires a write primitive (OOB write, UAF, etc.) which this bug does not
  provide. This is a **valid Phase-6 hard blocker** for escalation.
- **Trigger**: unprivileged user `readdir` on a malicious hammer2 mount.
  Precondition: an admin has mounted (or made mountable via `vfs.usermount`)
  a malicious filesystem image. This is a realistic threat model (USB media,
  downloaded images).

## Fix

`fix.diff` adds validation in `hammer2_vop_readdir`:

- **INODE branch**: if `ripdata->meta.name_len > HAMMER2_INODE_MAXNAME` (256),
  log a warning and skip the entry (release gdata, `goto next_entry`).
- **DIRENT branch**: if `bref.embed.dirent.namlen > HAMMER2_INODE_MAXNAME`,
  log a warning and skip the entry.
- A `next_entry:` label is added at the bottom of the for-loop for clean
  skip flow.

The fix is minimal and targeted at the root cause. Validated on a built-and-booted
single-fix kernel (#1): the PoC returns 136 bytes (legitimate entries only),
no OOB read, no panic, guest stays up.

## Files

| File | Description |
|------|-------------|
| `forge.c` | Image forger: patches namlen + fixes XXH64/CRC32C chain |
| `crc32ctab.h` | CRC32C lookup table for volume header CRC |
| `poc.c` | Unprivileged getdents trigger with 128 KB buffer |
| `setup_image.sh` | Creates the hammer2 image with >64-byte filename |
| `build.sh` | Builds forge + poc |
| `run.sh` | Full reproduction chain |
| `fix.diff` | git-apply-able fix for hammer2_vnops.c |
| `fix_build.log` | Full kernel build output (single-fix kernel) |
| `fix_run.log` | PoC output on fixed kernel (136 bytes, no OOB) |
| `env.txt` | Guest environment info |
