# 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 `vnconfig`s the
image and `chown`s `/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.
