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

hammer2_chain_cmp key-range end overflow: crafted bref with key + (1<<keybits) > 2^64 wraps the comparator range, mis-ordering the in-memory rbtree (pruned by RB_SCAN, duplicated by every lookup) and regressing iteration keys β€” unkillable getdents live-lock, hung umount, and unbounded kernel chain leak from a mounted crafted image

Summary

Nothing validates that a media blockref's key range fits the keyspace: with key=0xC000000000000001 and keybits=63 (defined shifts, not the DF-2606 UB class) the range end wraps below the key, hammer2_chain_cmp report the chain fully-left of everything (including its own left neighbors), RB_SCAN prunes it, and every lookup re-creates a duplicate chain - one leaked hammer2_chain per iteration until unmount - while key_next regresses so directory iteration can NEVER advance: getdents live-locks unkillably (kill -9 -> EPERM), umount hangs, interrupt-livelock console spam, chain_allocs monotone creep. Stock-kernel-reproduced with a full causality control (same key, keybits=0: 200k entries, delta 0, clean umount - the wrap is the trigger). bref_sane+skip fix validated on a rebuilt kernel (1M entries, delta 0, clean umount). No corruption primitive - exhaustion/wedge only, but the system does not recover without hard reset.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5784 Β· 21 files
FileTypeDescriptionSize
README.md β€” 4.2 KB ↓ raw
VERDICT.md β€” 5.0 KB ↓ raw
forge_df5784.py β€” 8.5 KB view raw
leakloop.c β€” 1.5 KB view raw
build.sh β€” 222 B view raw
run_df5784.sh β€” 1.4 KB view raw
base.img β€” 64.0 MB ↓ download
craftA.img β€” 64.0 MB ↓ download
craftB.img β€” 64.0 MB ↓ download
craftC.img β€” 64.0 MB ↓ download
run.log β€” 890 B view raw
run_wedge_transcript.txt β€” 1.5 KB view raw
run_control_craftC.log β€” 250 B view raw
run_fix.log β€” 637 B view raw
build.log β€” 242 B view raw
fix_build.log β€” 291 B view raw
fix.diff β€” 2.0 KB view raw
env.txt β€” 375 B view raw
code_hashes.txt β€” 157 B view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 4.2 KB view raw

DF-5784 β€” hammer2 chain comparator key-range overflow (pass 3)

Finding (short)

