# DF-2619 VERDICT

**Status: reproduced (impact: kernel memory disclosure to userspace via getdents; panic variant also reproduced). Confidence: certain.**

## Root cause (line-precise)

`hammer2_chain_dirent_test()` compares a DIRENT chain's on-media name
against a lookup key using the **attacker-controlled on-disk
`bref.embed.dirent.namlen`** with no bound against the chain's actual data
block size (`chain->bytes`, derived from `data_off`'s radix):

```c
sys/vfs/hammer2/hammer2_chain.c:5781-5790
	if (chain->bref.type == HAMMER2_BREF_TYPE_DIRENT &&
	    chain->bref.embed.dirent.namlen == name_len) {
		if (name_len > sizeof(chain->bref.check.buf) &&        /* >64 */
		    bcmp(chain->data->buf, name, name_len) == 0) {     /* unbounded */
```

`chain->data` points into a 64KB DIO buffer at
`data_off & ~HAMMER2_OFF_MASK_RADIX` (chain.c:1100 `chain->data = (void
*)bdata` ← `hammer2_io_data()` io.c:555-563).  Nothing validates
`namlen <= chain->bytes`:

* chain load (chain.c:920-1105) only checks the window-fit invariant
  `KKASSERT((lbase+lsize-1) & pmask) == pbase` (io.c:127) — a radix-7
  (128-byte) block at window offset 0xFF80 **passes** (0xFF80+0x7F = 0xFFFF
  stays inside the 64KB window) while `namlen`=255 walks 127 bytes past
  both the block and (because the block ends exactly at the buffer end)
  past the **DIO buffer itself**.

The same unbounded `namlen` has a second, worse consumer —
`hammer2_vop_readdir()`:

```c
sys/vfs/hammer2/hammer2_vnops.c:723-737
	namlen = bref.embed.dirent.namlen;                    /* on-disk, 255 */
	if (namlen <= sizeof(bref.check.buf)) ...
	else { dname = hammer2_xop_gdata(&xop->head)->buf; }  /* DIO+0xFF80 */
	r = vop_write_dirent(&error, uio, ..., namlen, dname); /* -> userspace */
```

## PoC

`forge_2619.py` (DF-2616 prior-art technique): base image
`newfs_hammer2 -L testvol` + one 255-char-named file; its DIRENT bref (in
the PFS root blockset, key = dirhash(name), namlen 255) is relocated to
`data_off = 0x210FF87` (window 0x2100000, offset 0xFF80, radix 7); the
first 128 bytes of the name are written at 0x210FF80 so the compare walks
the whole in-buffer range and continues past the buffer; ancestors
(dirent bref, PFS inode bref, sroot bref) set to CHECK_NONE (methods=0)
and the three volhdr CRC32Cs recomputed, so mount succeeds normally.

## What was observed (all on the provided QEMU guest)

1. **Leak, stock INVARIANTS kernel #0** (`run_stock.log`): `getdents` on
   the forged mount returns the 255-byte dirent name whose last 127 bytes
   are the kernel memory located directly after the 64KB DIO buffer —
   captured bytes include pointer-like `6f 54 f3 78 23 5a 06 00`
   (LE 0x00065a2378f3546f, repeated), `c0 01 00 00` (448), `02 00 00 00`
   — adjacent buffer-header/cache content.  Control image: clean
   255×'A' name, `stat` succeeds.
2. **Panic, stock kernel #0** (`console_excerpts.txt` [A]): same run
   family, different heap state — `Fatal trap 12, supervisor read, page
   not present` at `memcpy+0x19` (RIP 0xffffffff80bcaba9 = `memcpy`,
   verified via `nm`), fault VA page-granular = first page after the DIO
   buffer.  The read is the dirent name copy.
3. **Panic inside the cited compare, instrumented kernel #1**
   (`console_excerpts.txt` [C]): `stat()` of the 255-char name faults at
   `hammer2_chain_dirent_test+0x89: cmpb %sil,(%rcx,%r8,1)` — the
   per-byte name compare itself reading past the buffer (the
   instrumented loop is semantically identical to the stock `bcmp`).
4. **Full backtrace, instrumented kernel #1** (`console_excerpts.txt`
   [D]): `memcpy+0x19 ← hammer2_vop_readdir+0x5c8` — the vnops.c:729
   `vop_write_dirent` copy of `namlen` bytes from `chain->data->buf`.

Threat model: mounting the crafted image needs root (or
`vfs.usermount=1` with an owned device — same precondition as all
hammer2-image findings, incl. prior art DF-2616/17/18/20/27); the
`getdents` disclosure itself is then available to **any** user with read
access to the directory.  Real-world trigger without a crafted image:
on-media corruption of a hammer2 directory entry (namlen ≥ block size is
accepted by the reader).  Escalation: read-only primitive; 127 bytes of
buffer-cache-adjacent KVA per call — KASLR/heap-layout aid, no write.

## Fix

`fix.diff` (two hunks, both consumers):

1. chain.c: only run the data-block `bcmp` when
   `name_len <= (size_t)chain->bytes` (malformed entry never matches).
2. vnops.c readdir: skip the entry when `namlen >
   1 << (bref.data_off & HAMMER2_OFF_MASK_RADIX)` (the radix is available
   in the frontend's copy of the bref).

A load-time rejection (`chain->error = HAMMER2_ERROR_CHECK` in
`hammer2_chain_load_data`) was considered and **rejected**: the readdir
xop feeds chains without checking `chain->error` and
`hammer2_xop_gdata()` would then run on a NULL `chain->data`, trading the
OOB read for a NULL-deref panic.

## Fix validation (kernel #2, `fix_run.log`)

* Forged image: `getdents` returns cleanly (rc=0), the malformed 255-A
  entry is **absent** (no garbage tail), `stat(A×255)` → ENOENT, healthy
  entry `longname_marker_check` still lists (ino 1024) and stats.
* Control base image: unchanged — 255-A entry lists, `stat` succeeds
  (ino 1025) → **no regression on legitimate long-name dirents**.
* No panics during the whole validation run.
