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)
PoC verification
Evidence pack
findings/poc/DF-3212 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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-646validation gap;ffs_oldfscompatre-derives masks only forfs_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.imgmounted 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 EFAULTfor unmapped adjacent slots ANDNON-ZERO KERNEL DATAwindows 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/22for 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β superblockfs_qbmaskpatcher (file offset SBOFF+1336)trigger.cβ 15-slot single-offset prober (k*0x10000+0x123)trigger2.cβ 7-slot Γ 5-page scanner with hexdump + marker searchsetup.sh/run.shβ guest orchestrationfix.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
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 1024image, 1 MB all-zeroleak.bincreated under clean geometry, clean unmount, thencraft_imgbinary-patchesfs_qbmask0x1fff β 0x7ffff (file offset SBOFF+1336) and the image is mounted RW (mount -o noatime,noclusterr). Mount succeeds β stockffs_mountfsvalidates only magic/bsize bounds (sys/vfs/ufs/ffs_vfsops.c:642-646) andffs_oldfscompatre-derives masks only forfs_inodefmt < FS_44INODEFMT(ffs_vfsops.c:802-816); modernnewfssets inodefmt=2 (verified in craft output).- Trigger: unprivileged user (
su -m pwn),pread(512)at offsetsk*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
.rodataformat 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/ngctlhelp/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 insideuiomovebpatufs_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 infix.diffis 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 intrigger, 35/35 intrigger2β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
fixedKernel 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']
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.
No comments yet.