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

Overlapping key ranges in on-disk blockref arrays unvalidated β€” chain-insert collision panic or RB_REMOVE wipes the parent's rbtree root

Field Value
ID DF-2618
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:H
CWE CWE-20 Improper Input Validation (β†’ resource leak / RB-tree topology corruption)
File sys/vfs/hammer2/hammer2_chain.c
Lines 313-320
Area vfs
Confidence certain
Discovered 2026-08-28
Pass 2 (GLM 5.3 second pass)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

hammer2_chain_cmp() counts overlapping key ranges as a match, but nothing validates that a blockref array read from media contains non-overlapping ranges. When a lookup instantiates a chain for a second, partially overlapping bref, RB_INSERT in hammer2_chain_insert() returns the existing chain: on INVARIANTS kernels an immediate KASSERT panic; on release builds the code nevertheless sets HAMMER2_CHAIN_ONRBTREE, chain->parent and bumps core.chain_count on a chain never linked into the tree. When that chain is later removed (lastdrop or delete-helper), RB_REMOVE on the zeroed, never-linked node takes the parent==NULL path of sys/sys/tree.h:641-652 and assigns RB_ROOT(head)=NULL β€” wiping the parent's entire live tree while its chains are still linked, orphaning them permanently.

Root cause

chain.c:104-117 (cmp: overlap == match); chain.c:313-316: xchain = RB_INSERT(...); KASSERT(xchain == NULL, ...) β€” KASSERT is INVARIANTS-only (sys/sys/systm.h:96-118); chain.c:317-320 then unconditionally sets ONRBTREE/parent/++chain_count/++generation on the unlinked chain; removal paths RB_REMOVE it at chain.c:623-624 (lastdrop) and 3556/3623 (delete helper). The chain was allocated M_ZERO (chain.c:203-204) so its rbnode links are NULL: RB_REMOVE's both-children-NULL + parent-NULL branch executes RB_ROOT(head) = child = NULL (sys/sys/tree.h:641-652). Reachable with A=[K,K+0xF] in the rbtree and crafted base entry B=[K+8,K+0x17]: a lookup of [K+0x10,K+0x17] makes base_find return B (chain.c:5035) while hammer2_chain_find misses A, so chain_get(B) (chain.c:2610) runs the colliding insert β€” from plain readdir iteration or a single lookup with the right hash range. Adjacent-range media writes also hit the unconditional panic at chain.c:5308-5311.

Threat model & preconditions

  • Attacker position: crafted filesystem image, mounted; unprivileged user triggers with ls/readdir/name lookup whose key falls in the non-overlapped sub-range of the second entry.
  • Privileges gained or impact: deterministic panic on INVARIANTS kernels; on release builds permanent kernel-memory leak of every cached chain under the parent (unreachable but still counted in core.chain_count), chain_count/live_count desynchronization wedging indirect-block collapse and lastdrop, and silent lookup misses of live entries.
  • Required config or capabilities: mount of crafted image.
  • Reachability: readdir/lookup through the crafted directory.

Proof of concept

Build & run

craft image: two dirent brefs with partially overlapping ranges in a
directory inode's blockset, e.g. A{key=0x1000,keybits=4} and
B{key=0x1008,keybits=4} (recompute parent check); mount; ls /mnt/dir

Expected output

INVARIANTS: panic "assertion xchain == NULL failed" at chain.c:314.
Release: after B's last drop, chain stats show chain_count>0 on a parent with
an 'empty' tree; leaked chains never freed; cached entries vanish from
lookups.

Impact

Panic (INVARIANTS) or permanent kernel heap leak + topology corruption (release). No direct memory corruption; integrity/availability.

Treat an RB_INSERT collision as a media-corruption error on all builds instead of asserting; poison the parent's error so iterating lookups terminate instead of spinning to the maxloops panic.

