# DF-2618 VERDICT — verified reproduced (panic) on stock INVARIANTS kernel; fix validated on rebuilt kernel

Guest: DragonFly dfbsd 6.5-DEVELOPMENT #0 x86_64 (stock, INVARIANTS),
dfbsd-qemu/vm.sh.  Baseline panic captured 2026-08-28 ~19:44 UTC; fix
validated 19:52-19:58 UTC on rebuilt kernel #1.

## 1. Reproduction (baseline, stock kernel #0)

Crafted image `craft2618.img` (forge details in README.md):

    root@dfbsd# mount -t hammer2 /dev/vn0@testvol /mnt/h2   # OK
    root@dfbsd# ls /mnt/h2                                   # a b c d  (OK)
    root@dfbsd# stat /mnt/h2/a                               # OK (inum 1024)
    root@dfbsd# stat /mnt/h2/c
    panic: hammer2_chain_insert: collision 0xfffff80118c22100 0xfffff80118c21980 (key=0000000000000401)

Full serial capture in `panic.txt`; guest went down (`vm.sh status` => down).
The panic is 100% deterministic across the two runs performed (run.log stops
mid-script at the same stat; panic2.txt = second capture during re-test of
the identical image — identical signature).

### Why it fires (line-precise)

1. `forge_df2618.py` sets INODE(1024).keybits=1 => [1024,1025] and
   INODE(1025).keybits=1 => [1025,1026] in the indirect array at media
   offset 0x1c01000 (array order by key preserved: 1024 < 1025 < 1026 < 1027).
2. `stat a` (nresolve) -> `hammer2_chain_inode_find(1024)`
   (chain.c:5640) -> `hammer2_chain_lookup` [1024,1024] ->
   `base_find` (chain.c:4921) stops at INODE(1024) -> `hammer2_chain_get`
   (chain.c:2051) -> `hammer2_chain_insert` (chain.c:292) inserts
   chain(1024)=[1024,1025] into the indirect parent's core.rbtree.
   The inode (ip) keeps the chain referenced (pinned) after the stat.
3. `stat c` -> `hammer2_chain_inode_find(1026)` -> lookup [1026,1026]:
   - `base_find`: INODE(1024).end=1025 < 1026 -> advance; INODE(1025).end=
     1026 >= 1026 -> break at the *overlapping* entry (chain.c:4975-4986).
   - `hammer2_chain_find` (chain.c:1931, RB_SCAN of the rbtree): pinned
     chain(1024) covers [1024,1025], not 1026 -> no match.
   - combined_find (chain.c:5020): only the blockref matched ->
     `hammer2_chain_get` on INODE(1025) (chain.c:2613).
4. `hammer2_chain_insert` -> `RB_INSERT` (chain.c:313) walks to pinned
   chain(1024): `hammer2_chain_cmp` (chain.c:97-118):
   c1=[1024,1025] (new chain), c2=[1024,1025] (existing) -> overlap ->
   returns 0 -> `RB_INSERT` returns the existing node (sys/sys/tree.h:674-675)
   -> `KASSERT(xchain == NULL)` (chain.c:314) -> **panic**, exactly the
   finding's claim.

Impact on the stock kernel: local kernel panic (DoS) from mounting and
stat'ing files on a corrupt/crafted hammer2 volume.  Precondition: the mount
itself (root, or unprivileged with vfs.usermount=1 + accessible device) —
matching the Medium severity and hammer2 bucket of the filed finding.

## 2. Control (stock kernel #0 and fixed kernel #1)

Pristine `base2618.img`: mount, ls, all stats, open, read-back — all succeed
on both kernels (`control.log`, `fix_control_run.log`).  The forge — not the
base image or the CHECK_NONE edits — is what triggers the bug.

## 3. Release-build consequence (rbtree root wipe) — source trace

Not demoed on a no-INVARIANTS kernel (extra ~35-min build for an effect that
is fully determined by the code); traced line-by-line instead:

- Chains are allocated M_ZERO (chain.c:203-204), so a fresh chain's
  `rbnode` (rbe_left/rbe_right/rbe_parent/rbe_color) is all-zero.
- On collision `RB_INSERT` returns *without* linking or `RB_SET`-ting the new
  element (tree.h:674-675 returns `tmp`; `RB_SET` at tree.h:677 is never
  reached), so the phantom chain's rbnode stays all-NULL.
- Release build compiles out the KASSERT; chain.c:317-319 then sets
  `HAMMER2_CHAIN_ONRBTREE`, `chain->parent = parent`, and bumps
  `parent->core.chain_count` for a chain that is **not** in the tree.
- `hammer2_chain_get` returns the phantom as a normal chain (error==0; the
  `bcmp` at chain.c:2634 passes since bref was copied from the same bref).
