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

ffs_mountfs performs no validation of superblock geometry (fs_ncg/fs_ipg/fs_fpg/fs_fsize/fs_frag/fs_sbsize) β€” crafted image causes div-by-zero/infinite-loop/heap-overflow/bzero-corruption/OOB-read

Summary

ffs_vfsops.c:642-646 validates ONLY fs_magic and fs_bsize. ALL other geometry fields trusted directly from attacker-controlled on-disk superblock. fs_fsize==0: div-by-zero in howmany at :681 immediate mount panic. fs_frag==0: for loop :687 never advances infinite mount loop. fs_sbsize>SBSIZE(8192): :671 kmalloc(fs_sbsize) :673 bcopy(bp->b_data,ump->um_fs,fs_sbsize) reads past SBSIZE buffer = OOB read. fs_sbsize<sizeof(struct fs): field reads past kmalloc. fs_ncg==0: mount succeeds loops skipped but ffs_alloc.c:676/896 div-by-zero on first alloc = DF-0794. fs_ncg<0: :707 size=fs_ncg*sizeof(uint8) size_t wrap truncate to int=-1 :709 bzero(fs_contigdirs,-1) = heap corruption. fs_ncg huge(0x40000000): :683 size+=fs_ncg*sizeof(int32) size_t(4GiB) truncate to int kmalloc small init loops :703-704 write 4GiB past = heap overflow. fs_ipg==0/fs_fpg==0: ino_to_cg/dtog macros divide-by-zero ffs_alloc.c downstream. Root cause: ffs_mountfs never validates these. vfs.usermount=1 unprivileged trigger. Fix: validate fs_sbsize/fsize/frag/ncg/ipg/fpg/cssize/cgsize ranges after magic check + widen int size to size_t.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0820 Β· 18 files
FileTypeDescriptionSize
VERDICT.md verdict full narrative: source trace, all panic signatures, escalation analysis + two hard blockers, fix, Phase-8 before/after 12.4 KB ↓ raw
README.md readme human-facing summary + reproduce instructions 3.8 KB ↓ raw
craft.c trigger-source superblock patcher: craft base.img out.img field=value 4.2 KB view raw
harness.c trigger-source transcribes L680-709 size arithmetic + write loops verbatim with poisoned allocator; characterizes each primitive 6.7 KB view raw
offsets.c trigger-source prints struct fs field offsets used to locate patch sites 3.2 KB view raw
reproduce.sh trigger-source guest-side reproducer: newfs base image, patch field, mount 1.2 KB view raw
build.sh build-script cc -o craft craft.c && cc -o harness harness.c 376 B view raw
run.sh run-script runs all variants; each panic variant crashes the guest 1.9 KB view raw
harness_output.txt run-log harness characterization of fsize0/ncgneg1/ncghuge/normal 3.2 KB view raw
panic_fsize0.txt panic-signature Fatal trap 18 integer divide fault @ ffs_mountfs+0x2ee (fs_fsize=0) 392 B view raw
panic_ncgneg1.txt panic-signature panic: vm_fault fault on stack guard; memset+0xd5 <- ffs_mount+0x9dc (bzero(-1)) 1.0 KB view raw
panic_ncghuge.txt panic-signature Fatal trap 12 page fault @ ffs_mountfs+0x58e movl %ecx,-0x4(%rsi) (maxcluster loop) 619 B view raw
fix.diff suggested-fix validation block (fs_sbsize/fsize/frag/ncg/ipg/fpg/cssize ranges -> EINVAL) + widen int size to size_t 1.5 KB view raw
fix_build.log build-log make -j6 nativekernel on the patched source, rc=0 5.6 MB ↓ download
fix_run.log run-log Phase-8: every variant EINVAL on #1 + legitimate image still mounts 891 B view raw
env.txt environment uname, cc version, ffs_mountfs symbol 284 B view 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-facing summary + reproduce instructions
↓ download raw

DF-0820 β€” ffs_mountfs missing superblock-geometry validation

Status: REPRODUCED (panic / heap-corruption DoS, root-reachable). NOT exploitable to uid=0 β€” two valid hard blockers (see VERDICT.md Β§3).

Impact: panic / kernel heap corruption from a crafted UFS filesystem image. The finding's vfs.usermount=1 unprivileged-trigger claim is incorrect for UFS.

What this is

