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

MSDOSFS_LOCK_MP expands to nothing: the free-cluster allocator (clusteralloc/chainalloc/usemap_alloc + pm_inusemap scan) is unsynchronized on SMP β€” double-allocation/cross-linked files (release) or KASSERT panic (INVARIANTS)

Field Value
ID DF-3093
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H
CWE CWE-362
File sys/vfs/msdosfs/msdosfs_fat.c
Lines allocator core (~:340-450); the empty macros at msdosfsmount.h:216-218
Area vfs/msdosfs
Confidence certain
Discovered 2026-09-06
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

MSDOSFS_LOCK_MP()/UNLOCK_MP() expand to nothing (msdosfsmount.h:216-218) and struct msdosfsmount has no lock member, so clusteralloc(), chainalloc(), usemap_alloc() and the pm_inusemap scan run unsynchronized on SMP. Two concurrent extendfile() calls (any unprivileged users writing files on the same FAT mount) can observe the same free run and both chainalloc() it: INVARIANTS kernels panic in usemap_alloc's KASSERT 'Allocating used sector...'; release kernels silently chain two FAT chains over the same clusters β€” cross-linked files, silent data corruption of whichever file flushes second. Raced 3Γ—(6 procs Γ— 150 Γ— 32KiB) on the 6-vCPU guest with a foreign-byte checker (findings/poc/DF-3093/): no hit within the bounded stress (window is per-run tight; single-run winner needs the two find_next_free scans to interleave inside one count-wide alloc loop) β€” race is real by construction (empty macros, no other serialization covers the allocator) but honestly not_reproduced in bounded time. Fix: give msdosfsmount a real lock (lockmgr) behind MSDOSFS_LOCK_MP or serialize the allocator on a per-mount token.

Timeline

  • 2026-09-06 Discovered during pass-2 audit of msdosfs_fat.c (GLM 5.3); raced 3 rounds clean, unsynchronized-by-construction proven.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3093 Β· 24 files
FileTypeDescriptionSize
race2.c β€” 6.2 KB view raw
fat_scan.c β€” 6.6 KB view raw
conc.c β€” 3.3 KB view raw
run2.sh β€” 1.8 KB view raw
run3.sh β€” 2.1 KB view raw
run4.sh β€” 2.5 KB view raw
run5.sh β€” 3.0 KB view raw
run6.sh β€” 1.8 KB view raw
run.log β€” 5.6 KB view raw
run3.log β€” 695 B view raw
run4.log β€” 776 B view raw
run5.log β€” 975 B view raw
run6.log β€” 1.5 KB view raw
run6-tailfail.log β€” 1.4 KB view raw
run7-delaykernel.log β€” 777 B view raw
instr-run3.log β€” 1.4 KB view raw
trace-excerpts.txt β€” 3.9 KB view raw
instr.patch β€” 2.4 KB view raw
racestress.c β€” 3.1 KB view raw
run.sh β€” 160 B view raw
VERDICT.md β€” 6.7 KB ↓ raw
README.md β€” 2.5 KB ↓ raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 4.0 KB view raw

DF-3093 β€” msdosfs free-cluster allocator unsynchronized on SMP (VERIFY: NOT REPRODUCED)

Claim: MSDOSFS_LOCK_MP expands to nothing, so clusteralloc()/usemap_alloc() race across CPUs β†’ double allocation β†’ KASSERT panic (INVARIANTS) or cross-linked files (release).

Result: premise verified true, manifestation not reproduced. The macros are empty (msdosfsmount.h:216-218), no lock member exists, and a diagnostic kernel PROVED >200 concurrent clusteralloc critical-section overlaps (3 CPUs deep) under adversarial load β€” but the double-allocation itself never fired in ~500 barrier-synchronized allocation bursts across 6 geometries (0 doubles, clean FAT in-degree scans, 0 foreign bytes). The scan-phase window (bitmap read β†’ first bit set) is sub-microsecond while the allocator's pm_nxtfree + first-run-wins structure effectively serializes run claiming.

Reproduce

# on the QEMU guest as root (vnconfig/mount need root; the write(2)
# trigger itself is unprivileged once mounted)
cd /root/df3093
cc -O2 -o race2 race2.c && cc -O2 -o fat_scan fat_scan.c
sh run2.sh 20 6 3 2097152     # base campaign (~2 min)
sh run4.sh 2 6 2 16777216     # fallback-forced geometry
sh run6.sh 2 6 2 16777216     # word-straddling runs

Success criterion for the finding: fat_scan reports max in-degree >= 2 / CORRUPTION FOUND, or race2 prints foreign bytes ... REPRODUCED. Expected (observed every time): clean scans, 0 foreign bytes.

Files

race2.c        barrier racer: CPU-pinned children, per-line go words,
               pre-created files, one big write(2) each, foreign-byte check
fat_scan.c     offline FAT12/16/32 in-degree scanner (deterministic
               cross-link detector)
