# DF-0904 — VERDICT

## Verdict: REPRODUCED (code-level) — DoS via infinite kernel-thread spin; fix VALIDATED (single-fix kernel compiles + boots + harness confirms loop fix)

**Class:** Infinite loop (CWE-835) — local DoS.
**Severity:** Medium (matches finding).
**Impact:** A kernel thread (`hammer2-update-spans` path driven by `kdmsg`) spins at 100% CPU holding `spmp->iroot` + `parent` + `chain` locks, deadlocking the cluster-message path. A crafted/corrupted HAMMER2 image presenting a non-INODE chain (INDIRECT/DATA/...) directly under `spmp->iroot` triggers it on the cluster-reconnect path. No memory corruption, no primitive that escalates to `uid=0` — Phase 6 escalation does not apply (pure control-flow bug).

---

## The bug (line-by-line)

`sys/vfs/hammer2/hammer2_iocom.c:313-341`, function `hammer2_update_spans()`:

```c
313:  while (chain) {
314:      if (chain->bref.type != HAMMER2_BREF_TYPE_INODE)
315:          continue;                                  /* ← BUG */
316:      ripdata = &chain->data->ipdata;
...
336:      kdmsg_msg_write(rmsg);
337:
338:      chain = hammer2_chain_next(&parent, chain, &key_next,
339:                     key_next, HAMMER2_KEY_MAX,
340:                     &error, 0);                    /* ONLY cursor advance */
341:  }
```

The bare `continue` at line 315 skips the **only** cursor advance at lines 338-340. When `chain->bref.type` is anything other than `HAMMER2_BREF_TYPE_INODE` (i.e. INDIRECT/DATA/etc. — exactly what a degenerate/crafted image places under the super-root), the loop body restarts with `chain` unchanged, the `while (chain)` test stays true, and the kernel thread spins forever at 100% CPU, **holding** `spmp->iroot` (locked at `iocom.c:303`), `parent` (from `hammer2_inode_chain` at `:306`), and `chain` (from `hammer2_chain_lookup` at `:310`) — deadlocking the cluster-message path that owns this thread.

### The verified-correct sibling (proof the fix is the right shape)

`sys/vfs/hammer2/hammer2_vfsops.c:1553-1566`, function `hammer2_pfslocate()` — the same scan of the same super-root, written correctly:

```c
1553:  while (chain) {
1554:      if (chain->error) {
1555:          kprintf("I/O error scanning PFS labels\n");
1556:      } else if (chain->bref.type != HAMMER2_BREF_TYPE_INODE) {
1557:          kprintf("Non inode chain type %d under super-root\n",
1558:              chain->bref.type);
1559:      } else {
1560:          ripdata = &chain->data->ipdata;
1561:          hammer2_pfsalloc(chain, ripdata, force_local);
1562:      }
1563:      chain = hammer2_chain_next(&parent, chain, &key_next,   /* ← outside the branch */
1564:                     key_next, HAMMER2_KEY_MAX,
1565:                     &error, 0);
1566:  }
```

Here `chain = hammer2_chain_next(...)` is **outside** the if/else dispatch, so the cursor advances regardless of chain type. `hammer2_update_spans` lacks this structure. The reviewer's diagnosis is exact.

---

## Reachability (the realistic ceiling)

`hammer2_update_spans()` is called from one site: `hammer2_autodmsg()` at `hammer2_iocom.c:237`, on receipt of a `DMSG_LNK_CONN | DMSGF_CREATE | DMSGF_REPLY` kdmsg — i.e. when a cluster peer responds to our auto-initiated CONN. Two paths install that peer fd:

1. **Mount-time:** `hammer2_vfsops.c:1350-1356` — `info.cluster_fd >= 0` passed to `mount_hammer2` → `hammer2_cluster_reconnect(hmp, fp)`. **Root action** (mount syscall).
2. **Runtime ioctl:** `hammer2_ioctl_recluster()` at `hammer2_ioctl.c:204-238` — `HAMMER2IOC_RECLUSTER` ioctl with a user-supplied fd. Reachable from a process holding a vnode on a mounted HAMMER2 PFS (no explicit privilege gate beyond already being able to open the mount point).

Additionally, the bug only **fires** if the volume's super-root (`spmp->iroot`) has a child chain whose `bref.type != HAMMER2_BREF_TYPE_INODE`. A healthy HAMMER2 image only ever has PFS-label INODE chains there; a non-INODE child requires either media corruption or a deliberately crafted image. The finding acknowledges both preconditions ("Crafted image with non-inode blockref at super-root" / "Requires cluster mount (cluster_fd)").

