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

hammer2_inode_create_normal stomps a live inode when the media-seeded inum allocator collides β€” only a compiled-out KKASSERT guards it (panic on INVARIANTS; meta clobber + duplicate on-media chain insertion on production kernels)

Summary

create_normal calls inode_get(pmp, NULL, inum, -1) assuming a freshly-allocated inum never collides β€” but inode_get returns the EXISTING live inode on a hash hit and the sole guard is a KKASSERT compiled out on production kernels. The allocator seed pmp->inode_tid comes verbatim from untrusted on-media iroot meta.pfs_inum at mount (clamped only to >=1024) and newinum is a bare sequential fetchadd; a crafted image (or a crash that loses the last iroot flush while later inode chains reached media) hands create_normal an inum belonging to a materialized inode: it then wholesale-overwrites the live inode's meta (type, rmajor/rminor, uid/gid, mode, nlinks=1, DIRECTDATA β€” while meta.size survives, leaving size>512 files flagged DIRECTDATA), sets CREATING on a fully-linked inode, and repoints its cluster to new detached chains, dropping the real ones; the flusher then inserts a duplicate chain at the same key into the on-media topology. Guest-reproduced: host-side image patcher rewrites pfs_inum to collide; mount + stat (materialize) + unpriv mkdir -> INVARIANTS panic at hammer2_inode.c:1159 (production: straight-line stomp, code-verified). Metadata/integrity corruption + inode-identity confusion persisting across remounts; no uid0 chain. Runtime-check + EEXIST-refusal fix validated A/B on rebuilt guest kernel: mkdir refused, origfile byte-identical, clean sync/umount.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5836 Β· 13 files
FileTypeDescriptionSize
README.md β€” 2.7 KB ↓ raw
VERDICT.md β€” 5.4 KB ↓ raw
build_image.sh β€” 714 B view raw
craft_image.py β€” 2.3 KB view raw
run.sh β€” 160 B view raw
run.log β€” 2.4 KB view raw
run.fixed.log β€” 869 B view raw
boot_panic_raw.txt β€” 2.4 KB view raw
build.log β€” 1.4 KB view raw
env.txt β€” 460 B view raw
fix.diff β€” 1.4 KB view raw
verdict.json β€” 4.7 KB view raw
SHA256SUMS.txt β€” 292 B view raw

DF-5836 β€” hammer2 duplicate-inum inode stomp in hammer2_inode_create_normal

What

sys/vfs/hammer2/hammer2_inode.c hammer2_inode_create_normal() assumes the freshly allocated inum never matches an existing in-memory inode. The only guard is a KKASSERT (compiled out on production kernels). When the pmp inode allocator is seeded from on-media iroot meta.pfs_inum (attacker-controlled on a crafted image; stale after an iroot-flush-error crash on a legit fs), hammer2_inode_get() returns the LIVE existing inode and create_normal stomps its meta-data wholesale (type, uid/gid, mode, nlinks=1, DIRECTDATA) while the old meta.size survives, sets HAMMER2_INODE_CREATING on a fully-linked inode, and repoints its chains to new detached ones β€” the next filesystem sync double-inserts a chain into the media topology at the same key.

Reproduce

  1. Build pristine image (root in guest): ./build_image.sh β†’ origfile at inum 1024 (= HAMMER2_INODE_START), fs label TESTFS, PFS-root inode block at media offset 0x8800c00 (see craft_image.py asserts).
  2. Pull, craft, push (host): scp dfbsd:/root/h2.img . then python3 craft_image.py h2.img h2c.img then scp h2c.img dfbsd:/root/h2c.img (craft.py rewrites pfs_inum 1026β†’1023, disables the two parent-link check codes by setting bref methods to 0x00, recomputes the three volume-header CRC-32Cs β€” pure-python CRC-32C, self-tested).
  3. Trigger (root in guest, fresh boot): ./run.sh - mount TESTFS - stat /mnt/h2/origfile materializes inum 1024 into the pmp inum hash - mkdir /mnt/h2/boomdir β†’ first hammer2_trans_newinum() returns 1024 β†’ hammer2_inode_get() finds the live inode β†’ boom.

Expected

  • Stock (INVARIANTS) kernel β€” panic: assertion "(nip->flags & HAMMER2_INODE_CREATING) == 0 && nip->cluster.nchains == 0" failed in hammer2_inode_create_normal at /usr/src/sys/vfs/hammer2/hammer2_inode.c:1159 trace: hammer2_inode_create_normal+0x3a9 ← hammer2_vop_nmkdir ← vop_nmkdir ← kern_mkdir ← sys_mkdir (see run.log / boot_panic_raw.txt)
  • Production (non-INVARIANTS) kernel: no panic; the stomp proceeds silently β€” origfile's in-memory meta is replaced by the new directory's (type/uid/mode/ nlinks/ctime/DIRECTDATA flip) and the next sync attempts the duplicate chain insertion (analysis; not run here because the guest kernel is INVARIANTS).
  • Patched kernel (fix.diff): mkdir fails EEXIST, dmesg shows hammer2: duplicate inode number 1024 detected, create refused, origfile byte-identical, sync/umount clean (run.fixed.log).

