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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3122 Β· 22 files
FileTypeDescriptionSize
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:

  1. Attacker-driven walk: the reload trusts the node's count (u16 from disk), so level->h_entry advances through up to count entries β€” every entry's h_blk directly selects which directory block blkatoff() reads next. A count of 0xFFFF in a 1 KiB node (capacity: 127 entries) walks h_entry past the end of the buffer.
  2. 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 past bp->b_data, and if the parity gate passes, ext2_htree_get_block() (:383) reads 4 more OOB bytes and feeds them to blkatoff() 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 or vfs.usermount=1 β€” same precondition class as DF-0850/DF-0877/DF-3083)
  • any user can then trigger with a single stat of 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).

VERDICT.md
↓ download raw

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)

  1. ext2_lookup_ino() dispatches to ext2_htree_lookup() because the dir has IN_E3INDEX (sys/vfs/ext2fs/ext2_lookup.c:377-382).
  2. ext2_htree_find_leaf() walks the crafted root (limit==124 passes the :308-310 root-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, whose h_blk legitimately points at the leftmost child β€” this is the DF-3085 "underflow" case and it is format-correct, see notes-df3085.md).
  3. ext2_htree_lookup() searches leaf block 3 (real dirent data): miss.
  4. 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-151 blkatoff()s the next root entry's block β€” the crafted node B2 β€” and installs it as h_levels[1] with zero validation (no count/limit/capacity checks; contrast find_leaf's :313-315).
  5. The do-while loop (:381-415) then consumes B2's entries: h_entry walks 0,1,2,…,126 while h_entry < h_entries + get_count(h_entries) with count = 0xFFFF (:127-129) β€” every entry's h_blk (attacker bytes) choosing the next blkatoff() target (:383-384). Entry 126's block pointer = 7 β†’ offset 7168 = a hole β†’ zero-filled buffer β†’ ext2_check_direntry rejects rec_len == 0 β†’ ext2_dirbad() β†’ panic (writable mount) at exactly the chosen offset.
  6. 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 at b_data+1024..1027; passing the parity gate, ext2_htree_get_block() (:383) reads b_data+1028..1031 and feeds the garbage to blkatoff(). In 3/3 fresh-boot runs the gate rejected the garbage β†’ clean ENOENT, guest survived (silent OOB read). Source-certain regardless: count is 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 payloadname050 removed 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.

notes-df3085.md
↓ download raw

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

  1. 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 real h_blk pointing at the leftmost leaf. Selecting it via found = start - 1 for low hashes is format-correct, identical to Linux's dx_probe (frame->at = p - 1).
  2. 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); C open/lstat/unlink of payloadname051 β†’ all succeed.
  3. The original "rm of payloadname01..50 silently no-oped" observation is a name-width artifact: the files are payloadname001..100 (3-digit, created via seq -w 1 100), but DF-3082's trigger.sh removal loop used seq -w 1 50 β†’ payloadname01..50 (2-digit) β€” names that never existed; rm -f on 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Patch 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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Sep 6 21:28:18 UTC 2026

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.