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)
PoC verification
Evidence pack
findings/poc/DF-3232 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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=0default) β or fully unprivileged withvfs.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/0xffwrite 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 blockpanic β 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.
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 onlyfs_magicandfs_bsize β [sizeof(struct fs), MAXBSIZE].fs_fragshiftis never validated or re-derived (ffs_oldfscompatat :796-818 only touches qbmask/qfmask forfs_inodefmt < FS_44INODEFMT).sys/vfs/ufs/ffs_alloc.c:1538-1544β free path:blkno = fragstoblks(fs, bno)thenffs_isfreeblock/ffs_setblockwith that index; alloc path:1137/:1208-1209identical withffs_isblock/ffs_clrblock.sys/vfs/ufs/ffs_subr.c:221-307β the helpers themselves contain no bounds; withfs_frag == 8the bitmap isfpg/8bytes and the byte index must be< fpg/8;fs_fragshift = 0multiplies 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:
- 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-alignedb_data. - 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. - 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 β ffon disk βffs_setblock'scp[5648] = 0xff(ffs_subr.c:294) executed and persisted. - 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 offsetcg_freeoff + bnowithbnochosen through the file's block pointer β up tofs_fpgbytes 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
fixedPatched 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
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.
No comments yet.