ext2_htree_check_next() loads dx nodes during the miss-walk with zero validation β attacker-driven directory-block walk and OOB heap read past the index buffer (kernel panic from crafted ext2 image)
Summary
ext2_htree_lookup()'s collision-scan reloads sibling index nodes in ext2_htree_check_next (:142-152) and installs them as h_levels[] with no validation at all - not the count/limit cross-check find_leaf applies (:313-315), not the node-capacity check. On a crafted htree image (dir_index feature, RW mount - DF-0850/DF-0877/DF-3083 class), any unpriv user issuing one lookup of an absent name drives: (1) an attacker-driven walk of up to count(0xFFFF) entries whose h_blk fields choose each blkatoff() target, and (2) an OOB read - entry index 127 sits at node offset 8+8*127=1024, the first byte past the 1 KiB buffer: ext2_htree_get_hash (:136) reads 4 kernel-heap bytes past bp->b_data for the parity gate and ext2_htree_get_block (:383) can read 4 more OOB bytes as a directory offset. Distinct from DF-0850: the PoC's find_leaf descent node is fully honest (limit==127==node_limit), so DF-0850's fix does not block this chain. VERIFIED stock INVARIANTS guest: stage 1 -> deterministic panic 'ext2_dirbad: bad dir ino 12 at offset 7168' (7*1024 == entry 126's crafted block pointer), backtrace ext2_search_dirblock<-ext2_htree_lookup<-ext2_lookup; stage-2 variant (entry 126 = valid miss leaf) reaches entry 127 = fully OOB: 3/3 fresh boots silent ENOENT (gate rejects heap garbage) - OOB read source-certain, documented honestly. Fix validated (validate the freshly-read node in check_next exactly as find_leaf validates the root + capacity check, rebuilt kernel #1): both stages clean ENOENT, no panic; valid-image regression 100/100.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3122 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 5.1 KB | β raw | |
| craft.sh | β | 5.8 KB | view raw | |
| trigger.sh | β | 482 B | view raw | |
| run.sh | β | 222 B | view raw | |
| build.sh | β | 112 B | view raw | |
| vntool.c | β | 1.1 KB | view raw | |
| build_a.py | β | 6.0 KB | view raw | |
| run.log | β | 1.1 KB | view raw | |
| run2.log | β | 475 B | view raw | |
| run3.log | β | 475 B | view raw | |
| panic.txt | β | 798 B | view raw | |
| bootlog.full.txt | β | 2.3 KB | view raw | |
| fix.diff | β | 1.1 KB | view raw | |
| build.log.gz | β | 182.6 KB | β download | |
| fix_run1.log | β | 341 B | view raw | |
| fix_run2.log | β | 218 B | view raw | |
| fix_valid.log | β | 350 B | view raw | |
| env.txt | β | 621 B | view raw | |
| notes-df3085.md | β | 2.2 KB | β raw | |
| verdict.json | β | 4.9 KB | view raw | |
| manifest.json | β | 1.1 KB | view raw |
DF-3122 β ext2_htree_check_next() reloads dx nodes with zero validation β attacker-driven walk + OOB heap read past the directory-index buffer
What (severity: Medium, bucket: memcorrupt, crafted ext2 image + RW mount)
ext2_htree_check_next() (sys/vfs/ext2fs/ext2_htree.c:142-152) loads the
next index node during a lookup miss-walk and applies no validation at
all β not the count/limit cross-check ext2_htree_find_leaf() applies
(:313-315), not the node-capacity check (ext2_htree_node_limit()), nothing.
Two consequences on a crafted htree index:
- Attacker-driven walk: the reload trusts the node's count (u16 from
disk), so
level->h_entryadvances through up tocountentries β every entry'sh_blkdirectly selects which directory blockblkatoff()reads next. A count of0xFFFFin a 1 KiB node (capacity: 127 entries) walksh_entrypast the end of the buffer. - OOB heap read: entry index 127 lives at node offset
8 + 8*127 = 1024== the first byte past the 1 KiB buffer;ext2_htree_get_hash(h_entry)(:136) reads 4 kernel-heap bytes pastbp->b_data, and if the parity gate passes,ext2_htree_get_block()(:383) reads 4 more OOB bytes and feeds them toblkatoff()as a file offset.
Distinct from DF-0850 (missing interior-node limit validation in
find_leaf's :335 descent): the stage-1 PoC's descent node B1 is fully
honest (limit == ext2_htree_node_limit() == 127, count=1), so a kernel
carrying DF-0850's recommended fix still takes this path. DF-0850's fix does
not touch check_next.
Reproduce
Host (needs mke2fs/debugfs/e2fsck β e2fsprogs 1.47.0 used):
./craft.sh # builds valid.img, a1.img (panic), a2.img (OOB probe)
Guest (root): sh trigger-root.sh /root/a1.img (vntool attach + RW mount + chmod)
Guest (maxx): stat /mnt/e2/d/zzqq01
Expected on stock kernel:
panic: ext2_dirbad: : bad dir ino 12 at offset 7168: mangled entry ext2_dirbad() / ext2_search_dirblock() / ext2_htree_lookup() / ext2_lookup()
offset 7168 = the 126th crafted entry's block pointer (blk 7) β proof that the unvalidated reload happened and the walk consumed 126 attacker-crafted entries before reading a hole block chosen by the attacker.
a2.img variant: entry 126 points at a valid miss-leaf, so the walk reaches
entry 127 β entirely past the 1 KiB buffer: the 4-byte OOB read is
source-certain (h_levels[].h_entry < h_entries + 0xFFFF at :127-129, gate
value read at :136 from b_data+1024..1027). Runtime outcome is
heap-dependent: in 3/3 fresh-boot runs the garbage failed the parity gate and
the lookup returned clean ENOENT (guest survived) β the OOB read itself is
invisible without heap grooming.
Preconditions
- crafted ext2 image with
dir_index, mounted read-write (mount needs root orvfs.usermount=1β same precondition class as DF-0850/DF-0877/DF-3083) - any user can then trigger with a single
statof an absent name
Fix
fix.diff β validate the reloaded node in check_next (count != 0,
count <= limit, limit == ext2_htree_node_limit()); on failure stop the scan
(brelse the new buffer, return 0 β ENOENT, linear fallback preserved upstream).
Validated: patched kernel survives both PoC images, valid-image lookups still
work 100/100 (see VERDICT.md).
DF-3122 VERDICT
Bottom line
REPRODUCED (panic / crafted-image DoS + demonstrated attacker-driven walk; 4-byte OOB heap read source-certain, runtime-silent in 3/3 probes).
On the stock INVARIANTS kernel (#0, 6.5-DEVELOPMENT), an unprivileged user
(maxx) issuing one stat of an absent name inside an htree-indexed
directory of a crafted ext2 image (RW-mounted by root) panicked the kernel:
panic: ext2_dirbad: : bad dir ino 12 at offset 7168: 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 vop_old_lookup() at vop_old_lookup+0x50
Why (code trace, exact chain)
ext2_lookup_ino()dispatches toext2_htree_lookup()because the dir hasIN_E3INDEX(sys/vfs/ext2fs/ext2_lookup.c:377-382).ext2_htree_find_leaf()walks the crafted root (limit==124 passes the:308-310root-limit check, count=3, ind_levels=1), binary search selects entry 1 β descends into B1, a fully honest interior node (limit=127 == ext2_htree_node_limit(), count=1) β returns entry 0 (the node's count/limit overlay entry, whoseh_blklegitimately points at the leftmost child β this is the DF-3085 "underflow" case and it is format-correct, see notes-df3085.md).ext2_htree_lookup()searches leaf block 3 (real dirent data): miss.ext2_htree_check_next()(ext2_htree.c:113-155): level-1 entry exhausted β advances the root entry pointer, parity gate passes (next_hash == H|1), then:144-151blkatoff()s the next root entry's block β the crafted node B2 β and installs it ash_levels[1]with zero validation (no count/limit/capacity checks; contrastfind_leaf's:313-315).- The do-while loop (
:381-415) then consumes B2's entries:h_entrywalks 0,1,2,β¦,126 whileh_entry < h_entries + get_count(h_entries)withcount = 0xFFFF(:127-129) β every entry'sh_blk(attacker bytes) choosing the nextblkatoff()target (:383-384). Entry 126's block pointer = 7 β offset 7168 = a hole β zero-filled buffer βext2_check_direntryrejectsrec_len == 0βext2_dirbad()β panic (writable mount) at exactly the chosen offset. - OOB read: had entry 126 pointed at a valid miss-leaf (a2.img), the walk
reaches entry 127 at node offset
8+8*127 = 1024β the first byte past the 1 KiB buffer.ext2_htree_get_hash()(:136) then reads kernel heap atb_data+1024..1027; passing the parity gate,ext2_htree_get_block()(:383) readsb_data+1028..1031and feeds the garbage toblkatoff(). In 3/3 fresh-boot runs the gate rejected the garbage β clean ENOENT, guest survived (silent OOB read). Source-certain regardless:countis never bounded by the node capacity in this path.
Distinctness from DF-0850
DF-0850 = missing interior-node limit validation in find_leaf's descent
(:335-339). This finding = missing everything validation in
check_next's reload (:144-151) β a different function, different missing
check, reached only via the miss-walk. The stage-1 PoC's descent node (B1) is
honest (limit == node_limit == 127), so DF-0850's recommended fix does not
prevent this chain; the panic offset (7168) is chosen by the reloaded node's
entry 126, which find_leaf never touches.
Baseline (stock kernel #0)
- stage 1 (a1.img): panic above (run.log) β 1/1 attempts.
- stage 2 (a2.img): silent ENOENT, guest up β 3/3 fresh boots (run2.log, run3.log) β OOB read occurs invisibly; gate rejects heap garbage.
Fix validation (kernel #1, built in-guest)
fix.diff validates the reloaded node in ext2_htree_check_next (count != 0,
count <= limit, limit == ext2_htree_node_limit()); on failure it brelse's
the new buffer and returns 0 (scan stops β ENOENT).
Built with make -j6 nativekernel KERNCONF=X86_64_GENERIC in the guest
(build.log, rc=0), make installkernel, reboot (uname in env.txt):
- stage 1 (a1.img): no panic;
stat /mnt/e2/d/zzqq01β clean ENOENT; guest up (fix_run1.log). - stage 2 (a2.img): no panic; clean ENOENT; guest up (fix_run2.log).
- regression β valid e2fsck -fyD image:
[ -e payloadname001..100 ]sweep ok=100 miss=0,rm -v payloadname050removed cleanly (100β99), open/lstat/unlink via C tester all succeed (fix_valid.log). No behavioral regression on legitimate indexed directories.
Impact ceiling
Crafted-image class (root mount or vfs.usermount=1), then any user:
deterministic kernel panic (DoS) and an OOB-read primitive (4β8 bytes per
walk step past a 1 KiB buffer-cache object, extendable with heap grooming).
No direct disclosure channel demonstrated; same bar as DF-0850 β Medium.
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.
DF-3085 disproof notes (mechanism attribution: ext2_htree.c pass 2, 2026-09-06)
DF-3085 (filed against sys/vfs/ext2fs/ext2_lookup.c) claims: "htree lookup misses existing entries whose hash sorts below the first dx entry (dx count/limit overlay used as an entry) β existing files unopenable/undeletable on valid indexed dirs", with mechanism "ext2_htree_find_leaf's binary search resolves hashes below the first real entry to found = start-1 = the count/ limit overlay entry itself (ext2_htree.c:312-333)".
Result: the mechanism is FALSE and the observed symptom was an artifact
- On-disk: an
e2fsck -fyD-built dx root (e2fsprogs 1.47.0) does carry a leading sentinel β raw dump of the DF-3082 harness image:count=3, limit=124; Entry #0: Hash 0x00000000, block 1; Entry #1: 0x640baa44β2; Entry #2: 0xb5f131c0β3. Entry 0 (the count/limit overlay) holds a realh_blkpointing at the leftmost leaf. Selecting it viafound = start - 1for low hashes is format-correct, identical to Linux'sdx_probe(frame->at = p - 1). - Runtime (stock kernel #0, valid image, RW mount):
-
[ -e payloadname001..100 ]sweep: ok=100 miss=0 β lookups of names hashing below the first real dx entry (e.g. payloadname001, hash 0x0d164900 < 0x640baa44) succeed. -rm -v payloadname050β removed (100β99); Copen/lstat/unlinkof payloadname051 β all succeed. - The original "rm of payloadname01..50 silently no-oped" observation is a
name-width artifact: the files are
payloadname001..100(3-digit, created viaseq -w 1 100), but DF-3082's trigger.sh removal loop usedseq -w 1 50βpayloadname01..50(2-digit) β names that never existed;rm -fon them is a silent no-op by POSIX. This reproduces the "entries after rm 50: 100" line exactly. The DF-3082 fix-run's "100 β 140" count is the same artifact (50 no-op rms + 40 creates).
Correction for the record
ext2_htree_find_leaf() has no underflow bug in the sentinel selection; the
real htree lookup on valid e2fsck/Linux indexes behaves correctly. DF-3085
should be reclassified invalid/artifact by its owner. (The genuine lookup-side
issue in this file is DF-3122: the unvalidated node reload in
ext2_htree_check_next.)
Fix verification
fixedPatch applied to /usr/src in guest, kernel rebuilt (4299 cc, rc=0) and installed. Baseline panic reproduced on #0; on #1 the identical trigger returns clean ENOENT with the guest up for both a1.img and a2.img, and a valid e2fsck-indexed image still passes 100/100 lookups plus a successful rm β fix holds with no regression.
['fix_run1.log', 'fix_run2.log', 'fix_valid.log', 'build.log.gz', 'fix.diff']
Confirmed kernel references
Detail
Exploit chain
crafted ext2 image (dir_index, RW mount by root) -> any user stats absent name -> find_leaf descends honest root+B1 -> miss in leaf -> check_next exhausts B1, advances root entry, parity gate passes, blkatoff reloads crafted B2 with no validation -> walk consumes attacker entries (count=0xFFFF unchecked) -> entry 127 read at b_data+1024 = OOB heap read -> garbage-driven blkatoff/dirbad -> kernel panic (DoS). OOB read primitive: 4-8 bytes per walk step past a 1KiB buffer-cache object.
Evidence (decisive lines)
['panic.txt (panic signature + backtrace, offset 7168 = crafted entry 126 blk)', 'run.log (stock stage-1 panic)', 'run2.log/run3.log (stock stage-2 silent OOB probes)', 'fix_run1.log fix_run2.log (patched kernel #1: no panic)', 'fix_valid.log (regression: ok=100, rm ok)', 'build.log.gz (in-guest kernel build, 4299 cc, rc=0)', 'craft.sh (deterministic image builder incl. hash port validated vs e2fsck)', 'VERDICT.md (full trace), notes-df3085.md (DF-3085 disproof)']
PoC changes
Seed sketch (from DF-3082 family) had no crafted-node machinery at all: wrote a deterministic builder (craft.sh + embedded python half_md4 port, validated 3/3 against e2fsck's dumped hashes) producing honest root/B1 + unvalidated-reload node B2 (count=0xFFFF); first attempt misplaced the dx-node overlay at offset 12 β struct ext2fs_htree_node's fake dirent is 8 bytes (no name padding), fixed to offsets 8/10/12.
Verified recommended fix
Validate the reloaded node in ext2_htree_check_next (count != 0 && count <= limit && limit == ext2_htree_node_limit(ip); brelse + return 0 on failure) β fix.diff, validated in-guest.
Verdict
REPRODUCED on the stock INVARIANTS kernel (#0): one unprivileged stat() of an absent name in an htree-indexed directory of a crafted RW-mounted ext2 image panics the kernel β panic: ext2_dirbad at offset 7168, backtrace ext2_search_dirblock <- ext2_htree_lookup <- ext2_lookup. The offset is the block pointer of entry #126 of a dx node loaded by ext2_htree_check_next (ext2_htree.c:144-151) with ZERO validation β not the count/limit cross-check find_leaf applies (:313-315), not the node-capacity check (ext2_htree_node_limit). count=0xFFFF walks h_entry through 126 crafted entries (attacker-chosen blkatoff targets) and entry #127 lives at node offset 1024 == first byte past the 1KiB buffer: ext2_htree_get_hash() (:136) reads 4 kernel-heap bytes OOB and, gate permitting, ext2_htree_get_block() (:383) reads 4 more OOB bytes as a file offset. The stage-2 OOB probe (a2.img) ran silently (3/3 fresh boots: gate rejected garbage, clean ENOENT, guest survived) β the OOB read is source-certain but invisible without heap grooming. Distinct from DF-0850: the PoC's descent node B1 is fully honest (limit==127==node_limit), so DF-0850's fix does not block this chain. Fix validated in-guest (kernel #1): both stages clean ENOENT, no panic; valid-image regression 100/100 lookups + rm works.
No comments yet.