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

hammer2_xop_inode_chain_sync: KKASSERT(0) kernel panic on INODE/DIRENT-typed child during file truncation β€” crafted/corrupted-image local DoS

Summary

The truncation delete loop in the chain_sync backend worker walks every child of a file inode and switches on bref.type; only DATA is handled and DIRENT/INODE hit KKASSERT(0) β€” always compiled in, so release kernels panic too. chain_lookup performs an unfiltered scan of the inode blockset and the loop never checks chain->error before the switch, so a crafted (or corrupted) image that places an INODE/DIRENT-typed blockref under a regular-file inode panics the kernel on the next truncate. Guest-verified end-to-end: crafted 2GB image with a DATA->INODE type-flipped blockref and full upward integrity-tree repair (xxhash64 + icrc fixpoint) so it passes every kernel validation; mount, open O_RDWR, ftruncate, close -> stock panic at hammer2_xops.c:1582 through the primary xop thread, guest dead; single-fix kernel survives the identical PoC. Reliable full local DoS; no memory-corruption primitive (the assert fires before further use of the chain). Fail-with-CHECK-error fix validated A/B.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5843 Β· 15 files
FileTypeDescriptionSize
mkbadimg.c β€” 8.6 KB view raw
trig.c β€” 951 B view raw
build.sh β€” 137 B view raw
run.sh β€” 90 B view raw
build.log β€” 205 B view raw
run.log β€” 2.3 KB view raw
panic.txt β€” 559 B view raw
env.txt β€” 291 B view raw
fix.diff β€” 614 B view raw
fix_build.log β€” 4.7 KB view raw
fix_run.log β€” 1022 B view raw
README.md β€” 3.6 KB ↓ raw
VERDICT.md β€” 5.2 KB ↓ raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 4.4 KB view raw

DF-5843 β€” hammer2_xop_inode_chain_sync KKASSERT(0) panic via crafted image

panic: assertion "0" failed in hammer2_xop_inode_chain_sync at /usr/src/sys/vfs/hammer2/hammer2_xops.c:1582

What this is

hammer2_xop_inode_chain_sync() (sys/vfs/hammer2/hammer2_xops.c:1536) is the backend worker that implements file truncation bookkeeping. When the frontend flags a shrink (xop->meta.size < xop->osize, :1560), it walks every child chain of the file inode from lbase to HAMMER2_KEY_MAX (:1570-1594) and switches on chain->bref.type (:1579). Only HAMMER2_BREF_TYPE_DATA is handled; DIRENT and INODE hit KKASSERT(0) (:1582) β€” a guaranteed kernel panic, compiled into release kernels (KKASSERT is always active).

