β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0904

Infinite kernel-thread loop in hammer2_update_spans when non-inode chain under super-root

Summary

hammer2_iocom.c:315 if(chain->bref.type!=HAMMER2_BREF_TYPE_INODE) continue. continue bypasses chain=hammer2_chain_next at :338-340 the only cursor advance. First non-inode chain (INDIRECT/DATA/etc.) under spmp->iroot = infinite spin holding iroot+parent+chain locks. vfsops.c:1553-1566 sibling handles correctly (warn+advance). Crafted image with non-inode blockref at super-root = cluster path deadlock. Requires cluster mount (cluster_fd).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0904 Β· 13 files
FileTypeDescriptionSize
harness.c trigger-source deterministic code-level PoC replicating buggy iocom.c:313-341 loop + fixed vfsops.c:1553-1566 sibling 5.2 KB view raw
build.sh build-script cc -O2 -o harness harness.c 342 B view raw
run.sh run-script ./harness 368 B view raw
build.log build-log harness compile output 65 B view raw
run.log run-log decisive harness run, full output 587 B view raw
fix_build.log fix-build-log 35327-line make -j6 nativekernel log for the single-fix kernel 5.6 MB ↓ download
fix_run.log fix-run-log harness run on the patched #1 kernel 574 B view raw
fix.diff suggested-fix git-apply-able warn+advance+continue mirroring vfsops.c sibling 788 B view raw
env.txt environment uname, kern.version #0/#1, cc, hammer2 tools, patch-dry-run 656 B view raw
VERDICT.md verdict full narrative: mechanism, reachability, Phase 8 validation 10.7 KB ↓ raw
README.md readme summary + build/run 2.2 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme summary + build/run
↓ download raw

DF-0904 β€” hammer2_update_spans infinite-loop PoC

What this is

A deterministic code-level harness proving the infinite-loop defect in hammer2_update_spans() (sys/vfs/hammer2/hammer2_iocom.c:313-341).

The bug: the bare continue at line 315 skips the only cursor advance (hammer2_chain_next at lines 338-340), so any non-INODE chain under the HAMMER2 super-root pins the loop forever at 100% CPU, holding iroot+parent+chain locks β†’ cluster-message path deadlock (DoS).

The harness replicates both the buggy iocom.c loop structure and the verified-correct vfsops.c:1553-1566 sibling structure, and shows the qualitative difference (spin vs. terminate) over the same input.

Why a harness (not an in-kernel trigger)

The in-kernel trigger requires: 1. A cluster-mount setup (root passes cluster_fd, OR a process issues HAMMER2IOC_RECLUSTER on a mounted PFS), AND 2. A peer on the cluster fd that replies with DMSG_LNK_CONN|CREATE|REPLY, AND 3. A crafted/corrupted HAMMER2 image with a non-INODE blockref directly under the super-root inode.

Each hop is heavy; combined they make a deterministic kernel trigger impractical in the run budget. The defect itself is pure control flow with no data-dependent branching, so a structural harness is fully decisive.

Build & run (as unprivileged user maxx)

./build.sh        # cc -O2 -o harness harness.c
./run.sh          # ./harness

Expected output (bug present AND bug fixed β€” harness exercises both paths)

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 fix

fix.diff β€” replace the bare continue with a warn+advance+continue block (mirrors the vfsops.c sibling). Validated on a single-fix #1 kernel: compiles clean, boots clean, harness's fixed-loop path confirms termination.

See VERDICT.md for the full line-by-line trace, reachability analysis, and Phase 8 validation.

VERDICT.md verdict full narrative: mechanism, reachability, Phase 8 validation
↓ download raw

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():

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:

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.

