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)
PoC verification
Evidence pack
findings/poc/DF-0824 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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_mvstatusis 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 (addsEXT2_CHECKPATH_MAXDEPTH=256tosys/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β fullnativekernelbuild log (rc=0).env.txtβ guest environment.
Fix verification
fixedVALIDATED. 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.
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.
No comments yet.