- When its last ref drops, `hammer2_chain_lastdrop` (chain.c:618-628) sees
  ONRBTREE set and calls `RB_REMOVE(&parent->core.rbtree, chain)`
  (chain.c:623).  In `name##_RB_REMOVE` (tree.h:585): `RB_LEFT(elm)==NULL`
  -> `child = RB_RIGHT(elm) = NULL` (tree.h:597-598); `parent =
  RB_PARENT(elm) = NULL` (tree.h:641); `color = RB_COLOR(elm) = RB_BLACK(0)`
  -> parent==NULL branch executes **`RB_ROOT(head) = child = NULL`**
  (tree.h:651-652), wiping the parent's live rbtree root even though it
  contains other referenced chains.  `RB_REMOVE_COLOR(head, NULL, NULL)`
  (tree.h:654-655) is a no-op (loop condition tree.h:510-511 false).
- Consequences: every chain still linked in that tree becomes unreachable by
  lookup (they keep their refs/locks; `chain_count` is left inflated), later
  lookups re-create chains for the same brefs into the now-empty tree, and
  the orphaned chains can never be found for deletion/flush -> permanent
  chain leak + lookup misses, and duplicated in-memory state for the same
  media bref — structural memory corruption of the chain topology, exactly
  as the finding describes.  (Also note tree.h:641-652 runs with the parent
  core spinlock held, so at least the wipe itself is not racy.)

So the finding's release-path claim is code-confirmed; only the KASSERT panic
is directly observable on this guest's INVARIANTS kernel.

## 4. Fix validation (fix.diff, kernel #1 built 19:52:28 UTC 2026)

`fix.diff` (git-apply-able, verified with `git apply --check` against the
read-only sys/ tree; applied inside the guest's /usr/src copy):

1. `hammer2_chain_insert` (chain.c:313-320): on `RB_INSERT` collision,
   rate-limited `krateprintf` + `error = HAMMER2_ERROR_CHECK`, **no**
   ONRBTREE/parent/chain_count/generation mutation (removes the KASSERT and
   the phantom-state bug in one stroke).
2. `hammer2_chain_get` (chain.c:2089-2101): EAGAIN keeps the existing
   unlock/drop/NULL race path; corruption (CHECK) returns the chain locked
   with `chain->error` set so callers can skip the entry.
3. `hammer2_chain_lookup` (chain.c:2616) and `hammer2_chain_scan`
   (chain.c:2985): on such an errored unlinked chain, unlock/drop and resume
   the scan at `bref->key + (1 << bref->keybits)` — the same advance the
   DELETED-skip path uses — so iteration can never spin on the bad entry.
4. Flush-time `chain_get` sites (create_indirect chain.c:3942, indirect
   maintenance chain.c:4195): skip past the corrupt bref (key advanced),
   keeping those loops finite.
5. `hammer2_chain_create` (chain.c:3325): insert failure marks
   `chain->error` (chain destroyed cleanly on drop since ONRBTREE is unset).

Rebuild: `cd /usr/src && make -j6 nativekernel && make installkernel`
(fix_build.log, BUILD_RC=0, 38176 lines untrimmed), reboot into #1.

Rerun of the **exact same PoC**:

- `trigger.sh control` (pristine): identical-to-stock behavior, all
  operations succeed (fix_control_run.log).
- `trigger.sh craft` (forged): mount OK, `ls` lists a b c d, `stat a` OK,
  `stat b`/`stat c` fail ENOENT (graceful: the overlapping entries shadow
  keys 1025/1026 — pre-fix the same lookups either ENOENT'd (b) or
  PANICKED (c)), script completes, **guest stays up** (fix_run.log).
- Console shows the fix's detection message exactly once:
  `hammer2_chain_insert: collision 0xfffff80118d82880 0xfffff80118d82100
  (key=0000000000000401)` (dmesg.txt).
- Write smoke test on the corrupt image: create/read/sync/umount of
  /mnt/h2/newfile all succeed; system (hammer2 root) stable (fix_write_smoke.log).

fix_status: **fixed** — baseline behavior (deterministic panic) gone,
no regression on the control image, sane degradation on the corrupt image.

## 5. Notes / limitations

- The panic needs only VOP lookup activity; the explicit fd-pin originally
  planned for the trigger turned out unnecessary (the inode cache alone
  pins the colliding chain across the two stats).
- The release-build root-wipe was verified by trace, not runtime (see §3);
  building a no-INVARIANTS kernel was out of time budget and would not
  change the classification (the INVARIANTS panic proves the overlap
  primitive; the trace proves the release consequence).
- `not-a-bug` check: `hammer2_chain_cmp`'s overlap==match semantics are
  intentional (comment chain.c:104-107) and load-bearing for
  `hammer2_chain_find`; the defect is the absence of corruption handling
  when the on-disk array violates the no-overlap invariant, which the fix
  adds at the single choke point all media-driven inserts pass through.
