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)
PoC verification
Evidence pack
findings/poc/DF-3190 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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)
sys/vfs/ufs/ffs_vfsops.c:641-646β mount checks onlyfs_magicandfs_bsize β [sizeof(struct fs), MAXBSIZE].fs_fmask/fs_qfmask(andfs_bmask/fs_qbmask/shifts) are copied into the livestruct fsverbatim (fs_qfmaskviabcopyof the superblock,ffs_vfsops.c:667-674).sys/vfs/ufs/ffs_vfsops.c:812-815βffs_oldfscompatrecomputes the masks only whenfs_inodefmt < FS_44INODEFMT; any image newfs(8) produces today keeps the crafted values.sys/vfs/ufs/fs.h:506βfragroundup(fs,size) = ((size)+fs_qfmask) & fs_fmaskis pure attacker arithmetic with those two fields.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)allocatesnumfrags(nsize)frags βgetblk(vp, lbn, nsize)β buffernsizebytes for asize-byte write. Sibling sinks::168-171(osize/breadpath),:119/:129(blksize()/ffs_realloccg), ffs_fragextend'scg_frsumstore (ffs_alloc.c, secondary).sys/vfs/ufs/ufs_readwrite.c:346-354βuiomovebp(bp, bp->b_data + blkoffset, xfersize, uio)withxfersize == 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:
- Per-buffer KVA reservation β every VMIO buffer owns a
MAXBSIZE(64 KB) KVA reservation (sys/kern/vfs_bio.c:640), andallocbufmaps onlydesiredpages = pages(bufsize)(vfs_bio.c:3038-3057,pmap_qenteronly 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 isbufsize β roundup(bufsize, PAGE_SIZE)(β€ 4095 B into the buffer's own last page) β samevm_pageas the buffer itself, no cross-object effect. copyinon-fault window (sys/kern/kern_subr.c:136with DFBSD'scopyinfault handler) β every userβkernel copy throughuiomoveis fault-tolerant, so the out-of-mapping portion returns EFAULT rather thanFatal trap 12. (Kernel-internal stores like DF-0894'sbap[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, dmesgffs_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_nindiris still copied unvalidated (ffs_vfsops.c:729) β and is not re-reported here; thefs_nindircompanion check belongs to DF-0894's fix. - The
fs_maxbpg==0divide-by-zero inffs_blkpref:805(reachable fromffs_ballocon 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
fixedCrafted 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']
Confirmed kernel references
- sys/vfs/ufs/ffs_balloc.c:194
- sys/vfs/ufs/ffs_balloc.c:202
- sys/vfs/ufs/ffs_balloc.c:168
- sys/vfs/ufs/ffs_balloc.c:119
- sys/vfs/ufs/fs.h:506
- sys/vfs/ufs/ffs_vfsops.c:641
- sys/vfs/ufs/ffs_vfsops.c:812
- sys/vfs/ufs/ufs_readwrite.c:329
- sys/vfs/ufs/ufs_readwrite.c:354
- sys/vfs/ufs/ffs_alloc.c:1504
- sys/kern/vfs_bio.c:640
- sys/kern/kern_subr.c:136
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.
No comments yet.