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)
PoC verification
Evidence pack
findings/poc/DF-5836 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
- 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). - Pull, craft, push (host):
scp dfbsd:/root/h2.img .thenpython3 craft_image.py h2.img h2c.imgthenscp 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). - Trigger (root in guest, fresh boot):
./run.sh- mount TESTFS -stat /mnt/h2/origfilematerializes inum 1024 into the pmp inum hash -mkdir /mnt/h2/boomdirβ firsthammer2_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:1159trace: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 showshammer2: 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).
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)
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).sys/vfs/hammer2/hammer2_flush.c:308-315βhammer2_trans_newinum()hands out sequential inums from that seed.sys/vfs/hammer2/hammer2_inode.c:1156βhammer2_inode_create_normal()callshammer2_inode_get(dip->pmp, NULL, inum, -1). On an inum collisionhammer2_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).sys/vfs/hammer2/hammer2_inode.c:1158-1159β the only guard isKKASSERT((nip->flags & HAMMER2_INODE_CREATING) == 0 && nip->cluster.nchains == 0)β compiled out on production kernels.- Stomp:
hammer2_inode.c:1160sets CREATING on the live inode;:1165-1215overwrites type/rmajor/uid/gid/mode/nlinks=1/ctime/mtime and (for REGFILE/SOFTLINK creations) ORs in DIRECTDATA at:1212-1215WITHOUT resetting the survivingmeta.size(size is never assigned) β a size>512 file can be flipped to DIRECTDATA;:1257repoints the live inode's cluster to the new detached chains, dropping its real chains. 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 andhammer2_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_getattron 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
fixedBaseline 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
Confirmed kernel references
- sys/vfs/hammer2/hammer2_inode.c:1156
- sys/vfs/hammer2/hammer2_inode.c:1158
- sys/vfs/hammer2/hammer2_inode.c:1160
- sys/vfs/hammer2/hammer2_inode.c:1165
- sys/vfs/hammer2/hammer2_inode.c:1212
- sys/vfs/hammer2/hammer2_inode.c:1257
- sys/vfs/hammer2/hammer2_inode.c:1743
- sys/vfs/hammer2/hammer2_vfsops.c:1977
- sys/vfs/hammer2/hammer2_flush.c:308
- sys/vfs/hammer2/hammer2_flush.c:902
- sys/vfs/hammer2/hammer2_vfsops.c:2685
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.
No comments yet.