Heap OOB write in ffs_write via crafted fs_qbmask (missing xfersize bounds check present in ffs_read)
Summary
ufs_readwrite.c:292 blkoffset=blkoff(fs,uio_offset)=offset&fs_qbmask. fs_qbmask loaded from disk NO validation (vfsops.c:642-643 only magic+bsize). Crafted fs_qbmask=0x3FFF vs bsize=8192: blkoff(10000)=10000 >fs_bsize. :293 xfersize=fs_bsize-blkoffset=8192-10000=-1808 NEGATIVE. ffs_read:147 has if(xfersize<=0)panic but ffs_write has NO such check. balloc size=blkoffset+xfersize=8192 passes panic check. :356 uiomovebp(bp,bp->b_data+10000,-1808,uio): -1808 as size_t=0xFFFFF8F0. OOB write 1808B past 8192B buffer. Attacker controls offset/content/size. Crafted FFS image mount+write. Heap overflow slab grooming priv-esc.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0903 · 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_img.c | trigger-source | UFS superblock fs_qbmask patcher (8-byte LE write at file offset 9528) | 5.0 KB | view raw |
| trigger.c | trigger-source | pwrite(fd, buf, 16, 10000) against a file on the crafted mount | 2.9 KB | view raw |
| harness.c | trigger-source | deterministic math transcription with accurate line 294-295 size_t clamp model | 7.3 KB | view raw |
| promote_test.c | trigger-source | minimal C program proving size_t/int promotion defeats the OOB chain | 1.6 KB | view raw |
| offsets.c | trigger-source | struct fs field offset printer | 1.4 KB | view raw |
| reproduce.sh | trigger-source | guest-side orchestrator (build, newfs, patch, mount, trigger) | 1.6 KB | view raw |
| build.sh | build-script | host-runnable build entry point | 504 B | view raw |
| run.sh | run-script | host-runnable run entry point (harness + live trigger) | 833 B | view raw |
| build.log | build-log | successful PoC build output | 173 B | view raw |
| run.log | run-log | harness + promote_test + live trigger + panic signature | 6.0 KB | view raw |
| reproduce_live.out | run-log | live trigger output (mount+write -> panic) | 1.1 KB | view raw |
| promote_test.out | run-log | C promotion test output | 355 B | view raw |
| panic.txt | panic-signature | ffs_balloc: blk too big panic excerpt from boot.log | 528 B | view raw |
| panic_full.txt | panic-signature | panic with surrounding boot.log context | 719 B | view raw |
| fix.diff | suggested-fix | validate fs_qbmask/fs_qfmask at ffs_mountfs; reject malformed superblock with EINVAL | 1.4 KB | view raw |
| fix_build.log | build-log | single-fix #1 kernel build output (nativekernel, rc=0) | 5.6 MB | ↓ download |
| fix_run.log | run-log | PoC on #1 patched kernel: mount rejected, no panic, no regression | 1.1 KB | view raw |
| env.txt | environment | uname, cc version, vfs.usermount | 205 B | view raw |
| VERDICT.md | verdict | full narrative: partial reproduction, mechanism, fix validation | 6.6 KB | ↓ raw |
| README.md | readme | human-readable reproduce + threat model + fix summary | 2.7 KB | ↓ raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | ↓ download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0903 — ffs_write panic via crafted fs_qbmask (PARTIAL REPRODUCTION)
Summary
The finding claims a heap OOB write in ffs_write via a crafted fs_qbmask
loaded from disk without validation. The validation gap is real and
reproduced, but the finding's specific OOB-write mechanism (negative
xfersize reaching uiomove as a wrapped size_t) is defeated by a
size_t/int promotion at ufs_readwrite.c:294-295 that clamps the
negative xfersize to a small positive value first. The actual observed
effect is an unconditional panic("ffs_balloc: blk too big") — a kernel
DoS, not a memory-corruption primitive.
Net impact: root→kernel DoS (panic). UFS mount is root-only
(vfs.usermount=0), so this is a hardening gap, not an unpriv LPE.
How to reproduce
Build (host)
./build.sh # builds craft_img + trigger + harness inside the guest
Run (host)
./run.sh # runs the harness (deterministic math) AND the live trigger
Manual reproduction inside the guest (as root)
sh /root/reproduce.sh qbmask3fff
This will:
1. newfs -b 8192 -f 1024 a base image (so normal fs_qbmask=0x1FFF)
2. Patch fs_qbmask to 0x3FFF via craft_img
3. Mount RW
4. pwrite(fd, buf, 16, 10000) to a file on the mount
Expected on unpatched #0 GENERIC: kernel panic
panic: ffs_balloc: blk too big (guest dies).
Expected on patched #1 kernel: mount rejected
(mount_ufs: ... incorrect super block), no panic.
The bug (confirmed in source)
ufs_readwrite.c:292—blkoffset = blkoff(fs, uio_offset)=offset & fs_qbmaskufs_readwrite.c:293—xfersize = fs->fs_bsize - blkoffset(negative ifblkoffset > fs_bsize)ufs_readwrite.c:294-295—if (uio->uio_resid < xfersize) xfersize = uio->uio_resid;DEFEATS the negative-xfersize chain viasize_t/intpromotion (uio_residissize_t,xfersizeisint).ffs_balloc.c:91—panic("ffs_balloc: blk too big")fires whenblkoffset + xfersize > fs_bsize.
Why it is NOT a heap OOB write (as the finding claims)
The finding overlooked that uio->uio_resid is size_t (unsigned). When
the int xfersize is negative, the comparison uio_resid < xfersize
promotes xfersize to a huge size_t, making the comparison TRUE, and
xfersize is clamped to uio_resid (a small positive). The negative
value never reaches uiomove. See promote_test.c and the Conclusion
section of harness.c output.
Fix
fix.diff adds geometry validation at ffs_mountfs mount time. Validated
on a single-fix #1 kernel: crafted image rejected with EINVAL,
legitimate filesystems unaffected.
Threat model
vfs.usermount=0 — root-only mount. This is a root→kernel DoS / hardening
gap, not an unprivileged LPE.
DF-0903 — ffs_write heap OOB via crafted fs_qbmask (PARTIAL REPRODUCTION)
Verdict (one line)
REPRODUCED as kernel PANIC (DoS) — but the finding's specific OOB-write
mechanism is INCORRECT. The crafted fs_qbmask IS loaded verbatim and
drives xfersize negative as claimed, but a size_t/int promotion at
ufs_readwrite.c:294-295 silently clamps the negative xfersize to a
small positive value before it can reach uiomove. The actual observed
effect is an unconditional panic("ffs_balloc: blk too big") at
ffs_balloc.c:91, not a heap OOB write.
Mechanism (what actually happens)
The real validation gap (CONFIRMED)
ffs_vfsops.c:ffs_mountfs reads the on-disk superblock into ump->um_fs
via bcopy at line 673. The only geometry validation is at lines 642-646:
if (fs->fs_magic != FS_MAGIC || fs->fs_bsize > MAXBSIZE ||
fs->fs_bsize < sizeof(struct fs)) {
error = EINVAL;
goto out;
}
fs_qbmask is NOT validated. ffs_oldfscompat (line 796) re-derives
fs_qbmask = ~fs_bmask ONLY inside if (fs->fs_inodefmt < FS_44INODEFMT)
(line 802-816). Modern newfs sets fs_inodefmt = FS_44INODEFMT (=2), so
the recompute is skipped and the on-disk fs_qbmask is used verbatim.
A crafted fs_qbmask larger than fs_bsize-1 is accepted. Confirmed
empirically: craft_img patches fs_qbmask from 0x1FFF to 0x3FFF
(fs_bsize=8192) and the image mounts RW.
What ffs_write actually does with the poisoned mask
At ufs_readwrite.c:290-295:
for (error = 0; uio->uio_resid > 0;) {
lbn = lblkno(fs, uio->uio_offset);
blkoffset = blkoff(fs, uio->uio_offset); /* = offset & 0x3FFF */
xfersize = fs->fs_bsize - blkoffset; /* e.g. 8192-10000 = -1808 */
if (uio->uio_resid < xfersize) /* size_t < int ! */
xfersize = uio->uio_resid; /* clamp */
uio->uio_resid is size_t (unsigned, sys/sys/_uio.h:69).
xfersize is int (signed, ufs_readwrite.c:220). The comparison
size_t < int triggers the C "usual arithmetic conversions": the int is
promoted to size_t, so a NEGATIVE xfersize becomes a huge size_t
((size_t)-1808 = 0xFFFFFFFFFFFFF8F0). The comparison
16 < 0xFFFFFFFFFFFFF8F0 is TRUE, so line 295 clamps xfersize
to uio_resid (16). The negative xfersize never survives to line 356.
Why the finding's OOB-write chain is unreachable
With the clamped xfersize=16 and blkoffset=10000, the call at line 329:
error = VOP_BALLOC(vp, uio->uio_offset, xfersize, ...);
enters ffs_balloc (ffs_balloc.c:88-91):
size = blkoff(fs, ap->a_startoffset) + ap->a_size; /* 10000 + 16 = 10016 */
if (size > fs->fs_bsize)
panic("ffs_balloc: blk too big"); /* 10016 > 8192 -> PANIC */
The unconditional panic (NOT INVARIANTS-gated) fires. Control never
reaches uiomovebp at ufs_readwrite.c:356. Reproduced empirically:
panic: ffs_balloc: blk too big ffs_balloc() at ffs_balloc+0xfff vop_balloc() at vop_balloc+0xaf ffs_write() at ffs_write+0x16a <- the VOP_BALLOC call site vop_write() at vop_write+0x9d vn_write() at vn_write+0x134
The promote_test.c helper proves the C promotion semantics that defeat
the chain:
xfersize (int) = -1808 (size_t)xfersize = 0xfffffffffffff8f0 uio_resid = 16 LINE 294: uio_resid < xfersize is TRUE (size_t promotion) LINE 295: xfersize = uio_resid = 16 xfersize after clamp = 16 (POSITIVE -- OOB path defeated) balloc: blkoffset + xfersize = 10000 + 16 = 10016 > fs_bsize=8192 => PANIC
Threat model
vfs.usermount = 0 on the audit guest (verified). UFS mounts are
root-only. This is a root→kernel hardening gap, NOT an unprivileged
LPE. The realistic impact ceiling is: root can panic the kernel (DoS)
via a crafted filesystem image. The finding's "heap OOB write → slab
grooming → priv-esc" chain is not reachable, and even if it were, root can
already corrupt kernel memory via /dev/mem or kldload, so the
additional impact would be marginal.
Exploit chain
None. Not a memory-corruption primitive on master — the negative
xfersize is clamped by the size_t/int promotion at line 294-295
before it reaches uiomove. The only effect is the unconditional
ffs_balloc: blk too big panic. This is a DoS (root→kernel), not a
write primitive. No escalation attempt is warranted — the cited primitive
does not exist as described.
Fix (validated)
findings/poc/DF-0903/fix.diff adds a geometry validation block in
ffs_vfsops.c:ffs_mountfs immediately after the existing magic/bsize
check (line 646). It rejects the mount with EINVAL if:
- fs_fsize <= 0 or fs_fsize > fs_bsize
- fs_bsize or fs_fsize is not a power of 2
- fs_qbmask != (int64_t)(fs_bsize - 1) (the critical invariant for
blkoff() to stay in [0, fs_bsize))
- fs_qfmask != (int64_t)(fs_fsize - 1)
Built as a single-fix #1 kernel and validated:
- Baseline #0 (unpatched): crafted image mounts RW; pwrite at
offset 10000 → panic: ffs_balloc: blk too big, guest dies.
- Patched #1: crafted image is rejected at mount
(mount_ufs: ... incorrect super block, MOUNT_RC=1); trigger never
runs; guest stays up.
- No regression: a legitimate newfs -b 8192 -f 1024 image still
mounts, accepts writes, and reads back correctly on #1. The guest's
own /boot (bsize=16384) and root filesystem boot normally.
This fix supersedes the finding markdown's proposal (which suggested
adding the missing xfersize <= 0 panic in ffs_write); the mount-time
validation is the cleaner root-cause fix because it rejects the malformed
superblock before any blkoff/xfersize arithmetic can run, and it cannot
be bypassed by also crafting fs_bmask consistently.
PoC files (under findings/poc/DF-0903/)
craft_img.c— superblockfs_qbmaskpatcher (8-byte LE write at file offsetSBOFF + offsetof(struct fs, fs_qbmask) = 8192 + 1336 = 9528).trigger.c—pwrite(fd, buf, 16, 10000)against a file on the crafted mount; on#0this triggers the panic.harness.c— deterministic transcription of the blkoff/xfersize arithmetic with the line 294-295 size_t-promotion clamp modeled accurately. Proves the OOB write is unreachable.promote_test.c— minimal C program demonstrating the size_t/int promotion that defeats the finding's chain.reproduce.sh— guest-side orchestrator (build, newfs, patch, mount, trigger).build.sh/run.sh— host-runnable build/run entry points.panic.txt—ffs_balloc: blk too bigpanic signature fromboot.log.fix.diff— git-apply-able unified diff (validated).fix_build.log/fix_run.log— full Phase 8 logs.
Fix verification
fixedVALIDATED. On the unpatched #0 baseline the crafted image (fs_bsize=8192, patched fs_qbmask=0x3FFF) mounts RW and pwrite at offset 10000 panics with 'ffs_balloc: blk too big' (guest dies, DDB on serial). On the single-fix #1 kernel the same crafted image is REJECTED at mount with 'mount_ufs: /dev/vn0 on /mnt/test: incorrect super block' (MOUNT_RC=1) -- trigger never runs, no panic, guest stays up. Legitimate UFS images (newfs -b 8192 -f 1024) still mount RW, accept writes, and read back correctly on #1, and the guest's own /boot (bsize=16384, qbmask=0x3FFF=bsize-1) and root fs boot normally -> no regression. Fix closes the bug.
BEFORE (#0 baseline, unpatched): MOUNT_RC=0 (crafted image mounts RW); pwrite(fd, buf, 16, 10000) -> panic: ffs_balloc: blk too big -> guest DIES. AFTER (#1 single-fix kernel): mount_ufs: /dev/vn0 on /mnt/test: incorrect super block; MOUNT_RC=1 (mount rejected with EINVAL); trigger never reached; guest stays UP. Legit image: LEGIT_MOUNT_OK + write/read test passed (no regression).
Confirmed kernel references
- sys/vfs/ufs/ufs_readwrite.c:292
- sys/vfs/ufs/ufs_readwrite.c:293
- sys/vfs/ufs/ufs_readwrite.c:294
- sys/vfs/ufs/ufs_readwrite.c:295
- sys/vfs/ufs/ufs_readwrite.c:329
- sys/vfs/ufs/ufs_readwrite.c:356
- sys/vfs/ufs/ffs_balloc.c:89
- sys/vfs/ufs/ffs_balloc.c:90
- sys/vfs/ufs/ffs_balloc.c:91
- sys/vfs/ufs/ffs_vfsops.c:642
- sys/vfs/ufs/ffs_vfsops.c:643
- sys/vfs/ufs/ffs_vfsops.c:644
- sys/vfs/ufs/ffs_vfsops.c:673
- sys/vfs/ufs/ffs_vfsops.c:796
- sys/vfs/ufs/ffs_vfsops.c:802
- sys/vfs/ufs/ffs_vfsops.c:814
- sys/vfs/ufs/ffs_vfsops.c:815
- sys/vfs/ufs/fs.h:277
- sys/vfs/ufs/fs.h:487
- sys/vfs/ufs/fs.h:488
- sys/sys/_uio.h:69
- sys/kern/kern_subr.c:183
Detail
Exploit chain
none. Not a memory-corruption primitive on master: the negative xfersize is clamped by the size_t/int promotion at ufs_readwrite.c:294-295 before reaching uiomove, so the cited heap-OOB-write -> slab-grooming -> priv-esc chain is not reachable. The only effect is the unconditional ffs_balloc: blk too big panic (DoS). Threat model is root->kernel (vfs.usermount=0 verified; UFS mount is root-only) so even a real OOB write would be a hardening gap, not an unpriv LPE. No escalation attempted because the cited primitive does not exist as described; harness.c + promote_test.c demonstrate precisely why. (Non-corruption class: panic DoS only.)
Evidence (decisive lines)
panic: ffs_balloc: blk too big / cpuid = 0 / ffs_balloc() at ffs_balloc+0xfff 0xffffffff80906cff / vop_balloc() at vop_balloc+0xaf 0xffffffff8070b94f / ffs_write() at ffs_write+0x16a 0xffffffff809125aa (VOP_BALLOC call site, line 329) / vop_write() at vop_write+0x9d / vn_write() at vn_write+0x134 / Debugger(panic) / Stopped at Debugger+0x7c. promote_test output: xfersize(int)=-1808, (size_t)xfersize=0xfffffffffffff8f0, uio_resid=16 -> LINE 294 TRUE (size_t promotion) -> LINE 295 xfersize=uio_resid=16 (POSITIVE -- OOB path defeated) -> balloc: 10000+16=10016 > 8192 => PANIC
PoC changes
Created findings/poc/DF-0903/ from scratch. craft_img.c patches the 8-byte fs_qbmask at file offset SBOFF+1336=9528 (offset verified via offsets.c). trigger.c does pwrite(fd,buf,16,10000). harness.c was rewritten mid-run when the first live run revealed the finding's OOB claim was wrong: it now accurately models the line 294-295 size_t/int promotion that defeats the negative-xfersize path, plus the ffs_balloc panic. promote_test.c is a minimal C proof of the promotion semantics. reproduce.sh forces newfs -b 8192 -f 1024 so the crafted 0x3FFF (vs normal 0x1FFF) is genuinely anomalous (default newfs makes bsize=16384 where 0x3FFF IS the normal value). fix.diff was authored twice: the first version used the wrong invariant fs_bmask!=fs_bsize-1 which rejected /boot and dropped the guest to single-user; corrected to fs_qbmask!=(int64_t)(fs_bsize-1) which is the true relationship (verified via dumpfs on /boot: bsize=16384, qbmask=0x3FFF=bsize-1).
Verified recommended fix
fix.diff adds a geometry validation block in ffs_vfsops.c:ffs_mountfs immediately after the existing magic/bsize check (line 646): reject the mount with EINVAL if fs_fsize<=0 or fs_fsize>fs_bsize, if fs_bsize/fs_fsize are not powers of 2, OR if fs_qbmask != (int64_t)(fs_bsize-1) / fs_qfmask != (int64_t)(fs_fsize-1). This is the critical invariant for blkoff() (ufs/fs.h:487-488) to stay in [0,fs_bsize), and cannot be bypassed by also crafting fs_bmask consistently. SUPERSAEDES the finding markdown's proposal (which suggested adding the missing xfersize<=0 panic in ffs_write mirroring ffs_read:147): the mount-time validation is the cleaner root-cause fix because it rejects the malformed superblock before any blkoff/xfersize arithmetic can run. Validated on a single-fix #1 kernel (kern.version 6.5-DEVELOPMENT #1 Tue Jul 7 06:16:07 UTC 2026).
Verdict
PARTIAL REPRODUCTION. The validation gap is REAL and reproduced: crafted fs_qbmask IS loaded verbatim (ffs_vfsops.c:673 bcopy; ffs_oldfscompat only re-derives it when fs_inodefmt
No comments yet.