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

ext2_dirbad panic()s a writable-mounted ext2 on any mangled directory entry β€” one flipped byte = reboot-loopable local DoS

Summary

ext2_dirbad (:771-784) calls panic() whenever the mount is not MNT_RDONLY and a walked dirent fails ext2_check_direntry (rec_len<12, %4!=0, <REC_LEN(namlen), cross-block, ino>icount) - invoked from ext2_search_dirblock's mangled-entry path on every linear and htree lookup and the i_size-too-small fixup (:532-537). A single patched byte (rec_len 16->17) in a crafted image panics the stock kernel on the first stat/open that walks past it; the RDONLY branch plus the walker's skip-to-next-block recovery prove non-fatal handling is already implemented. Also the automatic follow-on of DF-3082 on valid images. VERIFIED guest: RW mount + stat past corrupt byte -> panic; RO mount survives identical lookups; fix validated (printf instead of panic): ENOENT + console log, no panic.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3083 Β· 12 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 2.2 KB ↓ raw
craft.py β€” 2.1 KB view raw
run.sh β€” 913 B view raw
fix_run.sh β€” 644 B view raw
fix.diff β€” 1.4 KB view raw
run.log β€” 164 B view raw
panic.txt β€” 770 B view raw
fix_run.log β€” 325 B view raw
verdict.json β€” 3.5 KB view raw
manifest.json β€” 967 B view raw
env.txt β€” 772 B view raw

DF-3083 β€” ext2_dirbad() panics a writable-mounted ext2 on any mangled directory entry (local DoS from a crafted image)

Summary

ext2_dirbad() (sys/vfs/ext2fs/ext2_lookup.c:771-784) reacts to a directory-entry consistency failure with panic() whenever the mount is not read-only:

if ((mp->mnt_flag & MNT_RDONLY) == 0)
        panic("ext2_dirbad: %s: bad dir ino %ju at offset %ld: %s\n", ...);
else
        SDT_PROBE4(...);           /* read-only: log and continue */

It is called from the dirent walkers whenever ext2_check_direntry() (ext2_lookup.c:794-819) rejects an on-disk entry β€” i.e. any of:

  • rec_len < EXT2_DIR_REC_LEN(1), rec_len % 4 != 0, rec_len < EXT2_DIR_REC_LEN(namelen), entry crosses block boundary, ino > s_inodes_count (ext2_lookup.c:801-810);
  • the "i_size too small" fixup path (ext2_lookup.c:532-537).

Call sites: ext2_search_dirblock() "mangled entry" (ext2_lookup.c:698-706), reached from the linear searchloop (ext2_lookup.c:444) and from ext2_htree_lookup() (sys/vfs/ext2fs/ext2_htree.c:399). Any stat/open/ls -l whose lookup walks past the corrupt byte panics the kernel.

A single flipped byte in a directory block (e.g. rec_len 16 β†’ 17) turns any writable-mounted ext2 image into an immediate kernel panic; the read-only branch proves the non-fatal handling already exists.

Reachability

  • crafted ext2 image (one corrupt dirent byte) mounted read-write β€” by root, or by an unprivileged user with vfs.usermount=1 (the image may also arrive on removable media);
  • trigger is a plain path lookup β€” unprivileged, e.g. ls -l or stat of any name at/after the corrupt entry;
  • also fires as the follow-on of DF-3082 on a valid image (freed leaf blocks read back zero-filled β†’ "mangled entry" β†’ panic).

