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

msdosfs doscheckpath() unbounded ".." ancestor walk β€” rename(2) kernel livelock wedging the whole system (crafted FAT ".." cycle)

Field Value
ID DF-3037
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:C
CWE CWE-835 / CWE-400
File sys/vfs/msdosfs/msdosfs_vnops.c
Lines 1045 (loop: msdosfs_lookup.c:822-863)
Area vfs/msdosfs
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket base:vfs
Reported pending
Known CVE none
CVE match novel

Summary

msdosfs_rename() calls doscheckpath(ip, dp) when moving a directory to a different parent. doscheckpath walks the destination parent's ancestor chain via on-disk ".." entries and only terminates at the root cluster or the source cluster β€” no iteration bound. A crafted (or fsck-failing) FAT image whose ".." chain cycles without passing through either makes the loop run forever: after the first pass every bread() is a buffer-cache hit and every deget() a denode-hash hit, so the loop never sleeps again, rename(2) never returns, SIGKILL is never delivered, and the tight token churn starves the machine. VERIFIED on the stock INVARIANTS guest: control rename returns in 0s; with a two-directory B↔D ".." cycle, rename hung, kill -9 ineffective, and the 6-CPU guest stopped completing ssh connections (banner-exchange timeout) with no panic β€” system-wide DoS until reboot. Any local user with write access to a read-write msdosfs mount containing a cyclic ".." chain (crafted image mounted by root, or self-mounted with vfs.usermount=1). Current FreeBSD has the identical unbounded for(;;) β€” upstream-live hardening gap. Fix validated in-guest (depth cap 4096 β†’ EINVAL): cycle rename now fails instantly, guest fully responsive, control rename unaffected.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of msdosfs_vnops.c (GLM 5.3); system-wide livelock reproduced + fix validated.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3037 Β· 16 files
FileTypeDescriptionSize
dirpatch.c β€” 2.9 KB view raw
trigger.c β€” 585 B view raw
build.sh β€” 213 B view raw
run.sh β€” 2.7 KB view raw
run.log β€” 1.7 KB view raw
run.2.log β€” 1.3 KB view raw
run.fixed.log β€” 2.0 KB view raw
fix_run.log β€” 2.0 KB view raw
fix_build.log β€” 15.0 KB view raw
fix_build_summary.txt β€” 15.0 KB view raw
fix.diff β€” 824 B view raw
env.txt β€” 205 B view raw
VERDICT.md β€” 4.9 KB ↓ raw
README.md β€” 1.8 KB ↓ raw
manifest.json β€” 1017 B view raw
verdict.json β€” 4.6 KB view raw

DF-3037 β€” PoC evidence pack

msdosfs doscheckpath() unbounded ".." ancestor walk β†’ rename(2) kernel livelock / system-wide DoS via crafted FAT image.

  • File audited: sys/vfs/msdosfs/msdosfs_vnops.c (call site msdosfs_rename line 1045); root cause in sys/vfs/msdosfs/msdosfs_lookup.c:822-863.
  • Severity: Medium (local DoS, crafted-image-mount precondition).
  • Verdict: reproduced (baseline: hang + unkillable process + guest-wide ssh outage; fix validated β€” see fix.diff, run.fixed.log).

Contents

file what
dirpatch.c image tool: lists ./.. directory entries (offset + own cluster); patches a .. entry's deStartCluster
trigger.c plain rename(2) caller
build.sh compiles both (guest)
run.sh full flow: base image β†’ control rename β†’ install B↔D .. cycle β†’ exploit rename under watchdog
run.log baseline run on stock kernel: control OK; cycle rename hangs; kill -9 ineffective; capture session lost when guest wedged
run.2.log post-trigger probe: ssh "Connection timed out during banner exchange" (guest-wide outage, no panic on serial)
run.fixed.log same run.sh on patched kernel #1: cycle rename returns EINVAL in <1 s, guest stays responsive
fix.diff bound the walk (depth cap 4096 β†’ EINVAL)
verdict.json / manifest.json machine verdict

Reproduce

# guest (root), stock kernel:
sh /root/df3037/build.sh && sh /root/df3037/run.sh     # β†’ hang + wedge (expect to lose the guest)
# guest (root), patched kernel:
sh /root/df3037/run.sh                                  # β†’ "rename: Invalid argument", guest fine

