# DF-0762 — NULL deref in `hammer2_chain_lastdrop` no-parent retry path

## Verdict
**REPRODUCED (code-level certain) — local DoS (kernel panic) on a HAMMER2 filesystem under churn.**
The NULL-deref is a provable static fact (not probabilistic); the only probabilistic
element is winning the concurrent-ref-bump race that *reaches* the buggy retry line.
The live race did not panic within bounded churn time, but the bug is unambiguous and
the missing check is confirmed line-by-line below. **Fix VALIDATED** on a single-fix
`#1` kernel: offending line removed, patched kernel compiles/boots, identical hammer2
churn runs clean with no panic and no regression.

| field | value |
|---|---|
| status | reproduced |
| impact | dos (NULL-deref → kernel panic) |
| confidence | certain (static proof) |
| severity (finding) | Medium |
| preconditions | a mounted HAMMER2 filesystem (mount is root-only — acceptable mount-threat-model precondition); heavy concurrent churn (unprivileged) wins the race |

## Mechanism (trigger → primitive → effect)

`hammer2_chain_lastdrop()` (`sys/vfs/hammer2/hammer2_chain.c`) is reached when a
chain's refcount is dropping 1→0 (caller `hammer2_chain_drop`, line 357-359). At
the topology-teardown tail of `lastdrop`:

- **L577** `parent = chain->parent;` — `parent` may legitimately be `NULL` (a chain
  detached from the topology, or a PFS-root chain whose own parent is gone).
- **L593** `if (parent) {` — the *parent* case. It correctly acquires
  `hammer2_spin_ex(&parent->core.spin)` at **L594** before the cmpset, and releases
  it at **L599** (retry) / **L643** (success).
- **L646** `} else {` — the **No-parent case**, where `parent == NULL`.
- **L650** `if (atomic_cmpset_int(&chain->refs, 1, 0) == 0) {` — the 1→0 transition
  failed because a concurrent `hammer2_chain_ref` (atomic bump) raced refs 1→2 in
  the window. **This is the retry path.**
- **L654** `hammer2_spin_unex(&parent->core.spin);` — **BUG.** In this `else` branch
  `parent` is `NULL`, and **no parent spinlock was ever acquired** in this branch.
  `&parent->core.spin` evaluates to `NULL + offsetof(hammer2_chain, core) +
  offsetof(hammer2_chain_core, spin)`. `core` is the 2nd field of `hammer2_chain`
  (after `hammer2_mtx_t lock`, `sys/vfs/hammer2/hammer2.h:325-326`) and `spin` is
  the 1st field of `hammer2_chain_core` (`hammer2.h:238-239`), so the deref lands
  at `NULL + sizeof(hammer2_mtx_t)` — a low, unmapped address → **page fault →
  kernel panic (DoS)**. The line is doubly wrong: it dereferences NULL *and*
  releases a spinlock that was never held.

Compare the *parent* retry (L599), which legitimately releases `parent->core.spin`
because L594 acquired it. The no-parent retry is a copy/paste of that block without
the corresponding acquire; it should only release the locks actually held in this
branch — `chain->core.spin` (L655) and `chain->lock` (L656) — which were acquired
earlier in `lastdrop`.

## Reachability / threat model

- `parent == NULL` on entry to the teardown tail is routine: it happens for any
  chain detached from the topology whose refs are being dropped (e.g. a parent
  chain that lost its last child via the recursive `rdrop` return at L698 and
  itself has no parent), and for PFS-root chains during unmount.
- The cmpset at L650 fails when a concurrent `hammer2_chain_ref` bumps refs 1→2.
  The kernel itself acknowledges such races exist (`h2race1` detection kprintf at
  `hammer2_chain.c:403`).
- **Trigger (unprivileged):** once a HAMMER2 filesystem is mounted (root
  precondition — an admin has mounted a filesystem image, an acceptable
  mount-threat-model precondition), heavy concurrent create/delete/rename/sync
  churn by an unprivileged user drives blocktable maintenance and topology
  teardown, transiently producing parentless chains whose last-drop races a
  concurrent ref → the buggy retry fires → panic.