Reproduced (baseline, stock INVARIANTS kernel #0)

mount -t ext2fs -o ro /dev/vn0 /mnt/e2 ; ls -l /mnt/e2/d   # control: survives
umount /mnt/e2
mount -t ext2fs /dev/vn0 /mnt/e2                            # read-write
stat /mnt/e2/d/file4
panic: ext2_dirbad: /mnt/e2: bad dir ino 12 at offset 56: mangled entry
ext2_dirbad() at ext2_dirbad+0x32
ext2_search_dirblock() at ext2_search_dirblock+0x194
ext2_lookup() at ext2_lookup+0x1fe
vop_compat_nresolve() at vop_compat_nresolve+0x82

offset 56 = the patched entry (rec_len 16β†’17, rec_len % 4 != 0).

Fix

fix.diff (shared with DF-3082's pack) β€” always log, never panic; the walker already contains the recovery (skip to the next block, ext2_lookup.c:702-706). Validated on a patched kernel: the RW mount survives the same stat (printf on console instead of panic).

Contents

  • mangle.patch.py (embedded in craft steps) β€” flips one rec_len byte
  • run.sh β€” guest root: RO control + RW trigger
  • run.log, panic.txt β€” baseline evidence
  • fix.diff, fix_run.log β€” fix validation
  • verdict.json, manifest.json, VERDICT.md
VERDICT.md
↓ download raw

DF-3083 VERDICT

Bottom line

REPRODUCED. A single corrupted byte in a directory entry (rec_len 16 β†’ 17, violating rec_len % 4 == 0) turns any read-write mounted ext2 filesystem into an immediate kernel panic: one unprivileged path lookup that walks past the entry (e.g. stat /mnt/e2/d/file4) hits ext2_check_direntry() failure β†’ ext2_search_dirblock() calls ext2_dirbad() (sys/vfs/ext2fs/ext2_lookup.c:701) β†’ panic() (ext2_lookup.c:777-780) because the mount is not MNT_RDONLY. The identical lookup on a read-only mount survives (the non-fatal SDT branch), and the walker already contains the recovery (skip to next block, ext2_lookup.c:702-706).

Baseline run (stock #0)

RO control survived (mount/ls/umount all completed); RW mount:

stat /mnt/e2/d/file4
panic: ext2_dirbad: /mnt/e2: bad dir ino 12 at offset 56: mangled entry
ext2_dirbad() at ext2_dirbad+0x32
ext2_search_dirblock() at ext2_search_dirblock+0x194
ext2_lookup() at ext2_lookup+0x1fe
vop_compat_nresolve() at vop_compat_nresolve+0x82

(panic.txt; offset 56 = the patched entry in dir inode 12.)

Trigger caveat (poc_changes)

The first attempt used ls -l as the trigger; the mangled entry also breaks readdir, which EIOs before emitting names, so no lstat ever walked into the corrupt entry. A direct stat of a name positioned after the corrupt entry is the deterministic trigger (run.sh updated accordingly).

Fix validation (kernel #1, single-fix build shared with DF-3082)

Same image, RW mount, same lookup:

stat file4 rc=1 (ENOENT expected, NO panic)
stat file1 ok
ext2_dirbad: : bad dir ino 12 at offset 56: mangled entry   <- console printf
FIX-OK: RW mount survived mangled-entry lookup

The console line proves the walker reached the same code point and now logs instead of panicking; entries before the corrupt byte remain resolvable.

Impact

Local kernel panic (DoS) from a crafted image on a writable mount β€” by an admin mounting untrusted media, or fully unprivileged with vfs.usermount=1 (0 on this guest, but a supported configuration). Also the automatic follow-on of DF-3082 on valid images (freed htree leaf blocks read back zero-filled β†’ "mangled entry" β†’ panic), which is how DF-3082's demonstration ended.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Same mangled image, RW mount, same lookup on the single-fix kernel: ENOENT returned, console prints 'ext2_dirbad: : bad dir ino 12 at offset 56: mangled entry', entries before the corrupt byte still resolve (stat file1 ok), guest stays up (fix_run.log).

['fix_run.log', 'fix.diff', 'build.log.gz (in DF-3082 pack, shared build)']
↓ 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

crafted image (1 flipped byte) + RW mount (root, or unpriv with vfs.usermount=1) -> stat/open/ls -l of any name at/after the corrupt entry -> ext2_search_dirblock:701 -> ext2_dirbad:778 panic -> reboot-loopable local DoS; also the automatic follow-on of DF-3082 on valid images (zero-filled freed htree leaves)

Evidence (decisive lines)

['panic.txt: panic ext2_dirbad /mnt/e2 ino 12 offset 56 via ext2_lookup<-ext2_search_dirblock<-vop_compat_nresolve', 'run.log: RO control survived; RW trigger transcript', "fix_run.log: 'stat file4 rc=1 (ENOENT expected, NO panic)' + console ext2_dirbad printf + FIX-OK", 'fix.diff (shared with DF-3082)']

PoC changes

Initial trigger used ls -l; readdir EIOs before emitting names on this image so no lstat reached the corrupt entry - replaced with a direct stat of a name positioned after the corrupt entry (deterministic). run.sh updated; craft.py in pack regenerates the image.

Verified recommended fix

replace the panic with printf+SDT in ext2_dirbad (fix.diff, validated in-guest)

Verdict

One corrupted dirent byte (rec_len 16->17, so rec_len % 4 != 0) in a crafted ext2 image, mounted read-write, panics the stock kernel on the first unprivileged path lookup that walks past it: ext2_check_direntry rejects the entry, ext2_search_dirblock calls ext2_dirbad (ext2_lookup.c:701), which panics on any non-MNT_RDONLY mount (ext2_lookup.c:777-780). The identical lookup on a read-only mount survives (control run), and the walker already skips to the next block, so the panic is pure fail-stop policy. Single-fix kernel (printf instead of panic): same lookup returns ENOENT, console shows the ext2_dirbad message, no panic.