Success criterion (vulnerable): TRIGGER STILL RUNNING AFTER 15s, KILL -9 INEFFECTIVE, subsequent ssh sessions time out during banner exchange, serial console shows no panic.

VERDICT.md
↓ download raw

DF-3037 β€” msdosfs doscheckpath() unbounded ".." walk β†’ rename(2) kernel livelock (system-wide DoS)

Where

for (;;) {                                        /* <-- NO BOUND */
    ...
    scn = dep->de_StartCluster;
    error = bread(pmp->pm_devvp, de_bn2doff(pmp, cntobn(pmp, scn)),
                  pmp->pm_bpcluster, &bp);        /* cached after 1st pass */
    ...
    ep = (struct direntry *) bp->b_data + 1;      /* the ".." entry */
    ...
    scn = getushort(ep->deStartCluster);
    ...
    if (scn == source->de_StartCluster) { error = EINVAL; break; }
    if (scn == MSDOSFSROOT) break;                /* only clean exits */
    ...
    vput(DETOV(dep));
    if ((error = deget(pmp, scn, 0, &dep)) != 0)  /* hash hit forever */
        break;
}

The loop walks the destination-parent's ancestor chain via the on-disk ".." entries and terminates only when it reaches the root cluster or the source. Every FAT directory entry on the volume is attacker-controlled when a crafted image is mounted (the project's standing crafted-media threat model, see DF-2902/DF-3015). If the ".." chain contains a cycle that never passes through the root cluster or the source cluster, the loop never terminates: after the first pass every bread() is a buffer-cache hit and every deget() is a denode-hash hit, so the loop never sleeps again β€” the rename(2) syscall never returns, the thread cannot be killed (kill -9 is never delivered), and the token churn (denode hash / vnode / buffer tokens re-acquired in a tight spin) starves the whole machine: on the 6-CPU test guest, sshd stopped completing connections ("Connection timed out during banner exchange") within seconds of the trigger. No panic, no core β€” a hard livelock requiring a reboot.

Current FreeBSD (sys/fs/msdosfs/msdosfs_lookup.c, doscheckpath) has the same unbounded for (;;) β€” this is an upstream-live hardening gap, not DFly-only regression.

Preconditions

  • A FAT12/16/32 filesystem whose ".." chain (from any directory reachable as a rename destination parent) contains a cycle not passing through cluster 0 / the FAT32 root cluster. fsck_msdosfs rejects such images, but the kernel mounts them without validation.
  • The filesystem must be mounted read-write (rename) β€” by root, or by an unprivileged user with vfs.usermount=1 and ownership of the backing device/image.
  • Trigger: rename("/mnt/A/moveme", "/mnt/B/m2") where B's ancestor chain is cyclic. Any local user with write access to the mount.

Impact

Local denial of service, kernel-wide: one unkillable kernel thread, one CPU pinned, and (observed) total loss of interactive service on the test guest (sshd unable to complete new sessions) until reboot. Crafted-image-mount precondition β†’ Medium.

Reproduce (guest, root)

See run.sh. Summary:

  1. dd 16 MB image, newfs_msdos -F 16, mount, mkdir A A/moveme B D.
  2. Unmount; dirpatch img maps each . entry β†’ (offset, own cluster).
  3. Control: mount, trigger /mnt/A/moveme /mnt/B/m2 β†’ returns instantly (rename returned success).
  4. Re-create image; patch B/..β†’cluster(D), D/..β†’cluster(B) (16-bit deStartCluster of the .. entries).
  5. Mount, run trigger /mnt/A/moveme /mnt/B/m2 under a 15 s watchdog.

Expected (vulnerable kernel): trigger still running after 15 s; kill -9 ineffective; guest stops servicing ssh (banner timeout); serial console shows no panic. Observed exactly this on stock X86_64_GENERIC #0 β€” see run.log, run.2.log.

Fix

fix.diff β€” bound the walk (depth > 4096 β†’ EINVAL). A legitimate ancestor chain can never exceed namei-reachable depth (PATH_MAX bounds the number of components of any path that can name the rename target), so the cap cannot reject a valid rename.

