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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3233 Β· 11 files
FileTypeDescriptionSize
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_direnter ufs_lookup.c:793-870 β€” reads ep->d_ino/d_reclen/d_namlen from dirbuf, then bcopy's a new directory entry (attacker-named, ~28+ bytes) at dirbuf + dsize
  • ufs_dirremove/whiteout :941-948 β€” ep->d_ino = UFS_WINO; ep->d_type = DT_WHT
  • ufs_dirrewrite :1023-1026 β€” ep->d_ino = newinum; ep->d_type = newtype
  • ufs_dirhash dirhash.c:468 β€” entry scan through dp

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 to i_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_qbmask patched 0x1fff β†’ 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) returned EEXIST (the OOB state machine consuming wild bytes) and survived; the immediate unprivileged touch /mnt/d/zz β†’ fatal supervisor WRITE in memset (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.

VERDICT.md
↓ download raw

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_blkatoff computes bsize = blksize(fs, ip, lbn) (fs_bsize / fsize-mask derivation) but returns *res = bp->b_data + blkoff(fs, uoffset) with blkoff = uoffset & fs_qbmask (fs.h:487-488) β€” an independent, unvalidated superblock field. No bounds check ties blkoff to bsize. Same pattern at ffs_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) recomputes fs_qbmask only 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 the ffs_write/ffs_read syscall paths; this is the directory-lookup / dirent-enter sink inside ffs_subr.c itself.)
  • sys/vfs/ufs/ufs_lookup.c:390-405 β€” a crafted free-slot entry at offset S makes lookup return dp->i_offset = S, slotstatus = FOUND.
  • sys/vfs/ufs/ufs_lookup.c:793 β€” ffs_blkatoff(dvp, S, &dirbuf, &bp); :806-870 read ep->d_ino/d_reclen/d_namlen from dirbuf and bcopy the new (attacker-named) entry at dirbuf + 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 unprivileged touch /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 through dirbuf should.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched 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
↓ 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 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.