conc.c         userland concurrency/parallelism probe
run2.sh        base campaign (fresh volume per cycle)
run3-6.sh      escalating geometries (fallback-forced, half-word runs,
               word-straddling runs)
run.log        20-cycle base campaign output
run3-7*.log    full campaign logs (7 = 100us-delay diagnostic kernel)
instr-run3.log instrumented-kernel campaign log
trace-excerpts.txt  kernel-side E/X TSC traces + counter deltas (key data)
instr.patch    the diagnostic kernel patch (sysctls + print-only
               double-alloc detector + overlap counter) vs stock fat.c
racestress.c / run.sh   the original v1 seed (superseded)
VERDICT.md     full narrative with path:line citations
manifest.json / verdict.json
VERDICT.md
↓ download raw

DF-3093 β€” VERDICT

Status: NOT REPRODUCED (extensive adversarial campaign, kernel-instrumented; the missing-lock premise is verified true, the race window opens in practice, but the claimed double-allocation never manifested in bounded time)

What the finding claims

MSDOSFS_LOCK_MP()/MSDOSFS_UNLOCK_MP() expand to nothing (sys/vfs/msdosfs/msdosfsmount.h:216-218) and struct msdosfsmount has no lock member (verified: full struct read, no lock/token), so clusteralloc()/chainalloc()/usemap_alloc() and the pm_inusemap scan run unsynchronized on SMP. Two concurrent extendfile() calls (any users writing different files on the same FAT mount) can observe the same free run and both chainalloc() it: INVARIANTS kernels panic in usemap_alloc()'s KASSERT "Allocating used sector" (msdosfs_fat.c:381); release kernels silently chain two FAT chains over the same clusters β€” cross-linked files.

What was verified TRUE (premise)

  • msdosfsmount.h:216-218 β€” the three macros are literally empty; no lock member exists in struct msdosfsmount; nothing else in the allocation path takes any lock (fatentry/fatchchain also rely on the same empty macros). The allocator is unsynchronized by construction. CONFIRMED.
  • Concurrent execution DOES occur: a diagnostic kernel (stock code plus an in-flight counter and a print-only double-allocation detector β€” instr.patch, the INVARIANTS KASSERT made harmless) recorded >200 clusteralloc critical-section overlap events, up to 3 CPUs deep, under 6 CPU-pinned, barrier-synchronized writers on a 6-vCPU guest (run4: 66 events; run6: +137; minimal test: 25, live-polled depth 3). See trace-excerpts.txt.

What was NOT reproduced (the claim itself)

Across ~4 hours, 3 instrumented kernels and ~500 barrier-synchronized allocation bursts in 6 adversarial geometries, zero double-allocations: debug.df3093_double never moved from 0; the offline FAT in-degree scanner (fat_scan.c, deterministic cross-link detector: every cluster must have in-degree ≀ 1) found every unmounted image clean; the per-writer foreign-byte check never fired; no "Freeing unused sector" kernel messages.

Campaign geometries (each mounted via vnconfig + mount_msdos as root β€” mount requires root or vfs.usermount=1; the trigger itself is plain write(2) and would work unprivileged once mounted):

  1. run2.sh: 20 cycles, 6 staggered writers Γ— 150 Γ— 32 KiB (the original seed shape) β€” clean.
  2. race2 v3: 6 CPU-pinned (usched_set) writers, per-cache-line spin barrier, files pre-created and pre-opened, single large write(2) per round β€” 64 MiB (16384-cluster) counts on 2 GiB FAT32 β€” clean.
  3. run3.sh: fragmented 64 Γ— 8 MiB runs (fallback path forced) β€” clean.
  4. run4.sh: whole-volume fragmentation (500 Γ— 1 MiB, delete alternating; no contiguous run β‰₯ count anywhere) β€” 66 overlaps, 0 doubles β€” clean.
  5. run5.sh: half-word 4-cluster free runs (concurrent commits share bitmap words; targets the non-atomic |= lost-update angle) β€” clean (setup tail-fill bug noted in run5.log).
  6. run6.sh: 132-cluster word-straddling runs + 16 MiB writes β€” 137 new overlaps, 0 doubles β€” clean.

Diagnostic kernels (see trace-excerpts.txt): * kernel #2: 100 ΞΌs busy-delay between the fallback decision and the commit β€” widens the decide window 1000Γ— β€” still 0 doubles. * kernel #4: + 200 ΞΌs delay after the pm_nxtfree read + per-call entry/exit TSC trace β€” on this kernel the extend phases of concurrent writes ran strictly sequentially (never observed on the timing-unmodified kernel #1, which recorded the 66/137 overlaps). Anomaly treated as a diagnostic-kernel artifact.

Why the race window does not convert into a double (analysis)

