DragonFlyBSD Kernel Audit
← triage · dashboard
DF-0903

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0903 · 22 files
FileTypeDescriptionSize
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
README.md readme human-readable reproduce + threat model + fix summary
↓ download 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:292blkoffset = blkoff(fs, uio_offset) = offset & fs_qbmask
  • ufs_readwrite.c:293xfersize = fs->fs_bsize - blkoffset (negative if blkoffset > fs_bsize)
  • ufs_readwrite.c:294-295if (uio->uio_resid < xfersize) xfersize = uio->uio_resid; DEFEATS the negative-xfersize chain via size_t/int promotion (uio_resid is size_t, xfersize is int).
  • ffs_balloc.c:91panic("ffs_balloc: blk too big") fires when blkoffset + 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.

VERDICT.md verdict full narrative: partial reproduction, mechanism, fix validation
↓ download raw

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 — superblock fs_qbmask patcher (8-byte LE write at file offset SBOFF + offsetof(struct fs, fs_qbmask) = 8192 + 1336 = 9528).
  • trigger.cpwrite(fd, buf, 16, 10000) against a file on the crafted mount; on #0 this 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.txtffs_balloc: blk too big panic signature from boot.log.
  • fix.diff — git-apply-able unified diff (validated).
  • fix_build.log / fix_run.log — full Phase 8 logs.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 7 06:16:07 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 /boot/kernel/kernel = a7647ebbd146a864ae9140c708ef122fc2669f2fb8fe78e788eb6e79ad7fec46)

Confirmed kernel references

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_inodefmtuio_resid is size_t (sys/sys/_uio.h:69) and xfersize is int (ufs_readwrite.c:220), so line 294 'if (uio->uio_resid < xfersize)' promotes the int to size_t, making the negative xfersize huge, the comparison TRUE, and line 295 CLAMPS xfersize to uio_resid (a small positive) before it can reach uiomove at line 356. The clamped xfersize then trips ffs_balloc's UNCONDITIONAL panic('ffs_balloc: blk too big') at ffs_balloc.c:91 because blkoffset+xfersize=10000+16>fs_bsize. Confirmed empirically on #0 GENERIC: panic signature is exactly 'panic: ffs_balloc: blk too big' with stack ffs_balloc<-vop_balloc<-ffs_write+0x16a<-vop_write<-vn_write. promote_test.c proves the size_t/int promotion. Net impact is kernel PANIC (DoS), NOT a heap OOB write; the finding's CWE-787 classification is incorrect (actual: CWE-20/CWE-754).