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

Unvalidated bref->keybits from disk causes undefined behavior in shift operations throughout chain lookups

Summary

On-disk bref->keybits (uint8_t 0-255) used directly as shift count in (hammer2_key_t)1<<keybits throughout chain code no bounds check. For keybits>63 UB shift>=type width. Code at :4555 explicitly detects keybits>64 but only Debugger() no-op without DDB then uses invalid value anyway. UB produces wrong key-range calc on x86_64 masks to 6 bits keybits=64 gives range 1 byte. Corrupts RB-tree comparator chain_cmp misplaced chains overlapping/missing key coverage. Filesystem-corruption logic-bug not memory-corruption. Mount crafted image keybits=65. [VERIFIED pass2: reproduced on stock kernel with a fully checksum-valid crafted image - keybits 65 mounts unrejected, readdir lists all 25 names while every lookup returns ENOENT with files provably intact on media, and one rw create hits the 4728 twin of the cited check whose only handling is kprintf plus Debugger fubar halting all CPUs in DDB on two independent boots; masked shifts produce wrong values only, all scans index-bounded so no memory-safety primitive; root-mounted media reach; three-site keybits-lt-63 validation fix-validated - pristine bit-identical, crafted cleanly handled, the exact stress that froze stock twice completes clean]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2606 Β· 22 files
FileTypeDescriptionSize
README.md β€” 4.5 KB ↓ raw
VERDICT.md β€” 7.9 KB ↓ raw
h2keybits.c β€” 10.6 KB view raw
build.sh β€” 92 B view raw
mkimage.sh β€” 1.6 KB view raw
run.sh β€” 120 B view raw
stress.sh β€” 793 B view raw
stress-fix.sh β€” 956 B view raw
mkfs.log β€” 751 B view raw
patch.log β€” 1.4 KB view raw
dump.pristine.log β€” 1.1 KB view raw
dump.patched.log β€” 1.2 KB view raw
run.a.pristine.log β€” 336 B view raw
run.b.patched.log β€” 658 B view raw
serial-hang.log β€” 2.1 KB view raw
serial-hang.2.log β€” 2.9 KB view raw
runfix.a.pristine.log β€” 336 B view raw
runfix.b.patched.log β€” 658 B view raw
runfix.stress.log β€” 458 B view raw
env.txt β€” 952 B view raw
fix.diff β€” 2.1 KB view raw
verdict.json β€” 5.6 KB view raw

DF-2606 β€” Unvalidated bref->keybits from disk β†’ UB shifts in hammer2 chain lookups

sys/vfs/hammer2/hammer2_chain.c β€” on-media bref.keybits (uint8_t, 0–255) is used directly as the shift count in (hammer2_key_t)1 << keybits all over the chain code. hammer2_key_t is uint64_t, so keybits β‰₯ 64 is undefined behavior; x86-64 SHL masks the count mod 64 (65 β†’ 1), silently collapsing a 2^63-byte key range into 2 bytes. The only keybits > 64 checks in the file (:4555-4558 freemap, :4728-4731 dir) are kprintf + Debugger("fubar") no-ops that then use the value anyway β€” and on the stock guest kernel (DDB compiled in) Debugger() halts the whole machine.

Verified on the QEMU guest (DragonFly 6.5-DEVELOPMENT, stock X86_64_GENERIC, guest /usr/src == audited tree, md5-confirmed) with a fully checksum-valid crafted image: newfs_hammer2 β†’ populate β†’ clean unmount β†’ byte-patch one INDIRECT blockref keybits 63 β†’ 65 inside the PFS-root inode's embedded array, fixing up the XXH64 chain checks (default check algo) and the three volume-header iCRC32s.

Demonstrated (stock kernel)

# observation evidence
1 Crafted image mounts with no rejection; no CRC/check errors in dmesg run.b.patched.log, dump.patched.log
2 readdir still lists all 25 names, but every name lookup returns ENOENT (stat/cat/ls of any file or subdir) β€” the directory listing lies; files provably exist on media (pristine mount lists + md5s them before and after) run.a.pristine.log, run.b.patched.log
3 One file create (or a 60-file batch) on the rw-mounted crafted image hits hammer2_chain_indkey_dir :4728 β†’ bad bref + Debugger("fubar") β†’ all CPUs stopped, machine frozen at db>, ssh dead. Reproduced on two independent boots serial-hang.log, serial-hang.2.log
4 No memory corruption: the shift UB on x86-64 only masks the count; all indices stay in-bounds (base array scans are bounded by count). Honest ceiling: namespace/logic corruption + Debugger-halt DoS VERDICT.md

Fix (validated A/B on rebuilt kernel)

