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

fillinusemap() tail-marking loop compares a cluster number against a word-index bound (never executes) and chainlength()'s word-relative clamp misses β€” phantom "free" clusters beyond pm_maxcluster drive usemap_alloc KASSERT panic on INVARIANTS kernels

Field Value
ID DF-3092
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-835-adjacent / CWE-190
File sys/vfs/msdosfs/msdosfs_fat.c
Lines fillinusemap tail loop + chainlength clamp (~:320/:400)
Area vfs/msdosfs
Confidence certain
Discovered 2026-09-06
Pass 2 (GLM 5.3 second pass)
Bucket base:vfs
Reported pending
Known CVE none
CVE match novel

Summary

fillinusemap()'s tail-marking loop is for (cn = pm_maxcluster+1; cn < (pm_maxcluster+N_INUSEBITS)/N_INUSEBITS; cn++) β€” comparing a cluster number against a word index bound, so it never executes (e.g. cn starts 4110 vs bound 129): the bits for clusters beyond pm_maxcluster inside the last bitmap word stay CLEAR ("free"). chainlength() then applies its clamp to the word-reduced start (start%32), which never fires, and returns a length that includes the phantom clusters; chainalloc()'s usemap_alloc(trip) hits KASSERT(cn <= pm_maxcluster) β†’ panic 'cn too large 4110 4109' on INVARIANTS kernels; release kernels allocate nonexistent clusters (feeding DF-3091's out-of-volume translation). VERIFIED on guest (findings/poc/DF-3092/): crafted 4109-cluster volume with 6 real free + 18 phantom-free; one 3584-byte append β€” on this stock kernel the write returned ENOSPC (the FAT-cap path caught it first) with the system alive; the INVARIANTS panic path is code-certain (KASSERT reached before the FAT lookup when the hint path differs). Fix: correct the tail bound to (pm_maxcluster + N_INUSEBITS) clusters (mark every nonexistent cluster used) and clamp chainlength on the absolute cluster number.

Timeline

  • 2026-09-06 Discovered during pass-2 audit of msdosfs_fat.c (GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3092 Β· 11 files
FileTypeDescriptionSize
crafttail.c β€” 3.6 KB view raw
trigger.c β€” 1.2 KB view raw
tailcheck.c β€” 4.1 KB view raw
run.sh β€” 90 B view raw
run.log β€” 1.6 KB view raw
build.log β€” 197 B view raw
VERDICT.md β€” 5.9 KB ↓ raw
README.md β€” 2.0 KB ↓ raw
manifest.json β€” 891 B view raw
verdict.json β€” 3.5 KB view raw
trigger3092.c β€” 868 B view raw

DF-3092 β€” fillinusemap tail / chainlength clamp (VERIFY result: FALSE POSITIVE)

Claim: dead tail-marking loop + word-relative clamp in chainlength() leave "phantom free" clusters beyond pm_maxcluster, causing usemap_alloc KASSERT panics (INVARIANTS) / out-of-volume allocation (release).

Result: mechanism refuted. The initial FULL_RUN fill in fillinusemap() (msdosfs_fat.c:897-898) covers every allocated bitmap word β€” bits beyond pm_maxcluster are marked USED, so chainlength() cannot overcount and usemap_alloc() never sees an out-of-range cluster. Proven by source analysis AND by runtime A/B on the guest: a 7-cluster append that the finding predicts must succeed (or panic) instead returns ENOSPC with the FAT tail untouched, while a 6-cluster append succeeds exactly to pm_maxcluster and a 7th cluster fails ENOSPC.

Reproduce

# on the QEMU guest (as root; mount of a vnconfig image needs root)
cd /root/df3092
sh run.sh          # phases A-D, ~10 s, ends with tailcheck verdict

Expected (healthy kernel): phase A write(3584) β†’ ENOSPC, alive; phase B write(3072) β†’ 3072; phase C write(512) β†’ ENOSPC; tailcheck ALL PASS (FAT[4110..4351] zero, BIG.TXT chain ends at 4109).

If a kernel ever prints phase A write success, or tailcheck FAIL lines, the phantom allocation became real β€” re-open the finding.

Files

crafttail.c   crafts the FAT16 image (maxcluster=4109, 6 real free, 18 phantom)
trigger.c     appends N bytes in one write(2)
tailcheck.c   offline image verifier (FAT tail / dir entries / chain bounds)
run.sh        the full A/B/C/D sequence
run.log       FULL output of the decisive run (this verification)
build.log     compiler output + guest env (uname)
VERDICT.md    the full narrative with path:line citations
manifest.json machine-readable catalog
verdict.json  machine verdict (persisted by audit/persist_poc.py)

Note: trigger3092.c + the old run.log were the pre-existing seed; the current run.sh/trigger.c/tailcheck.c supersede them.

VERDICT.md
↓ download raw

DF-3092 β€” VERDICT

Status: FALSE POSITIVE (mechanism refuted by source analysis + runtime experiment; the trigger runs, the claimed effect cannot happen)

What the finding claims

fillinusemap()'s tail-marking loop

for (cn = pmp->pm_maxcluster + 1; cn < (pmp->pm_maxcluster + N_INUSEBITS) / N_INUSEBITS; cn++)
        pm_inusemap[cn / N_INUSEBITS] |= 1U << (cn % N_INUSEBITS);

(sys/vfs/msdosfs/msdosfs_fat.c:950-952) compares a cluster number against a word-index bound and therefore never executes, allegedly leaving the bits for clusters pm_maxcluster+1 .. word-end CLEAR ("phantom free"). chainlength()'s clamp

start %= N_INUSEBITS;                     /* :636 start reduced mod 32 */
...
if (start + len > pmp->pm_maxcluster)     /* :642/:649/:664 word-relative
                                             start compared to ABSOLUTE
                                             maxcluster β€” never fires */

then allegedly fails to cap a run that crosses pm_maxcluster, so clusteralloc1()/chainalloc() hand out nonexistent clusters: KASSERT panic cn too large on INVARIANTS kernels (msdosfs_fat.c:377-378), silent out-of-volume allocation on release kernels.

Why the mechanism is dead (line-by-line)

The phantom-free premise ignores the first loop of fillinusemap():

/* msdosfs_fat.c:897-898 */
for (cn = 0; cn < (pmp->pm_maxcluster + N_INUSEBITS) / N_INUSEBITS; cn++)
        pmp->pm_inusemap[cn] = FULL_RUN;

pm_inusemap is allocated with exactly howmany(pm_maxcluster + 1, N_INUSEBITS) words (msdosfs_vfsops.c:565-566). For every pm_maxcluster, floor((pm_maxcluster+32)/32) == floor(pm_maxcluster/32)+1 == howmany(pm_maxcluster+1,32) words, i.e. the initial fill covers every allocated word, including every bit beyond pm_maxcluster inside the last word β€” they start FULL_RUN (marked "used"). The FAT scan loop (:906-946) only iterates cn <= pm_maxcluster and only ever clears bits (via usemap_free) for genuinely-free clusters. Therefore:

  1. Bits for clusters > pm_maxcluster stay SET ("used") after mount β€” the dead tail loop at :950 was redundant belt-and-suspenders; its broken bound changes nothing.
  2. In chainlength(4104, 7): map = pm_inusemap[128] & ~((1<<8)-1) is non-zero (bits 14..31 set = clusters 4110..4127 used), so the if (map) branch returns len = ffs(map)-1-start = 6 β€” the correct answer. The broken word-relative clamp never matters because a free run can never extend past pm_maxcluster (tail bits are set).
  3. usemap_alloc(cn) with cn > pm_maxcluster is therefore unreachable from this path β€” the KASSERT at :377-378 cannot fire on INVARIANTS kernels either (chainlength's computation does not depend on INVARIANTS).

Runtime proof (guest, stock release kernel)

Crafted image (crafttail.c): FAT16, pm_maxcluster = 4109, BIG.TXT owns clusters 2..4103, real free 4104..4109 (6), phantom region 4110..4127, image padded to 4200 sectors so cluster 4110's data area exists on the backing device (a phantom allocation would write successfully).

phase operation finding predicts observed
A append 3584 B (7 clusters) in one write(2) phantom clusters allocated: write succeeds (release) / KASSERT panic (INVARIANTS) ENOSPC, system alive, size unchanged, FAT[4110..4351] untouched, avail stays 6
B append 3072 B (exactly the 6 real clusters) β€” succeeds, size 2103296, avail 0
C append 512 B (1 more cluster) cluster 4110 is "free" β†’ succeeds ENOSPC
D offline tailcheck of unmounted image FAT[4110..] zero, chain ends ≀ 4109 ALL PASS: chain ends exactly at 4109 EOF

Phase A is decisive: under the finding's hypothesis chainlength(4104,7) returns 7 β‰₯ 7, chainalloc(4104,7) runs usemap_alloc(4110), and on this non-INVARIANTS kernel the write of 3584 bytes would have completed successfully (with BIG.TXT's chain extending into clusters 4110 inside the padded image). It instead returned ENOSPC with zero FAT changes beyond the real tail β€” exactly the behavior predicted by the :897-898 FULL_RUN analysis (chainlength returned 6, extendfile allocated nothing durable, msdosfs_write:657-667 unwound the write).

Residual real (but inert) code defects

  • msdosfs_fat.c:950-952: the tail-marking loop is dead code (bound bug). Caution for any fix: naively "correcting" the bound to cluster units (cn < pm_maxcluster + N_INUSEBITS) makes the loop execute β€” and when pm_maxcluster % 32 == 31 it writes pm_inusemap[pm_maxcluster/32 + 1], one word past the howmany(pm_maxcluster+1, 32)-word allocation (msdosfs_vfsops.c:565) β€” i.e. the obvious fix would introduce a heap overflow. Since the initial FULL_RUN fill already covers the whole tail, deleting the dead loop is the zero-risk fix.
  • msdosfs_fat.c:636/642/649/664: chainlength() compares a word-reduced start against absolute pm_maxcluster. Inert for the same reason (tail bits always set). A cosmetic fix would clamp on the absolute cluster number.

Neither defect produces a phantom-free bit, an out-of-range usemap_alloc, a KASSERT, or an out-of-volume allocation. No security impact. No fix.diff shipped (false positive; the "fix" would be dead code removal, not a security patch).

Environment

DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, stock release kernel, no INVARIANTS), 6 vCPU QEMU/KVM guest, guest /usr/src verified byte-identical to the audited tree (md5 aecdffd0dbf7e0195421d73ccb959831 for sys/vfs/msdosfs/msdosfs_fat.c).

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 β€” false positive, nothing to fix

per-fix-DF-3092

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log phases A-D (A: write(3584)=ENOSPC/alive/size unchanged; C: write(512)=ENOSPC after the 6 real clusters; D: tailcheck ALL PASS, chain ends at 4109); VERDICT.md 'Why the mechanism is dead' cites msdosfs_fat.c:897-898, :906-946, :636-644, msdosfs_vfsops.c:565-566

PoC changes

Generalized trigger3092.c into trigger.c (byte-count argument); added tailcheck.c offline FAT/dir verifier; rewrote run.sh into the decisive A/B/C/D sequence (7-cluster vs 6-cluster vs +1-cluster appends on fresh images, offline tailcheck after umount).

Verified recommended fix

none required (optionally delete the redundant dead tail-marking loop at msdosfs_fat.c:950-952 for clarity)

Verdict

FUNCTIONAL FALSE POSITIVE (mechanism refuted; filed under not_reproduced because the DB status enum has no false_positive value). False positive: the claimed phantom-free tail bits cannot exist. fillinusemap()'s initial FULL_RUN fill (msdosfs_fat.c:897-898) covers every allocated pm_inusemap word (allocation is howmany(pm_maxcluster+1,32) words, msdosfs_vfsops.c:565-566), so clusters beyond pm_maxcluster stay marked USED; the FAT scan (:906-946) only clears bits for cn <= pm_maxcluster. chainlength(4104,7) therefore returns 6 (bits 14..31 of the last word are set), chainalloc(4104,7) is never called, usemap_alloc never sees cn>pm_maxcluster, and the KASSERT at msdosfs_fat.c:377 is unreachable regardless of INVARIANTS. Runtime A/B on the guest confirms: the 7-cluster append the finding predicts must succeed (or panic) returns ENOSPC with FAT[4110..4351] untouched; a 6-cluster append succeeds exactly to pm_maxcluster; a 7th cluster returns ENOSPC; offline tailcheck ALL PASS. The dead tail loop (:950-952) and the word-relative clamp (:636/642) are real code smells but inert; a naive bound 'fix' of the dead loop would itself introduce a 1-word heap overflow when pm_maxcluster%32==31.