@@ -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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. fix.diff applies cleanly to /usr/src (patch -p1, Hunk #1 succeeded at 311). Single-fix kernel built with make -j6 nativekernel KERNCONF=X86_64_GENERIC (NK_DONE rc=0, 35327-line log saved), installed via make installkernel, rebooted to DragonFly 6.5-DEVELOPMENT #1 (today's ts, BuildID matches fresh build). Patched source at /usr/src/sys/vfs/hammer2/hammer2_iocom.c:313-322 on the running #1 system contains the warn+advance+continue block. Harness's fixed-loop path (which replicates that exact structure) terminates in 4 iterations instead of spinning -- the qualitative before/after difference (infinite spin vs. terminate-in-N) is decisive for this pure-control-flow defect. Guest stable after boot (98% idle CPU, no spinning hammer2 threads). Baseline #0 still has the bare continue (confirmed before applying the diff). The narrow in-kernel trigger (cluster_fd + crafted image + DMSG-speaking peer) leaves no gray area: the continue-bypasses-advance defect is structurally either present or absent, and on the patched kernel it is absent.

BEFORE (unpatched #0, sys/vfs/hammer2/hammer2_iocom.c:313-315):  `if (chain->bref.type != HAMMER2_BREF_TYPE_INODE) continue;` -- harness buggy-loop over this structure: INFINITE LOOP (watchdog @ 100001 iters, cursor stuck on non-inode chain). AFTER (patched #1, same lines): `if (chain->bref.type != HAMMER2_BREF_TYPE_INODE) { kprintf(...); chain = hammer2_chain_next(...); continue; }` -- harness fixed-loop over this structure: terminated, processed 3 PFS labels in 4 iters. Patched #1 kernel: `sysctl kern.version => DragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 01:54:46 UTC 2026`, sha256(/boot/kernel/kernel)=8c77a538..., uptime shows 98% idle CPU, no hammer2 threads spinning.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 01:54:46 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (sha256 8c77a53890c0ed869a954421d5a43e7a25423f964bad8a51d619fc4b40a52fa2, BuildID da57d439097fc5447d60f01812d998ab93e06944)

Confirmed kernel references

Detail

Exploit chain

none -- pure control-flow DoS (CWE-835 infinite loop), not memory corruption. No primitive derivable toward uid0; Phase 6 escalation does not apply. Impact ceiling: a kernel thread pinned at 100% CPU holding HAMMER2 iroot/parent/chain locks, deadlocking the cluster-message path for the affected hammer2 mount. Reachable only via the cluster path (mount cluster_fd or HAMMER2IOC_RECLUSTER ioctl) on a corrupted/crafted image, so an unprivileged user cannot trigger it without an admin mounting (or letting them mount) a malicious image and bringing up a cluster peer.

Evidence (decisive lines)

harness output: `buggy loop (iocom.c:313-341 verbatim, with watchdog): RESULT: INFINITE LOOP -- watchdog tripped at 100001 iters (cursor stuck on the non-inode chain)` vs `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.` Source: iocom.c:315 `if(chain->bref.type!=HAMMER2_BREF_TYPE_INODE) continue;` skips iocom.c:338 `chain = hammer2_chain_next(...)`. Sibling vfsops.c:1563 places chain_next outside the if/else.

PoC changes

Authored the full evidence pack from scratch (no prior PoC existed on disk despite the DB row). Wrote harness.c -- a deterministic structural harness replicating both the buggy iocom.c:313-341 loop body and the verified-correct vfsops.c:1553-1566 sibling body over the same 4-element media (2 INODE PFS + 1 INDIRECT + 1 INODE), with a watchdog so the buggy run terminates to REPORT the spin. Added build.sh/run.sh/VERDICT.md/manifest.json/fix.diff/env.txt and the full build/run logs.

Verified recommended fix

In sys/vfs/hammer2/hammer2_iocom.c:313-341, replace the bare continue at :315 with a warn-and-advance block: 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; -- structurally identical to the project's own correct scan at hammer2_vfsops.c:1553-1566. Minimal, one-logical-change, targets the exact root cause (missing cursor advance). SUPersedes any pre-verification proposal. Full git-apply-able diff in findings/poc/DF-0904/fix.diff.

Verdict

REPRODUCED (code-level). The bug is a textbook infinite loop: sys/vfs/hammer2/hammer2_iocom.c:313-341 has while (chain) { if (chain->bref.type != HAMMER2_BREF_TYPE_INODE) continue; ... chain = hammer2_chain_next(...); } where the bare continue at :315 skips the ONLY cursor advance at :338-340. Any non-INODE chain (INDIRECT/DATA) under spmp->iroot therefore pins the loop forever at 100% CPU holding iroot+parent+chain locks => cluster-message-path deadlock (DoS). Confirmed by (a) line-by-line source trace against the verified-correct sibling hammer2_vfsops.c:1553-1566 (hammer2_pfslocate) which places chain_next OUTSIDE the type dispatch and so always advances; (b) a deterministic harness replicating both loop structures that shows the buggy version spinning forever (watchdog @ 100001 iters) and the vfsops.c-shaped version terminating in 4 iters over the same input. Reachability: hammer2_update_spans is called only from hammer2_autodmsg on receipt of a DMSG_LNK_CONN|CREATE|REPLY, reachable via mount-time cluster_fd (hammer2_vfsops.c:1350-1356) or the HAMMER2IOC_RECLUSTER ioctl (hammer2_ioctl.c:204-238); the spin itself additionally requires a corrupted/crafted image placing a non-INODE blockref directly under the super-root. The threat model is therefore root-assisted cluster mount of an attacker-supplied image -- a hardening/DoS gap, consistent with the Medium severity. The in-kernel end-to-end trigger requires a multi-hop setup (crafted image + cluster_fd + DMSG-speaking peer), so the deterministic structural harness is the PoC.