Fix validation

  • Baseline (stock #0): hang + unkillable + guest wedged (run.log, run.2.log).
  • Patched (#1 Sat Sep 5 15:02:31 UTC 2026 with fix.diff, patch -p1 clean, make nativekernel KERNCONF=X86_64_GENERIC BUILD_RC=0, make installkernel INSTALL_RC=0): cycle-rename returns EINVAL instantly (rename: Invalid argument, trigger exits rc=1 in <1 s), guest fully responsive (uptime/load normal, run.sh completes RUN_RC=0), control rename still succeeds in 0 s (run.fixed.log / fix_run.log). fix_status: fixed.

Note: the full 14 897-line build log lived on the guest and was lost with the post-validation vm.sh reset with-src (required to clear the baseline wedge dirt); fix_build.log preserves the decisive build/install tails (BUILD_RC=0 / INSTALL_RC=0). Guest was left booted on the clean with-src snapshot (stock #0 kernel, no PoC residue).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Rebuilt X86_64_GENERIC with fix.diff (doscheckpath depth cap 4096 -> EINVAL; patch -p1 applied cleanly, make nativekernel BUILD_RC=0, installkernel INSTALL_RC=0, kernel #1 booted). Baseline kernel #0 (same PoC, run.log): cycle-rename hangs forever, kill -9 ineffective, guest-wide ssh outage. Patched kernel #1 (run.fixed.log): identical crafted image + identical rename returns 'rename: Invalid argument' (EINVAL) in <1s, trigger exits rc=1, guest fully responsive (uptime/load normal, run.sh completes RUN_RC=0); control rename on unpatched image still succeeds in 0s. Bad behavior gone.

run.fixed.log (control OK + cycle rename EINVAL + responsive guest); fix_build.log (build/install completion tails, BUILD_RC=0/INSTALL_RC=0); fix.diff applies cleanly (patch -p1 verified in-guest and out-of-guest)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 5 15:02:31 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

craft FAT16 image (16MB, newfs_msdos) with dirs A/A/moveme/B/D -> patch the 16-bit deStartCluster of B's and D's '..' entries to point at each other (dirpatch) -> root mounts rw (vnconfig+mount_msdos; or user with vfs.usermount=1 owning the image) -> any user with write access runs rename(/mnt/A/moveme, /mnt/B/m2) -> kernel spins forever in doscheckpath: unkillable thread + token-churn livelock -> guest-wide DoS until reboot

Evidence (decisive lines)

["run.log: control rename 'rename returned success ... elapsed=0s'; after cycle patch 'TRIGGER STILL RUNNING AFTER 15s', 'KILL -9 INEFFECTIVE (stuck in kernel) -- DoS CONFIRMED'", "run.2.log: post-trigger ssh probe exits 255 'Connection timed out during banner exchange' (guest no longer services logins); boot.log serial console shows no panic", "run.fixed.log: patched kernel - 'rename: Invalid argument' in <1s, process exits, guest responsive afterwards", 'fix.diff: depth cap 4096 in doscheckpath, applies cleanly (patch --dry-run verified) and builds (nativekernel BUILD_RC=0)']

PoC changes

none vs seed (pack authored fresh in this run; no seed existed)

Verified recommended fix

Bound the doscheckpath '..' ancestor walk (depth cap 4096 -> EINVAL); see fix.diff.

Verdict

msdosfs_rename() calls doscheckpath() (vnops.c:1045) whose ancestor walk (lookup.c:822-863) has no iteration bound; on a crafted FAT image whose rename-destination '..' chain cycles without passing through the root or source cluster, the walk never terminates (after the first pass every bread() is a cache hit and every deget() a hash hit, so it never sleeps). Verified on the stock INVARIANTS guest: control rename returns in 0s; with a two-directory B<->D '..' cycle installed, rename(2) never returned, kill -9 was never delivered, and the whole 6-CPU guest stopped servicing ssh (banner-exchange timeouts) with no panic on serial β€” a system-wide livelock requiring reboot. Bound-the-walk fix.diff validated: patched kernel #1 returns EINVAL for the cycle rename in <1s and the guest stays fully responsive; control rename unaffected. Current FreeBSD has the same unbounded loop, so this is an upstream-live hardening gap.