# DF-0771 — Missing data_len validation on HAMMER inode load — crafted-image heap OOB read

**Verdict: REPRODUCED (info-leak / heap OOB read) — and FIX VALIDATED.**

## The bug (one paragraph)

`hammer_get_inode()` (`sys/vfs/hammer/hammer_inode.c:525`) loads an inode from
disk with an unconditional 128-byte struct copy:

```c
523:  if (*errorp == 0) {
524:      ip->ino_leaf = cursor.node->ondisk->elms[cursor.index].leaf;
525:      ip->ino_data = cursor.data->inode;     /* sizeof = 128 bytes */
```

`cursor.data` is set by `hammer_btree_extract()` (`hammer_btree.c:737`) to the
return value of `hammer_bread_ext(hmp, data_off, data_len, ...)`, which is
`(char *)buffer->ondisk + xoff` where `xoff = data_off & HAMMER_BUFMASK`
(`hammer_ondisk.c:1116-1170`). The buffer is `HAMMER_BUFSIZE` (16384) bytes.
The **only** length validation on the whole load path is the `KKASSERT` at
`hammer_btree.c:734`:

```c
KKASSERT(data_len >= 0 && data_len <= HAMMER_XBUFSIZE);   /* <= 65536 */
```

i.e. the on-disk `data_len` is **never** checked against
`sizeof(struct hammer_inode_data)` (128). The struct copy therefore always
reads 128 bytes from `cursor.data`; when the attacker-controlled
`data_offset`'s within-buffer `xoff` is `> 16384 - 128 = 16256`, the copy
runs **past the 16 KiB data buffer into adjacent kernel heap**.

## Why the CRC gate does not catch it

`hammer_crc_test_leaf()` (`hammer_crc.h:294`) compares the on-disk
`data_crc` against `hammer_crc_get_leaf()`. For an `HAMMER_RECTYPE_INODE`
record, `hammer_crc_get_leaf()` (`hammer_crc.h:267-271`) does:

```c
case HAMMER_RECTYPE_INODE:
    if (leaf->data_len != sizeof(struct hammer_inode_data))
        return(0);   /* "This shouldn't happen" */
```

i.e. a wrong-sized INODE record makes `hammer_crc_get_leaf()` return `0`. A
crafted image that also sets the on-disk `data_crc = 0` then satisfies
`leaf->data_crc == hammer_crc_get_leaf()` as `0 == 0` → the CRC test
**passes** and the wrong-sized record is accepted. (Confirmed in the
harness for both vol_version 6 and 7.)

## Reproduction — deterministic harness (`df0771_harness.c`)

A self-contained userspace program that copies the real kernel structs
(`hammer_inode_data`, `hammer_btree_leaf_elm`) and the verbatim CRC inline
logic from `hammer_crc.h`, then proves both halves of the bug:

```
sizeof(struct hammer_inode_data) = 128
CRC test for INODE leaf with data_len=1, data_crc=0:
   vol_version=6 -> PASS      vol_version=7 -> PASS
BUG PART 1 CONFIRMED: CRC gate bypassed
TEST 2: xoff=16280, struct copy reads 128 bytes at [16280..16408)
   OOB region = [16384..16408) = 24 bytes past buffer end.
   inode_data.atime (last 8 bytes, all in OOB region) hex: 77 77 77 77 77 77 77 77
BUG PART 2 CONFIRMED: struct copy at hammer_inode.c:525 read 24 bytes
   past the 16 KiB HAMMER data buffer end into adjacent kernel heap.
==== DF-0771 REPRODUCED (info-leak / heap OOB read) ====
```

## Reproduction — live kernel path (`reproducer-live.sh`)

A real HAMMER filesystem image is created (`newfs_hammer`), then
`corrupt` (which links the real `iscsi_crc32` from `sys/libkern/icrc32.c`)
walks the B-Tree, finds the root-inode leaf (`obj_id=1, rec_type=INODE`),
rewrites `data_len = 1`, `data_crc = 0`, bumps `data_offset`'s `xoff` to
16264 (so the 128-byte struct copy overruns the 16 KiB buffer by 8 bytes —
the `atime` field lands entirely past the end), recomputes the B-Tree node
CRC, and writes it back. Mounting the image:

**UNPATCHED baseline kernel (`6.5-DEVELOPMENT #0`):** the kernel **panics**
in `hammer_get_inode` reading `atime` past the buffer into an unmapped page:

```
Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xfffff80061e2a000
fault code             = supervisor read data, page not present
instruction pointer    = 0x8:0xffffffff80938a90
Stopped at hammer_get_inode+0x400:  movq 0x78(%rax),%rax
```

