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)
PoC verification
Evidence pack
findings/poc/DF-5843 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 claimstype = HAMMER2_BREF_TYPE_INODE. - The image gets mounted (root, or a user when
vfs.usermountpolicies 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:
dda 2GB image,newfs_hammer2, mount, write a 128KB fileBIGTGTNAME(two direct DATA blockrefs at keys 0 and 0x10000), unmount../mkbadimg /root/h2.img BIGTGTNAMEβ flips the second blockref's type DATA(3) β INODE(1), retargets itsdata_offradix to 10 (1024B inode), and repairs the whole xxhash64/icrc check tree upward (file inode β PFS-root inode β superroot inode β volume headersroot_blockset+icrc_sects[6]/[7]+icrc_volheader) via a fixpoint loop, so the crafted media still passes every validation the kernel performs.- 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 commandsbuild.log,run.log,panic.txtβ decisive untrimmed outputenv.txtβ guest environmentfix.diffβ one-line fix (KKASSERT(0) β HAMMER2_ERROR_CHECK)VERDICT.md,manifest.json,verdict.jsonβ verdict + narrative
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:
hammer2_chain_lookup()scans the inode'su.blockset.blockref[0..3]raw and returns a chain for any bref type present (hammer2_chain.c:2482-2511);HAMMER2_LOOKUP_NODIRECTonly short-circuits the DIRECTDATA-embedded case (hammer2_chain.c:2496-2502).- The truncate loop does not check
chain->errorbefore the switch, so even a checksum-errored planted chain reaches the assert. - 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=0on 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
typeDATA(3) β INODE(1) and retargeted itsdata_offradix 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_blocksetbref, plus volume-headericrc_sects[6]/icrc_sects[7]/icrc_volheader(fixpoint loop; XXH64 seed0x4d617474446c6c6e, 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
fixedSingle-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)
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.
No comments yet.