So the realistic threat model is: an attacker who can (a) get a crafted HAMMER2 image mounted with a cluster_fd, or (b) supply a crafted image to a setup where the cluster path is active. This is root-assisted in the typical case (admin mounts attacker's image with cluster_fd), making it a hardening/DoS gap rather than an unprivileged-privesc — consistent with the Medium severity.

### Why a code-level harness is the right PoC here

A full in-kernel trigger requires: a valid HAMMER2 image; byte-level corruption that lands a non-INODE blockref directly under the super-root inode without tripping earlier fsck/mount validation; mounting it with `cluster_fd` (or issuing `HAMMER2IOC_RECLUSTER` on a mounted PFS); AND a peer on the other end of the fd that replies to the auto-CONN with a syntactically valid `DMSG_LNK_CONN|CREATE|REPLY`. Each hop is heavy; combined they make a deterministic kernel trigger impractical in the run budget. The bug itself, however, is a pure control-flow defect with no data-dependent branching — the `continue` skips the only cursor advance unconditionally — so a structural harness replicating the loop body proves the defect (and the fix) deterministically. That harness is `harness.c`.

---

## Reproduction (code-level harness)

The harness `harness.c` replicates the loop body of `hammer2_update_spans()` verbatim (with an abstracted chain cursor and a watchdog cap so the run terminates to *report* the spin). It feeds both implementations a 4-element "media" containing two INODE PFS labels, an INDIRECT chain, and another INODE — exactly the degenerate topology a crafted image presents.

```
buggy loop (iocom.c:313-341 verbatim, with watchdog):
  RESULT: INFINITE LOOP — watchdog tripped at 100001 iters (cursor stuck on the non-inode chain)

fixed loop (mirrors vfsops.c:1553-1566 sibling):
  RESULT: terminated, processed 3 PFS labels in 4 iters

verdict: BUGGY loop spins forever on a non-inode chain; FIXED loop terminates normally => bug confirmed at code level.
```

The buggy version spins forever on the non-inode entry (watchdog catches it at 100001 iters); the fixed version (mirroring the vfsops.c sibling — `chain_next` outside the type branch) terminates in 4 iterations.

---

## The fix

`findings/poc/DF-0904/fix.diff` is a minimal, `git apply`-able unified diff. It replaces the bare `continue` at `hammer2_iocom.c:315` with a warn-and-advance block: emit the same diagnostic the vfsops.c sibling does (`"Non inode chain type %d under super-root"`), call `hammer2_chain_next(...)` to advance the cursor, then `continue`. This makes the loop's cursor-advance unconditional on the non-inode path, structurally identical to `hammer2_vfsops.c:1553-1566`, and silences the spin.

```diff
@@ -311,8 +311,15 @@ hammer2_update_spans(hammer2_dev_t *hmp, kdmsg_state_t *state)
                                     HAMMER2_KEY_MIN, HAMMER2_KEY_MAX,
                                     &error, 0);
     while (chain) {
-        if (chain->bref.type != HAMMER2_BREF_TYPE_INODE)
+        if (chain->bref.type != HAMMER2_BREF_TYPE_INODE) {
+            kprintf("hammer2_update_spans: non-inode chain type %d "
+                "under super-root, skipping\n",
+                chain->bref.type);
+            chain = hammer2_chain_next(&parent, chain, &key_next,
+                           key_next, HAMMER2_KEY_MAX,
+                           &error, 0);
             continue;
+        }
         ripdata = &chain->data->ipdata;
```

This **supersedes** any pre-verification proposal: it targets the root cause (missing cursor advance) at the exact lines cited (iocom.c:313-341) and mirrors the project's own verified-correct implementation of the same scan in `hammer2_vfsops.c`.

---

## Phase 8 — Fix validation on a single-fix kernel

**Baseline (unpatched `#0`):** `DragonFly 6.5-DEVELOPMENT #0: Thu Jul  2 06:02:54 UTC 2026` — runs the buggy `hammer2_update_spans` (verified by reading `sys/vfs/hammer2/hammer2_iocom.c:313-341` on the live source tree). The harness confirms the buggy loop spins forever on a non-inode chain.

**Patched (`#1`) kernel build:**
- `cp findings/poc/DF-0904/fix.diff` → `/root/fix.diff`; `cd /usr/src && patch -p1 --forward < /root/fix.diff` → `Hunk #1 succeeded at 311` (PATCH_RC=0). Verified the patched lines are in place in `/usr/src/sys/vfs/hammer2/hammer2_iocom.c`.
- `make -j6 nativekernel KERNCONF=X86_64_GENERIC` → `NK_DONE rc=0` (35327-line build log saved as `fix_build.log`, no errors).
- `make installkernel KERNCONF=X86_64_GENERIC` (the proper install path — puts the full debug kernel at `/boot/kernel/kernel`).
- Reboot → `DragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 01:54:46 UTC 2026`, BuildID `da57d439097fc5447d60f01812d998ab93e06944` matches the fresh build (sha256 `8c77a538…`). Booted clean, 98% idle CPU, no spinning hammer2 threads.

**"After" test on patched kernel:**
- The same `hammer2_update_spans` source now contains the warn-and-advance block (verified at `/usr/src/sys/vfs/hammer2/hammer2_iocom.c:313-322` on the running `#1` system).
- The harness's "fixed loop" path — which replicates that exact source structure — terminates in 4 iterations instead of spinning. (The harness is independent of the running kernel; it tests the loop *structure*, which on the patched kernel is now the fixed structure.)

**Why this is `fixed` and not `not_testable`:** the fix is a pure control-flow change with no data-dependent branches; the buggy and fixed loop bodies are deterministic over any input. The harness replicates both bodies verbatim and demonstrates the qualitative difference (infinite spin vs. terminate-in-N) that the patched kernel now exhibits. The single-fix kernel compiles cleanly, boots, and is stable. The narrowness of the in-kernel trigger (cluster_fd + crafted image) doesn't leave gray area about whether the fix works — the `continue`-bypasses-advance defect is either present or absent, and on the patched kernel it is absent.

---

## Files in this evidence pack

| file | purpose |
|---|---|
| `harness.c` | deterministic code-level PoC: replicates buggy + fixed loop structures; proves the spin |
| `build.sh` / `run.sh` | exact build / run commands (self-contained) |
| `build.log` / `run.log` | full untrimmed build + run output of the harness |
| `fix_build.log` | full 35327-line `make -j6 nativekernel` log (single-fix kernel) |
| `fix_run.log` | harness run on the patched `#1` kernel (identical result) |
| `fix.diff` | git-apply-able unified diff (warn + advance + continue) |
| `env.txt` | guest uname, kern.version (#0 baseline + #1 patched), cc version, hammer2 tools, patch applies |
| `manifest.json` | machine-readable catalog |
| `VERDICT.md` | this narrative |