fix.diff validates keybits ≀ 63 at the three places on-media blockrefs enter the range math (hammer2_chain_load_data, hammer2_base_find forward scan, hammer2_base_insert validate), mirroring the CHECK-FAIL error idiom. With the fixed kernel: pristine image bit-identical behavior (same 25 entries, same md5s β€” runfix.a.pristine.log); crafted image mounts and is cleanly handled (corrupt subtree simply unreachable, deterministic); the rw stress that froze the stock kernel twice now completes all creates, syncs and unmounts with zero kernel complaints, guest healthy (runfix.stress.log, reproduced twice).

Reproduce

In the DragonFly guest (as root), with this directory copied to /root/df2606:

./build.sh          # cc h2keybits.c + kernel icrc32.c + xxhash.c
./mkimage.sh        # newfs, populate, pristine copy, keybits 63->65 patch
./run.sh            # A/B/C demonstration (pristine / patched / pristine)
./stress.sh         # WARNING: freezes the stock kernel at db> (DoS proof)

Expected on stock kernel: A lists 25 + md5s; B mounts with no rejection, lists 25 names, every lookup ENOENT; C lists 25 + md5s again. stress.sh halts the machine ("bad bref chain ..." + Debugger("fubar") on the serial console).

Attack preconditions (reachability)

Attacker must be root on the box (or have equivalent rights to vnconfig + mount_hammer2 a crafted image): vfs.usermount=0 and no unprivileged path exists to feed hammer2 a crafted image on this configuration. A remote/unprivileged trigger does not exist; the bug class is malicious-media handling. Severity stays Medium (fail-closed media validation missing); impact ceiling is filesystem logic corruption plus a Debugger()-halt DoS β€” no memory-safety primitive found (see VERDICT.md).

Files

h2keybits.c        walker/patcher (dumps topology, patches keybits, fixes all checks)
build.sh mkimage.sh run.sh stress.sh stress-fix.sh   exact runnable commands
mkfs.log patch.log dump.pristine.log dump.patched.log  image construction
run.a.pristine.log run.b.patched.log                  stock-kernel A/B (run 1, captured)
serial-hang.log serial-hang.2.log                    stock-kernel DDB halts (2 boots)
runfix.a.pristine.log runfix.b.patched.log runfix.stress.log   fixed-kernel A/B/stress
fix.diff           git-apply-able fix (validated: nativekernel build + install)
env.txt VERDICT.md manifest.json verdict.json
VERDICT.md
↓ download raw

DF-2606 VERDICT β€” reproduced

Finding: sys/vfs/hammer2/hammer2_chain.c uses on-media bref->keybits (uint8_t) directly as the shift count in (hammer2_key_t)1 << keybits throughout the chain lookup machinery, with no bounds validation. hammer2_key_t is uint64_t (hammer2_disk.h:429), so keybits >= 64 is undefined behavior. The only keybits>64 checks in the file β€” hammer2_chain.c:4555-4560 (freemap indirect calculation) and its twin hammer2_chain.c:4728-4733 (directory indirect calculation) β€” print "bad bref chain" and call Debugger("fubar"), then fall through and use the value anyway (nkeybits = bref->keybits, then 1 << (nkeybits - 1) = 1 << 64, more UB).

How it was proven (stock kernel, 2 independent runs + 2 independent halts)

Image construction (fully checksum-valid malicious media)

h2keybits.c walks the image the way the kernel does β€” volume header (magic + three iCRC32s, hammer2_ondisk.c:528-558) β†’ sroot_blockset.blockref[0] β†’ super-root inode β†’ PFS "LOCAL" root inode β€” then rewrites the keybits byte of the first INDIRECT entry in the LOCAL root's embedded 4-slot blockset (63 β†’ 65) and repairs every integrity check upward: XXH64(LOCAL inode) into the sroot array entry, XXH64(sroot inode) into the volume header, then the volume header iCRCs in dependency order (SECT1 β†’ SECT0 β†’ volheader, matching mkfs_hammer2.c:599-611; SECT1's crc word lives inside SECT0's covered range). Result: the kernel sees 100% valid metadata β€” every check it performs passes β€” while slot 1's computed range collapses from [0 .. 0x7FFFFFFFFFFFFFFF] to [0 .. 1] because x86-64 SHL masks the count mod 64 (1<<65 β†’ 1<<1 = 2).

(b) Mount acceptance β€” no rejection anywhere

mount_hammer2 /dev/vnX@LOCAL /mnt succeeds (run.b.patched.log); dmesg shows the normal mount sequence and no check failures. The kernel loads the volume header, super-root inode, and PFS root inode without ever validating keybits of the array entries those blocks contain.

(a) UB arithmetic outcome β€” misplaced chains / lost key coverage

