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

Unvalidated fs_fragshift scales ffs_subr.c block-bitmap byte indexes up to 8x past the cylinder-group bitmap β€” OOB read plus single-byte 0x00/0xff OOB write at attacker-chosen offset, from one unprivileged ftruncate() on a crafted mount (silent write persisted to media)

Summary

The block-bitmap helpers ffs_isblock/ffs_isfreeblock/ffs_clrblock/ffs_setblock index the cg free-block bitmap (fpg/fs_frag bytes) with h = fragstoblks(fs,bno) = bno >> fs->fs_fragshift. fs_fragshift is read from the superblock and never validated or re-derived anywhere (ffs_vfsops.c:642-646 checks only magic/bsize; ffs_oldfscompat never touches it). With fs_fragshift=0 and fs_frag left at a valid 8, every index is 8x too large - beyond DF-0919, which needs an invalid fs_frag and only panics the switch(). VERIFIED on stock INVARIANTS guest, 3 boots: (1) fatal trap 12 supervisor READ at ffs_isfreeblock+0x19, guest down; (2) panic 'ffs_blkfree: freeing free block' - OOB read of a live kernel byte used as an oracle; (3) silent OOB WRITE cp[bno]=0xff 16 bytes past the bitmap, persisted to disk after clean unmount (byte at image offset 31852 flips 00->ff). Unpriv trigger: open()+ftruncate(0)+sync by uid 1001 on the crafted mount (victim's di_db[0] forged; fs_fragshift 3->0, fs_fpg enlarged, FS_DOSOFTDEP cleared). Attacker-offset read + one-byte 0x00/0xff write into buffer-cache memory, offset tunable up to fs_fpg past the bitmap. uid0 hard blocker documented (value constrained, adjacent-slot victims not reliably steerable under MAXBSIZE KVA reservations). Fix validated on rebuilt kernel #1: mount-time fragment-geometry gate rejects crafted image ('ffs_mountfs: inconsistent fragment geometry'), stock /boot+hammer2 boot unaffected.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3232 Β· 16 files
FileTypeDescriptionSize
README.md β€” 5.3 KB ↓ raw
VERDICT.md β€” 4.2 KB ↓ raw
sbtool2.c β€” 5.5 KB view raw
trigger.c β€” 1.2 KB view raw
build.sh β€” 79 B view raw
setup.sh β€” 1.7 KB view raw
run.sh β€” 188 B view raw
run.log β€” 1.4 KB view raw
run.2.log β€” 775 B view raw
run.3.log β€” 1.2 KB view raw
run.control.log β€” 533 B view raw
panic.txt β€” 2.2 KB view raw
panic2.txt β€” 2.3 KB view raw
env.txt β€” 255 B view raw
fix.diff β€” 1.6 KB view raw
verdict.json β€” 5.4 KB view raw

DF-3232 β€” Unvalidated fs_fragshift scales the FFS block-bitmap byte index

up to 8Γ— past the cylinder-group bitmap β†’ OOB read AND single-byte 0x00/0xff OOB write in buffer-cache memory from one unprivileged ftruncate() (sys/vfs/ufs/ffs_subr.c)

What

The block-bitmap helpers in ffs_subr.c index the cylinder-group free-block bitmap (one bit per fragment β‡’ fpg / fs_frag bytes) with a block number derived by shifting a fragment number down by the superblock's fs_fragshift:

/* fs.h:518 */  fragstoblks(fs, frags)  ==  frags >> fs->fs_fragshift
/* ffs_alloc.c:1538 */ blkno = fragstoblks(fs, bno);   /* bno = dtogd(freed blk) */
/* ffs_alloc.c:1539 */ ffs_isfreeblock(fs, blksfree, blkno);   /* OOB READ  */
/* ffs_alloc.c:1544 */ ffs_setblock(fs, blksfree, blkno);      /* OOB WRITE */

ffs_mountfs validates only fs_magic and the fs_bsize range (ffs_vfsops.c:642-646) and ffs_oldfscompat (ffs_vfsops.c:796-818) never touches fs_fragshift, so an image with

fs_frag     = 8      (valid β€” the switch() in every bitmap helper is happy)
fs_fragshift= 0      (should be 3: log2(fs_frag))
fs_fpg      = 131072 (so dtogd(bno) == bno keeps the forged block in cg0)

mounts cleanly, and every bitmap index becomes 8Γ— too large: with fs_frag == 8 the bitmap holds one byte per 8 frags, but h = bno >> 0 = bno addresses one byte per frag β€” cp[bno] walks off the end of the bitmap and off the end of the fs_cgsize-byte bread()'d cg buffer.

Sinks in sys/vfs/ufs/ffs_subr.c (all reached from ordinary file operations on the crafted mount):

sink line op
ffs_isblock 227/230/233/236 OOB read (alloc path :1137)
ffs_isfreeblock 250 OOB read (free path :1539)
ffs_clrblock 270 OOB write cp[h] = 0 (alloc path :1209)
ffs_setblock 294 OOB write cp[h] = 0xff (free path :1544)

The freed/allocated block number bno is attacker-chosen (a file's di_db[] on the image), so the OOB offset is attacker-chosen up to fs_fpg bytes past the bitmap start β€” with fs_fpg = 131072 that is up to ~123 KB past b_data, i.e. across multiple 64-KB buffer-cache KVA slots into other buffers' data. Write value is a full byte: 0xff (setblock), 0x00 (clrblock case 8).

Preconditions

  • crafted FFS image (any unprivileged user can prepare it);
  • mount by root (vfs.usermount=0 default) β€” or fully unprivileged with vfs.usermount=1 (same profile as DF-0894/0903/3190/3212);
  • trigger is one syscall by any user with write permission on one file.

PoC (verified on the stock INVARIANTS guest, X86_64_GENERIC #0)

sbtool2 builds a stock 128 MB newfs -b 8192 -f 1024 image, creates victim (one 8 K block), then offline patches the superblock (fs_fragshift 3→0, fs_fpg→131072, softdep flag cleared) and points victim's di_db[0] at a chosen fragment BNO. Root mounts; maxx (uid 1001) runs trigger = open()+ftruncate(0)+sync().

Real geometry (dumped by sbtool2 info): cg buffer fs_cgsize = 8192, cg_freeoff = 1628, bitmap = fpg/8 = 5632 bytes, cg0 block at byte 24576.

run BNO cp[BNO] lands at result
run.log 16000 b_data+17628 (+9.4 KB past 8 KB buffer) Fatal trap 12, supervisor READ, page not present β€” Stopped at ffs_isfreeblock+0x19: cmpb $0,(%rsi,%rdx,1); fault VA βˆ’ 0x44DC (=1628+16000) is page-aligned b_data. Guest down.
panic2 6000 bitmap+368 (mapped, nonzero cluster-map byte) panic: ffs_blkfree: freeing free block — dev=vn0, block=6000 (OOB read returned live kernel data); stack ffs_truncate→ffs_blkfree→ffs_blkfree_cg.
run.3.log 5648 bitmap+16 (mapped, zero cluster-sum byte) silent OOB WRITE: trigger survives, clean unmount, byte at image offset 31852 (cg0+freeoff+5648) flipped 00 β†’ ff on disk.

Control: identical image/trigger with stock fs_fragshift β€” survives, truncates to 0, clean unmount (run.control.log).

Build / run

# guest, as root:
sh /root/df3232/build.sh          # cc sbtool2.c trigger.c
sh /root/df3232/setup.sh 16000    # build+poison image, mount crafted RW
su -m maxx -c "/tmp/trig3232 /mnt/victim"     # β†’ fatal trap (run 1)
# variants: setup.sh 6000 β†’ panic 'freeing free block'
#           setup.sh pick β†’ auto-picks a zero byte β†’ silent 0xff write

Impact

  • attacker-offset single-byte 0x00/0xff write into buffer-cache memory (groomable: with BNO past 64 KB the write crosses into neighbouring buffers' mapped data β€” same KVA layout DF-3190 documented), and
  • attacker-offset OOB read used as a value oracle (freeing free block panic ⇔ byte β‰  0), and
  • two deterministic unprivileged reboot-equivalent panics.

No uid=0 chain was completed: the write value is constrained to one byte (0x00/0xff) and the victim buffer population at the target KVA slot is not steerable enough on this configuration to reach a credential-bearing object reliably β€” documented as the hard blocker (see VERDICT.md).

Fix

Validate the shift/mask geometry at mount (see fix.diff): reject unless fs_frag ∈ {1,2,4,8}, (1 << fs_fragshift) == fs_frag, fs_bsize == fs_fsize << fs_fragshift, fs_bmask == fs_bsize-1, fs_fmask == fs_fsize-1 and the 64-bit masks are the proper complements. This also closes the mask-craft family (DF-3190/3212/0903) at the same gate.

VERDICT.md
↓ download raw

DF-3232 VERDICT

Status: reproduced (impact: kernel memory corruption — OOB read + single-byte 0x00/0xff OOB write at attacker-chosen offset; demonstrated as two deterministic panics and one on-disk-persisted silent write; unpriv→ uid0 chain NOT completed — blocker documented below).

Root cause (line-accurate)

  • sys/vfs/ufs/fs.h:518-521 β€” fragstoblks(fs,frags) = frags >> fs->fs_fragshift, blkstofrags = blks << fs->fs_fragshift.
  • sys/vfs/ufs/ffs_vfsops.c:642-646 β€” mount validates only fs_magic and fs_bsize ∈ [sizeof(struct fs), MAXBSIZE]. fs_fragshift is never validated or re-derived (ffs_oldfscompat at :796-818 only touches qbmask/qfmask for fs_inodefmt < FS_44INODEFMT).
  • sys/vfs/ufs/ffs_alloc.c:1538-1544 β€” free path: blkno = fragstoblks(fs, bno) then ffs_isfreeblock/ffs_setblock with that index; alloc path :1137/:1208-1209 identical with ffs_isblock/ffs_clrblock.
  • sys/vfs/ufs/ffs_subr.c:221-307 β€” the helpers themselves contain no bounds; with fs_frag == 8 the bitmap is fpg/8 bytes and the byte index must be < fpg/8; fs_fragshift = 0 multiplies every index by 8.

fs_frag itself staying valid (8) is what takes this beyond DF-0919: DF-0919's panic/OOB requires fs_frag βˆ‰ {1,2,4,8}; here every switch() is satisfied and the OOB is purely in the index scaling.

How reproduced (stock INVARIANTS guest, X86_64_GENERIC #0, 3 boots)

Crafted image = stock newfs -b 8192 -f 1024 + offline patch (sbtool2): fs_fragshift 3→0, fs_fpg→131072 (keeps the forged block in cg0 through dtogd), FS_DOSOFTDEP cleared (synchronous free), and the victim file's di_db[0] set to the chosen fragment. Root mounts; maxx (uid 1001) runs open+ftruncate(0)+sync:

  1. bno=16000 β€” cp[16000] = b_data+17628 (+9.4 KB past the 8192-byte cg buffer): Fatal trap 12, supervisor read, page not present, Stopped at ffs_isfreeblock+0x19 (ffs_subr.c:250). Fault VA βˆ’ 0x44DC(=1628+16000) is exactly page-aligned b_data.
  2. bno=6000 β€” byte past the 5632-byte bitmap but inside the mapped buffer (cluster-map region, nonzero): panic: ffs_blkfree: freeing free block β€” dev=vn0, block=6000 β€” the OOB read returned live kernel data and the kernel used it as an oracle.
  3. bno=5648 β€” zero byte past the bitmap inside the buffer: trigger survives, clean unmount, and the image byte at cg0+freeoff+5648 (offset 31852) flipped 00 β†’ ff on disk β€” ffs_setblock's cp[5648] = 0xff (ffs_subr.c:294) executed and persisted.
  4. control β€” identical image/trigger with stock fs_fragshift: survives, truncates, unmounts cleanly.

Primitive & uid0 assessment

  • Read: OOB byte at chosen offset (value oracle via panic/no-panic).
  • Write: single byte 0x00 (clrblock) / 0xff (setblock) at offset cg_freeoff + bno with bno chosen through the file's block pointer β€” up to fs_fpg bytes past the bitmap start (demonstrated +9.4 KB; layout allows ≫ 64 KB, crossing into neighbouring buffer KVA slots).
  • uid0 chain NOT completed, honestly: (a) the value is constrained to one byte of 0x00/0xff per free; (b) the victim buffer occupying the target KVA slot is not reliably steerable on this guest β€” the cg buffer itself is transient (bread/bdwrite), so grooming a credential-bearing object into the adjacent 64 KB slot deterministically was not achievable in the attempt budget. The on-disk-persisted write (run 3) proves the write executes at a chosen offset; turning it into uid0 remains open.

Fix

fix.diff β€” mount-time geometry gate in ffs_mountfs: reject unless fs_frag ∈ {1,2,4,8}, 1<<fs_fragshift == fs_frag, fs_bsize == fs_fsize<<fs_fragshift, bshift/fshift/bmask/fmask consistent, and (modern inodefmt) qbmask/qfmask are the exact complements. Validated: see fix section of verdict.json (patched kernel

1 rejects the crafted image at mount; stock image unaffected).

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

Patched kernel #1 (fix.diff applied to guest /usr/src, make nativekernel + installkernel): DF-3232 crafted image is rejected at mount with 'ffs_mountfs: /mnt: inconsistent fragment geometry' / mount EINVAL (fix_patched.log); stock newfs image mounts and operates normally; guest boots cleanly (its UFS /boot and hammer2 root pass the gate). Previously-observed trap/panic/on-disk-write are unreachable because the trigger filesystem can no longer mount.

findings/poc/DF-3232/fix_patched.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Wed Sep 9 00:49:30 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

crafted image (root mount, or unprivileged with vfs.usermount=1) -> victim file's di_db[0] forged to chosen frag B -> unprivileged ftruncate -> ffs_truncate -> ffs_blkfree_cg:1538 blkno=B>>fs_fragshift(=B) -> :1539 ffs_isfreeblock OOB READ (trap on unmapped reservation / panic on nonzero byte) -> :1544 ffs_setblock OOB WRITE cp[B]=0xff (observed persisted to media at +16 bytes past the bitmap; layout permits offsets past 64KB into neighbouring buffers). uid0 open: single-byte 0x00/0xff at chosen buffer-cache-relative offset; no deterministic credential-object grooming achieved.

Evidence (decisive lines)

['findings/poc/DF-3232/panic.txt: Fatal trap 12 supervisor READ, Stopped at ffs_isfreeblock+0x19 (run bno=16000)', 'findings/poc/DF-3232/panic2.txt: panic ffs_blkfree: freeing free block, dev=vn0 block=6000, stack ffs_truncate->ffs_blkfree->ffs_blkfree_cg (run bno=6000)', 'findings/poc/DF-3232/run.3.log: pre-trigger 00-byte vs post-trigger ff at image offset 31852 after unprivileged ftruncate + clean umount (silent OOB write persisted)', 'findings/poc/DF-3232/run.control.log: stock-geometry control survives', 'findings/poc/DF-3232/fix_patched.log: patched kernel #1 rejects the crafted image at mount; stock image unaffected']

PoC changes

authored in this pass; two fixes during bring-up: victim inode is 3 not 4 (auto-detect added), and 8-alignment of the forged bno is required by ffs_blkfree's fragnum check (setup.sh auto-picks an aligned zero target byte for the write-proof run)

Verified recommended fix

Mount-time fragment-geometry validation in ffs_mountfs (fs_frag power-of-2 <= MAXFRAG, 1<<fs_fragshift==fs_frag, bsize==fsize<<fragshift, bshift/fshift/bmask/fmask/qbmask/qfmask consistent) - validated in-guest: crafted image rejected at mount, stock images and the guest's own /boot+hammer2 unaffected.

Verdict

Verified on the stock INVARIANTS guest (X86_64_GENERIC #0) across three fresh boots: with fs_fragshift patched 3->0 (fs_frag left at the valid 8, fs_fpg raised to keep the forged block in cg0), a single unprivileged open()+ftruncate(0) drives ffs_blkfree_cg -> fragstoblks(bno)=bno>>0 -> ffs_isfreeblock/ffs_setblock at cp[bno] where the bitmap is only fpg/8 bytes. bno=16000: Fatal trap 12 supervisor READ page-not-present stopped at ffs_isfreeblock+0x19 (ffs_subr.c:250), fault VA minus (cg_freeoff 1628 + 16000) equals the page-aligned buffer base - a 9.4KB OOB read past the 8192-byte cg buffer. bno=6000: panic 'ffs_blkfree: freeing free block dev=vn0 block=6000' - the OOB read returned a live nonzero kernel byte from past the bitmap. bno=5648: the free SURVIVES and the clean unmount persists 0xff at image offset 31852 = cg0+freeoff+5648 - ffs_setblock's cp[5648]=0xff (ffs_subr.c:294) executed 16 bytes past the bitmap. Control with stock fs_fragshift survives cleanly. Primitive: single-byte 0x00/0xff write and byte read at attacker-chosen offset up to fs_fpg past the bitmap (spans neighbouring buffer-cache KVA slots), trigger one syscall by uid 1001. Impact recorded as panic (deterministic reboot-equivalent) with the silent-corruption write demonstrated on disk; uid0 chain not completed - the write value is one constrained byte and the adjacent-slot victim population is not reliably steerable on this guest (per-buffer MAXBSIZE KVA reservations), documented in VERDICT.md.