--- a/sys/vfs/hammer2/hammer2_chain.c
+++ b/sys/vfs/hammer2/hammer2_chain.c
@@ -310,10 +310,23 @@ hammer2_chain_insert(hammer2_chain_t *parent, hammer2_chain_t *chain,
    /*
     * Insert chain
     */
    xchain = RB_INSERT(hammer2_chain_tree, &parent->core.rbtree, chain);
-   KASSERT(xchain == NULL,
-       ("hammer2_chain_insert: collision %p %p (key=%016jx)",
-       chain, xchain, chain->bref.key));
+   if (xchain != NULL) {
+       /*
+        * Overlapping key range vs an existing chain β€” the
+        * on-disk blockref array is corrupt.  We MUST NOT set
+        * ONRBTREE/parent/chain_count on an unlinked chain: on
+        * release builds the later RB_REMOVE() in lastdrop or
+        * the delete helper would wipe RB_ROOT of the parent's
+        * live tree (never-linked node, NULL parent -> root=0).
+        *
+        * Error the parent so lookup/scan iteration terminates
+        * (chain.c:2473 / 2876) instead of retrying forever.
+        */
+       parent->error = HAMMER2_ERROR_CHECK;
+       error = HAMMER2_ERROR_CHECK;
+       goto failed;
+   }
    atomic_set_int(&chain->flags, HAMMER2_CHAIN_ONRBTREE);
    chain->parent = parent;

References

  • sys/sys/tree.h:641-652 (RB_REMOVE NULL-parent root wipe)
  • DF-0763 class (crafted blockref arrays)

Timeline

  • 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2618 Β· 25 files
FileTypeDescriptionSize
README.md β€” 3.9 KB ↓ raw
VERDICT.md β€” 8.3 KB ↓ raw
mkbase2618.sh β€” 808 B view raw
forge_df2618.py β€” 11.1 KB view raw
trigger.sh β€” 1.3 KB view raw
build.sh β€” 728 B view raw
run.sh β€” 682 B view raw
base2618.img β€” 64.0 MB ↓ download
craft2618.img β€” 64.0 MB ↓ download
code_hashes.txt β€” 398 B view raw
control.log β€” 686 B view raw
run.log β€” 175 B view raw
run2.log β€” 175 B view raw
craft_run.log β€” 175 B view raw
panic.txt β€” 1.1 KB view raw
panic2.txt β€” 789 B view raw
env.txt β€” 1.7 KB view raw
dmesg.txt β€” 243 B view raw
fix.diff β€” 5.1 KB view raw
fix_build.log β€” 5.7 MB ↓ download
fix_control_run.log β€” 686 B view raw
fix_run.log β€” 631 B view raw
fix_write_smoke.log β€” 111 B view raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 6.6 KB view raw

DF-2618 β€” Overlapping key ranges in on-disk blockref arrays unvalidated β€” chain-insert collision panic / rbtree root wipe

  • Verified 2026-08-28 by df-bsd-reviewer (verify mode) on the QEMU/KVM DragonFly guest, stock INVARIANTS kernel #0 (Thu Jul 2 06:02:54 2026) and fix-validated on rebuilt kernel #1. *

What the finding claims

hammer2_chain_cmp() (sys/vfs/hammer2/hammer2_chain.c:97-118) treats overlapping [key, key + 2^keybits - 1] ranges as a match (cmp == 0). A crafted on-disk parent whose blockref array contains two overlapping brefs drives RB_INSERT in hammer2_chain_insert() (chain.c:313-320) into a collision: INVARIANTS builds panic at the KASSERT (chain.c:314); release builds continue with a phantom chain (ONRBTREE set but never linked) whose later RB_REMOVE wipes parent->core.rbtree's root (sys/sys/tree.h:641-652).

Result

REPRODUCED (panic), then FIXED.

  • Baseline (stock INVARIANTS kernel): mounting the forged image and stating a file whose inum-keyed lookup crosses the overlapping entries panics deterministically:

    panic: hammer2_chain_insert: collision 0xfffff80118c22100 0xfffff80118c21980 (key=0000000000000401)

    hammer2_chain_insert() at hammer2_chain_insert+0x15b hammer2_chain_get() at hammer2_chain_get+0x64 hammer2_chain_lookup() at hammer2_chain_lookup+0x615 hammer2_chain_inode_find() at hammer2_chain_inode_find+0x117 hammer2_xop_nresolve() at hammer2_xop_nresolve+0x1a5

(panic.txt, serial capture; guest down.) The trigger is pure namei/readdir activity on a mounted image β€” no special privileges beyond the mount itself.

  • Release-build consequence (rbtree root wipe): verified by line-precise source trace β€” see VERDICT.md Β§3. Not demoed on a no-INVARIANTS kernel (build cost); the trace is exact and the INVARIANTS panic proves the primitive.

  • Fix validation (fix.diff, rebuilt kernel #1): the same crafted image mounts, ls lists every dirent, lookups that cross the corrupt entries fail with ENOENT instead of panicking, the fix's rate-limited hammer2_chain_insert: collision ... (key=0000000000000401) message appears exactly once on the console, a write to the corrupt directory succeeds, umount is clean, and the system (which roots on hammer2) stays up. Control image: 100% normal behavior on both kernels.

Reproduce

Host side (from this directory):

./build.sh     # mkbase2618.sh in guest -> base2618.img; forge -> craft2618.img; push
./run.sh       # control run (no panic), then craft run (panic on stock kernel)

Guest side:

sh /root/poc/df2618/trigger.sh control   # pristine image: everything works
sh /root/poc/df2618/trigger.sh craft     # forged image: panic at `stat c`
                                        # (serial: vm.sh log; see panic.txt)

For fix validation: apply fix.diff in the guest /usr/src, make -j6 nativekernel && make installkernel, reboot, rerun both triggers (fix_control_run.log / fix_run.log / fix_write_smoke.log).

The forged image

forge_df2618.py walks volhdr -> sroot -> PFS "testvol" inode -> indirect array and patches two file-INODE brefs so their key ranges overlap:

INODE(a) key=1024 keybits 0->1   => [1024,1025]   (swallows 1025)
INODE(b) key=1025 keybits 0->1   => [1025,1026]   (extends beyond)

Sorted array order is preserved (only keybits changed). CHECK_NONE (methods=0x00) is set on the covering indirect bref, the PFS inode bref and the sroot bref, and all volhdr CRC32Cs are recomputed (technique proven in DF-2616/DF-2617/DF-2620).

Trigger mechanics (see VERDICT.md for the full trace): stat a pins chain(1024)=[1024,1025] in the indirect chain's core.rbtree via the inode cache; stat c -> hammer2_chain_inode_find(1026) -> base_find skips [1024,1025] (its end < 1026) and stops at the overlapping [1025,1026] bref; hammer2_chain_find misses (pinned chain ends at 1025); chain_get -> RB_INSERT -> cmp == 0 -> KASSERT panic.

VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fixed: identical PoC on the patched kernel mounts/lists/stats without panic (corrupt-shadowed names degrade to ENOENT, one rate-limited collision message on console); control image identical to stock; write path on the corrupt volume + clean umount + system stability verified; kernel boots and roots on hammer2 throughout

fix.diff (git apply --check clean, 8 hunks applied in-guest), fix_build.log (BUILD_RC=0), fix_control_run.log, fix_run.log, fix_write_smoke.log, dmesg.txt (fix collision message on patched kernel)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Fri Aug 28 19:52:28 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unprivileged (post-mount) namei on a crafted/corrupt hammer2 volume -> hammer2_chain_inode_find(inum) -> chain_lookup: base_find selects the overlapping bref while the pinned sibling chain does not cover the key -> chain_get -> RB_INSERT -> hammer2_chain_cmp overlap==match -> collision -> KASSERT panic (INVARIANTS) or phantom chain (release) whose lastdrop RB_REMOVE nulls parent->core.rbtree.rbh_root (tree.h:651-652), structurally corrupting the chain topology. Impact ceiling: kernel DoS (verified) + in-memory chain-cache corruption/leak on release builds (traced); no R/W primitive demonstrated, consistent with the filed Medium severity.

Evidence (decisive lines)

["panic.txt + panic2.txt: two identical serial captures of 'panic: hammer2_chain_insert: collision ... (key=0000000000000401)' with the full backtrace (fresh boot each)", "run.log / run2.log: trigger output stops exactly at 'stat /mnt/h2/c' both times", 'control.log / fix_control_run.log: pristine image fully functional on stock and patched kernels (forge, not the base image, causes the bug)', 'forge_df2618.py: walks volhdr->sroot->PFS->indirect array, sets keybits so sibling INODE ranges overlap, CHECK_NONE ancestors, recomputes volhdr CRC32Cs', 'VERDICT.md section 3: line-by-line release-build root-wipe trace', "fix_build.log (38176 lines, BUILD_RC=0) + fix_run.log + fix_write_smoke.log + dmesg.txt: patched kernel #1 handles the same image gracefully (console 'hammer2_chain_insert: collision ... key=...0401' rate-limited message, no panic, write path OK)"]

PoC changes

Seed had no runnable code. Wrote mkbase2618.sh (4-file base image), forge_df2618.py (overlap forge inside the root directory's INDIRECT array - the seed sketch assumed direct blockset entries, but hammer2 pushes children into indirects even at 4 files; cribbed the volhdr/sroot/PFS walk, CHECK_NONE ancestors and CRC32C recompute from DF-2616/DF-2617/DF-2620), and trigger.sh. Deterministic holder: stat a pins chain(1024) via the inode cache; stat c crosses the overlap - the planned fd-pin turned out unnecessary. The readdir-only trigger from the seed does NOT fire (readdir sweeps only the dirent-hash space, vnops.c:673); the inum-keyed inode_find path is the working trigger.

Verified recommended fix

hammer2_chain_insert: on RB_INSERT collision, rate-limited kprintf + return HAMMER2_ERROR_CHECK without setting ONRBTREE/parent/chain_count; hammer2_chain_get returns the errored unlinked chain; lookup/scan (and the two flush-time chain_get sites) skip past bref->key + (1<<keybits) so iteration stays finite (see fix.diff)

Verdict

REPRODUCED on the stock INVARIANTS kernel: a forged hammer2 image whose indirect blockref array contains two overlapping [key,key+2^keybits-1] ranges (INODE(1024)->[1024,1025], INODE(1025)->[1025,1026]) panics deterministically at hammer2_chain_insert's KASSERT (chain.c:314) on mount+stat, with the exact claimed backtrace (hammer2_xop_nresolve -> hammer2_chain_inode_find -> hammer2_chain_lookup -> hammer2_chain_get -> hammer2_chain_insert, panic key=0x401). Verified twice across fresh boots (panic.txt, panic2.txt); control image fully clean; guest down each time. The release-build consequence (RB_INSERT collision -> phantom chain with ONRBTREE set but never linked -> later RB_REMOVE takes the NULL-parent branch and executes RB_ROOT(head)=NULL at sys/sys/tree.h:651-652, wiping the parent's live rbtree -> chain leak + lookup misses) is verified by line-precise source trace (chain.c:203-204 M_ZERO alloc, tree.h:674-677 no RB_SET on collision, chain.c:317-319 flags set anyway, chain.c:618-628 lastdrop RB_REMOVE) but not demoed on a no-INVARIANTS kernel. fix.diff (graceful collision refusal at chain_insert + loop-safe skip-past-corrupt-entry at all chain_get call sites) rebuilt as kernel #1: the identical crafted image mounts, lists, and stats without panic (corrupt-shadowed names ENOENT, one rate-limited console collision message), writes to the corrupt directory succeed, control image unchanged, system stable. fix_status=fixed.