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

ext2_checkpath infinite loop on crafted cyclic .. directory entries β€” uninterruptible kernel hang

Summary

ext2_lookup.c:1212-1241 for(;;) loop walks target directory .. chain to root. Breaks only on: vtype!=VDIR :1213 vn_rdwr error :1220 malformed name :1223 dotdot_ino==source :1229 dotdot_ino==EXT2_ROOTINO :1233. NO depth limit NO cycle detection NO signal-pending check. Crafted image A/..=B B/..=A (neither source nor root): infinite loop kernel thread spins forever SIGKILL cannot stop vnode refs pinned until reboot. Created offline (debugfs/libext2fs) β€” impossible via mkdir/rename kernel always sets .. to true parent hard-links dirs forbidden. Trigger: mount crafted ext2 image RW then rename(2) directory into cyclic parent. Same pattern in ufs_checkpath ufs_lookup.c:1130-1166. Fix: cap loop at EXT2_CHECKPATH_MAXDEPTH=256 return EINVAL.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0824 Β· 14 files
FileTypeDescriptionSize
craft_img.py trigger-source host-side tool: builds the cyclic-ext2 image (mke2fs + debugfs + raw byte patch of .. inode) 4.0 KB view raw
df0824.img trigger-source crafted 1MB ext2 image with A<->B .. cycle 1.0 MB ↓ download
trigger.sh trigger-source guest-side trigger: kldload + mount + rename (older version with background subshell) 2.7 KB view raw
run.sh run-script clean reproducible run script (mount + timed rename + stuck-process check) 1.4 KB view raw
build.sh build-script verifies image presence (no compilation needed) 477 B view raw
fix.diff suggested-fix adds EXT2_CHECKPATH_MAXDEPTH=256 depth cap to ext2_checkpath 832 B view raw
VERDICT.md verdict full narrative: root cause, mechanism, reproduction, fix validation 5.0 KB ↓ raw
README.md readme this finding's evidence-pack manifest 1.3 KB ↓ raw
run.log run-log baseline (unpatched) reproduction: stuck mv process, SIGKILL-proof, CPU time -> infinity 2.3 KB view raw
fix_run.log fix-run-log patched-module runs: EINVAL in 0s, no hang 1.5 KB view raw
fix_build.log build-log full nativekernel build log (rc=0) 5.6 MB ↓ download
env.txt environment guest uname, kern.version, module sha256, cc version 422 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme this finding's evidence-pack manifest
↓ download raw

DF-0824: ext2_checkpath infinite loop on crafted cyclic .. directory entries

Severity: Medium β€” local DoS via crafted ext2 image (uninterruptible kernel hang) CWE: CWE-835 (Infinite Loop) Status: REPRODUCED on unpatched #0; FIX VALIDATED (single-file module rebuild).

Summary

ext2_checkpath() (sys/vfs/ext2fs/ext2_lookup.c:1212-1241) walks a target directory's .. parent chain in an unbounded for(;;) loop with no depth limit, no cycle detection, and no signal-pending check. A crafted ext2 image with cyclic .. entries (A→B→A) makes it loop forever when a directory is renamed into the cyclic parent. The kernel thread spins at 100% CPU and cannot be killed by SIGKILL.

Reproduce

The image must be crafted offline (impossible via online mkdir/rename). On a Linux host with e2fsprogs:

python3 craft_img.py     # produces df0824.img (1MB ext2, A<->B .. cycle)
scp df0824.img run.sh dfbsd:/root/
ssh dfbsd
sh /root/run.sh          # as root
Kernel Result
Unpatched #0 mv S A/S_moved hangs forever; kernel thread R3 @ 100% CPU, SIGKILL-proof
Fixed ext2fs.ko mv: rename S to A/S_moved: Invalid argument; returns in 0s

See VERDICT.md for the full mechanism, run.log for baseline proof, fix_run.log for the fixed-kernel proof, and fix.diff for the patch.

VERDICT.md verdict full narrative: root cause, mechanism, reproduction, fix validation
↓ download raw

DF-0824: ext2_checkpath infinite loop on crafted cyclic .. directory entries

Verdict: REPRODUCED (uninterruptible kernel hang / DoS) β€” FIX VALIDATED

Severity: Medium (local DoS via crafted filesystem image; requires admin to mount) Impact: dos β€” uninterruptible kernel-thread spin; SIGKILL cannot stop it CWE: CWE-835 (Infinite Loop / Loop with Unreachable Exit Condition)

Root cause

ext2_checkpath() (sys/vfs/ext2fs/ext2_lookup.c:1212-1241) walks a target directory's .. parent chain to verify that the source of a rename is not an ancestor of the target. The walk is a for(;;) loop whose only exits are:

Line Exit condition
1213 vp->v_type != VDIR (ENOTDIR)
1220 vn_rdwr I/O error
1223 malformed .. name (ENOTDIR)
1229 dotdot_ino == source->i_number (EINVAL)
1233 dotdot_ino == EXT2_ROOTINO (reached root)