Mounting a crafted image requires root (or vfs.usermount=1 + user-owned device); after mount, the trigger itself is a plain mkdir (any user with write permission on the mount).

VERDICT.md
↓ download raw

DF-5836 β€” VERDICT

Status: reproduced (panic on INVARIANTS kernel; live-inode meta stomp on production kernels by code analysis). Fix validated A/B on the same image.

Root cause chain (path:line)

  1. sys/vfs/hammer2/hammer2_vfsops.c:1977 β€” at first root-vnode materialization the pmp inum allocator is seeded straight from untrusted on-media data: pmp->inode_tid = meta->pfs_inum + 1 (clamped only to β‰₯ HAMMER2_INODE_START=1024 at :1981). No validation against inums already present on media (mount is lazy, no scan).
  2. sys/vfs/hammer2/hammer2_flush.c:308-315 β€” hammer2_trans_newinum() hands out sequential inums from that seed.
  3. sys/vfs/hammer2/hammer2_inode.c:1156 β€” hammer2_inode_create_normal() calls hammer2_inode_get(dip->pmp, NULL, inum, -1). On an inum collision hammer2_inode_get()'s found-path (hammer2_inode.c:871-905) returns the EXISTING, live, locked inode (inum-hash hit, hammer2_inode_lookup, hammer2_inode.c:548-569).
  4. sys/vfs/hammer2/hammer2_inode.c:1158-1159 β€” the only guard is KKASSERT((nip->flags & HAMMER2_INODE_CREATING) == 0 && nip->cluster.nchains == 0) β€” compiled out on production kernels.
  5. Stomp: hammer2_inode.c:1160 sets CREATING on the live inode; :1165-1215 overwrites type/rmajor/uid/gid/mode/nlinks=1/ctime/mtime and (for REGFILE/SOFTLINK creations) ORs in DIRECTDATA at :1212-1215 WITHOUT resetting the surviving meta.size (size is never assigned) β€” a size>512 file can be flipped to DIRECTDATA; :1257 repoints the live inode's cluster to the new detached chains, dropping its real chains.
  6. sys/vfs/hammer2/hammer2_vfsops.c:2685-2690 + hammer2_inode.c:1743-1767 β€” at the next filesystem sync the flusher sees CREATING on a fully-linked inode and hammer2_inode_chain_ins() inserts a duplicate chain at the same key (inum) into the on-media topology.

Trigger surface: mount of a crafted image (root, or unprivileged with vfs.usermount=1 and a user-writable device); the trigger itself is an unprivileged mkdir/touch/mknod on that mount. A non-crafted route also exists in theory: if a crash loses the last iroot flush while later inode chains made it to media, pfs_inum on media is stale vs existing inums and the next mount seeds a colliding allocator (flush updates ipdata->meta.pfs_inum = chain->pmp->inode_tid, hammer2_flush.c:902, only on PFSROOT chain flush).

Guest run (baseline, INVARIANTS kernel)

DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul  2 06:02:54 UTC 2026 X86_64_GENERIC
mount -t hammer2 /dev/vn0@TESTFS /mnt/h2   -> MOUNTED (volheader CRCs + disabled checks accepted)
stat /mnt/h2/origfile                      -> materializes inum 1024
mkdir /mnt/h2/boomdir                      ->
panic: assertion "(nip->flags & HAMMER2_INODE_CREATING) == 0 && nip->cluster.nchains == 0" failed in hammer2_inode_create_normal at /usr/src/sys/vfs/hammer2/hammer2_inode.c:1159
hammer2_inode_create_normal+0x3a9
hammer2_vop_nmkdir+0xb5
vop_nmkdir+0x5b
kern_mkdir+0xf2
sys_mkdir+0x51
Debugger("panic")

(full serial: run.log / boot_panic_raw.txt)

The panic proves the duplicate-inum path reaches create_normal holding a live, chain-populated inode. On a production kernel the KKASSERT is absent (KKASSERT is INVARIANTS-gated) and the stomp at :1160-1215 proceeds β€” that code path is straight-line, no further guards (verified by reading).

Fix validation (A/B, same crafted image)

