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

Unvalidated superblock fragment-mask fields (fs_fmask/fs_qfmask) let fragroundup() round DOWN in ffs_balloc β€” undersized kernel buffer handed to ffs_write for an attacker-sized/-contented overrun copy, and deterministic unprivileged-triggered kernel panic

Summary

ffs_mountfs validates only fs_magic and the fs_bsize range (ffs_vfsops.c:641-646) and ffs_oldfscompat recomputes the masks only for pre-4.4 inode formats, so the on-disk fs_fmask (int32) and fs_qfmask (int64) are attacker-chosen. fragroundup(fs,size)=((size)+fs_qfmask)&fs_fmask (fs.h:506) can therefore ROUND DOWN: ffs_balloc.c:194 computes nsize=fragroundup(fs_bsize)=2048 for a 16384-byte write, ffs_alloc allocates one fragment, and :202 getblk()s a 2048-byte VMIO buffer that ffs_write then fills via uiomovebp(bp->b_data+blkoffset, 16384) (ufs_readwrite.c:354) - an 8x oversized copy with fully controlled content. Sibling sinks: :168-171 bread on the existing-fragment path, :119/:129 blksize()/ffs_realloccg with craft-derived osize, and ffs_fragextend's cg_frsum OOB store (secondary). VERIFIED stock guest 3/3 fresh boots: unpriv uid 1001 single write(16384) after root mounts crafted vn image -> 'panic: ffs_blkfree: bad size' (dev=vn0 bno=1041 bsize=16384 size=16384), guest down; baseline write succeeds. On this kernel silent cross-buffer corruption is blocked by per-buffer MAXBSIZE KVA reservations (vfs_bio.c:640) and copyin's on-fault EFAULT window - the EFAULT cleanup then panics in ffs_blkfree_cg:1504 under the poisoned qfmask; on kernels lacking either property the same image yields a silent controlled heap overrun (uid=0 route blocked, line-accurate blockers in VERDICT.md). Fix validated (kernel #1): mount-time fragment-geometry validation rejects the crafted image ('ffs_mountfs: inconsistent fragment geometry'), stock image unaffected. Variant of DF-0820's root cause with novel sink/primitive.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3190 Β· 17 files
FileTypeDescriptionSize
sbxtool.c β€” 4.0 KB view raw
trigger.c β€” 1.2 KB view raw
build.sh β€” 117 B view raw
run.sh β€” 241 B view raw
fs.h β€” 24.2 KB view raw
build.log β€” 273 B view raw
run.baseline.log β€” 1.0 KB view raw
run.log β€” 1.3 KB view raw
run.2.log β€” 1.3 KB view raw
console.craft1.txt β€” 2.3 KB view raw
console.craft2.txt β€” 2.2 KB view raw
console.craft3.txt β€” 2.2 KB view raw
run.patched.log β€” 1.2 KB view raw
run.patched.baseline.log β€” 1.1 KB view raw
env.txt β€” 309 B view raw
fix.diff β€” 1.8 KB view raw
VERDICT.md β€” 7.3 KB ↓ raw
VERDICT.md
↓ download raw

DF-3190 β€” VERDICT

status: reproduced / impact: panic (deterministic) / confidence: certain primitive: kernel buffer undersized by superblock-controlled arithmetic, then overwritten with an attacker-sized, attacker-contented copy β€” plus a deliberate consistency panic on the error-cleanup path. Fix validated: crafted image rejected at mount on the patched kernel; stock image unaffected.

Root cause (line-accurate)

  1. sys/vfs/ufs/ffs_vfsops.c:641-646 β€” mount checks only fs_magic and fs_bsize ∈ [sizeof(struct fs), MAXBSIZE]. fs_fmask/fs_qfmask (and fs_bmask/fs_qbmask/shifts) are copied into the live struct fs verbatim (fs_qfmask via bcopy of the superblock, ffs_vfsops.c:667-674).
  2. sys/vfs/ufs/ffs_vfsops.c:812-815 β€” ffs_oldfscompat recomputes the masks only when fs_inodefmt < FS_44INODEFMT; any image newfs(8) produces today keeps the crafted values.
  3. sys/vfs/ufs/fs.h:506 β€” fragroundup(fs,size) = ((size)+fs_qfmask) & fs_fmask is pure attacker arithmetic with those two fields.
  4. sys/vfs/ufs/ffs_balloc.c:193-202 β€” fresh direct block: nsize = fragroundup(fs, size) (size == fs_bsize for the PoC write) β†’ ffs_alloc(nsize) allocates numfrags(nsize) frags β†’ getblk(vp, lbn, nsize) β€” buffer nsize bytes for a size-byte write. Sibling sinks: :168-171 (osize/bread path), :119/:129 (blksize()/ffs_realloccg), ffs_fragextend's cg_frsum store (ffs_alloc.c, secondary).
  5. sys/vfs/ufs/ufs_readwrite.c:346-354 β€” uiomovebp(bp, bp->b_data + blkoffset, xfersize, uio) with xfersize == fs_bsize >> buffer size.

What was run (guest DragonFly dfbsd 6.5-DEVELOPMENT #0,

X86_64_GENERIC, INVARIANTS on, DIAGNOSTIC off)

run image trigger result
run.baseline.log stock sb uid=1001 write(16384) success, file 16384 B, no panic
run.log + console.craft1.txt poisoned uid=1001 write(16384) panic (cpuid 5)
run.2.log + console.craft2.txt poisoned same, fresh boot panic (cpuid 0)
console.craft3.txt poisoned same, fresh boot panic (cpuid 3)
run.patched.log poisoned, kernel #1 w/ fix same mount rejected (incorrect super block), no panic, guest up
run.patched.baseline.log stock sb, kernel #1 w/ fix uid=1001 write(16384) success β€” no regression

Craft: fs_fmask = 0x1fff, fs_qfmask = 0x800 on a stock newfs image (bsize 16384, fsize 2048, frag 8) β‡’ fragroundup(16384) = 2048.

Panic chain (why it panics in ffs_blkfree and not in uiomove)

The oversized copy is attempted: uiomove β†’ copyin(user, b_data, 16384) (sys/kern/kern_subr.c:136). The buffer is VMIO with one page mapped; bytes 2048..4095 overflow into that page's tail, and at b_data+4096 the copy faults. DragonFly's copyin has an on-fault window (returns EFAULT on any fault during the copy) so the wild write is converted to EFAULT instead of trapping. ffs_write then runs its IO_UNIT error path (ufs_readwrite.c:404-410) β†’ ffs_truncate(vp, osize=0); but ip->i_size was already advanced to 16384 (ufs_readwrite.c:348-350) before the failed copy, so the truncate frees i_db[0] = 1041 as a full block (size = 16384), and ffs_blkfree_cg (ffs_alloc.c:1504-1507) panics on fragoff(fs, 16384) = 16384 & 0x800 β‰  0 under the poisoned fs_qfmask:

dev=vn0, bno = 1041, bsize = 16384, size = 16384, fs = /mnt
panic: ffs_blkfree: bad size
ffs_blkfree_cg β†’ ffs_blkfree β†’ ffs_truncate β†’ ffs_write β†’ vop_write

Exploitability ceiling (documented hard blockers for uid=0 via this sink)

The primitive is a genuine attacker-sized/-contented write directed at kernel KVA (b_data + [0, fs_bsize), buffer of nsize bytes). Two mechanisms, verified in source, cap it at EFAULT/panic on this kernel:

  1. Per-buffer KVA reservation β€” every VMIO buffer owns a MAXBSIZE (64 KB) KVA reservation (sys/kern/vfs_bio.c:640), and allocbuf maps only desiredpages = pages(bufsize) (vfs_bio.c:3038-3057, pmap_qenter only for the buffer's own pages). Writes past the buffer's last mapped page land in its own unmapped reservation, never in a neighbour buffer β€” cross-buffer grooming is structurally impossible via this overflow. The only silent-overflow window is bufsize β†’ roundup(bufsize, PAGE_SIZE) (≀ 4095 B into the buffer's own last page) β€” same vm_page as the buffer itself, no cross-object effect.
  2. copyin on-fault window (sys/kern/kern_subr.c:136 with DFBSD's copyin fault handler) — every user→kernel copy through uiomove is fault-tolerant, so the out-of-mapping portion returns EFAULT rather than Fatal trap 12. (Kernel-internal stores like DF-0894's bap[in_off] have no such window — different finding, different sink.)

Secondary sink (same root cause, cross-file): ffs_fragextend cgp->cg_frsum[i - numfrags(fs, osize)]-- β€” with stock fs_fshift the index stays within Β±fs_frag of the array (intra-struct cg corruption, silent allocator-metadata corruption); crafting fs_fshift to widen the index is gated by the early cs_nffree < numfrags(bsize-osize) bail-out (ffs_alloc.c fragextend entry), which rejects the enlarged request before the store. Capped at intra-buffer corruption / DoS on this kernel.

Net: reliable unprivileged-triggered kernel panic (DoS) once the crafted image is mounted, with a demonstrated buffer-size-decoupling memory-safety violation whose silent-corruption routes are individually blocked and documented above. uid=0 not achieved from this sink on this guest β€” blocked by (1)+(2), not by lack of primitives.

Fix validation

fix.diff (authored against the read-only sys/ tree; applied only inside the guest's /usr/src copy): validates in ffs_mountfs that fs_fsize/fs_frag/fs_bmask/fs_fmask/fs_qbmask/fs_qfmask/ fs_bshift/fs_fshift/fs_fragshift are exactly the values recomputed from fs_bsize/fs_fsize (power-of-two chain, fs_frag ≀ MAXFRAG), else EINVAL.

  • make nativekernel KERNCONF=X86_64_GENERIC β€” clean (-Werror), RC=0 (build.log); installed as kernel #1 Tue Sep 8 05:12:48 UTC 2026.
  • Crafted image β†’ mount_ufs: /dev/vn0 on /mnt: incorrect super block, dmesg ffs_mountfs: inconsistent fragment geometry, no panic, trigger unreachable (run.patched.log).
  • Stock image β†’ mounts, uid=1001 write(16384) succeeds, 16384-byte file (run.patched.baseline.log). No regression.

Notes

  • DF-0894 (crafted fs_nindir, bap[indirs[i].in_off] OOB in this same function) is not fixed in this tree β€” fs_nindir is still copied unvalidated (ffs_vfsops.c:729) β€” and is not re-reported here; the fs_nindir companion check belongs to DF-0894's fix.
  • The fs_maxbpg==0 divide-by-zero in ffs_blkpref:805 (reachable from ffs_balloc on every allocation) is the DF-0820 umbrella family and is not re-filed.
  • Guest reset to the clean-source snapshot after validation.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Crafted image rejected at mount (mount_ufs: incorrect super block; dmesg ffs_mountfs: inconsistent fragment geometry) β€” no panic, trigger unreachable; stock image mounts and the unprivileged 16384-byte write still succeeds on the patched kernel. Baseline bad behaviour GONE, legitimate behaviour preserved.

['build.log: nativekernel RC=0, kernel #1 Sep 8 05:12:48 UTC 2026', 'run.patched.log: mount rejection, SURVIVED-CRAFTED-RC=1, guest up', 'run.patched.baseline.log: BASELINE-RC=0, 16384-byte file via uid=1001']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 8 05:12:48 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

crafted FFS image (fs_fmask=bsize/2-1, fs_qfmask=fsize) -> root mounts -> unprivileged write(16384) -> ffs_balloc returns 2048-byte buffer (ffs_alloc 1 frag @1041, getblk 1 page) -> uiomove copies 16384 controlled bytes; bytes 2048..4095 overflow buffer's own page tail, copy faults at b_data+4096 -> copyin fault window -> EFAULT -> ffs_write IO_UNIT -> ffs_truncate frees i_db[0] as full block -> panic('ffs_blkfree: bad size'). uid=0 NOT reached: (1) per-buffer 64K KVA reservation maps only pages(bufsize) so the overrun can never reach a neighbouring buffer's data; (2) every user->kernel copy rides copyin's on-fault window -> EFAULT instead of trap; (3) secondary sink cg_frsum[i - numfrags(osize)]-- is index-capped by the cs_nffree early-exit and stays intra-buffer. Primary objective outcome: primitive proven (controlled overrun copy), escalation blocked by kernel-copy architecture, documented line-accurate in VERDICT.md.

Evidence (decisive lines)

["console.craft1.txt / console.craft2.txt / console.craft3.txt: three deterministic 'panic: ffs_blkfree: bad size' traces (ffs_blkfree_cg <- ffs_blkfree <- ffs_truncate <- ffs_write <- vop_write) on fresh boots, cpuids 5/0/3", 'run.baseline.log: identical image, stock superblock, uid=1001 write(16384) succeeds, 16384-byte file, no panic', "run.patched.log + env.txt: kernel #1 with fix.diff rejects the crafted image at mount ('incorrect super block', dmesg 'ffs_mountfs: inconsistent fragment geometry'), no panic", 'run.patched.baseline.log: stock image on patched kernel still mounts and accepts the unprivileged write (no regression)', 'VERDICT.md: line-accurate chain and the two wild-write blockers']

PoC changes

Seed PoC written fresh for this finding (no prior seed): sbxtool.c patches the PRIMARY superblock (not cg headers as DF-3035's cgxtool); 'poison' computes fmask=bsize/2-1 and qfmask=fsize from the image's own geometry so fragroundup(bsize)==fsize for any newfs default; trigger.c runs as unprivileged uid 1001 with pattern content; run.sh handles baseline/crafted modes and unprivileged execution via su -m maxx.

Verified recommended fix

In ffs_mountfs, validate the derived fragment geometry against values recomputed from fs_bsize/fs_fsize (power-of-two fsize/frag, fs_frag==bsize/fsize<=MAXFRAG, fs_fmask==~(fsize-1), fs_qfmask==fsize-1, bmask/qbmask and bshift/fshift/fragshift likewise) and reject with EINVAL β€” see fix.diff.

Verdict

Unvalidated superblock fragment-mask fields (fs_fmask int32 / fs_qfmask int64) make fragroundup() round DOWN: ffs_balloc.c:194 sizes a one-fragment (2048 B) VMIO buffer for a 16384-byte write, and ffs_write's uiomove then performs an attacker-sized, attacker-contented 8x overrun copy at bp->b_data. On this kernel the beyond-page portion of the wild write is converted to EFAULT by copyin's on-fault window (kern_subr.c:136) and the per-buffer MAXBSIZE KVA reservation (vfs_bio.c:640) confines writes to the buffer's own pages, so silent cross-buffer corruption is structurally blocked (documented blockers); the EFAULT error-cleanup (ffs_write IO_UNIT -> ffs_truncate) then frees the 1-frag allocation as a full block and ffs_blkfree_cg:1504 panics on fragoff(fs,16384)!=0 under the poisoned qfmask. Deterministic: 3/3 unprivileged triggers (uid 1001) panicked from a fresh vm.sh reset each time; stock-superblock control write succeeds. Root mount of crafted media required (vfs.usermount=0), trigger is a single unprivileged write(2) β€” same threat model as DF-0820/DF-3035.