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)
PoC verification
Evidence pack
findings/poc/DF-0834 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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'scraft_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 rewritedotdot_ino(offset 12).craft_image.shβ NEW. Guest-side script:newfsa 4 MB UFS1/FFS image on avnconfigvnode device,mkdir S A B,stattheir inodes, unmount, runpatch_ufs.trigger.shβ NEW. Mounts the crafted image and launchesmv S A/S_movedin the background, pollingpsto capture the stuck thread. Emits to both stdout (ssh stream) andtrigger.out(file) so early output survives the wedge.run.shβ NEW. Simpler mount+rename+timeout variant for scripted runs.build.shβ NEW. Compilespatch_ufs.c.fix.diffβ NEW. Git-apply-able unified diff addingUFS_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
fixedVALIDATED. 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.
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.
No comments yet.