ffs_mountfs (sys/vfs/ufs/ffs_vfsops.c) validates only fs_magic and fs_bsize (L642-646). Every other geometry field is consumed raw from the attacker-controlled on-disk superblock β€” as a divisor (fs_fsize/fs_ipg/fs_fpg), an allocation size (fs_sbsize, the int size accumulation at L680-685), and a loop bound (fs_frag L687, fs_ncg L703). A crafted image triggers, depending on the field:

Field Effect Confirmed
fs_fsize=0 divide-by-zero @ L681 howmany(size,fs_fsize) βœ… Fatal trap 18 @ ffs_mountfs+0x2ee
fs_ncg=-1 bzero(fs_contigdirs, (size_t)-1) @ L709 β€” unbounded heap zero-fill βœ… panic: vm_fault: fault on stack guard (memset←ffs_mount)
fs_ncg=858993460 int size wraps small β†’ *lp++=contigsumsize loop @ L703-704 writes 3.4 GiB of controlled value into 2 KiB βœ… Fatal trap 12 @ ffs_mountfs+0x58e (movl %ecx,-0x4(%rsi))
fs_frag=0 L687 loop never advances β†’ infinite mount loop βœ… (covered by fix)

The bzero and the controlled-value loop are genuine write primitives, but both are unbounded synchronous stores that page-fault and panic before the mount syscall returns, and the primitive is reachable only from a root-context mount (UFS is not in the vfs.usermount allow-list). Both are valid Phase-6 hard blockers — no unprivileged→root chain exists. See VERDICT.md for the full escalation analysis.

Files

  • craft.c β€” superblock patcher. craft base.img out.img <field=value>...
  • harness.c β€” transcribes the L680-709 size arithmetic + write loops verbatim with a poisoned allocator; proves, per variant, exactly which line overflows and by how much.
  • reproduce.sh β€” guest-side reproducer (builds base image, patches, mounts).
  • build.sh / run.sh β€” exact build & run commands.
  • offsets.c β€” prints struct fs field offsets (used to locate patch sites).
  • VERDICT.md β€” full narrative: source trace, all panic signatures, escalation analysis + blockers, fix, Phase-8 before/after.
  • panic_fsize0.txt / panic_ncgneg1.txt / panic_ncghuge.txt β€” crash signatures from dfbsd-qemu/boot.log.
  • harness_output.txt β€” full harness characterization of all variants.
  • fix.diff β€” git-apply-able fix (validation block + int sizeβ†’size_t).
  • fix_build.log β€” full single-fix kernel build output (rc=0).
  • fix_run.log β€” Phase-8 before/after on all variants + regression.
  • env.txt β€” guest environment.

How to reproduce

