# DF-0776 — Missing production bounds validation on HAMMER B-tree node `count`/`type`

## Verdict
**REPRODUCED. FIX VALIDATED (status: fixed).** On the GENERIC (INVARIANTS-ON)
kernel the bug manifests as a kernel **panic** (local DoS) at
`hammer_btree.c:1278`. On a production (INVARIANTS-OFF) kernel the same forged
node drives deterministic **OOB kernel-heap read** (info leak) and **OOB
kernel-heap write** (corruption). The `fix.diff` runtime validation closes both;
the panic is gone on a single-fix `#1` kernel.

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

HAMMER B-tree nodes are read from disk into `struct hammer_node_ondisk`
(`hammer_btree.h:218`), which contains a fixed-size element array
`elms[HAMMER_BTREE_LEAF_ELMS]` = `elms[63]` (`hammer_btree.h:244`). The
node-header fields `count` (`int32_t`, offset 16) and `type` (`uint8_t`,
offset 20) are attacker-controllable via the on-disk image.

**The load path validates ONLY the CRC, never count/type:**
- `hammer_load_node` (`hammer_ondisk.c:1269`): loads the node buffer, then at
  `:1313-1325` runs **only** `hammer_crc_test_btree()` (`hammer_crc.h:237`).
  If the CRC passes, `HAMMER_NODE_CRCGOOD` is set and the node is handed to
  callers with `count`/`type` **trusted unvalidated**.
- `hammer_crc_test_btree` computes CRC32C (`iscsi_crc32`, `sys/libkern/icrc32.c`)
  over `[4:4096)`. CRC32C is **not crypto-secure** — an attacker recomputes it
  after forging `count`, so the CRC gate is **forgeable**. (Userland `hammer show`
  validates count at `cmd_show.c:264-289`; the kernel does NOT.)

**All in-kernel bounds checks are KKASSERT (no-op on production):**
- `hammer_btree_insert` `:818` — `KKASSERT(node->count < HAMMER_BTREE_LEAF_ELMS)` — no-op on production.
- `btree_search` `:1278` — `KKASSERT(node->count <= HAMMER_BTREE_LEAF_ELMS)` — no-op on production.
- `hammer_btree_delete` `:900-901` — `KKASSERT(i >= 0 && i < ondisk->count)` — no-op.

**OOB READ** (`btree_search`, reached by any `ls`/`stat`/`readdir`):
- `:1089` `hammer_btree_search_node()` (`:1390-1413`) does a binary search with
  `s = node->count`; for count=200 the midpoint indexes `elms[100]`, `elms[150]`,
  etc. — up to **8320+ bytes past `elms[]`** into adjacent kernel heap.
- `:1290` leaf loop `while (i < node->count) { elm = &node->elms[i]; ... }` walks
  `elms[0..199]`, reading `elms[63..199]` OOB.
- `:1090` internal loop `while (i <= node->count)` — same OOB for internal nodes.

**OOB WRITE** (`hammer_btree_insert` `:819-824`, reached by any create/mkdir):
```c
if (i != node->count)
    bcopy(&node->elms[i], &node->elms[i+1], (node->count - i) * sizeof(*elm));
```
With count=200, i=0: `bcopy` of `(200-0)*64 = 12800` bytes starting at `elms[1]`,
writing **8832 bytes past the 4096-byte node buffer** into kernel heap.

### Why the CRC gate does not stop it
The node CRC covers `[4:4096)` — INCLUDING `count`. The attacker forges
`count=200`, then recomputes `crc = iscsi_crc32(node[4:4096])`. The recomputed
CRC matches, so `hammer_crc_test_btree` passes. The self-check in `craft_img.c`
confirms this: the original node CRC matches our `iscsi_crc32` implementation
(`crc ok=1`) before forging, proving the CRC implementation is correct.

## Trigger
1. Root creates a small HAMMER v1 image (`newfs_hammer`), populates entries,
   unmounts (acceptable precondition).