hammer2_chain_lookup() does not filter child bref types (hammer2_chain.c:2482-2511: it scans the inode's u.blockset.blockref[] and returns any entry found), and the loop runs regardless of chain->error β€” so an INODE- or DIRENT-typed blockref planted under a regular-file inode (on a crafted or corrupted image) reaches the assert on the next ftruncate()/close of that file.

Threat model

  • Attacker crafts a hammer2 image (offline image editor β€” see mkbadimg.c) in which one of a regular file's direct data blockrefs claims type = HAMMER2_BREF_TYPE_INODE.
  • The image gets mounted (root, or a user when vfs.usermount policies allow). Crafted-image bugs are also reachable on legitimately corrupted media.
  • Any unprivileged user who can open the file O_RDWR and ftruncate it (or simply open+close it if the frontend already flagged RESIZED) panics the kernel: full local DoS.

Reproduce (on the audit QEMU guest, as root)

./build.sh          # compiles mkbadimg + trig (needs /usr/src in the guest)
./run.sh            # builds pristine image, patches it, mounts, triggers

run.sh does:

  1. dd a 2GB image, newfs_hammer2, mount, write a 128KB file BIGTGTNAME (two direct DATA blockrefs at keys 0 and 0x10000), unmount.
  2. ./mkbadimg /root/h2.img BIGTGTNAME β€” flips the second blockref's type DATA(3) β†’ INODE(1), retargets its data_off radix to 10 (1024B inode), and repairs the whole xxhash64/icrc check tree upward (file inode β†’ PFS-root inode β†’ superroot inode β†’ volume header sroot_blockset + icrc_sects[6]/[7] + icrc_volheader) via a fixpoint loop, so the crafted media still passes every validation the kernel performs.
  3. Re-attach and mount the crafted image; ./trig /mnt/BIGTGTNAME 65536 (open O_RDWR + ftruncate to 64KB + close).

Expected output

Baseline (vulnerable kernel): after open ok / ftruncate -> 65536 the console shows

panic: assertion "0" failed in hammer2_xop_inode_chain_sync at /usr/src/sys/vfs/hammer2/hammer2_xops.c:1582
hammer2_xop_inode_chain_sync() at hammer2_xop_inode_chain_sync+0x161
hammer2_primary_xops_thread() at hammer2_primary_xops_thread+0x280

and the system drops into db> (dead). See panic.txt, run.log.

Patched kernel (fix.diff): no panic; trig prints SURVIVED: no panic (chain_sync did not hit KKASSERT) and the guest stays up (truncate is failed gracefully with HAMMER2_ERROR_CHECK).

Files

  • mkbadimg.c β€” crafted-image patcher (self-contained; links the guest's /usr/src/sys/vfs/hammer2/xxhash/xxhash.c + /usr/src/sys/libkern/icrc32.c)
  • trig.c β€” open/ftruncate/close trigger (unprivileged once mounted)
  • build.sh, run.sh β€” exact commands
  • build.log, run.log, panic.txt β€” decisive untrimmed output
  • env.txt β€” guest environment
  • fix.diff β€” one-line fix (KKASSERT(0) β†’ HAMMER2_ERROR_CHECK)
  • VERDICT.md, manifest.json, verdict.json β€” verdict + narrative
VERDICT.md
↓ download raw

DF-5843 VERDICT β€” REPRODUCED (panic, local DoS via crafted hammer2 image)

Status: reproduced Β· Impact: panic (kernel panic β†’ full local DoS) Β· Confidence: certain Β· Attempts: 4 (3 patcher iterations, 1 decisive trigger)

The bug

hammer2_xop_inode_chain_sync() β€” sys/vfs/hammer2/hammer2_xops.c:1536 β€” is the backend worker implementing truncation bookkeeping. On a shrink (xop->meta.size < xop->osize, :1560) it scans all children of the file inode from lbase to HAMMER2_KEY_MAX (:1570-1594, NODATA|NODIRECT) and switches on chain->bref.type (:1579). DIRENT/INODE types hit KKASSERT(0) at :1582 β€” always compiled in on DragonFly.

Why it is reachable with crafted media:

  1. hammer2_chain_lookup() scans the inode's u.blockset.blockref[0..3] raw and returns a chain for any bref type present (hammer2_chain.c:2482-2511); HAMMER2_LOOKUP_NODIRECT only short-circuits the DIRECTDATA-embedded case (hammer2_chain.c:2496-2502).
  2. The truncate loop does not check chain->error before the switch, so even a checksum-errored planted chain reaches the assert.
  3. Regular files legitimately never have non-DATA children, so the media is the only way to get one there β€” and media is attacker-controlled once you accept a crafted/corrupted image (mount by root, or via usermount policies; vfs.usermount=0 on the audit guest, so the mount step is privileged β€” the trigger itself, ftruncate(), is unprivileged).

Frontend reachability: hammer2_truncate_file() sets HAMMER2_INODE_RESIZED (hammer2_vnops.c:1266-1270); hammer2_inode_chain_sync() (hammer2_inode.c:1695) issues the xop synchronously on the next close/fsync/reclaim β€” the trigger is open(O_RDWR); ftruncate(fd, 65536); close(fd).

The reproduction (audit guest, stock kernel)

Built a 2GB hammer2 image (newfs_hammer2), wrote a 128KB file BIGTGTNAME (two direct DATA blockrefs, keys 0 and 0x10000), unmounted, then mkbadimg.c:

  • flipped the file inode's second direct blockref type DATA(3) β†’ INODE(1) and retargeted its data_off radix 16β†’10 so the planted chain instantiates with HAMMER2_INODE_BYTES;
  • repaired the entire integrity tree upward so the crafted media passes every kernel validation: xxhash64 checks of file inode β†’ PFS-root inode β†’ superroot inode β†’ volume-header sroot_blockset bref, plus volume-header icrc_sects[6]/icrc_sects[7]/icrc_volheader (fixpoint loop; XXH64 seed 0x4d617474446c6c6e, iscsi_crc32 β€” all ported/linked from the guest's own /usr/src).

Mount of the crafted image succeeds cleanly (ls -l shows the file). Then:

/root/trig /mnt/BIGTGTNAME 65536
open ok fd=3, ftruncate -> 65536
panic: assertion "0" failed in hammer2_xop_inode_chain_sync at /usr/src/sys/vfs/hammer2/hammer2_xops.c:1582
hammer2_xop_inode_chain_sync() at hammer2_xop_inode_chain_sync+0x161
hammer2_primary_xops_thread() at hammer2_primary_xops_thread+0x280
Debugger("panic")   -> db> (all CPUs stopped, guest dead)

(panic.txt, run.log β€” verbatim.)

Exploitability ceiling

This is a denial-of-service primitive, not memory corruption: the assert fires before any out-of-bounds access (the switch is entered with a valid locked chain; the panic is the defense, not the damage). There is no path from this specific site to code execution β€” the KKASSERT stops execution immediately. Impact = reliable local kernel panic: any unprivileged user who can open+truncate the planted file kills the machine; a corrupted (not malicious) device hitting the same media state would do the same in the wild.

Fix validation

Authored fix.diff (single change at :1582): KKASSERT(0) β†’ error = HAMMER2_ERROR_CHECK (mirrors the synthetic CHECK error hammer2_chain_inode_find uses for inconsistent media, hammer2_chain.c:5720). Cycle: patch -p1 into the guest /usr/src, make nativekernel KERNCONF=X86_64_GENERIC (BUILD_RC=0), kernel installed and rebooted (#1 Wed Oct 14 00:21:07 UTC 2026), identical PoC re-run:

open ok fd=3, ftruncate -> 65536
ftruncate ok, closing (vop_close -> chain_sync xop)
SURVIVED: no panic (chain_sync did not hit KKASSERT)
TRIG_EXIT=0        (guest stays up)

Baseline panics, patched survives β†’ fix_status: fixed (fix_run.log, fix_build.log).

PoC changes vs any seed

No orchestrator seed existed (pass-3 fresh finding). Development iterations: (1) name-signature scan failed β€” detached inodes store the "0x%016jx" inum string in filename[], fixed signature to detect file inodes by meta fields; (2) single-level check repair failed mount (check tree is recursive through indirect blocks and the volume header) β†’ replaced with fixpoint propagation of xxhash64 + vhdr ICRCs; (3) wle64-vs-u32 clobber of icrc_sects fixed; (4) planted bref data_off radix retargeted 16β†’10 so the INODE chain instantiates at 1024 bytes.

Guest left 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-fix kernel built from fix.diff (make nativekernel KERNCONF=X86_64_GENERIC, BUILD_RC=0), installed and booted (#1 Wed Oct 14 00:21:07 UTC 2026). Identical PoC pipeline re-run: no panic, trig exits 0, guest stays up ('SURVIVED: no panic'). Baseline stock kernel panicked at the same site. fix_patched_reproduced=false means the BUG did not reproduce on the patched kernel (expected direction).

fix_run.log (patched-kernel run), fix_build.log (kernel build), run.log+panic.txt (baseline panic)
↓ fix.diffper-fix-DF-5843

Confirmed kernel references

Detail

Exploit chain

crafted image (mkbadimg: DATA bref -> INODE bref + xxhash64/icrc check-tree fixpoint) -> mount (privileged mount or usermount policy) -> unprivileged open(O_RDWR)/ftruncate/close -> hammer2_xop_inode_chain_sync truncate scan (:1570) returns planted chain -> switch on bref.type -> KKASSERT(0) at :1582 -> kernel panic, all CPUs stopped. No memory-corruption primitive; ceiling is DoS.

Evidence (decisive lines)

panic.txt (panic + backtrace at hammer2_xops.c:1582), run.log (full pipeline incl. mkbadimg fixpoint output and trigger), fix_run.log (patched kernel: SURVIVED, exit 0), fix_build.log (nativekernel BUILD_RC=0), VERDICT.md (narrative)

PoC changes

no seed (pass-3 fresh finding); authored from scratch. Iterations: (1) inode-discovery signature fixed after learning detached inodes store the 0x%016jx inum string in filename[]; (2) single-level check repair broke mount -> replaced with fixpoint propagation of xxhash64 checks + volume-header icrc_sects[6]/[7] + icrc_volheader; (3) 8-byte write clobbering u32 icrc_sects fixed; (4) planted bref data_off radix retargeted 16->10 so the chain instantiates at HAMMER2_INODE_BYTES.

Verified recommended fix

Replace KKASSERT(0) at hammer2_xops.c:1582 with error = HAMMER2_ERROR_CHECK (fail the truncation gracefully on inconsistent media); see fix.diff.

Verdict

Reproduced on the audit guest (DragonFly 6.5-DEVELOPMENT, stock kernel). A crafted hammer2 image whose regular file carries an INODE-typed direct blockref (check tree repaired so the media passes every kernel validation) panics the kernel at hammer2_xops.c:1582 (KKASSERT(0) in the truncation delete loop of hammer2_xop_inode_chain_sync) when any user runs open(O_RDWR)+ftruncate()+close() on the file. chain_lookup returns children of any bref type (hammer2_chain.c:2482-2511) and the loop switches on bref.type without checking chain->error, so the assert is unconditional. Impact is a reliable local kernel panic (DoS), not memory corruption; no escalation path exists from this site because the assert stops execution before any further use of the planted chain. fix.diff (KKASSERT(0) -> error = HAMMER2_ERROR_CHECK) was built as a single-fix kernel and validated: baseline panics, patched kernel survives the identical trigger.