hammer2_chain_cmp() (sys/vfs/hammer2/hammer2_chain.c:97-118) computes a chain's key range as end = key + ((hammer2_key_t)1 << keybits) - 1 with no validation that the range wraps the 64-bit keyspace. A crafted filesystem image can carry a blockref whose key + (1 << keybits) > 2^64 (e.g. key=0xC000000000000001, keybits=63 β€” both fully defined shifts, no UB). The in-memory chain for that bref then compares as "fully to the left" (end < other.key) of every other chain β€” including chains to its own left β€” so:

  1. the chain is mis-placed in the parent's in-memory rbtree, and hammer2_chain_find()'s RB_SCAN (tree.h: comp < 0 descends RIGHT only) prunes it, so every lookup re-creates a duplicate chain from the same media bref (hammer2_chain_lookup β†’ hammer2_chain_get β†’ hammer2_chain_alloc, hammer2_chain.c:2610-2614), and
  2. iteration keys regress: key_next = key + (1 << keybits) wraps below key_beg (hammer2_chain.c:2748-2749, 2594-2595), so directory iteration can never advance past the element β€” the readdir xop backend/frontend (hammer2_xop_readdir, hammer2_xop_collect's collect_key advance, hammer2_admin.c:1000-1019) live-lock.

Observed on the guest (stock INVARIANTS kernel): mounting the crafted image and issuing one directory read leaves the reading process stuck in getdents forever, unkillable (kill -9 β†’ Operation not permitted), umount hangs, a CPU pegged with kernel interrupt-livelock messages (intr 16 on cpu1 at 40001/40000 hz, livelock limit engaged!), and the live chain counter vfs.hammer2.chain_allocs creeps upward (monotone leak; every lookup allocates a new duplicate chain that is dropped to 0 refs but remains in the rbtree until unmount β€” which can no longer happen). Threat model identical to DF-2616/DF-2617/DF-2618: crafted hammer2 image, mounted by root (or unprivileged user with vfs.usermount=1 and an owned vnode device).

Distinct from the known findings: DF-2618 is about overlapping ranges (cmp()==0 collisions/panics); DF-2606 is about UB shifts (keybits β‰₯ 64). This is a fully-defined-shift integer wrap of the range end that breaks the strict-weak ordering silently β€” no assert fires, no panic, the system just wedges and leaks.

Files

  • forge_df5784.py β€” host-side forger (builds craftA/craftB from base.img)
  • craftC variant: same patched key with keybits=0 (no wrap) β€” causality control, built with an inline python snippet (see build.sh)
  • leakloop.c β€” drains a directory via readdir(3), prints live vfs.hammer2.chain_allocs
  • run_df5784.sh β€” guest runner (control + crafted)
  • run_wedge_transcript.txt β€” captured wedged-state evidence
  • run_control_craftC.log β€” causality control output (200000 entries, CRAFTC_DELTA=0, umount OK)
  • fix.diff β€” validated fix (skip insane media brefs in hammer2_base_find()'s forward scan + hammer2_bref_sane() helper)

Build

  1. guest (root): sh mkbase.sh-equivalent (see build.sh) β†’ base.img
  2. host: python3 forge_df5784.py base.img craftA.img craftB.img
  3. host: inline python for craftC.img (keybits=0 variant) β€” see build.sh
  4. push to guest /root/poc/df5784/

Run (guest, root)

sh /root/poc/df5784/run_df5784.sh

Expected

  • control (base.img): leakloop completes 1,000,000 entries quickly, chain_allocs delta β‰ˆ 0, umount OK.
  • crafted (craftB.img): leakloop never completes; the ssh session must be abandoned; from a second shell: ps shows the loop R2, kill -9 β†’ Operation not permitted, umount hangs, chain_allocs creeps up.
  • causality control (craftC.img, same key, keybits=0): 200,000 entries complete, CRAFTC_DELTA=0, umount rc=0 β€” proves the wrap (not merely the odd key) is the trigger.

Fix validation

fix.diff makes hammer2_base_find() skip media elements that fail hammer2_bref_sane() (unknown type or wrapped key range), exactly as it skips EMPTY slots β€” lookup/scan/readdir then never select the poisoned element. Validated by rebuilding the kernel in the guest and re-running: crafted image mounts, ls returns (entry invisible), leakloop completes with delta 0, umount OK, no wedge. See VERDICT.md.

VERDICT.md
↓ download raw

DF-5784 β€” VERDICT

Status: reproduced (impact: dos β€” unkillable kernel-side live-lock + unbounded kernel memory growth from a mounted crafted hammer2 image; the mount cannot be unmounted afterwards). Fix validated (kernel rebuilt in guest: wedge gone, chain counter flat, umount clean).

Root cause (code)

hammer2_chain_cmp() (sys/vfs/hammer2/hammer2_chain.c:97-118) computes

c1_end = c1_beg + ((hammer2_key_t)1 << chain1->bref.keybits) - 1;   /* :109 */

Nothing between media ingest (hammer2_chain_get β†’ hammer2_chain_alloc, hammer2_chain.c:2050-2098 / 177-237) and this comparator validates that key + (1 << keybits) does not exceed 2^64. A crafted image bref with key = 0xC000000000000001, keybits = 63 (all shifts fully defined) has

end = 0xC000000000000001 + 0x7FFFFFFFFFFFFFFF (mod 2^64) = 0x4000000000000000 < key

so cmp(wrapped, X) returns -1 ("fully to the left") for every X β€” and cmp(X, wrapped) also returns -1 whenever X.end < wrapped.key. Two consequences, both demonstrated on the guest:

  1. rbtree mis-ordering + duplicate chains. The wrapped chain is inserted as a left child of everything (RB_INSERT uses cmp==0 only for true overlap β€” none here, so no assert fires even on INVARIANTS). hammer2_chain_find() (hammer2_chain.c:1931) runs RB_SCAN with hammer2_chain_find_cmp (hammer2_chain.c:1955-1969); DragonFly's RB_SCAN descends RIGHT-only when scancmp < 0 (sys/sys/tree.h, _##name##_RB_SCAN), so the scan prunes the mis-placed chain. hammer2_combined_find still selects its media bref via hammer2_base_find (hammer2_chain.c:4975-4986 forward scan breaks on scan->key > key_beg), so hammer2_chain_lookup (:2610-2614) calls hammer2_chain_get β†’ a fresh duplicate chain is allocated, inserted, returned, dropped to 0 refs β€” and never found again. Live counter vfs.hammer2.chain_allocs creeps upward monotonically; the duplicates can only be reclaimed at unmount β€” which hangs (see 2).

  2. Iteration regression + unkillable getdents live-lock. Iteration advance computes key_beg = key + (1 << keybits) (hammer2_chain.c:2748-2749 in hammer2_chain_next, :2594-2595 in hammer2_chain_lookup, and hammer2_base_find's scan_end :4992) β€” for the wrapped bref this is below the current position, so neither the chain iterator nor hammer2_xop_collect's collect_key = lokey+1 advance (hammer2_admin.c:1000-1019) can move the scan past the element. The readdir xop backend/frontend ping-pong (h2coll tsleep vs. FIFO stall wakeups) and the userspace getdents never returns.

Reproduction (stock INVARIANTS kernel #0 Jul 2 2026)

  • control base.img: 1M-entry readdir drain completes instantly, chain delta 0, umount OK.
  • craftB.img (f1 dirent bref key=0xC000000000000001 keybits=63; ancestors methods=CHECK_NONE; volhdr CRCs recomputed): single leakloop (readdir loop) never completes; process R2; kill -9 β†’ Operation not permitted; umount β†’ hangs (rc=124); serial console shows intr 16 on cpu1 ... livelock limit engaged!; chain_allocs creeps (818β†’830 over minutes). Transcript: run_wedge_transcript.txt.
  • causality control craftC.img (SAME patched key, keybits=0 β€” no wrap): 200,000 entries complete in 66,666 passes, CRAFTC_DELTA=0, umount rc=0 (run_control_craftC.log). The wrap β€” not merely the odd key β€” is the trigger.

Fix validation

fix.diff: hammer2_bref_sane() helper (reject unknown blockref types and wrapped key ranges) + hammer2_base_find() forward scan skips insane media elements exactly like EMPTY slots. Applied to guest /usr/src, kernel #1 (Oct 12 15:27:54 2026) built+installed, rebooted:

  • craftB on patched kernel: 1,000,000 entries complete (499,999 passes β€” the poisoned entry is simply not returned), CRAFTED_DELTA=0, umount OK (AFTER_UMOUNT=821). No wedge, no leak. (run_fix.log)
  • DF-5785's craftA also validated on the same kernel (no panic).

Impact ceiling

DoS (wedge + resource exhaustion) β€” a memory-corruption escalation from this primitive was not identified: duplicates are properly allocated hammer2_chain objects; the rbtree remains structurally valid (RB_REMOVE is linkage-based, no comparator use); no OOB access occurs inside the loop. The system requires a reboot to recover the mount.

Not-a-duplicate note

DF-2618 = overlapping ranges (cmp==0 collisions β†’ panic/tree-wipe); DF-2606 = keybits β‰₯ 64 UB shifts. This finding is a defined-behavior end-of-range wrap that silently breaks strict-weak ordering and iteration advancement β€” different trigger, different failure face (no panic at all, even on INVARIANTS).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src, kernel rebuilt+installed (#1 Oct 12 15:27:54 2026), rebooted, exact PoC re-run: craftB leakloop completes 1,000,000 entries with CRAFTED_DELTA=0 (baseline on stock kernel: unkillable wedge + monotone chain growth), umount succeeds (AFTER_UMOUNT=821). Bad behavior gone.

run_fix.log; fix_build.log; env.txt (fix kernel uname)
↓ fix.diffper-fix-DF-5784

Confirmed kernel references

Detail

Evidence (decisive lines)

run_wedge_transcript.txt (unkillable R2 process, kill -9 EPERM, umount rc=124, chain_allocs 818->830 creep); run_control_craftC.log (keybits=0 causality control: CRAFTC_DELTA=0, umount rc=0); run.log + run_fix.log (baseline wedge vs patched CRAFTED_DELTA=0, AFTER_UMOUNT=821); forge_df5784.py (image forger); VERDICT.md for the full analysis

PoC changes

Wrote the forger fresh (DF-2616 rig technique: methods=CHECK_NONE along the crafted path + volhdr CRC32C recompute); wrote leakloop.c (readdir drain with live vfs.hammer2.chain_allocs sampling via sysctlbyname); added the keybits=0 causality control craftC.img after observing the wedge to isolate the wrap as the trigger

Verified recommended fix

Validate media blockrefs at array-scan time: add hammer2_bref_sane() (type in 1..6, no key-range wrap) and skip insane elements in hammer2_base_find() exactly like EMPTY slots

Verdict

hammer2_chain_cmp() (hammer2_chain.c:109/111) never validates that key + (1<<keybits) fits the 64-bit keyspace; a crafted-image bref (key=0xC000000000000001, keybits=63, defined shifts) wraps its comparator range end below its begin, making the chain compare 'fully-left' of every other chain. The mis-placed chain is pruned by hammer2_chain_find()'s RB_SCAN (comp<0 descends right-only), so every lookup re-allocates a duplicate chain (chain_lookup:2610 -> chain_get -> chain_alloc), and iteration keys regress (key_next = key + (1< EPERM), umount hangs, interrupt-livelock messages on the console, and vfs.hammer2.chain_allocs creeps upward monotonically. Causality control (same patched key with keybits=0, no wrap) is completely benign: 200k entries, delta 0, umount OK. Fix (hammer2_bref_sane() + skip in hammer2_base_find) validated on a rebuilt kernel: crafted image now reads cleanly, CRAFTED_DELTA=0 over 1M entries, umount succeeds.