A NULL-deref at a fixed low offset is a **pure DoS** — no escalation chain is
possible (no attacker-controlled write content, no pivoting). This finding is DoS
only; there is no `uid=0` path.

## Reproduction attempt (live)

- Built `h2_churn` (multi-thread create/delete/rename/mkdir-tree/sync storm) as
  the unprivileged user `maxx` on a 2 GB HAMMER2 image mounted at `/h2test`.
- Ran ~3.5 min total of aggressive churn (4/2/2 → 8/4/4 → 10/6/6 threads) on the
  unpatched `#0` kernel. No panic fired: the race window (cmpset losing to a
  concurrent ref bump on a transiently-parentless chain) is genuinely narrow.
- This is expected; the bug is nonetheless a **static certainty** — once the
  else-branch retry is reached, `parent` is unconditionally `NULL` and L654
  unconditionally faults. Per the run-brief, a deterministic code-level
  confirmation is acceptable when the live race is too narrow; the line-by-line
  trace above is that confirmation.

## Fix

`fix.diff` removes the bogus `hammer2_spin_unex(&parent->core.spin);` at the
no-parent retry (old L654) and clarifies the comment. The no-parent branch never
acquires `parent->core.spin` (parent is NULL), so removing its release is the only
correct, minimal change. The remaining two releases (`chain->core.spin`,
`chain->lock`) are legitimate (held since earlier in `lastdrop`).

## Fix validation (Phase 8)

- **Baseline (`#0`, unpatched):** `kern.version` = `6.5-DEVELOPMENT #0`,
  Thu Jul 2 06:02:54 UTC 2026. Bug line `hammer2_spin_unex(&parent->core.spin);`
  present at L654. (Live race did not panic in bounded churn — narrow.)
- Applied `fix.diff` to in-guest `/usr/src`; line confirmed removed.
- Built single-fix kernel `make -j6 nativekernel KERNCONF=X86_64_GENERIC`
  (warm obj; `hammer2_chain.o` recompiled, `kernel.debug` relinked; rc=0;
  `options HAMMER2` builds hammer2 into the kernel, so the rebuilt
  `kernel.stripped` carries the fix — no module reinstall needed).
- Installed `/boot/kernel/kernel` ← `kernel.stripped`, rebooted.
- **Patched (`#1`):** `kern.version` = `6.5-DEVELOPMENT #1`,
  Thu Jul 9 12:55:57 UTC 2026. Bug line gone; comment updated.
- Re-ran the **identical** churn workload (`./h2_churn 120 10 6 6`) as `maxx`:
  no panic, no regression, `BG_DONE rc=0`, guest stayed up.
- **Verdict:** the offending NULL-deref line is removed by construction and the
  patched kernel is functionally clean; the live race is too narrow to serve as
  a panic before/after, so validation rests on the code-level removal (clean
  before/after of L654) + compile/boot + no-regression churn.

## PoC changes

- Authored the evidence pack from scratch (no prior PoC folder existed):
  `h2_churn.c` (multi-thread HAMMER2 churn harness), `setup_h2.sh` (creates a
  HAMMER2 image via `vnconfig`+`newfs_hammer2`, mounts, chowns to `maxx`),
  `build.sh`, `run.sh`, `fix.diff`, this `VERDICT.md`, `manifest.json`, full logs.
- `setup_h2.sh` uses DragonFly's `vnconfig` (not Linux `mdconfig`) on `vn0`.

## Reproduce

```
ssh dfbsd     # root: set up the hammer2 image + mount
  /bin/sh /root/poc/DF-0762/setup_h2.sh 2048     # creates /h2test, chowns to maxx
ssh dfbsd-maxx  # unprivileged: run the churn
  cd /h2test && /root/poc/DF-0762/h2_churn 120 10 6 6
# watch dfbsd-qemu/boot.log for a fatal-trap/page-fault in hammer2_chain_lastdrop
# (the race is narrow; the bug is a static certainty at hammer2_chain.c:654)
```