The unsynchronized section is real, but the allocator's structure self-defends: every caller reads newst = pm_nxtfree at scan entry and every commit advances it; the fallback's tie-break if (l > foundl) (strictly-greater, msdosfs_fat.c:768/786) makes every scanner pick the first free run from its (already advanced) newst; and a late scanner's chainlength() re-reads the bitmap words, sees the earlier committer's bits, and skips past. A double needs two CPUs to both complete their bitmap reads before either sets its first bit β€” a sub-microsecond overlap of the pure-memory scan phase, while the measured entry skew of even the tightest userland barrier (per-line go words, pinned CPUs) is tens of microseconds. The INVARIANTS-panic claim therefore remains a real theoretical possibility (the KASSERT would fire on any double), but it did not manifest and would require extreme luck or a much wider scan window (huge maps, slower memory) to hit on this class of hardware.

Impact assessment

  • INVARIANTS/debug kernels: KASSERT panic plausible but not demonstrated.
  • Release kernels: cross-linked files (silent data corruption) plausible but not demonstrated in ~4 h of adversarial racing.
  • The missing lock is nevertheless a genuine hardening defect: the code was designed to be serialized (MSDOSFS_LOCK_MP call sites exist throughout msdosfs_fat.c) and the primitive was never implemented. FreeBSD independently added pm_fatmutex for exactly this surface. Any future change that lengthens the scan/decision phase (bigger volumes, debug options, preemption inside the scan) re-opens the window that this campaign measured as open-but-narrow.

Reproduce

# guest: DragonFly 6.5-DEVELOPMENT X86_64_GENERIC (stock), 6 vCPU
cd /root/df3093
sh run2.sh 20 6 3 2097152        # base campaign
sh run4.sh 2 6 2 16777216        # fallback-forced geometry
sh run6.sh 2 6 2 16777216        # word-straddling geometry
# detection: fat_scan after every cycle + race2 foreign-byte check;
# on an instrumented kernel (instr.patch): sysctl debug.df3093_double

Expected on a healthy kernel: fat_scan: clean, foreign bytes 0. A cross-link shows as max in-degree >= 2 + CORRUPTION FOUND, or as foreign bytes in per-writer fill checks.

Environment

DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC (stock release kernel, no INVARIANTS), 6-vCPU QEMU/KVM, guest /usr/src byte-identical to the audited tree (md5 aecdffd0dbf7e0195421d73ccb959831 for msdosfs_fat.c); diagnostic kernels #1-#6 built with make nativekernel from the same tree (guest restored to the stock snapshot after verification).

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.

(No fix.diff for this item: documented recommendation only - see Fix above.)

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

n/a β€” not reproduced, nothing to A/B

per-fix-DF-3093

Confirmed kernel references

Detail

Evidence (decisive lines)

trace-excerpts.txt [2]/[2b] (kernel counters: 66 and +137 overlap events, 0 doubles on the timing-unmodified kernel); VERDICT.md 'What was verified TRUE' + 'Why the race window does not convert'; fat_scan clean results in run4.log/run6.log; instr.patch is the exact diagnostic kernel

PoC changes

Replaced the staggered v1 racestress with race2.c (CPU pinning via usched_set(2), per-cache-line barrier words, pre-created/pre-opened files, single large write(2) per round, errno/timing capture); added fat_scan.c (offline FAT in-degree cross-link detector); added run3-6.sh geometries forcing the fallback scan and word-straddling bitmap RMW overlaps; added instr.patch diagnostic kernel (sysctls debug.df3093_{inflight,overlaps,double}).

Verified recommended fix

implement a real per-mount lock (or lwkt token) behind MSDOSFS_LOCK_MP/UNLOCK_MP and lockinit/destroy it in msdosfs_mount/unmount, serializing clusteralloc/chainalloc/usemap_* like FreeBSD's pm_fatmutex

Verdict

Premise verified, manifestation not reproduced. MSDOSFS_LOCK_MP/UNLOCK_MP are literally empty macros (msdosfsmount.h:216-218), struct msdosfsmount has no lock member, and a diagnostic kernel (instr.patch: stock code + counters + a print-only form of the INVARIANTS KASSERT at msdosfs_fat.c:381) PROVED the race window opens in practice: >200 clusteralloc critical-section overlap events, up to 3 CPUs deep, under 6 CPU-pinned barrier-synchronized writers. But the claimed double-allocation never occurred: across ~4 hours, ~500 synchronized allocation bursts and 6 adversarial geometries (64MiB-2GiB FAT16/FAT32 volumes, fallback-scan-forcing fragmentation, half-word and word-straddling free runs, 100us/200us delay-injected kernels widening the decide window 1000x), debug.df3093_double stayed 0, every offline FAT in-degree scan was clean, no foreign bytes, no 'Freeing unused sector' messages. The pure-memory scan phase (bitmap read to first bit set) is sub-microsecond and the allocator's pm_nxtfree-advance + strictly-greater tie-break (msdosfs_fat.c:756/768) effectively serialize run claiming, so two CPUs never commit the same run. The INVARIANTS-panic and release-kernel cross-link claims remain theoretical on this hardware; the missing lock is still a genuine hardening defect worth fixing (FreeBSD added pm_fatmutex for this surface).