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)
PoC verification
Evidence pack
findings/poc/DF-3082 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_DIRHASHINDEXand the target directoryEXT2_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=1own 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 dtrigger.shβ guest unprivileged (maxx): rm/touch/stat sequencerun.log,panic.txt,mount.logβ baseline evidencefix.diff,fix_run.log,fix_build.logβ fix validationverdict.json,manifest.json,VERDICT.md
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)
ext2_lookup_ino()dispatches CREATE toext2_htree_lookup()(sys/vfs/ext2fs/ext2_lookup.c:377-382) because the dir hasEXT2_INDEX_FLβIN_E3INDEX(sys/vfs/ext2fs/ext2_inode_cnv.c:170).- Name not found β
ext2_htree_lookup()returns ENOENT after searching only the hash-path leaves;ssholds a slot inside one leaf and*endusefulpis leaf-local (sys/vfs/ext2fs/ext2_htree.c:381-418). case ENOENT:(ext2_lookup.c:387-389) βnotfound:buildsdp->i_endoff = roundup2(enduseful, DIRBLKSIZ)from that leaf-local enduseful (ext2_lookup.c:495-505).ext2_direnter()(ext2_lookup.c:948-954):ext2_add_entry()thenext2_truncate(dvp, dp->i_endoff)β frees every directory block after the searched leaf while the dx root still indexes them.- Vanished name lookup β dx leaf at offset 3072 β₯ truncated i_size β
bread returns a zero-filled hole β
ext2_check_direntry()rejectsrec_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 payloadname0Xsteps 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-srcsnapshot after the runs.
Fix verification
fixedSingle-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']
Confirmed kernel references
- sys/vfs/ext2fs/ext2_lookup.c:377
- sys/vfs/ext2fs/ext2_lookup.c:387
- sys/vfs/ext2fs/ext2_lookup.c:495
- sys/vfs/ext2fs/ext2_lookup.c:505
- sys/vfs/ext2fs/ext2_lookup.c:948
- sys/vfs/ext2fs/ext2_lookup.c:951
- sys/vfs/ext2fs/ext2_lookup.c:952
- sys/vfs/ext2fs/ext2_htree.c:389
- sys/vfs/ext2fs/ext2_htree.c:394
- sys/vfs/ext2fs/ext2_inode_cnv.c:170
- sys/vfs/ext2fs/ext2_vnops.c:1229
- sys/vfs/ext2fs/ext2_vnops.c:1784
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.
No comments yet.