# 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`:

```c
#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.
