# DF-0834: ufs_checkpath infinite loop on crafted cyclic `..` directory entries

## Verdict: REPRODUCED (uninterruptible system-wide hang / DoS) — FIX VALIDATED

**Severity:** Medium (local DoS via crafted filesystem image; requires admin to mount)
**Impact:** `dos` — system-wide uninterruptible hang; SIGKILL cannot stop the spinning
kernel thread; the guest becomes completely unresponsive (ssh dies at TCP banner exchange)
and must be hard-reset.
**CWE:** CWE-835 (Infinite Loop / Loop with Unreachable Exit Condition)

This is the **UFS analog of DF-0824** (ext2_checkpath) — identical bug class, identical
fix pattern. The two checkpath routines are line-for-line twins.

## Root cause

`ufs_checkpath()` (`sys/vfs/ufs/ufs_lookup.c:1130-1166`) 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 |
|------|----------------|
| 1131 | `vp->v_type != VDIR` (ENOTDIR) |
| 1138 | `vn_rdwr` I/O error |
| 1148-1150 | malformed `..` name (ENOTDIR) |
| 1154 | `dotdot_ino == source->i_number` (EINVAL) |
| 1158 | `dotdot_ino == rootino` (reached root) |
| 1162 | `VFS_VGET` error |

**There is NO depth limit, NO cycle-visited tracking, and NO signal-pending check.** A
crafted UFS 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.

The tight `VFS_VGET`/`vput` cycling contends heavily on the vnode interlock + mount
vnode-list lock, wedging not just the calling thread but the **entire system** — sshd
cannot fork/exec because even root-filesystem vnode operations contend on the same
global locks. Within ~1 second of the rename being triggered, the guest becomes
completely unresponsive to ssh (TCP banner exchange timeout). No kernel panic occurs
(serial console shows the login prompt unchanged); the system is hung, not crashed.
Recovery requires a hard reset (SIGKILL on the QEMU process); `vm.sh down` (clean
shutdown over ssh) hangs indefinitely because sshd can never respond.

## Reachability

`ufs_checkpath` is called from `ufs_rename()` at `ufs_vnops.c:976` when renaming a
directory across parent directories (`doingdirectory && newparent`). UFS/FFS is the
DragonFlyBSD **native root filesystem** — always available, no `kldload` needed.

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 by patching the on-disk `dotdot_ino` field (`struct dirtemplate`,
`sys/vfs/ufs/dir.h:136`) at byte offset 12 in the directory's first data block. The
threat model is a **malicious UFS 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.

## Reproduction (baseline, unpatched `#0`)

The crafted image (`craft_image.sh` + `patch_ufs.c`) contains:
```
/         (ino 2, root)
/S        (ino 3, source dir; normal `..`->2)
/A        (ino 4; FORGED `..`->5 (B))
/B        (ino 5; FORGED `..`->4 (A))
```

The patcher reads the FFS superblock (`SBOFF=8192`, `FS_MAGIC=0x011954`), walks the
cylinder-group / inode-table geometry using the kernel's own macros (`cgstart`, `cgimin`,
`itod`) from `<vfs/ufs/fs.h>`, finds each directory inode's first direct block
(`di_db[0]`), and rewrites offset 12 (`dotdot_ino`) to the partner inode. Verified
before/after:
```
BEFORE:  A '.'=4  '..'=2   |   B '.'=5  '..'=2
AFTER:   A '..'=5 (want 5) |   B '..'=4 (want 4)
```

Mount + rename triggers the hang. On the unpatched `#0` kernel, the foreground ssh
session streamed the mount + mv-launch output, then **stalled within ~1 second** of the
`mv S A/S_moved` launch — the guest became completely unreachable. The outer `timeout
35` killed ssh at 35s. Serial console (`boot.log`) shows no panic — just the login
prompt and the `vn4: MBR magic not found` messages from the image attach. This is a
hard DoS.

(Contrast with the ext2 sibling DF-0824: there the ext2fs module's checkpath hang was
observable per-process — `mv` at 100% CPU, SIGKILL-proof — but the system stayed
marginally responsive to `ps`. UFS checkpath wedges harder because `VFS_VGET` cycling
in the native root-fs vnode layer contends more heavily on global vnode locks.)

