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

ufs_checkpath infinite loop on crafted cyclic .. directory entries β€” uninterruptible kernel hang (UFS analog of DF-0824)

Summary

ufs_lookup.c:1130-1166 for(;;) loop walks target directory .. chain to root. No depth bound no visited-inode tracking. Terminates only on: vtype!=VDIR :1131 vn_rdwr error :1138 namlen!=2 or name!=.. :1148 dotdot_ino==source :1154 dotdot_ino==rootino :1158 VFS_VGET error :1162. Cycle excluding source and root: A/..=B B/..=A = infinite loop kernel thread spins forever holding vnode lock uninterruptible. Same defect as DF-0824 ext2_checkpath. Created offline (debugfs/fsdb binary patch .. d_ino) impossible via mkdir/rename. Trigger: crafted UFS image mount then rename(2) directory into cyclic parent. Fix: cap loop at UFS_CHECKPATH_MAXDEPTH=64 return ENOTDIR.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0834 Β· 16 files
FileTypeDescriptionSize
patch_ufs.c trigger-source UFS/FFS image byte-patcher: forges cyclic .. chain via dotdot_ino rewrite using kernel UFS headers/macros 5.7 KB view raw
craft_image.sh trigger-source guest-side: newfs + mkdir S/A/B + patch_ufs -> df0834.img with A<->B cycle 1.6 KB view raw
trigger.sh trigger-source mount crafted image + launch mv S A/S_moved; captures ps + SIGKILL-proof evidence 1.6 KB view raw
run.sh trigger-source simpler mount+rename+timeout variant for scripted runs 2.0 KB view raw
build.sh build-script cc -O2 -Wall -o patch_ufs patch_ufs.c 174 B view raw
fix.diff suggested-fix git-apply-able: adds UFS_CHECKPATH_MAXDEPTH=64 depth cap to ufs_checkpath, returns ENOTDIR 841 B view raw
README.md readme summary + reproduce instructions 2.6 KB ↓ raw
VERDICT.md verdict full narrative: mechanism, reachability, reproduction, fix validation 7.8 KB ↓ raw
run.log run-log baseline #0 run: streamed output up to the system wedge 2.0 KB view raw
fix_run.log run-log fixed #1 runs (x2) + regression test: ENOTDIR returned promptly 1.8 KB view raw
serial_evidence.txt panic-signature boot.log tail: login prompt + vn4 attach, NO panic (hang not crash) 555 B view raw
fix_build.log build-log nativekernel build log tail: rc=0, no errors 5.3 KB view raw
fix_build_full.log build-log nativekernel full build log excerpt 473 B view raw
env.txt environment uname, kern.version, cc version, sysctls, kernel sha256 532 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 summary + reproduce instructions
↓ download raw

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

Severity: Medium β€” local DoS via crafted UFS/FFS image (uninterruptible kernel hang) CWE: CWE-835 (Infinite Loop / Loop with Unreachable Exit Condition) Status: REPRODUCED on unpatched #0; FIX VALIDATED (single-fix kernel rebuild).

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.