With keybits=65 in the LOCAL root's array:

  • readdir still lists all 25 names β€” hammer2_xop_readdir (hammer2_xops.c:213-223) starts its range scan at lkey=0, which the collapsed [0,1] range still covers, descends into the indirect block and enumerates its (unmodified) children.
  • every nameβ†’inode lookup returns ENOENT β€” hammer2_xop_nresolve looks up the file's 64-bit dirhash lhc; hammer2_base_find (hammer2_chain.c:4977-4993) computes scan_end = key + ((hammer2_key_t)1 << scan->keybits) - 1 = key+1, the entry no longer covers any real hash, and the files become unreachable: stat /mnt/f001 β†’ ENOENT, cat /mnt/f013 β†’ ENOENT, ls /mnt/sub β†’ ENOENT β€” while mount of the pristine image (before and after) lists all 25 entries with identical md5s, proving the data is intact on media and only the kernel's key-coverage arithmetic is wrong.

The same broken math sits in the RB comparator hammer2_chain_cmp (hammer2_chain.c:109-111), hammer2_chain_find_cmp (:1962, :2025), and the flush/insert paths (:5307, :5357, :5366).

The :4555 Debugger() no-op β€” live, twice

Creating files on the rw-mounted crafted image drives hammer2_chain_indkey_dir (the check cited at :4555 has its identical twin at :4728-4731) over the keybits=65 entry:

bad bref chain 0 bref 0xfffff8005082e600
Debugger("fubar")
CPU5 stopping CPUs: 0x0000001f
 stopped
Stopped at      Debugger+0x7c:  movb    $0,0xbdaf09(%rip)
db>

