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

msdosfs_rename() restores stale de_fndoffset/de_fndcnt after dropping the target-directory lock β€” createde() blindly overwrites a concurrently-created dirent (TOCTOU)

Summary

msdosfs_lookup's EJUSTRETURN path records the insertion slot in denode state (dp->de_fndoffset/de_fndcnt, :314/:380). msdosfs_rename saves these (vnops:1009-1010), then DROPS the target directory vnode lock - across doscheckpath() for cross-parent directory renames (vnops:1045) and across its source relookup() for same-directory renames (vnops:1127) - and after relocking restores the STALE saved values (vnops:1186-1187), discarding the fresh slot its own relookup just computed. createde() then writes the new dirent at that offset with zero revalidation (:644-646 DE_EXTERNALIZE at bptoep(fndoffset)). A concurrent create() that slips into the unlock window lands on the SAME deterministic first-fit free slot (both lookups scan from offset 0), so its dirent - name, start-cluster, size - is clobbered by the renamed entry: the victim file's name vanishes, its data cluster orphaned, its cached denode aliases two metadata views. FreeBSD eliminated this exact stale-offset class by rewriting msdosfs_rename (2019). Any local user with write access to a FAT directory races a renamer against a creator; on the winning interleaving the creator's file is silently destroyed at the dirent level while remaining openable through cached state - integrity loss for co-located files (incl. other users' files in shared directories; slot choice deterministic, not name-dependent), plus on-disk corruption fsck_msdosfs does not flag. 10 guest runs (~1e6 window-opening renames) proved the window opens (kernel 'kern_rename: retry due to race') but the exact overwrite interleaving never landed (lock-handoff latency beats it on 6-vCPU KVM); detection verified non-blind (163k/163k rotation unlinks = zero thefts). No direct user->root (integrity corruption class). Fix authored + compile-verified (createde slot revalidation loop -> EEXIST; legitimate rename-reuse callers unaffected).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3116 Β· 14 files
FileTypeDescriptionSize
trigger.c β€” 7.4 KB view raw
build.sh β€” 66 B view raw
run.sh β€” 136 B view raw
wedge.sh β€” 1.7 KB view raw
fix.diff β€” 1.6 KB view raw
build.log β€” 304 B view raw
run.3.log β€” 1.7 KB view raw
run.5.log β€” 1.8 KB view raw
run.9.log β€” 1.9 KB view raw
run.10.wedge.log β€” 850 B view raw
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 5.3 KB ↓ raw
verdict.json β€” 3.8 KB view raw
manifest.json β€” 1.1 KB view raw

DF-3116 β€” msdosfs_rename() stale de_fndoffset TOCTOU (dirent slot overwrite)

What

