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

ffs_read OOB read into adjacent buffer-cache KVA: dead 'xfersize <= 0' guard lets a crafted fs_qbmask drive uiomovebp past the buffer (kernel .text/.rodata + other files' cached pages disclosed to an unprivileged reader)

Summary

In ffs_read(), blkoffset is derived from the attacker-controlled on-disk fs_qbmask (blkoff at fs.h:487-488) while bp->b_bufsize comes from an independent derivation (blksize() via ffs_blkatoff_ra = fs_bsize or the EOF frag-roundup). On a crafted-geometry UFS1 image (fs_qbmask patched 0x1fff->0x7ffff on an 8192-bsize fs; mount validates only magic/bsize at ffs_vfsops.c:642-646 and ffs_oldfscompat re-derives masks only for pre-4.4 inodefmt), blkoffset can exceed b_bufsize so xfersize goes negative. The clamp at :143 compares int against size_t uio_resid: the negative int promotes to a huge size_t, the comparison is TRUE, and xfersize is assigned uio_resid BEFORE the 'xfersize <= 0' panic guard at :147-150 - the guard is dead code for exactly the case it was written for. uiomovebp at :155 then copies attacker-chosen lengths from bp->b_data + blkoffset, up to 512KB past an 8192-byte buffer; every buffer owns a contiguous MAXBSIZE KVA slot so the source lands in neighboring buffers' mapped pages or unmapped KVA. VERIFIED on stock INVARIANTS guest: unpriv pread(2) at valid offsets on the crafted mount returned raw kernel .text (rip-relative code with kernel link-address immediates), kernel .rodata format strings, and other files' cached pages (ngctl string tables) - stable across 3 runs and 2 guest lives - plus EFAULT source-faults at valid offsets; clean-image control returns 15/15 zero reads. Fix validated in-guest (kernel #1): all invalid-geometry preads return EIO, legitimate read unaffected. Distinct sink/primitive from DF-0903 (write side) and DF-3190 (balloc). Read-only disclosure, no uid=0 primitive; preconditions identical to DF-0894/0903/3190 (High).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3212 Β· 17 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 7.5 KB ↓ raw
craft_img.c β€” 2.7 KB view raw
trigger.c β€” 2.8 KB view raw
trigger2.c β€” 2.2 KB view raw
setup.sh β€” 1.4 KB view raw
run.sh β€” 214 B view raw
build.sh β€” 177 B view raw
run.control_and_trig1.log β€” 641 B view raw
run2.trigger2.log β€” 25.1 KB view raw
fix_build.log β€” 5.2 MB ↓ download
fix_patched_mount.log β€” 231 B view raw
fix_patched_run.log β€” 1.1 KB view raw
env.txt β€” 150 B view raw
fix.diff β€” 1.0 KB view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 5.7 KB view raw

DF-3212 β€” ffs_read OOB read into buffer-cache KVA via dead xfersize guard (crafted fs_qbmask)

One line

An unprivileged read(2)/pread(2) at a valid file offset on a crafted-geometry UFS1 mount returns other kernel buffer-cache pages' contents (kernel .text/.rodata from the kernel-image file's cache pages, other files' data β€” e.g. /usr/sbin/ngctl string tables) or EFAULT, because the xfersize <= 0 panic guard in ffs_read() is dead code for exactly the case it exists for: the unsigned size_t promotion at ufs_readwrite.c:143 clamps the negative xfersize up to uio_resid before the guard, and uiomovebp(bp, bp->b_data + blkoffset, ...) then copies from up to fs_qbmask-width bytes past the buffer β€” into adjacent 64 KB buffer-cache KVA slots (sys/kern/vfs_bio.c:640: b_kvabase = vm_map_min(buffer_map) + MAXBSIZE * n).

Where

  • sys/vfs/ufs/ufs_readwrite.c:141-155 (ffs_read xfersize math + dead guard + sink)
  • geometry root cause shared with DF-0903/DF-3190 (unvalidated fs_qbmask, sys/vfs/ufs/ffs_vfsops.c:642-646 validation gap; ffs_oldfscompat re-derives masks only for fs_inodefmt < FS_44INODEFMT, ffs_vfsops.c:802-816) β€” but this is a different sink and a different primitive (read-side info leak vs write-side balloc panic / undersized-buffer overrun).

Reproduce (guest, root then unprivileged)

# setup as root (~1 min):
sh setup.sh          # builds craft_img + trigger, makes clean 8192/1024 UFS image,
                     # creates 1 MB zero file leak.bin, unmounts, patches fs_qbmask
                     # 0x1fff -> 0x7ffff, mounts crafted RW at /mnt/test
                     # (noatime,noclusterr)
# marker (optional root-only content to hunt for in the leak):
awk 'BEGIN{for(i=0;i<1000000;i++)printf "DF3212-ROOT-SECRET-%08d\n",i}' \
    > /root/.df3212_secret && chmod 600 /root/.df3212_secret
cat /root/.df3212_secret > /dev/null     # warm buffer cache

# decisive run as the unprivileged user:
su -m pwn -c /tmp/trigger2 /mnt/test/leak.bin

Expected

  • Control (clean image base.img mounted on /dev/vn1): every pread returns 512 zero bytes (file is all zeros) β€” SUMMARY nonzero=0 clean=15 efault=0.
  • Stock kernel + crafted image: n=-1 errno=14 EFAULT for unmapped adjacent slots AND NON-ZERO KERNEL DATA windows for mapped ones β€” kernel .rodata format strings (ahci_get_err_ccb: ci not 0 (%08x), ...rejected because of the security policy), raw kernel .text (x86-64, rip-relative), and other files' cached pages (ngctl help strings). Stable counts across runs (nonzero=2/10 clean=3 efault=10/22 for trigger/trigger2).
  • Fixed kernel (fix.diff): every crafted-offset pread returns -1 errno=5 (EIO), control still returns zeros, no leak, no panic.

Files

  • craft_img.c β€” superblock fs_qbmask patcher (file offset SBOFF+1336)
  • trigger.c β€” 15-slot single-offset prober (k*0x10000+0x123)
  • trigger2.c β€” 7-slot Γ— 5-page scanner with hexdump + marker search
  • setup.sh / run.sh β€” guest orchestration
  • fix.diff β€” in-file guard (blkoffset >= bp->b_bufsize β†’ EIO)
  • logs: run.control_and_trig1.log, run2.trigger2.log, fix_build.log, fix_baseline_reproduced.log, fix_patched_run.log, env.txt
VERDICT.md
↓ download raw

DF-3212 β€” VERDICT

Bottom line

REPRODUCED β€” kernel memory disclosure (info leak) via an out-of-bounds read in ffs_read() on a crafted-geometry UFS1 mount, triggered by a single unprivileged pread(2) at a valid file offset; fixed-kernel validation passed (surgical guard, no leak, no false positives on clean filesystems).

What was tested and how

Harness (reuses the DF-0903 pack mechanics)

  • setup.sh (root): newfs -b 8192 -f 1024 image, 1 MB all-zero leak.bin created under clean geometry, clean unmount, then craft_img binary-patches fs_qbmask 0x1fff β†’ 0x7ffff (file offset SBOFF+1336) and the image is mounted RW (mount -o noatime,noclusterr). Mount succeeds β€” stock ffs_mountfs validates only magic/bsize bounds (sys/vfs/ufs/ffs_vfsops.c:642-646) and ffs_oldfscompat re-derives masks only for fs_inodefmt < FS_44INODEFMT (ffs_vfsops.c:802-816); modern newfs sets inodefmt=2 (verified in craft output).
  • Trigger: unprivileged user (su -m pwn), pread(512) at offsets k*0x10000 + 0x123 (k=1..15) β€” all valid offsets inside the 1 MB file.

The bug (line-accurate)

sys/vfs/ufs/ufs_readwrite.c:141-155:

blkoffset = blkoff(fs, uio->uio_offset);     /* off & fs_qbmask -> up to 0x7FFFE */
xfersize = bp->b_bufsize - blkoffset;        /* 8192 - 0x50123 -> NEGATIVE      */
if (xfersize > uio->uio_resid)               /* int vs size_t: -330k -> huge    */
    xfersize = uio->uio_resid;               /* clamps to 512 β€” POSITIVE        */
if (xfersize > bytesinfile)                  /* fine                            */
    xfersize = bytesinfile;
if (xfersize <= 0)                           /* DEAD for this case              */
    panic("ufs_readwrite: impossible xfersize");
error = uiomovebp(bp, bp->b_data + blkoffset, xfersize, uio);  /* OOB SOURCE */

bp->b_bufsize comes from ffs_blkatoff_ra() β†’ blksize(fs, ip, lbn) β†’ fs_bsize (sys/vfs/ufs/ffs_subr.c:97-119), while blkoffset comes from the attacker's fs_qbmask β€” two independent derivations never cross-checked. The unsigned promotion at :143 converts the negative xfersize to a huge size_t, making the clamp TRUE and assigning a small positive value before the panic guard at :147 β€” the guard is dead code for exactly the negative case it was written for. uiomovebp then copies 512 bytes from b_data + k*64KB + 0x123. Every buffer owns a contiguous 64 KB KVA slot (sys/kern/vfs_bio.c:640, bp->b_kvabase = vm_map_min(buffer_map) + MAXBSIZE * n), so the source pointer lands in neighboring buffers' mapped pages (mapped β†’ disclosure) or unmapped KVA (β†’ EFAULT from uiomove's on-fault window, kern_subr.c).

Observed (stock kernel #0, INVARIANTS, two guest lives)

  • Control (clean base.img, same read pattern): 15/15 preads β†’ 512 zero bytes (SUMMARY nonzero=0 clean=15 efault=0) β€” impossible-to-misread baseline.
  • Crafted fs, life 1: trigger: nonzero=2 clean=3 efault=10 (identical across 3 runs); trigger2 (7 slots Γ— 5 pages): nonzero=10 clean=3 efault=22 (identical across 3 runs). Leaked windows contain:
  • kernel .rodata format strings: "...e rejected because of the security policy\0\0\0\0QoS AP lacks suffic..." and "%s: ahci_get_err_ccb: ci not 0 (%08x)\n(ap->ap_flags & AP_..." (sys/dev/disk/ahci, sys/netproto/802_11);
  • raw kernel .text β€” x86-64 code with rip-relative addressing and kernel link-address immediates (e.g. 48 8b 25 8a 9f 20 00, 8b 2d 6d 9a 20 00, e8 .. .. .. .. call sequences);
  • other files' cached pages (e.g. /usr/sbin/ngctl help/string tables: "Aliases:", "Summary:", "Rec'd data packet on..."). These page images come from buffer-cache slots adjacent to the trigger buffer β€” kernel-image file pages and unrelated files' data. EFAULT returns at valid offsets are themselves impossible on a clean fs and prove the OOB source fault.
  • Crafted fs, life 2 (fresh vm.sh reset with-src): baseline re-confirmed (SCAN nonzero=7 clean=3 efault=25) before building the fixed kernel.
  • No panic, guest stayed up throughout (dmesg tail clean).

Why this is a distinct finding (not DF-0903 / DF-3190 / DF-2920)

  • DF-0903 (write side): its verified verdict shows ffs_write's negative xfersize is clamped and then intercepted by panic("ffs_balloc: blk too big") (ffs_balloc.c:91) β€” net effect panic/DoS. DF-0903's pack never analyzed the read path; its title even (incorrectly) calls ffs_read's guard a working check. This finding is the read-side leak primitive: no balloc intercept, the OOB copy runs directly inside uiomovebp at ufs_readwrite.c:155.
  • DF-3190 (balloc undersize via qfmask + blkfree panic): different sink (ffs_balloc.c:194, ffs_blkfree_cg:1504). Same class of root cause (unvalidated superblock geometry) β€” DF-3190's mount-time validation fix closes this bug too, which strengthens the case for it; the in-file guard in fix.diff is the defense-in-depth that protects even if a geometry field slips validation.
  • DF-2920 (beyond-EOF VM pages): different mechanism entirely.

Impact

Unprivileged local user β†’ disclosure of kernel buffer-cache contents: whatever pages occupy adjacent buffer slots β€” kernel-image pages (.text/.rodata, useful for defeating any future KASLR-ish mitigation), and cached pages of other users' files (any file recently read by anyone, including root-only files read by root daemons/admin). Also a trivial local DoS flavor (xfersize == 0 sub-case β†’ panic("ufs_readwrite: impossible xfersize") when blkoffset == b_bufsize exactly, e.g. clustered buffers). Precondition: a crafted FFS image must be mounted (root action, or vfs.usermount=1) β€” identical to DF-0894/DF-0903/DF-3190 preconditions. No route to uid=0 from this primitive alone (read-only disclosure), so the escalation objective is documented as not-applicable for this class; the KVA-disclosure primitive itself is fully characterized above.

Fix validation (kernel #1, built in-guest from patched /usr/src)

  • make nativekernel && make installkernel && reboot (fix_build.log, 17087 lines, zero errors, "Kernel install completed").
  • Crafted fs + fixed kernel: every invalid-geometry pread β†’ -1 EIO (14 of 15 in trigger, 35/35 in trigger2 β€” SCAN nonzero=0 clean=0 efault=0 LEAK=0); the one offset whose true blkoffset (0x123) is legitimate (k=8, 0x80123 & 0x7ffff = 0x123) still returns its 512 zero bytes β€” the guard is surgical, not a blanket failure.
  • Clean fs + fixed kernel: SUMMARY nonzero=0 clean=15 efault=0 β€” behavior identical to stock; no false positives.
  • No panic, no leak, guest healthy after all runs.

fix.diff: rejects blkoffset >= bp->b_bufsize with EIO in ffs_read (bp is released by the existing error path at :186-194). Recommended complement: adopt DF-3190's mount-time geometry validation so the whole mask family dies at mount.

Artifacts

README.md (reproduce), craft_img.c, trigger.c, trigger2.c, setup.sh, run.sh, build.sh, run.control_and_trig1.log (stock: control + trigger1), run2.trigger2.log (stock: trigger2 full hexdumps), fix_build.log, fix_patched_mount.log / fix_patched_run.log (fixed kernel runs), env.txt, fix.diff, manifest.json, verdict.json.

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

Kernel rebuilt in-guest with fix.diff (make nativekernel && make installkernel, fix_build.log, zero errors). Patched kernel: crafted-fs preads at all 14/15 invalid-geometry offsets return -1 EIO (guard) and trigger2 35/35 probes return EIO with SCAN nonzero=0 clean=0 efault=0 LEAK=0; the single legitimate-blkoffset offset (k=8: 0x80123 & 0x7ffff = 0x123) still returns 512 zero bytes, proving the guard is surgical. Clean-image control on patched kernel: SUMMARY nonzero=0 clean=15 efault=0 β€” identical to stock, no false positives. No panic, no leak.

['findings/poc/DF-3212/fix_patched_run.log', 'findings/poc/DF-3212/fix_patched_mount.log', 'findings/poc/DF-3212/fix_build.log']
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Sep 8 13:58:18 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3212/run2.trigger2.log β€” full hexdumps: kernel .text (48 8b 25 8a 9f 20 00 rip-relative), ahci_get_err_ccb format string, ngctl string tables; SCAN nonzero=10 clean=3 efault=22', 'findings/poc/DF-3212/run.control_and_trig1.log β€” clean-image control SUMMARY nonzero=0 clean=15 efault=0 vs crafted nonzero=2 efault=10', 'findings/poc/DF-3212/fix_patched_run.log β€” fixed kernel: errno=5 EIO at all invalid offsets, k=8 (legit blkoffset) returns 512', 'findings/poc/DF-3212/fix_build.log β€” kernel #1 build+install, zero errors', 'findings/poc/DF-3212/fix.diff β€” blkoffset >= bp->b_bufsize guard']

PoC changes

No prior seed existed (new pass-2 finding). Harness reuses DF-0903's craft_img mechanics with a wider mask (0x7ffff vs 0x3fff) so blkoffset reaches 7 neighboring 64KB buffer slots; trigger uses pread() at valid offsets (< i_size) and classifies returns (zeros/EFAULT/non-zero) with marker search for a root-only 0600 file; mounts use -o noatime,noclusterr for deterministic b_bufsize == fs_bsize.

Verified recommended fix

In ffs_read, reject impossible geometry before the clamp: if (blkoffset >= bp->b_bufsize) { error = EIO; break; } (see fix.diff); complement with DF-3190's mount-time validation of the whole mask/shift family.

Verdict

REPRODUCED kernel-memory disclosure via OOB read in ffs_read() on a crafted-geometry UFS1 mount. ffs_read computes blkoffset from the on-disk fs_qbmask (loaded verbatim: ffs_vfsops.c:642-646 validates only magic/bsize, ffs_oldfscompat:802-816 re-derives masks only for pre-4.4 inodefmt) while bp->b_bufsize comes from blksize() via ffs_blkatoff_ra (ffs_subr.c:97-119). When the mask is wider than fs_bsize, xfersize = b_bufsize - blkoffset goes negative and the unsigned size_t promotion at ufs_readwrite.c:143 clamps it to a small positive value BEFORE the 'xfersize <= 0' panic guard at :147 (dead code for exactly its intended case), so uiomovebp at :155 copies from b_data + k*64KB + 0x123 into adjacent MAXBSIZE buffer-cache KVA slots (vfs_bio.c:640). Unprivileged pread(2) at valid offsets returned: kernel .rodata format strings (ahci/wlan), raw kernel .text (rip-relative code, kernel link-address immediates), and other files' cached pages (ngctl strings); unmapped neighbors yield EFAULT (also impossible on a clean fs). Deterministic: control on clean image 15/15 zeros; crafted runs stable across 3 runs x 2 guest lives (nonzero=2/10, efault=10/22 for trigger/trigger2). Fix validated in-guest (make nativekernel, kernel #1): invalid-geometry preads -> EIO, legitimate-blkoffset read (0x80123 & 0x7ffff = 0x123) still succeeds, clean-fs control unchanged 15/15 zeros. No panic on this path's leak runs (the xfersize==0 sub-case remains a reachable panic). Not a uid=0 primitive (read-only disclosure); leak fully characterized.