2. `craft_img` binary-patches every leaf node's `count` to 200 and recomputes
   the node CRC32C.
3. Root mounts the crafted image; unprivileged `ls`/`stat`/`readdir` triggers
   `btree_search` → OOB. On GENERIC, the mount's own root-inode lookup is the
   first hit → panic at mount time.

## GENERIC #0 reproduction (panic)

```
panic: assertion "node->count <= HAMMER_BTREE_LEAF_ELMS" failed in btree_search
       at /usr/src/sys/vfs/hammer/hammer_btree.c:1278
Trace beginning at frame 0xfffff801166db218
btree_search.constprop.12() at btree_search.constprop.12+0x11f8
hammer_btree_lookup() at hammer_btree_lookup+0x65
hammer_get_inode() at hammer_get_inode+0x280
hammer_vfs_vget() at hammer_vfs_vget+0x73
hammer_vfs_mount() at hammer_vfs_mount+0xb5e
```
Reproduced 2× on fresh `with-src` resets. See `panic.txt`.

## Production OOB characterization (harness)

`harness.c` transcribes the exact vulnerable loops verbatim with a poisoned
allocator (4096-byte node buffer + adjacent-heap sentinels):
- **OOB READ**: `hammer_btree_search_node` binary search accesses
  `elms[100]`, `elms[150]`, `elms[175]` etc.; leaf loop reads
  `elms[196..199]` = **256 bytes past the 4096-byte node buffer**. (With a
  smaller `search_node` starting index, the leaf loop alone can sweep
  `elms[63..199]` = 8768 bytes OOB.)
- **OOB WRITE**: `hammer_btree_insert` bcopy shifts `(200)*64 = 12800` bytes,
  **8832 bytes past the 4096-byte node buffer** into adjacent kernel heap.

## Impact ceiling / exploit chain

- **GENERIC (INVARIANTS ON, default)**: **panic / local DoS** at mount or first
  btree access. The KKASSERT catches the OOB before it lands. Impact: `panic`.
- **Production (INVARIANTS OFF)**: **OOB kernel-heap read (info leak)** +
  **OOB kernel-heap write (corruption)**. The read discloses adjacent slab data
  (potential KASLR defeat / credential-pointer leak); the write corrupts
  adjacent kernel objects.

**Escalation attempt (Phase 6):** The OOB write is attacker-triggered via
unprivileged `create`/`mkdir` on a mounted crafted image. However, on this
guest the **default kernel is GENERIC (INVARIANTS ON)**, where the KKASSERT
fires before the write — so the write primitive is only reachable on a
non-default (`noinv`) kernel. Per the bright-line rule, an INVARIANTS-OFF-only
escalation is a **non-default-kernel** result. On GENERIC, the demonstrated
impact is `panic` (DoS). The production OOB write primitive is characterized
by the harness; a full `uid=0` chain on `noinv` would require (1) mounting the
crafted image (root precondition), (2) grooming the HAMMER buffer-adjacent slab
to place a victim object (e.g. `struct file` / `struct ucred`) next to the
target B-tree node, (3) triggering `hammer_btree_insert` to overwrite the
victim via the bcopy shift, (4) forging a credential (no SMAP/SMEP) — but this
is blocked by INVARIANTS on the default kernel. **Reported impact: panic on
GENERIC; OOB read/write characterized on production.**

## 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 leaf nodes, sets `count=200`,
  recomputes node CRC via the kernel's own `iscsi_crc32`). Self-validates its
  CRC against on-disk values before patching.
- `icrc32.c` — verbatim copy of `sys/libkern/icrc32.c` (userspace CRC32C).
- `harness.c` — deterministic transcription of `hammer_btree_search_node`
  (`:1390`), `btree_search` leaf loop (`:1290`), and `hammer_btree_insert`
  bcopy (`:819-824`), proving the OOB read + write extents (production ceiling).