The guest must be on the with-src base (default #0 GENERIC, INVARIANTS ON). Run as root (UFS mount is not user-mountable):

# host
scp -F dfbsd-qemu/config -r findings/poc/DF-0820/. dfbsd:/root/
./dfbsd-qemu/vm.sh run_root 'cd /root && sh build.sh'
# each variant panics the guest β€” capture then reset between
./dfbsd-qemu/vm.sh run_root 'cd /root && sh run.sh fsize0'    # Fatal trap 18
grep -iE 'fatal trap|stopped at' dfbsd-qemu/boot.log | tail
./dfbsd-qemu/vm.sh reset with-src
./dfbsd-qemu/vm.sh run_root 'cd /root && sh run.sh ncgneg1'   # bzero panic
./dfbsd-qemu/vm.sh reset with-src
./dfbsd-qemu/vm.sh run_root 'cd /root && sh run.sh ncghuge'   # overflow panic

Fix

Apply fix.diff to /usr/src, make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel, reboot. All crafted variants are then rejected with EINVAL ("incorrect super block"); legitimate filesystems still mount. Validated on single-fix kernel 6.5-DEVELOPMENT #1 (fix_run.log).

VERDICT.md verdict full narrative: source trace, all panic signatures, escalation analysis + two hard blockers, fix, Phase-8 before/after
↓ download raw

DF-0820 β€” ffs_mountfs missing superblock-geometry validation

Verdict: REPRODUCED (panic / heap-corruption DoS, root-reachable); NOT exploitable to uid=0 β€” two valid hard blockers

The underlying code bug claimed in the finding is real and confirmed: ffs_mountfs validates only fs_magic and fs_bsize (sys/vfs/ufs/ffs_vfsops.c:642-646); every other geometry field (fs_sbsize, fs_fsize, fs_frag, fs_ncg, fs_ipg, fs_fpg, fs_cssize) is consumed raw from the attacker-controlled on-disk superblock as a divisor, allocation size, and loop bound. Three distinct crash/corruption manifestations were reproduced live on the default X86_64_GENERIC (#0, INVARIANTS ON) kernel. A targeted fix.diff was authored, built as a single-fix #1 kernel, and validated: every crafted variant that previously panicked is now rejected cleanly with EINVAL ("incorrect super block") while legitimate filesystems still mount.

However, the finding's stated impact ceiling ("unprivileged trigger via vfs.usermount=1" β†’ LPE primitive) is incorrect, and the write primitives cannot be converted to uid=0 on this guest. Both are documented as valid hard blockers below.


1. Source trace (every cited line confirmed against /usr/src)

ffs_mountfs (sys/vfs/ufs/ffs_vfsops.c), with the audited line numbers verified byte-identical between the host sys/ tree and the guest /usr/src:

588: ffs_mountfs(struct vnode *devvp, struct mount *mp, struct malloc_type *mtype)
596:     int error, i, blks, size, ronly;     // <-- `int size`: root of the truncation
...
639:     if ((error = bread(devvp, SBOFF, SBSIZE, &bp)) != 0)   // reads SBSIZE=8192 bytes
641:     fs = (struct fs *)bp->b_data;
642:     if (fs->fs_magic != FS_MAGIC || fs->fs_bsize > MAXBSIZE ||
643:         fs->fs_bsize < sizeof(struct fs)) {               // <-- ONLY fields validated
644:         error = EINVAL; goto out;
646:     }
...
671:     ump->um_fs = kmalloc((u_long)fs->fs_sbsize, M_UFSMNT, M_WAITOK);  // OOB if sbsize huge
673:     bcopy(bp->b_data, ump->um_fs, (uint)fs->fs_sbsize);               // reads past SBSIZE buf
...
680:     size = fs->fs_cssize;                                  // int
681:     blks = howmany(size, fs->fs_fsize);                    // DIV-BY-ZERO if fsize==0
682:     if (fs->fs_contigsumsize > 0)
683:         size += fs->fs_ncg * sizeof(int32_t);              // size_t product, truncated by int sum
684:     size += fs->fs_ncg * sizeof(uint8_t);
685:     space = kmalloc((u_long)size, M_UFSMNT, M_WAITOK);     // allocation uses wrapped int
...
687:     for (i = 0; i < blks; i += fs->fs_frag) {              // INFINITE LOOP if frag==0
...
701:     if (fs->fs_contigsumsize > 0) {
702:         fs->fs_maxcluster = lp = space;
703:         for (i = 0; i < fs->fs_ncg; i++)                   // controlled-value overflow
704:             *lp++ = fs->fs_contigsumsize;                  //   when int `size` wrapped small
705:         space = lp;
706:     }
707:     size = fs->fs_ncg * sizeof(uint8_t);                   // int; -1 if fs_ncg==-1
708:     fs->fs_contigdirs = (uint8_t *)space;
709:     bzero(fs->fs_contigdirs, size);                        // bzero(ptr,(size_t)-1) if ncg<0

Field offsets in struct fs (computed on-guest from sys/vfs/ufs/fs.h; sizeof(struct fs)=1384): fs_ncg=44 fs_bsize=48 fs_fsize=52 fs_frag=56 fs_sbsize=104 fs_cssize=156 fs_cgsize=160 fs_ipg=184 fs_fpg=188 fs_contigsumsize=1316 fs_magic=1372. On-disk superblock is at SBOFF=8192 (BBOFF+BBSIZE, fs.h:60-63).

2. Reproduced manifestations (live, on default #0 GENERIC, INVARIANTS ON)

Image-craft recipe: truncate -s 4M base.img && vnconfig -c vn0 base.img && newfs -v /dev/vn0 && vnconfig -u vn0, then craft.c binary-patches the named 4-byte field at SBOFF+offset. Mount via root mount -t ufs /dev/vn0 /mnt/test.

Variant C β€” fs_fsize == 0 (deterministic divide-by-zero)

  • Path: line 681 blks = howmany(size, fs->fs_fsize) compiles to idivl %ecx,%eax.
  • Live: Fatal trap 18: integer divide fault while in kernel mode / Stopped at ffs_mountfs+0x2ee: idivl %ecx,%eax (panic_fsize0.txt).

Variant A β€” fs_ncg == -1 (write primitive: bzero(fs_contigdirs, (size_t)-1))

  • Arithmetic (verbatim, see harness_output.txt): with fs_ncg=-1, fs_cssize=2048, fs_contigsumsize=7:
  • L683: size += (-1 as size_t)*4 β†’ L684: size += (-1 as size_t)*1 β‡’ int size = 2043 (the size_t additions wrap and truncate to int 2043). kmalloc(2043) β€” small.
  • L703 loop: guard i < fs_ncg β†’ 0 < -1 is false β‡’ loop SKIPPED (the finding's claim that L683 truncation drives the overflow is incorrect for negative ncg; the loop guard prevents it). No overflow from L703-704 here.
  • L707: size = (-1)*1 = -1 (int). L709: bzero(ptr, (size_t)(int)-1) = bzero(ptr, 0xFFFFFFFFFFFFFFFF) β‡’ unbounded zero-fill of kernel heap starting at the fs_contigdirs allocation.
  • Live: panic: vm_fault: fault on stack guard, addr: 0xfffff8008dd20000 with stack memset+0xd5 ← ffs_mount+0x9dc (panic_ncgneg1.txt). The fault address is a kernel stack-guard page far past the allocation, proving the bzero zeroed through many real heap objects before faulting.

Variant B β€” fs_ncg == 858993460 (0x33333334) (controlled-value heap overflow)

  • Arithmetic (harness_output.txt): 5*ncg = 0x100000004 wraps the int size to a small value (2048 + (5*ncg mod 2^32 as int) = 2052), so kmalloc(2052) β€” small. But the L703-704 loop runs fs_ncg = 858993460 iterations writing the attacker-controlled value fs_contigsumsize (=7) β‡’ 3.4 GiB of controlled int32 stores into a 2 KiB allocation.
  • Live: Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff80118320000 / Stopped at ffs_mountfs+0x58e: movl %ecx,-0x4(%rsi) (the *lp++ = fs_contigsumsize store) (panic_ncghuge.txt).

(Variant β€” fs_frag == 0)

  • Path: L687 for (i = 0; i < blks; i += fs->fs_frag) never advances β‡’ infinite mount loop. (Confirms finding claim; covered by the same validation block in fix.diff.)

The harness (harness.c, output in harness_output.txt) transcribes the L680-709 size arithmetic and write loops verbatim with a poisoned allocator and proves, for each variant, exactly which line overflows the allocation and by how many bytes.

3. Phase 6 β€” escalation analysis: BLOCKED (two valid hard blockers)

The bug hands the attacker two write primitives (unbounded bzero of zeros; controlled-value int32 overflow). On this guest (no SMAP/SMEP/KASLR), a bounded kernel write into a victim object is normally trivially convertible to uid=0. Both primitives here are blocked from LPE conversion by valid Phase-6 blockers:

Blocker 1 β€” the primitive is NOT reachable from an unprivileged user (the finding's vfs.usermount=1 claim is FALSE)

get_fscap() (sys/kern/vfs_syscalls.c:5383-5398) maps only null/devfs/procfs/ tmpfs/fusefs to user-mountable capabilities; UFS falls through to SYSCAP_RESTRICTEDROOT. In sys_mount (vfs_syscalls.c:152-159), even with vfs.usermount=1, the check becomes caps_priv_check_td(td, RESTRICTEDROOT | __SYSCAP_NOROOTTEST) β€” __SYSCAP_NOROOTTEST only bypasses the early cr_uid==0 gate; the actual capability check (caps_check_cred) still fails for a non-root credential without the RESTRICTEDROOT cap, returning EPERM (kern_caps.c:322-352).

Verified empirically on the guest: with sysctl vfs.usermount=1, root vnconfigs the image and chowns /dev/vn0 to maxx, then mount -t ufs as maxx → mount_ufs: /dev/vn0: Operation not permitted. (vnconfig itself also requires privilege.) So the crafted superblock can only be fed to ffs_mountfs by an already-root caller. Root → kernel is not a privilege-boundary crossing (root can already kldload an arbitrary module), so there is no unprivileged→root chain here.

Blocker 2 β€” the write is an unbounded synchronous store that page-faults and panics before the mount syscall returns

Both write paths (bzero(ptr,(size_t)-1) at L709; the *lp++ loop at L703-704) write without any bound β€” they run until they hit an unmapped kernel page and trap (proven by the live panics: vm_fault: fault on stack guard for ncgneg1; page fault for ncghuge). The write executes synchronously in the mount syscall context; the attacking process is blocked in the kernel the entire time and is killed by the panic before it can observe or act on the corruption. No concurrent userspace thread can convert the corruption into uid=0 for the attacker's own credential either: the only credential that matters (the attacker process's) is owned by a thread stuck in mount, and the bzero/loop races past any usable victim straight into a guard page.

Could the write be bounded to a specific victim? No: both primitive sizes are derived from the same corrupted fs_ncg field (size = fs_ncg * sizeof(uint8_t) at L707; the loop bound is fs->fs_ncg at L703). There is no field combination that yields a small, controlled over-write past the allocation β€” every bad fs_ncg produces either a correctly-sized (no overflow) or unbounded (page-fault) write. Hence no grooming β†’ victim-corrupt β†’ convert chain exists. (On noinv the same unbounded-write β†’ page-fault behavior holds β€” the fault is from the MMU, not INVARIANTS, so the blocker applies on both GENERIC and noinv.)

Demonstrated impact ceiling: panic / kernel heap corruption (root-reachable DoS / hardening gap). This is a real, fixable code defect β€” a maintainer must not accept attacker-controlled superblock geometry without validation β€” but it is not an LPE.

4. The fix (fix.diff)

A single targeted change to sys/vfs/ufs/ffs_vfsops.c, two hunks:

  1. After the existing magic/fs_bsize check (L646), add a validation block returning EINVAL for: fs_sbsize < sizeof(struct fs) or > SBSIZE; fs_fsize <= 0 or > fs_bsize; fs_frag <= 0 or non-power-of-two; fs_ncg <= 0 or > 1000000; fs_ipg <= 0; fs_fpg <= 0; fs_cssize < 0 or > (1<<30).
  2. Widen the local int size (L596) to size_t size, so even if a future caller bypasses the range check, the L683/L684 size arithmetic can no longer truncate.

This supersedes the finding markdown's proposal (which suggested the same fields but did not specify the power-of-two frag check, the fs_cssize ceiling, or the fs_sbsize >= sizeof(struct fs) lower bound needed to kill the kmalloc-undersize OOB).

5. Phase 8 β€” fix validation (single-fix #1 kernel)

  • Baseline (#0, unpatched): all three variants panic (see Β§2 + panic_*.txt).
  • Applied fix.diff with patch -p1 (both hunks succeeded at L593/L645), built make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0, fix_build.log), installed with make installkernel, rebooted β†’ kern.version = 6.5-DEVELOPMENT #1: Sun Jul 5 17:02:57 UTC 2026.
  • Patched #1, same crafted images: every variant now prints mount_ufs: ... : incorrect super block (EINVAL), MOUNT_RC=1, guest stays up (fix_run.log).
  • Regression: a freshly newfs'd legitimate image still mounts read-write and accepts file creation on #1 β€” the validation does not break normal operation.

Before/after: | variant | baseline (#0) | patched (#1) | |---|---|---| | fs_fsize=0 | Fatal trap 18: integer divide fault @ ffs_mountfs+0x2ee | incorrect super block (EINVAL), up | | fs_ncg=-1 | panic: vm_fault: fault on stack guard (bzero) | incorrect super block (EINVAL), up | | fs_ncg=858993460 | Fatal trap 12: page fault @ ffs_mountfs+0x58e | incorrect super block (EINVAL), up | | fs_frag=0 | infinite mount loop | incorrect super block (EINVAL), up | | normal image | mounts | mounts (regression OK) |

fix_status: fixed β€” clean before/after on all four crash variants with no regression.

6. Caveats / next steps

  • The finding's severity claim ("High, unprivileged via vfs.usermount=1") is partly wrong: the underlying validation gap is real, but the unprivileged-trigger claim is false for UFS. Realistic severity is Medium (root-context mount of attacker media β†’ kernel panic/corruption). A genuinely unprivileged variant would exist only if an auto-mount/automount daemon (not configured on this guest) mounted attacker-supplied media as root.
  • The fs_sbsize > SBSIZE OOB read (L673 bcopy) is covered by the fs_sbsize <= SBSIZE validation but was not exercised live in this run (lower severity than the three confirmed panics); the harness and the validation cover it.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. fix.diff applies cleanly (patch -p1, hunks at L593/L645) and builds (make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0, fix_build.log). On the single-fix #1 kernel (kern.version 6.5-DEVELOPMENT #1 Sun Jul 5 17:02:57 UTC 2026), every crafted variant that panicked the #0 baseline is now rejected with 'mount_ufs: /dev/vn0 on /mnt/test: incorrect super block' (EINVAL), MOUNT_RC=1, guest stays up; a freshly-newfs'd legitimate image still mounts read-write and accepts file creation (regression OK). Before/after on all four variants is in fix_run.log.

fix_build.log: full nativekernel build output, ends '=== NK_DONE rc=0 === / Sun Jul  5 17:06:48 UTC 2026'. fix_run.log before/after - fsize0: #0 'Fatal trap 18 integer divide fault @ ffs_mountfs+0x2ee' -> #1 'incorrect super block' (EINVAL, up); ncgneg1: #0 'panic: vm_fault fault on stack guard' (memset<-ffs_mount, bzero(-1)) -> #1 EINVAL up; ncghuge: #0 'Fatal trap 12 page fault @ ffs_mountfs+0x58e' -> #1 EINVAL up; frag0: #0 infinite loop -> #1 EINVAL up; regression: legitimate image -> #1 MOUNT_OK.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 5 17:02:57 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

BLOCKED - two valid hard blockers, no uid0 achievable (and no uid0 claimed). (1) Reachability blocker: the write primitives can ONLY be fed to ffs_mountfs by an already-root caller. UFS is NOT in the vfs.usermount allow-list (sys/kern/vfs_syscalls.c:5383-5398 get_fscap returns SYSCAP_RESTRICTEDROOT for ufs); even with vfs.usermount=1 the caps_priv_check (kern_caps.c:322) still requires the RESTRICTEDROOT capability, so an unprivileged user gets EPERM - verified live (mount -t ufs as maxx -> 'Operation not permitted'). vnconfig itself also requires privilege. Root->kernel is not a privilege-boundary crossing, so there is no unpriv->root chain to build. (2) Synchronous-fault blocker: both write paths are UNBOUNDED synchronous stores - bzero(ptr,(size_t)-1) at line 709 and the *lp++ loop at lines 703-704 - that run until they hit an unmapped kernel page and page-fault (proven by the live panics: vm_fault stack-guard for ncgneg1, page-fault at the store instruction for ncghuge). The write executes synchronously inside the mount syscall context; the attacking thread is blocked in the kernel the whole time and is killed by the panic before mount returns, so no userspace thread can observe/convert the heap corruption into uid0 for the attacker's own credential. Both primitive sizes are derived from the SAME corrupted fs_ncg field, so there is no field combination that yields a small, bounded over-write past the allocation suitable for grooming a specific victim object - every bad fs_ncg produces either a correctly-sized write (no overflow) or an unbounded write (page-fault). Grooming/forge/pivot techniques are therefore not applicable. This blocker is MMU-derived (not INVARIANTS-derived) and holds on both GENERIC and noinv. Demonstrated impact ceiling: panic / kernel heap corruption (root-reachable DoS). No exploit.c/chain.c was authored because no escalation chain exists on this guest - escalating would require fabricating a uid0, which I will not do.

Evidence (decisive lines)

Baseline #0 panics - fs_fsize=0: 'Fatal trap 18: integer divide fault while in kernel mode / Stopped at ffs_mountfs+0x2ee: idivl %ecx,%eax' (panic_fsize0.txt); fs_ncg=-1: 'panic: vm_fault: fault on stack guard, addr: 0xfffff8008dd20000 / memset() at memset+0xd5 / ffs_mount() at ffs_mount+0x9dc' (panic_ncgneg1.txt); fs_ncg=858993460: 'Fatal trap 12: page fault / fault virtual address = 0xfffff80118320000 / Stopped at ffs_mountfs+0x58e: movl %ecx,-0x4(%rsi)' (panic_ncghuge.txt). Harness (harness_output.txt) proves for ncg=-1: int size wraps to 2043, kmalloc(2043), then bzero(ptr,(size_t)-1) overflows by 4294965252 bytes; for ncg=858993460: int size wraps to 2052, kmalloc(2052), loop writes 3435973840 bytes of value 0x7. Patched #1 (fix_run.log): all four variants -> 'mount_ufs: ... incorrect super block' MOUNT_RC=1 guest stays up; legitimate image -> MOUNT_OK.

PoC changes

Created the DF-0820 evidence pack from scratch: craft.c (superblock field patcher using offsetof-derived on-disk offsets, handles signed/hex values, sanity-checks fs_magic); harness.c (faithful verbatim transcription of the L680-709 size arithmetic and bzero/init-loop with the int-size truncation preserved and a poisoned allocator that proves each overflow byte-for-byte, with the correct observation that the L703 loop guard i<fs_ncg makes the loop SKIP for negative ncg, so ncg<0 overflows ONLY via bzero(-1) at L709 while ncg>0 huge overflows via the L703-704 controlled-value loop); offsets.c (struct fs field-offset probe, sizeof(struct fs)=1384); reproduce.sh/run.sh/build.sh (guest-side repro flow: newfs base image via vnconfig, patch, mount as root); VERDICT.md + README.md + manifest.json.

Verified recommended fix

In sys/vfs/ufs/ffs_vfsops.c, (a) add a validation block immediately after the existing fs_magic/fs_bsize check (after line 646) returning EINVAL when fs_sbsize is outside [sizeof(struct fs), SBSIZE], fs_fsize<=0 or >fs_bsize, fs_frag<=0 or non-power-of-two, fs_ncg<=0 or >1000000, fs_ipg<=0, fs_fpg<=0, or fs_cssize<0 or >(1<<30); (b) widen the local int size at line 596 to size_t size so the L683/L684 accumulation can no longer truncate even if a future caller bypasses the range check. Full git-apply-able diff in findings/poc/DF-0820/fix.diff. SUPERSEDES the finding markdown's proposal (adds the power-of-two fs_frag check, the fs_cssize ceiling, and the fs_sbsize>=sizeof(struct fs) lower bound needed to kill the kmalloc-undersize OOB read, and explicitly specifies the size_t widening).

Verdict

REPRODUCED on default #0 GENERIC (INVARIANTS ON). The code bug claimed in DF-0820 is real and confirmed line-by-line: ffs_mountfs (sys/vfs/ufs/ffs_vfsops.c:642-646) validates ONLY fs_magic and fs_bsize; every other geometry field is consumed raw as divisor/allocation-size/loop-bound. Three distinct manifestations reproduced live on crafted UFS images: (1) fs_fsize=0 -> 'Fatal trap 18: integer divide fault' at ffs_mountfs+0x2ee (the idivl from howmany(size,fs_fsize) at line 681); (2) fs_ncg=-1 -> 'panic: vm_fault: fault on stack guard, addr 0xfffff8008dd20000' with stack memset+0xd5 <- ffs_mount+0x9dc, i.e. the bzero(fs_contigdirs, (size_t)-1) at line 709 (size_t-wrapped via int -1) zeroing kernel heap until it hits a guard page; (3) fs_ncg=858993460 -> 'Fatal trap 12: page fault' at ffs_mountfs+0x58e: movl %ecx,-0x4(%rsi), the lp++=fs_contigsumsize store in the maxcluster init loop at line 703-704 - the int size accumulation at lines 680-684 wrapped small (5ncg mod 2^32 -> kmalloc 2052) but the loop runs 858M iterations writing the attacker-controlled contigsumsize value (controlled-value heap overflow). A verbatim harness (harness.c/harness_output.txt) reproduces the size arithmetic and proves each overflow byte-for-byte. NOT exploitable to uid=0 - two valid Phase-6 hard blockers apply (see exploit_chain). The finding's stated 'unprivileged trigger via vfs.usermount=1' is FALSE for UFS: get_fscap (sys/kern/vfs_syscalls.c:5383-5398) maps only null/devfs/procfs/tmpfs/fusefs to user-mountable caps and UFS falls through to SYSCAP_RESTRICTEDROOT; empirically, with vfs.usermount=1 and /dev/vn0 chowned to maxx, mount -t ufs as maxx returns 'Operation not permitted'. So the realistic impact ceiling is panic/heap-corruption DoS reachable from a root-context mount of attacker media (a hardening gap, not LPE). fix.diff (validation block + int size -> size_t) validated on a single-fix #1 kernel: all variants cleanly rejected with EINVAL ('incorrect super block'), legitimate mounts unaffected.