There is NO depth limit, NO cycle-visited tracking, and NO signal-pending check. A crafted ext2 image where directory A's .. points to B and B's .. points back to A (neither being the source being renamed nor the root inode) makes the loop alternate A→B→A→B→... forever, each iteration calling vget()/vput() on the two cached vnodes.

Such cyclic .. entries are impossible to create online β€” mkdir/rename always set .. to the true parent and hard-linking directories is forbidden β€” but trivially created offline with debugfs or raw byte patching (the .. inode field is at offset 12 in a directory's first data block, per struct dirtemplate). The threat model is a malicious ext2 image mounted by an admin (or a user if vfs.usermount=1 + a root-created image owned by the user), then triggered by renaming a directory into the cyclic parent.

Reachability

ext2_checkpath is called from ext2_rename() at ext2_vnops.c:826 when doingdirectory && newparent (line 807) β€” i.e. renaming a directory across parent directories. The ext2 filesystem must be mounted (the ext2fs.ko module loads on demand). The same bug class exists in ufs_checkpath (DF-0834).

Reproduction (baseline, unpatched #0)

The crafted image (craft_img.py) contains:

/         (inode 2, root)
/S        (inode 12, source dir; normal `..`->2)
/A        (inode 13; FORGED `..`->14 (B))
/B        (inode 14; FORGED `..`->13 (A))

Mount + rename triggers the hang:

kldload ext2fs
vnconfig -c vn df0824.img       # -> vn4
mount -t ext2fs /dev/vn4 /mnt/df0824
mv /mnt/df0824/S /mnt/df0824/A/S_moved   # -> ext2_checkpath(S, A, ...) -> infinite loop

Observed on the unpatched kernel:

mv S A/S_moved   (process state R3 = RUNNING on CPU 3, 100% CPU)
   PID STAT        TIME UCOMM
  1141 R3       6:53.27 mv          (CPU time climbs monotonically)
  • The kernel-stuck thread survives kill -9 (SIGKILL cannot interrupt a thread spinning in kernel context with no PCATCH wait point).
  • CPU time accumulates indefinitely: 0:53 β†’ 1:39 β†’ 1:53 β†’ 6:53 over ~7 min.
  • The rename syscall never returns; /tmp/df0824_mvstatus is never written.
  • The vnode locks held by the spinning thread pin the ext2 filesystem until reboot.

Fix (validated)

fix.diff adds a depth cap to ext2_checkpath:

#define EXT2_CHECKPATH_MAXDEPTH 256 /* sane bound on .. chain length */

ext2_checkpath(...) {
    int error, namlen, depth = 0;
    ...
    for (;;) {
        if (depth++ >= EXT2_CHECKPATH_MAXDEPTH) {
            error = EINVAL; /* .. chain too long or cyclic */
            break;
        }
        ...
    }
}

256 is far beyond any legitimate directory-nesting depth (PATH_MAX=1024 with min 2-char names bounds real nesting at ~500, and real filesystems rarely exceed ~30). The cap turns the infinite loop into a clean EINVAL return, which ext2_rename propagates to userspace.

Before/after (same image, same trigger)

Kernel mv S A/S_moved result
Unpatched #0 ext2fs.ko HANGS β€” kernel thread R3 at 100% CPU, SIGKILL-proof, CPU time β†’ ∞
Fixed ext2fs.ko (EXT2_CHECKPATH_MAXDEPTH=256) mv: rename S to A/S_moved: Invalid argument; MV_RC=1 in 0s; guest healthy

The fix was rebuilt as a single-file module change (ext2fs.ko), installed to /boot/kernel/ext2fs.ko, and validated with two independent runs (both returned EINVAL in 0s).

Files

  • craft_img.py β€” host-side tool that builds the cyclic-ext2 image (mke2fs + debugfs mkdir + raw byte patch of .. inode at offset 12 in A's and B's data blocks).
  • df0824.img β€” the crafted 1 MB ext2 image (A↔B cycle).
  • trigger.sh β€” guest-side trigger: kldload ext2fs, vnconfig, mount, rename.
  • fix.diff β€” git-apply-able fix (adds EXT2_CHECKPATH_MAXDEPTH=256 to sys/vfs/ext2fs/ext2_lookup.c).
  • run.log β€” baseline reproduction (unpatched): stuck process, SIGKILL-proof.
  • fix_run.log β€” patched-kernel runs: EINVAL in 0s.
  • fix_build.log β€” full nativekernel build log (rc=0).
  • env.txt β€” guest environment.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. On the unpatched #0 baseline the rename mv S A/S_moved hangs forever in ext2_checkpath (kernel thread R3 at 100% CPU, SIGKILL-proof, CPU time -> infinity). On the single-fix ext2fs.ko module (EXT2_CHECKPATH_MAXDEPTH=256) the SAME rename returns EINVAL in 0s ('Invalid argument'), with no stuck process and a healthy guest. The fix closes the bug cleanly. Built via make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0); module installed to /boot/kernel/ext2fs.ko; validated with 2 independent runs.

BASELINE (#0, unpatched ext2fs.ko sha256=497134...): 'mv S A/S_moved' -> kernel thread PID 1141 state R3 (RUNNING), 100% CPU, TIME 6:53.27 and climbing; kill -9 1141 survived; /tmp/df0824_mvstatus never written. PATCHED (ext2fs.ko sha256=7cb9b30...): run1 'mv: rename S to A/S_moved: Invalid argument' (returned promptly); run2 'MV_RC=1 elapsed=0s'; no stuck mv process in ps; guest load 0.04.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel unchanged -- ext2fs is a loadable module; fixed ext2fs.ko sha256=7cb9b301950e7e7972c05bc4a85f0df5f583e930fbabf8882bb8381d22d33ba0, rebuilt from patched sys/vfs/ext2fs/ext2_lookup.c with EXT2_CHECKPATH_MAXDEPTH=256)

Confirmed kernel references

Detail

Exploit chain

none -- this is a pure DoS (infinite loop / uninterruptible kernel hang), not a memory-corruption primitive. No escalation chain applies. The threat model is a malicious ext2 image (mount-time / rename-time parsing of attacker-controlled on-disk data) that pins a kernel thread at 100% CPU forever, holding directory vnode locks until reboot. There is no write/UAF/type-confusion primitive to groom into uid=0.

Evidence (decisive lines)

BASELINE (#0, unpatched ext2fs.ko): trigger.sh output 'mounted vn4 at /mnt/df0824' / 'rename pid: 1140' / 'RESULT: HANG'; stuck process 'PID 1141 R3 6:53.27 mv S A/S_moved' (RUNNING on CPU 3, 100% CPU); kill -9 1141 did NOT kill it; CPU time climbed 0:53->1:39->1:53->6:53 over ~7min; /tmp/df0824_mvstatus never written. PATCHED (fixed ext2fs.ko sha256=7cb9b30..., EXT2_CHECKPATH_MAXDEPTH=256): 'mv: rename S to A/S_moved: Invalid argument' / 'MV_RC=1 elapsed=0s' (run 1 and run 2 both return EINVAL in 0s); no stuck process; guest healthy.

PoC changes

The finding folder findings/poc/DF-0824/ was empty (only registered in the DB); created the entire evidence pack from scratch: craft_img.py (host-side tool using mke2fs + debugfs mkdir + raw byte patch of the .. inode at offset 12 in A's and B's directory data blocks to forge the A<->B cycle), df0824.img (1MB crafted ext2 image), trigger.sh + run.sh (guest-side mount+rename trigger with timeout-bounded detection), fix.diff (EXT2_CHECKPATH_MAXDEPTH=256 depth cap), VERDICT.md, manifest.json, build.sh, env.txt, and the full baseline/fix logs.

Verified recommended fix

Add a depth cap to ext2_checkpath() in sys/vfs/ext2fs/ext2_lookup.c: define EXT2_CHECKPATH_MAXDEPTH 256 before the function, add int depth = 0 to the local declarations, and insert if (depth++ >= EXT2_CHECKPATH_MAXDEPTH) { error = EINVAL; break; } at the top of the for(;;) loop body. This turns the infinite .. walk into a clean EINVAL return, breaking the cyclic-image DoS. Matches the finding markdown's proposal (cap at 256, return EINVAL). The full git-apply-able diff is in findings/poc/DF-0824/fix.diff (validated: applies cleanly, compiles rc=0, eliminates the hang).

Verdict

REPRODUCED (uninterruptible kernel hang / DoS) + FIX VALIDATED. ext2_checkpath() at sys/vfs/ext2fs/ext2_lookup.c:1212-1241 walks the target directory's .. parent chain in an unbounded for(;;) loop with NO depth limit, NO cycle detection, and NO signal-pending check. A crafted ext2 image with cyclic .. entries (A->..=B, B->..=A, neither being the source nor root) makes the loop alternate A->B->A->B forever. Confirmed on the unpatched #0 kernel: the rename(2) mv S A/S_moved hangs in kernel state R3 (RUNNING on CPU) at 100% CPU, accumulating CPU time monotonically (0:53 -> 1:39 -> 6:53), and survives kill -9 (SIGKILL cannot interrupt a kernel-stuck thread). The crafted image is impossible to create online (mkdir/rename always set .. to the true parent; hard-linking dirs forbidden) but trivially forged offline (debugfs / raw byte patch of the .. inode at offset 12 in a directory's first data block) -- the threat model of a malicious ext2 image mounted by an admin. Fix: cap the loop at EXT2_CHECKPATH_MAXDEPTH=256 returning EINVAL. Validated: on the fixed ext2fs.ko the rename returns EINVAL in 0s; on the unpatched module it hangs forever.