- `build.sh`, `run.sh` — exact build/run commands.
- `fix.diff` — 3-file runtime validation + error propagation (see below).

## Recommended fix (`fix.diff`)
Three changes (all in `sys/vfs/hammer/`):
1. **`hammer_ondisk.c`** (`hammer_load_node`, after the CRC check): validate
   `type` (must be LEAF or INTERNAL) and `count` (`0 <= count <= max_elements(type)`)
   using the existing `hammer_node_max_elements()` inline (`hammer_btree.h:283`).
   Set `HAMMER_NODE_CRCBAD` + print a diagnostic if invalid — the node is then
   rejected with EIO exactly as a bad-CRC node is.
2. **`hammer_cursor.c`** (`hammer_init_cursor:200-202`): replace
   `KKASSERT(error == 0)` with proper error propagation (`if (error)
   hammer_done_cursor(cursor); return(error);`) so a rejected root node fails
   gracefully instead of panicking.
3. **`hammer_inode.c`** (`hammer_get_inode:499`): capture and check the return
   of `hammer_init_cursor` (was discarded), bailing with `*errorp` on failure.

**Supersedes** the finding markdown's proposal (which suggested only the
`hammer_ondisk.c` validation; the cascading cursor/inode fixes are required
for graceful rejection on GENERIC). Validated by Phase 8 (below).

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

- **Baseline `#0`** (`with-src`, INVARIANTS ON, unpatched): mount of the crafted
  image (leaf count=200, CRC forged) → **panic** at `hammer_btree.c:1278`
  (`panic.txt`). Reproduced 2× on fresh resets.
- **Single-fix `#1`** (same tree + `fix.diff` only, `kern.version` =
  `6.5-DEVELOPMENT #1: Sun Jul  5 12:23:02 UTC 2026`): identical crafted image,
  identical mount command → **`mount: Input/output error` (EIO)**, **no panic**.
  dmesg shows `hammer_load_node: B-TREE NODE @ 8000000021000000 BAD type=76
  count=200` (type 76 = 'L' = LEAF; count 200 detected and rejected).
  Deterministic across 3 runs. Guest healthy after each. A clean (unforged)
  HAMMER image mounts and operates normally on the same `#1` kernel — the fix
  does not break normal operation.

## Kernel references (confirmed during verification)
- `sys/vfs/hammer/hammer_ondisk.c:1313-1325` — CRC-only node validation (no count/type check)
- `sys/vfs/hammer/hammer_ondisk.c:1269` — `hammer_load_node` (the load path)
- `sys/vfs/hammer/hammer_btree.c:1278` — `KKASSERT(node->count <= HAMMER_BTREE_LEAF_ELMS)` (panic site)
- `sys/vfs/hammer/hammer_btree.c:818` — `KKASSERT(node->count < HAMMER_BTREE_LEAF_ELMS)` (insert)
- `sys/vfs/hammer/hammer_btree.c:819-824` — OOB WRITE (`bcopy` shift)
- `sys/vfs/hammer/hammer_btree.c:1089-1090` — OOB READ (internal loop + search_node)
- `sys/vfs/hammer/hammer_btree.c:1289-1290` — OOB READ (leaf loop + search_node)
- `sys/vfs/hammer/hammer_btree.c:1390-1413` — `hammer_btree_search_node` (binary search OOB)
- `sys/vfs/hammer/hammer_btree.h:215-216,244` — `HAMMER_BTREE_LEAF_ELMS=63`, `elms[63]`
- `sys/vfs/hammer/hammer_btree.h:283-292` — `hammer_node_max_elements()` (fix uses this)
- `sys/vfs/hammer/hammer_crc.h:237-247` — `hammer_crc_test_btree` (forgeable CRC32C)
- `sys/vfs/hammer/hammer_cursor.c:200-202` — cascading `KKASSERT(error==0)` (fix #2)
- `sys/vfs/hammer/hammer_inode.c:499` — discarded `hammer_init_cursor` return (fix #3)