msdosfs_rename() saves the target-directory slot bookkeeping (to_diroffset/to_count, from de_fndoffset/de_fndcnt recorded by msdosfs_lookup()'s EJUSTRETURN path), then drops the target directory vnode lock β€” across doscheckpath() (cross-parent directory renames, msdosfs_vnops.c:1045) and across its source relookup() (same-directory renames, msdosfs_vnops.c:1127) β€” and afterwards blindly restores the stale values (msdosfs_vnops.c:1186-1187) into createde(), which writes the new dirent at that offset with no revalidation (msdosfs_lookup.c:644-646, DE_EXTERNALIZE at bptoep(pmp, bp, ddep->de_fndoffset)).

A concurrent create() that slips into the unlock window lands its dirent on the same deterministic first-fit free slot (both lookups scan from offset 0), and the stale-slot createde() then overwrites the live dirent of the just-created file: the victim file's name, start-cluster and size in the directory are replaced by the renamed entry's. The victim's cluster is orphaned, the victim denode remains keyed at the stolen (dirclust, diroffset), and two metadata views alias one dirent.

PoC

trigger.c β€” three modes:

  • renamer <dir> <sec> β€” Window B racer: alternating same-dir file renames.
  • bouncer <p1> <p2> <sec> β€” Window A racer: cross-parent directory renames (mkdir D in p1; rename into p2). Every bounce runs doscheckpath() with tdvp unlocked.
  • creator <dir> <sec> β€” create X, verify via readdir() (stat() is served from the namecache and would mask the theft), rotate old files; rotation-unlink ENOENT is a second detector.
  • sweep <dir> <manifest> β€” post-run readdir-vs-manifest check.

wedge.sh β€” attribution-clean single-shot: fresh image, 30 s race, verify racers exited, then umount under a 20 s watchdog.

Build on guest: sh build.sh (cc -O2 -Wall -o trigger trigger.c). Run on guest as root: sh run.sh (mounts a 512 MB FAT32 image via vnconfig; racers run as nobody).

Expected result

  • Race interference is provably reached: the kernel itself logs kern_rename: retry due to race on: "D..." -> "D..." during the runs.
  • The precise dirent theft was not caught by the detectors in 10 runs / ~500k window-opening renames (lock-handoff wakeup latency beats both window variants at buffer-cache-hot speeds) β€” see VERDICT.md.
  • Racing this path reproducibly wedges the system (leaked mount refs β†’ umount hangs β†’ guest catatonic): that consequence is filed and verified as DF-3117.

Fix

fix.diff β€” createde() revalidates that all slots in [fndoffset - fndcnt*32, fndoffset] are still SLOT_EMPTY/SLOT_DELETED before writing; returns EEXIST otherwise. Compile-verified against the guest kernel module build (msdos.ko, -Werror) on 2026-09-06.

VERDICT.md
↓ download raw

DF-3116 VERDICT β€” stale de_fndoffset TOCTOU in msdosfs_rename β†’ createde overwrite

Status: not_reproduced (the dirent-theft interleaving itself); the code defect is proven by dataflow; the race family demonstrably fires (kernel logged rename races) and its adjacent consequence (system wedge) is reproduced and filed as DF-3117.

The defect (code-certain)

  1. sys/kern/vfs_default.c:1054 β€” vop_compat_nrename runs vop_old_lookup(tdvp, &tvp, &tcnp) with cn_nameiop = NAMEI_RENAME; for a non-existent target msdosfs_lookup returns EJUSTRETURN with tdvp locked and dp->de_fndoffset = first-fit free slot S (msdosfs_lookup.c:367-400).
  2. msdosfs_vnops.c:1009-1010 β€” to_diroffset = dp->de_fndoffset saved while tdvp is locked.
  3. msdosfs_vnops.c:1045 (Window A: doingdirectory && newparent) or msdosfs_vnops.c:1127 (Window B: newparent == 0) β€” tdvp unlocked; a concurrent create() in the same directory deterministically picks the same first-fit slot S (vop_compat_ncreate, vfs_default.c:349 β†’ msdosfs_lookup CREATE β†’ msdosfs_create β†’ createde writes X's dirent at S).
  4. msdosfs_vnops.c:1186-1187 β€” after relocking, dp->de_fndoffset = to_diroffset restores the stale slot; relookup's fresh value is discarded.
  5. msdosfs_lookup.c:644-646 β€” createde() does ndep = bptoep(pmp, bp, ddep->de_fndoffset); DE_EXTERNALIZE(ndep, dep); with zero revalidation β†’ X's live dirent is overwritten by the renamed entry. Victim's data cluster orphaned; victim denode stays keyed at the stolen slot (cross-file metadata confusion for as long as it is cached).

FreeBSD identified this same stale-offset family in its pre-2019 msdosfs_rename and resolved it by rewriting the function to re-derive the slot after every relock rather than restoring saved values.

Verification attempts (10 runs on the QEMU guest)

run window iterations dirent theft notes
run.3.log B (same-dir file rename) 431k renames / 163k creates 0 stat()-based detector was namecache-blind
run.4.log B 431k/163k 0 switched to readdir() detector
run.5.log A (shallow p2) 242k dir renames / 272k creates 0 window β‰ˆ one cached bread (~2 Β΅s)
run.7/8 A (path-buffer PoC bug fixed) – – my 512-byte path buffers truncated the 655-char deep path; kernel was fine
run.9.log A (100-deep p2, slow walk) 70k dir renames / 192k creates 0 umount hung; guest went catatonic β†’ DF-3117
run.10.wedge.log A (clean guest, attribution-clean) 38.6k dir renames / 100k creates 0 wedge reproduced: racers exited, umount watchdog never completed, ssh died

Detection was NOT the limiting factor after run.4: every created file is verified via readdir() (dirents read through the buffer cache, no namecache) and again via its rotation unlink 200 creates later (ENOENT β‡’ theft). Across all runs zero rotation-unlink failures occurred β€” the create-inside-the-window interleaving never landed.

Why it never landed (measured): Window B's unlock→relock gap is ~100 ns of instructions on the same CPU; wakeup latency of the blocked creator (~5-50 ¡s) loses the handoff every time. Window A was widened to the whole doscheckpath walk by placing the target parent at the bottom of a 100-deep ".." chain (each hop = bread + deget incl. a pcbmap(0xffff) chain walk + vput), giving ~200 ¡s windows at ~4k renames/s — still no landing. The kernel's own kern_rename: retry due to race on: "D68634" -> "D68634" (12,0) (sys/kern/vfs_syscalls.c:4323, seen on the console) proves concurrent modification during rename occurs, but the exact createde-runs-inside-the-window ordering did not happen in ~1e6 opportunities on this 6-vCPU KVM guest.

Classification: real code defect (blind trust of cached slot state across an explicit lock drop β€” CWE-367), exploitability demonstrated only down to "window provably opens under unprivileged racing"; the corruption itself not observed. Downgraded to confidence=likely, severity Medium. The verified DoS consequence of racing this same path is filed separately as DF-3117 (reproduced).

Fix validation

fix.diff adds a revalidation pass to createde() (all slots in [fndoffset βˆ’ fndcntΒ·32, fndoffset] must still read SLOT_EMPTY or SLOT_DELETED, else EEXIST). Compile-verified: applied to the guest's /usr/src copy, make in sys/vfs/msdosfs built msdos.ko clean with -Werror; source restored afterward. Runtime A/B not meaningful (baseline theft not reproduced); fix_status=not_testable.

Artifacts

  • trigger.c β€” racer (renamer/bouncer/creator/sweep), final
  • run.sh β€” full run: fresh FAT32 image, baseline + race + sweeps + fsck
  • wedge.sh β€” attribution-clean single-shot wedge probe (see DF-3117)
  • run.3.log, run.5.log, run.9.log, run.10.wedge.log β€” runs
  • build.log, fix.diff

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

Patch applied to guest /usr/src copy; sys/vfs/msdosfs module built clean (msdos.ko, -Werror). Runtime A/B not meaningful because the baseline dirent theft never reproduced on this guest; source restored afterward.

fix.diff; build ran 2026-09-06 on guest pid-253541 session (msdos.ko artifact removed after restore)
↓ fix.diffper-fix-DF-3116

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3116/VERDICT.md β€” full attempt matrix and code trace', 'findings/poc/DF-3116/run.10.wedge.log β€” clean 38.6k dir renames + 100k creates, zero theft, umount wedge', 'findings/poc/DF-3116/fix.diff β€” createde revalidation (compile-verified, msdos.ko -Werror)']

PoC changes

Seed sketch had no code; authored from scratch. Iterations: (1) stat()-based vanish detector was namecache-blind -> switched to readdir() (reads dirents via buffer cache) + rotation-unlink ENOENT detector; (2) 512-byte path buffers truncated the 100-deep chain path (my bug, kernel was fine) -> 1100-byte buffers; (3) Window B (same-dir file rename, ~100ns unlock-relock gap) never loseable to wakeup latency -> Window A cross-parent DIRECTORY renames with doscheckpath widened by a 100-deep .. chain (~200us windows); (4) alternating two-name bounce replaced by unique-name one-directional bounces so every rename takes the EJUSTRETURN path.

Verified recommended fix

createde(): revalidate that every slot in [de_fndoffset - de_fndcnt*32, de_fndoffset] still reads SLOT_EMPTY/SLOT_DELETED before writing; return EEXIST if stolen (fix.diff).

Verdict

Code-certain TOCTOU: msdosfs_rename saves de_fndoffset/de_fndcnt (vnops:1009-1010), drops the target-dir lock across doscheckpath (vnops:1045) or its source relookup (vnops:1127), then restores the stale slot (vnops:1186-1187) into createde() which writes DE_EXTERNALIZE with no revalidation (lookup.c:644-646), overwriting any dirent a concurrent create placed on the same deterministic first-fit slot. 10 guest runs (~1e6 window-opening renames, windows widened to ~200us via a 100-deep doscheckpath walk) never landed the exact overwrite interleaving (lock-handoff wakeup latency); detection was namecache-blind stat() in early runs, readdir()+rotation-unlink in later runs, zero hits. The race family provably fires (kernel logged 'kern_rename: retry due to race'), and racing this path reproducibly wedges the system β€” verified and filed separately as DF-3117. FreeBSD rewrote msdosfs_rename (2019) for this same stale-offset class.