The whole machine halts at the DDB prompt; ssh dies; only a reset recovers it. Observed on two independent boots (serial-hang.log, serial-hang.2.log), from a single echo x > /mnt/file (first boot) and from a 60-file create batch (second boot; the exact trigger depends on where the new entry's fsid-salted dirhash lands β€” only creates that force indirect-block topology math hit the path, a 60-name batch guarantees it). On a kernel built without DDB the same input executes nkeybits = bref->keybits (65) at :4733 and 1 << 64 at :4748 β€” silently poisoning the new indirect block's radix instead of halting.

Honest ceiling (per directive: privesc β€” close or flag)

  • No memory-safety primitive found. Every array walk is index-bounded by count; the UB manifests as wrong values (masked shift counts), never as out-of-bounds access. The RB comparator's "overlap counts as match" semantics keep the tree consistent for any comparator result. No path from the wrong-range math into an OOB write/read was identified: base_find/ combined_find return bref pointers inside the parent's data; chain instantiation copies the bref; type dispatch still checks bref.type.
  • Demonstrated impact: (1) filesystem namespace/logic corruption β€” a mounted crafted image makes files unreachable and directory listings lie (deterministic, 2/2 runs); (2) system-halt DoS via Debugger() from a single write on the rw-mounted crafted image (2/2 runs) β€” classified dos (machine frozen in DDB, not a crash-dump panic).
  • Privilege required: root (vnconfig + mount_hammer2; vfs.usermount=0). No unprivileged or remote trigger exists on the tested configuration, so this is a malicious-media validation bug, not an escalation vector. Medium is the right severity; privesc bucket: closed (no userβ†’root chain), flagged for the halt-on-write behavior which raises the practical stakes of the missing media validation.

False-positive check on the cited path

The finding's original line reference (:4555, freemap variant) is real but the directory twin (:4728-4731) is the variant actually reachable from ordinary filesystem writes β€” both encode the same defect (detect β†’ Debugger no-op β†’ use the value). All other cited shift sites verified by inspection and by the live lookup corruption they produce.

Fix validation (A/B on rebuilt kernel)

fix.diff adds keybits ≀ 63 validation at the three places on-media blockrefs enter range math, using the existing CHECK-FAIL idiom (krateprintf(&krate_h2chk, ...), chain->error = HAMMER2_ERROR_CHECK):

  1. hammer2_chain_load_data β€” refuse to load a chain whose bref keybits > 63 (covers brefs taken directly from the volume header, e.g. the sroot).
  2. hammer2_base_find forward scan β€” skip array entries with keybits > 63 (covers every lookup/readdir/indkey/flush scan of on-media arrays; kills the UB and makes handling deterministic).
  3. hammer2_base_insert validate loop β€” do not compute ranges from illegal entries (removes the last UB and an adjacent panic corner).

Built with make nativekernel KERNCONF=X86_64_GENERIC (6 min, BUILD_RC=0), installed, rebooted (#1: Tue Oct 20 02:17:42 UTC 2026):

  • Pristine image: bit-identical behavior β€” same 25 entries, same md5s (dcd4a4a4a13297c47baaf3a5a9229c1c / d38f0794546f4e219c015a1778663d99 / 6983b4cd210aab338877de6d3b33c926) β€” runfix.a.pristine.log.
  • Crafted image: cleanly handled β€” mount succeeds, the corrupt subtree is deterministically unreachable (no UB anywhere), guest fully healthy.
  • The rw stress that froze the stock kernel twice now completes: 60/60 creates, sync, clean unmount, dmesg free of fubar/bad bref/CHECK FAIL/ panic, guest alive β€” runfix.stress.log (run twice).

Bottom line

status=reproduced, impact=dos (plus verified filesystem logic corruption), confidence=certain. Root-only malicious-media trigger; no memory-safety or privilege-escalation primitive exists on this path. Fix validated end-to-end.

Guest returned clean (vm.sh reset with-src) after validation.

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

not_testable
↓ fix.diffper-fix-DF-2606

Confirmed kernel references

Detail

Exploit chain

root crafts image: newfs_hammer2 -> populate -> clean unmount -> h2keybits patch sets INDIRECT keybits=65 and repairs XXH64 checks + volhdr iCRCs -> mount_hammer2 accepts (no rejection) -> (logic corruption) all name lookups under the collapsed range return ENOENT while readdir still lists the names, files unreachable though intact on media; (DoS) one file create on the rw mount hits hammer2_chain.c:4728 kprintf + Debugger(fubar) -> machine frozen in DDB until power cycle. No user-to-root chain exists: trigger is root-only malicious media, corruption is logical not memory-unsafe.

Evidence (decisive lines)

run.b.patched.log (mount not rejected; 25 names listed; stat/cat/ls all ENOENT), run.a.pristine.log + run C in run.sh output (same 25 entries and md5s before/after - data intact on media), dump.patched.log (keybits=65 entry, computed range [0..1], all XXH64/iCRC checks OK), serial-hang.log and serial-hang.2.log (bad bref chain + Debugger(fubar) + db> prompt, two boots), runfix.a.pristine.log and runfix.stress.log (fixed kernel: pristine bit-identical, stress completes alive), fix.diff, VERDICT.md

PoC changes

The original sketch assumed plain icrc checks and a raw byte-patch. Reality: default check algorithm is XXHASH64 (HAMMER2_CHECK_DEFAULT, hammer2_disk.h:736) with seed 0x4d617474446c6c6e, so the patcher had to recompute XXH64 of the PFS-root inode (1024B) into the sroot array entry, XXH64 of the sroot inode into the volume header, and the three volume-header iCRC32s in dependency order SECT1 then SECT0 then volheader (icrc_sects[6] lives inside sect0s covered range - computing sect0 first produces an invalid header, discovered and fixed during bring-up). Wrote a full structural walker (h2keybits.c) instead of scanning for bytes; also switched the demo target from the volume-header root bref to an INDIRECT entry inside the PFS-root inode because that is what base_find actually misroutes.

Verified recommended fix

Validate bref keybits <= 63 where on-media blockrefs enter range math: reject chains with keybits > 63 in hammer2_chain_load_data (chain->error = HAMMER2_ERROR_CHECK), skip such entries in hammer2_base_find forward scan and in hammer2_base_insert validate - see fix.diff (validated A/B).

Verdict

REPRODUCED on the DragonFly 6.5-DEVELOPMENT stock kernel (guest /usr/src md5-identical to the audited tree). A fully checksum-valid crafted hammer2 image (newfs_hammer2, then keybits of an INDIRECT blockref in the PFS-root inode patched 63->65 with XXH64 chain checks and volume-header iCRC32s repaired) mounts with no rejection; on x86-64 the UB shift 1<<65 masks to 1<<1 and collapses a 2^63-byte key range to 2 bytes, so readdir still lists all 25 names while every name lookup returns ENOENT - lookup misses files that provably exist on media (pristine mount before/after lists and md5s them). Additionally, a file create on the rw-mounted crafted image drives hammer2_chain_indkey_dir over the keybits=65 entry and its keybits>64 check (hammer2_chain.c:4728-4731, twin of the cited :4555-4560) prints bad bref chain and calls Debugger(fubar), halting all CPUs and freezing the machine at the db> prompt - reproduced on two independent boots (serial-hang.log, serial-hang.2.log). Honest ceiling: no memory-safety primitive (all scans index-bounded; UB only produces masked shift counts), no unprivileged or remote trigger (vfs.usermount=0; vnconfig+mount require root) - impact is filesystem logic corruption plus a system-halt DoS, classified dos. privesc bucket closed. fix.diff (validate keybits<=63 in hammer2_chain_load_data, hammer2_base_find scan, hammer2_base_insert validate) was built via make nativekernel, installed and A/B validated: pristine image bit-identical (same entries and md5s), crafted image cleanly handled, and the exact rw stress that froze the stock kernel twice completes with zero kernel complaints.