ffs_blkatoff hands directory code an out-of-bounds *res pointer on crafted-geometry mounts (blkoff via unvalidated fs_qbmask vs blksize-derived buffer) β unprivileged creat/touch reads and writes directory-entry data tens of KB past the buffer; deterministic fatal supervisor-write trap
Summary
ffs_blkatoff (and _ra) size the buffer with bsize = blksize(fs,ip,lbn) but return *res = bp->b_data + blkoff(fs,uoffset) where blkoff = uoffset & fs->fs_qbmask - an independent, unvalidated superblock field. With fs_qbmask crafted larger than fs_bsize, *res points up to 2^63 past the buffer; ufs_direnter/ufs_dirremove/ufs_dirrewrite/ufs_dirhash then read and WRITE directory entries through it. VERIFIED twice on fresh boots: phantom EEXIST for a non-existent name (OOB read consuming wild bytes), then unpriv touch -> Fatal trap 12 supervisor WRITE at memset+0xd5 in the direnter entry-copy path - dirbuf = b_data + 69120 on an 8192-byte buffer (identical faulting instruction across boots). Write content is an attacker-named dirent (~28+ controlled bytes); landing offset tunable via the free-slot position and fs_qbmask - silent neighbour-buffer corruption when the target KVA slot is mapped, fatal trap when not. Novel sink vs DF-0903 (ffs_write) and DF-3212 (ffs_read): the directory-lookup/entry-write path. Fix: the shared mount-time geometry gate (same fix.diff as DF-3232) - crafted image rejected at mount; validated on rebuilt kernel.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3233 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.3 KB | β raw | |
| VERDICT.md | β | 4.1 KB | β raw | |
| craft3233.c | β | 5.6 KB | view raw | |
| trigger.c | β | 899 B | view raw | |
| run.sh | β | 182 B | view raw | |
| run.log | β | 1.0 KB | view raw | |
| run.2.log | β | 1.0 KB | view raw | |
| panic.txt | β | 2.1 KB | view raw | |
| panic.2.txt | β | 2.2 KB | view raw | |
| fix.diff | β | 1.6 KB | view raw | |
| verdict.json | β | 4.9 KB | view raw |
DF-3233 β ffs_blkatoff hands directory code an out-of-bounds *res
pointer on crafted-geometry mounts β unprivileged creat/touch reads and
WRITES directory entries tens of KB past the buffer
(sys/vfs/ufs/ffs_subr.c)
What
/* sys/vfs/ufs/ffs_subr.c:80 (ffs_blkatoff) and :151 (ffs_blkatoff_ra) */
if (res)
*res = (char *)bp->b_data + blkoff(fs, uoffset);
blkoff(fs, loc) is loc & fs->fs_qbmask (fs.h:487-488) and the buffer
was sized bsize = blksize(fs, ip, lbn) β an independent derivation
(fs_bsize, or fragroundup(blkoff(i_size)) via the fsize masks). Nothing
at mount validates that fs_qbmask matches fs_bsize (ffs_vfsops.c:642-646
checks only magic/bsize; ffs_oldfscompat recomputes masks only for
pre-4.4 inode formats, ffs_vfsops.c:812-815 β same validation gap as
DF-0903/DF-3212, but those verified the read/write syscall paths; this is
the directory-lookup sink). With fs_qbmask = 0x1ffff on a stock
8192/1024 filesystem, *res can point up to 128 KB past an 8192-byte
buffer.
Callers dereference and write through *res:
ufs_direnterufs_lookup.c:793-870 β readsep->d_ino/d_reclen/d_namlenfromdirbuf, then bcopy's a new directory entry (attacker-named, ~28+ bytes) atdirbuf + dsizeufs_dirremove/whiteout :941-948 βep->d_ino = UFS_WINO; ep->d_type = DT_WHTufs_dirrewrite:1023-1026 βep->d_ino = newinum; ep->d_type = newtypeufs_dirhashdirhash.c:468 β entry scan throughdp
Reachability (deterministic)
ufs_lookup's search loop (ufs_lookup.c:234-350) finds a "slot" when an
entry has slack (reclen - DIRSIZ β₯ needed); for slotstatus == FOUND it
returns dp->i_offset = slotoffset (:390-405) and ufs_direnter calls
ffs_blkatoff(dvp, dp->i_offset, &dirbuf, &bp) (:793). So a directory
that contains one crafted free-slot entry at a high offset aims the
wild pointer at an attacker-chosen offset & fs_qbmask.
PoC (verified on the stock INVARIANTS guest, X86_64_GENERIC #0)
craft3233 builds it offline from a stock newfs -b 8192 -f 1024 image:
- directory inode (the
mkdir-created/mnt/d) rewritten toi_size = 0x31000(200704), 25 blocks (12 direct + 13 via one indirect), mode 0777; - content: 6256 32-byte entries with
d_reclen == DIRSIZ(zero slack β no mid-directory slot) plus ONE free-slot entry (d_ino = 0,d_reclen = 512) at offset 0x30e00; - superblock
fs_qbmaskpatched0x1fff β 0x1ffff.
Root mounts; maxx (uid 1001) triggers. Expected arithmetic:
lbn(0x30e00) = 24 (β₯ NDADDR β bsize = fs_bsize = 8192, bread of the
real block-24 data), and
dirbuf = b_data + (0x30e00 & 0x1ffff) = b_data + 0x10e00 (+69120)
β 60928 bytes past the 8192-byte buffer
Observed, twice, from two fresh boots (panic.txt, panic.2.txt):
Fatal trap 12: page fault while in kernel mode fault code = supervisor write data, page not present instruction pointer = 0x8:0xffffffff80bca655 Stopped at memset+0xd5: repe stosq %es:(%rdi) β ufs_direnter compaction
- run 1: first
open(O_CREAT|O_EXCL)returnedEEXIST(the OOB state machine consuming wild bytes) and survived; the immediate unprivilegedtouch /mnt/d/zzβ fatal supervisor WRITE inmemset(the direnter entry-copy region). Guest down. - run 2 (fresh boot, identical image): identical outcome, identical faulting
instruction (
memset+0xd5), different fault VA (0xfffff8005c86a000 vs 0xfffff8005a396e00) β the write target tracks buffer-cache KVA layout, exactly as a buffer-adjacent OOB write should.
When the slot lands on mapped neighbour-buffer pages the same code performs a silent OOB read + write of attacker-named dirent bytes into another buffer's data (the first-trigger EEXIST shows the read side consuming wild data); when it lands on the unmapped remainder of the buffer's KVA reservation it is the observed fatal trap.
Preconditions
crafted FFS image + mount (root, or any user with vfs.usermount=1);
trigger is one creat/touch by any user with write permission on the
directory (mode 0777 in the crafted inode).
Fix
Same mount-time geometry gate as DF-3232 (fix.diff): fs_qbmask must
equal ~(int64_t)fs_bmask (and fs_bmask == fs_bsize-1, etc.) or the
mount is rejected β which also closes DF-3190/DF-3212/DF-0903's mask
family at the same checkpoint.
DF-3233 VERDICT
Status: reproduced (impact: kernel memory corruption β OOB read AND
OOB write of attacker-named directory-entry bytes through a wild *res
pointer; demonstrated as a deterministic fatal supervisor-write trap from
an unprivileged touch, twice, two fresh boots).
Root cause (line-accurate)
sys/vfs/ufs/ffs_subr.c:59-83βffs_blkatoffcomputesbsize = blksize(fs, ip, lbn)(fs_bsize / fsize-mask derivation) but returns*res = bp->b_data + blkoff(fs, uoffset)withblkoff = uoffset & fs_qbmask(fs.h:487-488) β an independent, unvalidated superblock field. No bounds check tiesblkofftobsize. Same pattern atffs_subr.c:151(_ra,(int)(uoffset - base_loffset)).sys/vfs/ufs/ffs_vfsops.c:642-646β mount validates only magic/bsize;ffs_oldfscompat(:812-815) recomputesfs_qbmaskonly for pre-4.4 inode formats, so on a modern image the crafted value is used as-is. (Same gap as DF-0903/DF-3212 β but those are theffs_write/ffs_readsyscall paths; this is the directory-lookup / dirent-enter sink insideffs_subr.citself.)sys/vfs/ufs/ufs_lookup.c:390-405β a crafted free-slot entry at offset S makes lookup returndp->i_offset = S,slotstatus = FOUND.sys/vfs/ufs/ufs_lookup.c:793βffs_blkatoff(dvp, S, &dirbuf, &bp);:806-870readep->d_ino/d_reclen/d_namlenfromdirbufandbcopythe new (attacker-named) entry atdirbuf + dsize.
With fs_qbmask = 0x1ffff (stock 0x1fff) and S = 0x30e00:
lbn = 24 β₯ NDADDR β bsize = 8192; dirbuf = b_data + (0x30e00 &
0x1ffff) = b_data + 69120 β 60928 bytes past the buffer.
How reproduced (stock INVARIANTS guest, 2 boots + 1 earlier boot)
craft3233 (pack) builds the image offline: 200704-byte directory of
6256 zero-slack 32-byte entries + one d_ino=0/d_reclen=512 free slot at
0x30e00 (12 direct + 13 indirect blocks, all real data), directory inode
mode 0777, superblock fs_qbmask = 0x1ffff. Root mounts; maxx (uid
1001) triggers.
- Boot 1:
open(O_CREAT|O_EXCL)βEEXIST(wild-pointer state machine consuming OOB bytes β the name does not exist), then unprivilegedtouch /mnt/d/zzβFatal trap 12: supervisor write data, page not presentβStopped at memset+0xd5(fault VA 0xfffff8005c86a000). Guest down. - Boot 2 (fresh
vm.sh reset with-src, identical image): identical sequence, identical faulting instruction (IP 0xffffffff80bca655), fault VA 0xfffff8005a396e00 β target tracks buffer-cache KVA layout, exactly as a neighbour-buffer write throughdirbufshould.
When the slot offset lands on mapped neighbour pages the same code
performs a silent OOB read+write of attacker-named dirent bytes
(the EEXIST run demonstrates the read side consuming wild data without
panicking); on the unmapped remainder of the buffer's MAXBSIZE KVA
reservation it is the observed fatal trap. The landing offset is
attacker-tunable through the free-slot position (any 512-aligned S in
the last indirect block) and through fs_qbmask itself (up to 2^63).
uid0 assessment
The write content is a UFS dirent β d_ino (kernel-allocated), d_reclen,
d_type, and up to 255 attacker-chosen name bytes β written at a
crafted offset past a directory buffer. A deterministic uid0 chain was
NOT completed: the neighbouring KVA slot's occupancy is not reliably
steerable (same blocker as DF-3232/DF-3190 on this guest: per-buffer
MAXBSIZE KVA reservations make cross-buffer placement layout-dependent).
The demonstrated floor is the deterministic supervisor-write fatal trap
(reboot-equivalent DoS) plus the code-proven silent-write path.
Fix
fix.diff (shared with DF-3232): mount-time geometry gate requiring
fs_qbmask == ~(int64_t)fs_bmask (with fs_bmask == fs_bsize-1) for
modern inode formats β the crafted image is rejected at mount.
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 (shared fix.diff: fs_qbmask must equal fs_bsize-1 for modern inode formats): the DF-3233 crafted image (qbmask 0x1ffff) is rejected at mount with 'ffs_mountfs: /mnt: inconsistent fragment geometry' / mount EINVAL (fix_patched.log); the fatal supervisor-write trap is unreachable because the trigger filesystem can no longer mount. Note: the first fix attempt had inverted mask semantics (fs_bmask is the round-DOWN mask ~(bsize-1), fs_qbmask the positive bsize-1) which false-positived the guest's own /boot; corrected and re-validated (guest boots cleanly, stock images mount).
findings/poc/DF-3233/fix_patched.log
Confirmed kernel references
Detail
Exploit chain
crafted image (root mount or vfs.usermount=1) -> unprivileged creat in crafted directory -> ufs_lookup slot FOUND at crafted offset 0x30e00 -> ufs_direnter -> ffs_blkatoff returns b_data+69128 (60928 past 8192-byte buffer) -> ep/nep reads + bcopy/memset writes through wild pointer -> fatal supervisor WRITE (observed) or silent neighbour-buffer corruption when the KVA slot is mapped (code-proven, read side observed via the phantom EEXIST). uid0 chain not completed: victim buffer at the adjacent KVA slot not reliably steerable on this guest (per-buffer MAXBSIZE reservations).
Evidence (decisive lines)
['findings/poc/DF-3233/panic.txt: Fatal trap 12 supervisor write, Stopped at memset+0xd5, current process = touch (run 1)', 'findings/poc/DF-3233/panic.2.txt: identical faulting instruction across a fresh boot (run 2)', 'findings/poc/DF-3233/run.log + run.2.log: EEXIST for a non-existent name (OOB read side), then fatal trap on touch', 'findings/poc/DF-3233/craft3233.c: image builder showing slot math (0x30e00 & 0x1ffff = 0x10e00 -> dirbuf +60928 past buffer)']
PoC changes
n/a (finding + PoC authored together in this pass; two builder fixes during bring-up: cg-relative inode addressing for a cg1 inode, and NBLOCKS round-up so the last directory block is actually written)
Verified recommended fix
Mount-time geometry validation in ffs_mountfs requiring fs_qbmask==fs_bsize-1 (and fs_qfmask==fs_fsize-1, fs_bmask/fs_fmask their complements, fragshift/fshift/bshift consistent) β rejects the crafted image at mount; see fix.diff.
Verdict
Verified on the stock INVARIANTS guest (X86_64_GENERIC #0): a crafted UFS1 image (fs_qbmask 0x1fff->0x1ffff, 200704-byte directory of 6256 zero-slack 32-byte entries plus one d_ino=0/reclen=512 free slot at 0x30e00) mounts cleanly; an unprivileged uid-1001 open(O_CREAT) returns EEXIST for a name that does not exist (the OOB read side consuming wild bytes through res), and the immediately following unprivileged touch /mnt/d/zz produces 'Fatal trap 12: supervisor write data, page not present' stopped at memset+0xd5 inside the ufs_direnter entry-copy path driven by dirbuf = bp->b_data + (0x30e00 & fs_qbmask) = +69120 on an 8192-byte buffer (60928 bytes past its end). Reproduced twice from two fresh vm.sh reset boots with the identical faulting instruction (IP 0xffffffff80bca655) and KVA-dependent fault addresses. Root cause is in-file: ffs_subr.c:80/:151 return res = bp->b_data + (uoffset & fs_qbmask) with no relation enforced between the attacker-controlled fs_qbmask and the blksize()-derived buffer size; mount validates neither (ffs_vfsops.c:642-646). Underlying primitive is an OOB read+write of attacker-named dirent bytes at a crafted offset (slot position and fs_qbmask both attacker-chosen); the demonstrated floor is the deterministic reboot-equivalent fatal trap.
No comments yet.