fix.diff converts the invariant into a runtime check and refuses the create (EEXIST) after disposing of the found inode with hammer2_inode_unlock() alone (the found-path holds exactly one ref β€” the hash-lookup ref; unlock() drops it).

  • First fix attempt incorrectly did unlock+drop (two drops, one ref) and crashed in hammer2_vop_getattr on the over-dropped inode β€” caught on the guest, corrected, rebuilt.
  • Final fix, rebuilt X86_64_GENERIC (nativekernel, 5.5 min), rebooted: mkdir /mnt/h2/boomdir -> mkdir: File exists (rc=1), NO panic dmesg: hammer2: duplicate inode number 1024 detected, create refused stat/cat origfile -> byte-identical before/after touch /mnt/h2/secondfile -> refused too (inum 1025 == subdir) sync; umount -> rc=0 (run.fixed.log)

Impact

  • INVARIANTS/debug kernels: kernel panic from mount+mkdir of a crafted image (DoS).
  • Production kernels: silent in-memory inode type-confusion + metadata clobber of a live file (ownership/mode/nlinks reset by the creator, DIRECTDATA/size inconsistency), plus on-media topology corruption via the duplicate chain insertion at the next sync β€” integrity corruption of the mounted fs and possible deletion/resurrection confusion for the collided inum. Not developed to memory corruption: the stomp itself is value-level; the double-insert corrupts the media topology (btree duplicate keys are tolerated by H2 lookups), and no OOB was found downstream of it in this session (strategy read/write embedded paths copy fixed 512B).

Severity: Medium (privileged-ish precondition β€” crafted-image mount; strong integrity/DoS impact once mounted). Bucket: hammer2.

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

Baseline stock kernel: panic at hammer2_inode.c:1159 on mount+stat+mkdir of the crafted image. Patched kernel (fix.diff, nativekernel rebuild, reboot): same image and commands -> mkdir fails EEXIST, 'duplicate inode number 1024 detected, create refused' on console, origfile metadata and content byte-identical, second create also refused, sync+umount clean. Bad behavior gone.

run.log vs run.fixed.log; fix.diff
↓ fix.diffper-fix-DF-5836

Confirmed kernel references

Detail

Exploit chain

root (or vfs.usermount=1) mounts crafted hammer2 image whose iroot meta.pfs_inum=1023 seeds inode_tid=1024 == existing origfile inum -> unprivileged mkdir on the mount -> hammer2_trans_newinum returns 1024 -> hammer2_inode_get returns the live origfile inode -> (INVARIANTS) panic at inode.c:1159 / (production) live-inode metadata clobber + CREATING set on fully-linked inode -> next sync attempts duplicate chain insertion (media topology corruption, inode identity confusion).

Evidence (decisive lines)

run.log & boot_panic_raw.txt (full serial: mount, panic at hammer2_inode.c:1159 with stack); run.fixed.log (patched kernel: mkdir refused EEXIST, dmesg 'duplicate inode number 1024 detected, create refused', origfile unchanged, clean umount); craft_image.py (self-verifying image patcher: pfs_inum + check-disable + CRC-32C recompute); build_image.sh / run.sh; fix.diff (runtime duplicate-inum check); env.txt

PoC changes

PoC authored from scratch (no seed): guest-side image builder (newfs_hammer2 via vnconfig), host-side pure-python CRC-32C patcher disabling the two parent-link check codes (bref methods=0x00) and rewriting iroot meta.pfs_inum 1026->1023, plus trigger script (mount + stat origfile to materialize inum 1024 + mkdir). Two patcher iterations: icrc_sects are u32 not u64, and icrc_volheader lives at 0xFFFC (last 4 bytes of the 64KB header), not 0xFFC.

Verified recommended fix

Replace the KKASSERT in hammer2_inode_create_normal() with a runtime duplicate-inum check that refuses the create with EEXIST (see fix.diff); optionally also sanity-scan the seed at mount.

Verdict

hammer2_inode_create_normal() guards the inode_get() duplicate-inum return with only a KKASSERT (sys/vfs/hammer2/hammer2_inode.c:1158-1159), compiled out on production kernels. The pmp inum allocator is seeded from untrusted on-media iroot meta.pfs_inum (hammer2_vfsops.c:1977), so a crafted image makes the first allocated inum collide with a live materialized inode; create_normal then stomps the live inode's meta (type/uid/mode/nlinks/DIRECTDATA with stale size), flags it CREATING, and the next fs sync double-inserts a duplicate chain into the media topology. Reproduced on the guest: crafted image (pfs_inum 1026->1023) + mount + stat origfile (inum 1024) + mkdir -> panic 'assertion ... failed in hammer2_inode_create_normal at hammer2_inode.c:1159' (trace create_normal <- vop_nmkdir <- kern_mkdir <- sys_mkdir). Production kernels skip the assert and proceed with the stomp (code-verified straight-line). Fix validated A/B on the same image: runtime check + EEXIST refusal -> no panic, origfile byte-identical, sync/umount clean.