## Fix (validated)

`fix.diff` adds a depth cap to `ufs_checkpath`, mirroring DF-0824's
`EXT2_CHECKPATH_MAXDEPTH`:

```c
#define	UFS_CHECKPATH_MAXDEPTH	64	/* sane bound on .. chain length */

ufs_checkpath(...) {
    int error, rootino, namlen, depth = 0;
    ...
    for (;;) {
        if (++depth > UFS_CHECKPATH_MAXDEPTH) {
            error = ENOTDIR;		/* .. chain too long or cyclic */
            break;
        }
        ...
    }
}
```

64 is far beyond any legitimate directory-nesting depth (real filesystems rarely exceed
~30 levels; `PATH_MAX=1024` with min 2-char names bounds real nesting at ~500). The
existing loop already uses `ENOTDIR` for malformed `..` entries (line 1151), so
`ENOTDIR` is the consistent error choice. The cap turns the infinite loop into a clean
return that `ufs_rename` propagates to userspace.

### Before/after (identical crafted image + trigger)

| Kernel | `mv S A/S_moved` result |
|--------|-------------------------|
| Unpatched `#0` | **HANGS** — entire guest wedges within ~1s; ssh dies (banner exchange timeout); SIGKILL-proof; serial shows login prompt (no panic); hard reset required |
| Fixed `#1` (`UFS_CHECKPATH_MAXDEPTH=64`) | `mv: rename S to A/S_moved: Not a directory`; **returns in <1s**; mv process exits immediately; guest fully responsive; **no regression** on normal directory renames |

The fix was built as a single-fix kernel (`make -j6 nativekernel KERNCONF=X86_64_GENERIC`,
warm obj, ~6 min), installed to `/boot/kernel/kernel` (stripped), and validated with two
independent trigger runs (both returned ENOTDIR in <1s) plus a regression test (normal
rename of a directory into a new parent on an acyclic UFS tree: `RC=0`, success).

## Escalation

None — this is a pure DoS (infinite loop / hang). There is no memory-corruption
primitive: the bug is an unbounded loop with no write/UAF/OOB component. The impact
ceiling is denial of service: an unprivileged user who can cause a malicious UFS image
to be mounted (or who mounts one themselves under `vfs.usermount=1`) can hang the entire
system, requiring a reboot.

## PoC changes

- **`patch_ufs.c`** — NEW. UFS/FFS image byte-patcher (the UFS analog of DF-0824's
  `craft_img.py`). Uses the DragonFlyBSD kernel's own UFS on-disk headers
  (`<vfs/ufs/fs.h>`, `<vfs/ufs/dinode.h>`) and macros (`cgstart`, `cgimin`, `itod`) to
  find each directory inode's first data block and rewrite `dotdot_ino` (offset 12).
- **`craft_image.sh`** — NEW. Guest-side script: `newfs` a 4 MB UFS1/FFS image on a
  `vnconfig` vnode device, `mkdir S A B`, `stat` their inodes, unmount, run `patch_ufs`.
- **`trigger.sh`** — NEW. Mounts the crafted image and launches `mv S A/S_moved` in the
  background, polling `ps` to capture the stuck thread. Emits to both stdout (ssh stream)
  and `trigger.out` (file) so early output survives the wedge.
- **`run.sh`** — NEW. Simpler mount+rename+timeout variant for scripted runs.
- **`build.sh`** — NEW. Compiles `patch_ufs.c`.
- **`fix.diff`** — NEW. Git-apply-able unified diff adding `UFS_CHECKPATH_MAXDEPTH=64`.
- **`run.log`** — baseline (unpatched `#0`): streamed output up to the wedge.
- **`fix_run.log`** — fixed (`#1`): ENOTDIR returned promptly, 2 runs + regression.
- **`serial_evidence.txt`** — boot.log tail showing no panic (hang, not crash).
- **`env.txt`** — guest environment (uname, cc, sysctls, kernel sha256).
- **`fix_build_full.log`** — nativekernel build log excerpt (`rc=0`, no errors).
