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

ext2 create in an htree-indexed directory truncates the directory mid-tree β€” mass silent data loss on valid Linux-created images, then kernel panic

Summary

ext2_lookup_ino dispatches CREATE lookups to ext2_htree_lookup (:377-397); on ENOENT the search state describes only the leaf blocks on the hash path (ext2_htree.c:389-392), yet notfound builds dp->i_endoff from that leaf-local enduseful (:495-505) and ext2_direnter compact-truncates the directory to it (:951-954). One unprivileged touch() in a 2-leaf indexed directory of an e2fsck-clean image collapsed 100 entries to 36 (65 silently destroyed) and the next lookup of a vanished name panicked the kernel (ext2_dirbad on the freed, zero-filled leaf). DF disabled the htree add path (#if 0 :920-941) but kept the linear compact-truncate - a dfly-specific regression firing on ordinary Linux ext3 images (Linux indexes dirs by default). All insert paths funnel here (create/mkdir/link/rename-into). VERIFIED guest: 100->36 entries on one touch, then panic; fix validated in-guest (skip compact-truncate when ext2_htree_has_idx): 40 creates grow 100->140, zero vanished, no panic.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3082 Β· 17 files
FileTypeDescriptionSize
README.md β€” 5.0 KB ↓ raw
VERDICT.md β€” 3.6 KB ↓ raw
craft.sh β€” 994 B view raw
mount.sh β€” 408 B view raw
trigger.sh β€” 1.5 KB view raw
fix_run.sh β€” 1.3 KB view raw
fix.diff β€” 1.4 KB view raw
vntool.c β€” 1.1 KB view raw
run.log β€” 328 B view raw
mount.log β€” 74 B view raw
panic.txt β€” 762 B view raw
fix_run.log β€” 514 B view raw
fix_mount.log β€” 74 B view raw
build.log.gz β€” 189.9 KB ↓ download
verdict.json β€” 4.3 KB view raw
manifest.json β€” 1.2 KB view raw
env.txt β€” 788 B view raw

DF-3082 β€” ext2 htree-indexed directory: create() truncates the directory mid-tree (mass silent data loss on a valid fs, then kernel panic)

Summary

ext2_lookup_ino() (sys/vfs/ext2fs/ext2_lookup.c:377-397) dispatches every namei op β€” including NAMEI_CREATE β€” to ext2_htree_lookup() when the directory carries EXT2_INDEX_FL (β†’ IN_E3INDEX, set from disk at sys/vfs/ext2fs/ext2_inode_cnv.c:170) and the fs has the dir_index compat feature. On a miss, ext2_htree_lookup() returns ENOENT after searching only the leaf blocks on the hash path (sys/vfs/ext2fs/ext2_htree.c:381-415): it leaves the caller's struct ext2fs_searchslot ss pointing at a slot inside one leaf block and *endusefulp (ext2_htree.c:389-392, updated leaf-locally by ext2_search_dirblock) at the end of the last used entry of that leaf.

ext2_lookup_ino()'s case ENOENT: (ext2_lookup.c:387-389) falls into the notfound: label, which builds the create parameters from this partial search state (ext2_lookup.c:495-505):

dp->i_offset = ss.slotoffset;
dp->i_count  = ss.slotsize;
...
dp->i_endoff = roundup2(enduseful, DIRBLKSIZ);   /* leaf-local! */

ext2_direnter() (ext2_lookup.c:948-954) then inserts the entry and "compacts" the directory by truncating it to i_endoff:

error = ext2_add_entry(dvp, &newdir);
if (!error && dp->i_endoff && dp->i_endoff < dp->i_size)
        error = ext2_truncate(dvp, (off_t)dp->i_endoff, IO_SYNC, ...);

For a linear (non-indexed) search enduseful spans the whole directory and the truncate is the historic UFS directory-compaction. For an indexed directory i_endoff is the end of one leaf block in the middle of the file: the truncate deletes every directory block after that leaf β€” while the htree index (dx root still in block 0) keeps referencing the freed blocks. All entries in later leaves are silently destroyed (i_size collapses); the freed blocks read back as zero-filled holes, so the very next lookup whose hash resolves into a freed leaf walks a zero block, ext2_check_direntry() rejects rec_len == 0, and ext2_dirbad() (ext2_lookup.c:771-784) panics the writable-mounted kernel (see DF-3083).

Note: FreeBSD's ext2 keeps ext2_htree_add_entry() enabled in ext2_direnter() and never runs the linear compact-truncate for indexed dirs. DragonFly disabled the htree add path (#if 0, ext2_lookup.c:920-941) but kept the truncate β€” a dfly-specific regression that fires on valid, e2fsck-clean, Linux-created ext3/ext2 images (Linux indexes directories by default: dir_index + EXT2_INDEX_FL).

All entry-insertion syscalls funnel here: create (ext2_vnops.c:1784), mkdir (:1229), link (:620), rename-into (:885).

Reachability

  • fs must carry EXT2F_COMPAT_DIRHASHINDEX and the target directory EXT2_INDEX_FL β€” the Linux default for ext3 dirs with >1 block of entries; the image need not be "crafted", merely Linux-created and e2fsck-clean (our trigger image is exactly that).
  • mount read-write (root mount, or vfs.usermount=1 own mount).
  • the creating user needs write permission in the directory β€” an unprivileged user once the fs is mounted.
  • the new name must hash into a non-last leaf whose free slack fits the new entry (with β‰₯2 leaves and normal e2fsck packing this hits within a few creates; in our run the first create triggered it).

Reproduced (baseline, stock INVARIANTS kernel #0)

mount -t ext2fs /dev/vn0 /mnt/e2          # htree.img: dir d, 100 entries,
                                          # 2 dx leaves (blocks 2,3), i_size 4096
$ ls /mnt/e2/d | wc -l                    # as unprivileged user maxx
100
$ rm payloadname01..50                    # (silently ENOENT β€” see notes)
$ touch zzq001
$ ls | wc -l
36                                        # 65 pre-existing entries DESTROYED
$ stat /mnt/e2/d/payloadname002
panic: ext2_dirbad: : bad dir ino 12 at offset 3072: mangled entry
ext2_dirbad() at ext2_dirbad+0x32
ext2_search_dirblock() at ext2_search_dirblock+0x194
ext2_htree_lookup() at ext2_htree_lookup+0x12e
ext2_lookup() at ext2_lookup+0x28d

offset 3072 = the freed leaf block 3: the index still points there, the block reads back zero-filled, the first zero dirent is "mangled" β†’ panic.

Fix

fix.diff β€” never compact-truncate an indexed directory:

+   if (!error && !ext2_htree_has_idx(dp) &&
+       dp->i_endoff && dp->i_endoff < dp->i_size)
        error = ext2_truncate(dvp, (off_t)dp->i_endoff, IO_SYNC,
            cnp->cn_cred);

Validated: single-fix make nativekernel build, same image, same user β€” no collapse (ls count keeps growing), no panic, mount survives stress.

Contents

  • craft.sh β€” host-side image builder (mke2fs + debugfs + e2fsck -fD)
  • mount.sh β€” guest root: vn attach + RW mount + chmod 777 d
  • trigger.sh β€” guest unprivileged (maxx): rm/touch/stat sequence
  • run.log, panic.txt, mount.log β€” baseline evidence
  • fix.diff, fix_run.log, fix_build.log β€” fix validation
  • verdict.json, manifest.json, VERDICT.md
VERDICT.md
↓ download raw

DF-3082 VERDICT

Bottom line

REPRODUCED. On the stock INVARIANTS kernel (#0), an unprivileged user (maxx) creating one file inside an htree-indexed directory of a valid, e2fsck-clean, Linux-toolchain-produced ext2 image (mounted read-write by root) caused ext2_direnter() to truncate the directory to the middle of the index tree: ls collapsed from 100 to 36 entries β€” 65 pre-existing entries silently destroyed β€” and the next lookup of a vanished name panicked the kernel.

Why (code trace)

  1. ext2_lookup_ino() dispatches CREATE to ext2_htree_lookup() (sys/vfs/ext2fs/ext2_lookup.c:377-382) because the dir has EXT2_INDEX_FL β†’ IN_E3INDEX (sys/vfs/ext2fs/ext2_inode_cnv.c:170).
  2. Name not found β†’ ext2_htree_lookup() returns ENOENT after searching only the hash-path leaves; ss holds a slot inside one leaf and *endusefulp is leaf-local (sys/vfs/ext2fs/ext2_htree.c:381-418).
  3. case ENOENT: (ext2_lookup.c:387-389) β†’ notfound: builds dp->i_endoff = roundup2(enduseful, DIRBLKSIZ) from that leaf-local enduseful (ext2_lookup.c:495-505).
  4. ext2_direnter() (ext2_lookup.c:948-954): ext2_add_entry() then ext2_truncate(dvp, dp->i_endoff) β€” frees every directory block after the searched leaf while the dx root still indexes them.
  5. Vanished name lookup β†’ dx leaf at offset 3072 β‰₯ truncated i_size β†’ bread returns a zero-filled hole β†’ ext2_check_direntry() rejects rec_len == 0 β†’ ext2_dirbad() (ext2_lookup.c:771-784) β†’ panic (writable mount).

DragonFly disabled the htree add path (#if 0, ext2_lookup.c:920-941) but kept the linear compact-truncate β€” FreeBSD keeps htree adds enabled and never runs this truncate for indexed dirs, so this is a dfly-specific regression that fires on ordinary Linux-created ext3 images.

Baseline run (stock #0)

entries before: 100
after touch zzq001: entries=36      <- one create destroyed 65 entries
panic: ext2_dirbad: : bad dir ino 12 at offset 3072: mangled entry
ext2_dirbad() / ext2_search_dirblock() / ext2_htree_lookup() / ext2_lookup()

(run.log, panic.txt; mount via vntool attach + mount -t ext2fs /dev/vn0.)

Fix validation (kernel #1, built in-guest from fix.diff)

make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0, 4299 cc steps, full log build.log.gz) + make installkernel + reboot.

Same image, same user (fix_run.sh): 40 creates grow the entry count monotonically 100 β†’ 140, zero vanished pre-existing entries, stat sweep over 40 surviving indexed entries all found, no panic, guest up:

entries after 40 touches: 140
vanished pre-existing entries: 0
FIX-OK: no truncate, no data loss
stat sweep: ok=40 notfound=0 (no panic = fix holds)

Exploit chain

Not a privilege-escalation primitive: the bug is silent mass data destruction (integrity) reachable by an unprivileged create, followed by a reliable kernel panic (availability) on any subsequent lookup of the destroyed namespace. No kernel memory corruption is involved β€” ext2_truncate uses the normal truncate machinery; the panic is the fail-stop ext2_dirbad() policy (DF-3083).

Notes

  • The rm payloadname0X steps were observed to silently no-op on this image (lookup ENOENT for names that exist β€” consistent with the dx binary-search resolving low hashes to the count/limit overlay when no leading sentinel entry exists, cf. the upstream "lost dirents" XXX at ext2_lookup.c:908-919). This quirk is not needed for the trigger: the truncate fired on the very first create, using only e2fsck packing slack.
  • Guest was reset to the clean with-src snapshot after the runs.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-fix kernel (only fix.diff applied to /usr/src): same image, same unprivileged user - no collapse (100->140 over 40 creates), zero vanished pre-existing entries, stat sweep of 40 surviving indexed entries ok, no panic, guest stayed up (fix_run.log).

['fix_run.log', 'fix_mount.log', 'build.log.gz', 'fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Sep 6 03:12:08 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv user touch() in RW-mounted indexed dir -> lookup(CREATE) via ext2_htree_lookup ENOENT with leaf-local slot/enduseful -> notfound builds dp->i_endoff = end of searched leaf -> ext2_direnter: add_entry + ext2_truncate(i_endoff) frees all later dir blocks -> 65 entries vanish silently; dx root still indexes freed blocks -> any later lookup hashing into a freed leaf breads a zero-filled hole -> ext2_check_direntry rejects rec_len==0 -> ext2_dirbad panic (writable mount)

Evidence (decisive lines)

["run.log: 'after touch zzq001: entries=36' + 'lost entries: 65' (baseline data loss)", 'panic.txt: panic ext2_dirbad ino 12 offset 3072 via ext2_htree_lookup<-ext2_search_dirblock<-ext2_lookup', 'fix_run.log: 40 touches 100->140, vanished=0, stat sweep ok=40, FIX-OK', 'build.log.gz: full nativekernel log, done-rc=0, 4299 cc steps']

PoC changes

Written fresh for this run (no seed pack existed): host-side craft.sh builds the indexed image with mke2fs+debugfs+e2fsck -fD; the rm-slack step turned out to be unnecessary (e2fsck packing slack alone triggers the truncate on the first create); trigger detection compares ls counts before/after each touch.

Verified recommended fix

guard the compact-truncate in ext2_direnter with !ext2_htree_has_idx(dp) (fix.diff, validated in-guest)

Verdict

On the stock INVARIANTS kernel, one unprivileged file create in an htree-indexed directory of a valid e2fsck-clean ext2 image (RW-mounted by root) made ext2_direnter truncate the directory to the middle of the index tree: 65 of 100 pre-existing entries were silently destroyed on the first touch, and the next lookup of a vanished name panicked the kernel (ext2_dirbad via ext2_htree_lookup on a freed, zero-filled leaf at offset 3072). Root cause: the htree ENOENT path leaves ss/enduseful leaf-local while ext2_direnter compact-truncates to dp->i_endoff. A single-fix kernel (!ext2_htree_has_idx guard on the truncate) built in-guest eliminates both the data loss (100->140 monotonic over 40 creates, zero vanished entries) and the panic.