`movq 0x78(%rax),%rax` loads the qword at offset `0x78 = 120` — that is the
`atime` field (the last 8 bytes of the 128-byte `struct hammer_inode_data`),
which with `xoff = 16264` lands at buffer byte `16264 + 120 = 16384`, i.e.
the first byte past the buffer. The adjacent page is unmapped → page fault.
(Reproduced twice, identical RIP `0xffffffff80938a90`.) With a mapped
adjacent heap page the read would silently leak heap bytes into
`inode_data.atime`/`mtime`, returned to userspace via `stat()`/`readlink()`.

The non-OOB variant (`data_len=1`, `data_crc=0`, `xoff=0`) **mounts
successfully** on the unpatched kernel — proving the CRC bypass accepts a
wrong-sized INODE record on the live path; the root inode loads with
data copied from the first 128 bytes of the buffer.

## Threat model & impact ceiling

- **Threat model:** crafted HAMMER filesystem image mounted by root /
  operator / removable-media auto-mounter (`vfs.usermount=0` on this guest,
  so unprivileged mount is not possible; the realistic trigger is an admin
  mounting attacker-supplied media — the standard mount-time-parsing
  attacker-controlled-image model used across the audit).
- **Primitive class:** **read-only** heap OOB read (info leak). The struct
  copy is a *load*, not a store; the bug gives no write/corruption primitive.
- **Impact ceiling:** kernel heap info leak (KASLR-defeat / pointer-leak
  class) up to 128 bytes per crafted inode, observable via `stat()`
  (`st_mtime`/`st_atime` map to the corrupted `inode_data.mtime`/`atime`)
  and via `readlink()` (`ext.symlink[24]`). When the adjacent page is
  unmapped the read manifests as a kernel panic (local DoS by an admin who
  mounts attacker media).
- **Phase-6 escalation:** **no chain derivable** — this is a genuinely
  read-only primitive (valid hard blocker per Phase 6). There is no write,
  no UAF, no refcount corruption, no type confusion; only an over-read. No
  privilege-escalation path exists from a pure info leak without a second,
  write-capable bug. The leak could *assist* a separate write primitive
  (e.g. defeat KASLR for a follow-on bug), but on this guest KASLR is
  already OFF, so even that assistance is moot here.

## Fix (`fix.diff`)

Minimal, targeted: validate `cursor.leaf->data_len ==
sizeof(struct hammer_inode_data)` in `hammer_get_inode()` *before* the
struct copy at line 525, setting `*errorp = EIO` (the existing error
cleanup at lines 584-592 frees the inode and returns NULL). One logical
change, 25 lines (mostly comment), in `sys/vfs/hammer/hammer_inode.c`.

(Supersedes the finding markdown's proposal, which also suggested changing
`hammer_crc.h` to `return (hammer_crc_t)-1`. That CRC change is broader and
risks side effects on every `hammer_crc_get_leaf` caller; the consumer-side
check at the actual OOB site is the correct, targeted root-cause fix and is
sufficient on its own — the wrong-sized record is now rejected at the only
place the untrusted `data_len` feeds a fixed-size struct copy.)

## Fix validation (Phase 8 — built, booted, re-run)

- **Baseline (`#0`, unpatched):** PoC `reproducer-live.sh oob` → **panic**
  `hammer_get_inode+0x400: movq 0x78(%rax),%rax` (OOB read into unmapped
  page). Non-OOB variant mounts successfully (CRC bypass confirmed live).
- **Single-fix kernel (`#1`, `Thu Jul 9 17:18:43 2026`,
  sha256 `acfae4f3…`):** same PoC → `mount: Input/output error` (EIO),
  guest stays up, dmesg shows the new guard firing:
  `hammer_get_inode: bad inode data_len 1 for obj_id=0000000000000001`.
  Both the OOB and the non-OOB variant are rejected. **Fixed.**

`fix_status: fixed` — clean before/after on the same PoC.

## Files in this evidence pack

| file | what |
|---|---|
| `df0771_harness.c` | deterministic userspace harness (real structs + verbatim CRC logic) |
| `corrupt.c` + `icrc32_kern.c` | crafted-image corruptor (real `iscsi_crc32` from libkern) |
| `build.sh` / `run.sh` | repro scripts (harness path; unprivileged) |
| `reproducer-live.sh` | live-path reproducer (needs root: vnconfig/newfs_hammer/mount_hammer) |
| `run.log` | harness output on baseline (#0) |
| `panic.txt` | kernel panic signature from `boot.log` (baseline #0, OOB variant) |
| `fix.diff` | the validated fix (git-apply-able) |
| `fix_build.log` / `fix_run.log` | single-fix kernel build log + patched-kernel run output |
| `env.txt` | guest uname / cc version / sysctls |
| `manifest.json` | machine-readable artifact catalog |
