# DF-0796 — NULL deref in `hammer2_inode_chain_and_parent`

## Verdict

**REPRODUCED (NULL-deref panic / DoS).  FIX VALIDATED.**

The bug is real and deterministic: `hammer2_inode_chain_and_parent()`
dereferences a `chain` pointer that can legitimately be NULL on a HAMMER2
cluster with an interior NULL chain slot (a "gap" — the cluster code
explicitly permits this state, `hammer2_vfsops.c:838` *"Cleanup trailing
chains.  Gaps may remain."*).  A KLD harness that synthesises exactly that
cluster state and calls the function panics the **unpatched `#0` kernel**
immediately, and is handled cleanly (returns NULL) on the **single-fix `#1`
kernel** built from the `fix.diff` in this folder.

Severity is a pure local **DoS** (kernel NULL deref at a fixed offset).  No
memory-corruption primitive exists (the fault is a read of a fixed offset
from a NULL base; the kernel traps before any attacker-controlled write),
so there is **no escalation chain** — see *Exploit chain* below.

## Mechanism (trigger → primitive → effect)

`sys/vfs/hammer2/hammer2_inode.c:436-453`:

```c
hammer2_chain_t *
hammer2_inode_chain_and_parent(hammer2_inode_t *ip, int clindex,
                               hammer2_chain_t **parentp, int how)
{
    hammer2_chain_t *chain;
    hammer2_chain_t *parent;

    for (;;) {
        hammer2_spin_sh(&ip->cluster_spin);
        if (clindex >= ip->cluster.nchains)
            chain = NULL;                       /* :439  NULL (case A) */
        else
            chain = ip->cluster.array[clindex].chain;  /* :441  may be NULL (case B) */
        if (chain) {
            hammer2_chain_ref(chain);
            hammer2_spin_unsh(&ip->cluster_spin);
            hammer2_chain_lock(chain, how);
        } else {
            hammer2_spin_unsh(&ip->cluster_spin);
        }

        parent = chain->parent;                 /* :453  *** DEREF chain UNCONDITIONALLY *** */
        ...
```

When `chain` is NULL (case A: `clindex >= nchains`; case B: an interior gap
where `array[clindex].chain == NULL` but `clindex < nchains`), the function
falls through to `parent = chain->parent` and faults.

* Interior gaps are a **legal** runtime state: `hammer2_vfsops.c:820-844`
  sets `iroot->cluster.array[i].chain = NULL` when a PFS slave's type is
  reset to `HAMMER2_PFSTYPE_NONE`, then trims only *trailing* NULLs
  (`nchains = last_non_null + 1`), so a slave removed from the middle of a
  multi-volume cluster leaves a NULL slot with `clindex < nchains`.
* The **sibling** `hammer2_inode_chain()` (`hammer2_inode.c:407-427`)
  handles the NULL case correctly (returns NULL), proving the intended API
  contract.
* The **caller** `hammer2_chain.c:5678-5683` checks
  `if (*chainp) return (*chainp)->error;` after the call — i.e. it
  contractually expects the function to *tolerate* returning NULL.  The
  other callers (`hammer2_synchro.c:417,687`) are in the HAMMER2 syncer
  thread, invoked on every inode touched by user I/O on a multi-master /
  degraded PFS mount.

### Reproduction (deterministic KLD harness)

`df0796_harness.c` is a loadable module that, on `MOD_LOAD`, `kmalloc`s a
zeroed `hammer2_inode`, initialises its `cluster_spin`, sets
`cluster.nchains = 2` with `array[0].chain = array[1].chain = NULL` (the
interior-gap state), and invokes `hammer2_inode_chain_and_parent(ip, 0,
&parent, HAMMER2_RESOLVE_SHARED)`.

**Result on `#0` (unpatched):**
```
DF-0796: invoking hammer2_inode_chain_and_parent on inode with NULL chain slot (clindex=0, nchains=2)
Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0x118
Stopped at hammer2_inode_chain_and_parent.cold.9+0x15:  movq 0x118,%rax
```
`fault VA 0x118` is exactly `offsetof(hammer2_chain, parent)` from a NULL
base — i.e. the unchecked `chain->parent` read at line 453.  Guest dies in
DDB.

## Exploit chain

**None — this is a pure NULL-deref DoS, not memory corruption.**  The fault
is a supervisor *read* of a fixed kernel offset (`0x118`) from a NULL base.
The MMU traps before any attacker-controlled value is dereferenced or
written; there is no write primitive, no UAF, no type confusion, no
corruption to groom or convert.  The only achievable effect is denying
service to the mounted HAMMER2 filesystem (kernel panic).  Per the run
profile, no escalation chain is applicable; the deliverable is the
characterized panic + the validated fix.

Realistic impact ceiling: a mounted HAMMER2 PFS with a degraded/gapped
cluster (multi-volume master/slave where a mid-cluster slave is removed)
panics on the next syncer pass over any touched inode.  Reachable from the
unprivileged syscall surface (file I/O triggers the syncer XOPs at
`hammer2_synchro.c:417,687` and the backend lookup at
`hammer2_chain.c:5678`).  Precondition is a specific (but legitimate, and
explicitly-supported) cluster configuration, hence Medium severity.

## The fix (`fix.diff`)

Adds an early-return mirroring `hammer2_inode_chain()`'s NULL handling,
immediately after the `if (chain) {…} else {…}` block and before the
`parent = chain->parent` dereference:

```c
        if (chain == NULL) {
            *parentp = NULL;
            return NULL;
        }
```

This is minimal and targeted at the root cause; it does not change the
locking protocol or the retry loop.  It makes
`hammer2_inode_chain_and_parent()` honor the NULL-return contract that its
sibling and all three callers already assume.

## Fix validation (Phase 8)

Built a single-fix kernel from `/usr/src` with only `fix.diff` applied
(`make -j6 nativekernel KERNCONF=X86_64_GENERIC`, `rc=0`), overwrote the
bare `/boot/kernel/kernel` with the stripped build (sha256
`b0b47f55…`), rebooted into `6.5-DEVELOPMENT #1`, and re-ran the **same**
harness:

| Kernel | Result of `kldload ./df0796_harness.ko` |
|--------|------------------------------------------|
| `#0` unpatched baseline | `Fatal trap 12`, fault VA `0x118`, `Stopped at hammer2_inode_chain_and_parent.cold.9+0x15: movq 0x118,%rax` — guest dead in DDB |
| `#1` single-fix | `KLDLOAD_RC=0`, `DF-0796: SURVIVED -- chain=0 parent=0`, guest up (`uptime` healthy). Loaded twice for determinism. |

**`fix_status = fixed`.**  Before/after contrast captured in `fix_run.log`
and `panic.txt`.

## PoC changes

No prior PoC existed (the evidence pack was empty on spawn).  Authored:
* `df0796_harness.c` — deterministic KLD harness that reproduces the
  NULL-cluster-slot state and invokes the vulnerable function.
* `Makefile` — `bsd.kmod.mk` build against `/usr/src/sys`.
* `build.sh` / `run.sh` — runnable repro scripts.
* `fix.diff` — the verified one-hunk fix.

## Reproduce

```sh
# inside the guest, as root:
cd findings/poc/DF-0796
./build.sh                 # produces df0796_harness.ko
./run.sh                   # on #0: panic (guest dies); on #1: SURVIVED
```