Summary

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.

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 a byte-patch of the .. inode field (dotdot_ino, offset 12 in a directory's first data block per struct dirtemplate, sys/vfs/ufs/dir.h:136). 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.

Reproduce

# on the DragonFlyBSD guest as root, in this directory:
./build.sh           # compiles patch_ufs (the image byte-patcher)
./craft_image.sh     # builds df0834.img: newfs + mkdir S/A/B + forge A<->B cycle
./run.sh             # mount + `mv S A/S_moved` -> ufs_checkpath infinite loop
Kernel Result
Unpatched #0 mv S A/S_moved hangs forever; kernel thread at ~100% CPU, SIGKILL-proof, vnode pinned
Fixed kernel (UFS_CHECKPATH_MAXDEPTH=64) 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: mechanism, reachability, reproduction, fix validation
↓ download raw

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:

#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).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. The identical crafted cyclic UFS image + trigger that wedged the entire guest on the unpatched #0 baseline (rename hangs forever, ssh dies, SIGKILL-proof, hard reset required) returns ENOTDIR promptly ('mv: rename S to A/S_moved: Not a directory' in <1s) on the single-fix kernel #1 (UFS_CHECKPATH_MAXDEPTH=64). The mv process exits immediately (no stuck thread), the guest stays fully responsive, and a regression test (normal rename of a directory into a new parent on an acyclic UFS tree) returns RC=0 (success, no regression). The fix closes the bug cleanly.

BASELINE #0: 'mv S A/S_moved' launched (pid=918) -> guest wedged within ~1s, ssh banner-exchange timeout, serial shows login prompt (no panic), hard QEMU reset required. || FIXED #1: 'mv: rename S to A/S_moved: Not a directory' returned in <1s; mv pid gone at ps+1s; trigger completed 07:20:55->07:21:01 (6s); 2 runs identical; regression test NORMAL_RENAME_RC=0. || Fix kernel sha256: 5f931d6b7ec6d2265202a512280ccc89d43402a39168bf41014893dc2419849a.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Jul 11 07:14:11 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (sha256 5f931d6b7ec6d2265202a512280ccc89d43402a39168bf41014893dc2419849a)

Confirmed kernel references

Detail

Exploit chain

none -- this is a pure DoS (infinite loop / hang) with no memory-corruption primitive (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 under vfs.usermount=1) can hang the entire system indefinitely, requiring a reboot. No escalation chain is derivable.

Evidence (decisive lines)

BASELINE (#0 unpatched): foreground ssh streamed mount+mv-launch output, then stalled within ~1s of 'mv S A/S_moved' (mv pid=918) -- guest became COMPLETELY unresponsive (ssh banner-exchange timeout); serial boot.log shows login prompt + 'vn4: MBR magic not found' with NO panic; outer timeout 35 killed ssh; vm.sh down hung indefinitely (sshd dead); hard QEMU kill required. Image verified: BEFORE A'..'=2 B'..'=2; AFTER A'..'=5(want 5) B'..'=4(want 4) -> cycle A(4)<->B(5) forged. || FIXED (#1, UFS_CHECKPATH_MAXDEPTH=64, sha256 5f931d6b...): 'mv: rename S to A/S_moved: Not a directory' returned in <1s; mv pid already gone at ps +1s; trigger done in 6s; guest fully responsive; 2 independent runs identical; regression test (normal rename on acyclic UFS tree) RC=0 success.

PoC changes

Authored the full evidence pack from scratch (no prior PoC existed for DF-0834): patch_ufs.c (UFS/FFS image byte-patcher using the kernel's own UFS on-disk headers fs.h/dinode.h and macros cgstart/cgimin/itod to find di_db[0] and rewrite dotdot_ino at offset 12); craft_image.sh (newfs + mkdir S/A/B + stat inodes + patch_ufs -> df0834.img with A<->B cycle); trigger.sh (mount + background mv + ps polling, emits to stdout+file to survive the wedge); run.sh (simpler scripted variant); build.sh (cc patch_ufs.c); fix.diff (UFS_CHECKPATH_MAXDEPTH=64 depth cap); VERDICT.md; manifest.json; run.log/fix_run.log/serial_evidence.txt/env.txt/fix_build logs.

Verified recommended fix

Add a depth cap to ufs_checkpath in sys/vfs/ufs/ufs_lookup.c: define UFS_CHECKPATH_MAXDEPTH 64 before the function, add 'int depth = 0' to the locals, and at the top of the for(;;) loop body add 'if (++depth > UFS_CHECKPATH_MAXDEPTH) { error = ENOTDIR; break; }'. ENOTDIR matches the existing error convention for malformed .. entries (line 1151). 64 far exceeds any legitimate directory-nesting depth. The full git-apply-able diff is in findings/poc/DF-0834/fix.diff. Matches the finding proposal's intent (bound the walk, return ENOTDIR); the finding markdown suggested 'cap at UFS_CHECKPATH_MAXDEPTH=64 return ENOTDIR' -- this fix implements exactly that.

Verdict

REPRODUCED (system-wide uninterruptible DoS) -- FIX VALIDATED. ufs_checkpath() (sys/vfs/ufs/ufs_lookup.c:1130-1166) 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 UFS/FFS image with cyclic .. entries (A->B->A) -- impossible to create online but trivial offline via dotdot_ino byte-patch at dir.h:136 offset 12 -- makes the loop alternate forever when a directory is renamed into the cyclic parent (ufs_vnops.c:976). The tight VFS_VGET/vput cycling monopolizes vnode-layer locks, wedging the ENTIRE guest (not just the calling thread) within ~1 second: ssh dies at TCP banner exchange, SIGKILL cannot stop the kernel thread, serial console shows the login prompt unchanged (hang, not panic), and a hard QEMU reset is the only recovery. Confirmed on the unpatched #0 kernel by crafting df0834.img (newfs + patch_ufs forging A(ino4)<->B(ino5) cycle, verified before/after) and triggering mv S A/S_moved -- the guest became completely unresponsive instantly. UFS analog of DF-0824 (ext2_checkpath), identical bug class and fix pattern.