# DF-2586 — Verification verdict

**Finding:** Integer underflow in `sysctl_kern_msgbuf` 3rd branch causes a kernel
OOB read via `copyout` (sys/kern/subr_prf.c:1177-1184).

**Verdict:** **REPRODUCED** — the buggy length math is real and produces a
kernel OOB read (proven by panic). **The unprivileged-reachability claim is
incorrect**: the OOB-underflow geometry requires a stale `msg_bufr`, reachable
only after root writes `kern.msgbuf_clear=1` (wheel-only). In normal operation
the same bug is a benign 2048-byte under-read with no leak.

> DF-2586 is the same defect as DF-0035 (identical file, lines, mechanism, fix).
> This run reproduces and validates it independently.

## 1. The bug is real in source

`sys/kern/subr_prf.c:1177-1184` (third branch of `sysctl_kern_msgbuf`):

```c
} else if (n <= mbp->msg_size - rindex_modulo) {
    /* Can handle in one linear section. */
    error = sysctl_handle_opaque(oidp,
                                 mbp->msg_ptr + rindex_modulo,
                                 n - rindex_modulo,     /* BUG: should be n */
                                 req);
}
```

The valid data length here is `n` (as branches 1 and 4 correctly use); passing
`n - rindex_modulo` mixes a byte count with a buffer offset. Because both are
`u_int`, the subtraction silently wraps to a ~4 GiB value when
`rindex_modulo > n`. That huge length reaches `sysctl_old_user`
(`sys/kern/kern_sysctl.c`), which clips it to `req->oldlen` and then `copyout`s
up to `oldlen` bytes from `msg_ptr + rindex_modulo` — a read that runs past
`msg_ptr + msg_size` into adjacent kernel memory.

Branch-3 condition: `rindex_modulo > xindex_modulo` AND `n <= msg_size - rindex_modulo`
⟺ `rindex_modulo + n <= msg_size`. In the wrap case (`xindex_modulo == 0`),
`rindex_modulo + n == msg_size`, so the correct length is `n`. The buggy value
`n - rindex_modulo = msg_size - 2*rindex_modulo` underflows when
`rindex_modulo > msg_size/2`.

## 2. Decisive empirical proof — kernel panic (this session, #0 baseline)

`msgbuf_oob_decisive.c` (root-only) uses `kvm_write` to place `msg_bufx` and
`msg_bufr` in the exact geometry that the natural post-`msgbuf_clear` path
produces (`msg_bufx = msg_size`, `msg_bufr = msg_size/2 + 100000` — so
`xindex_modulo==0`, `rindex_modulo = 624272 > msg_size/2 = 524272`, `n = 424272`),
then issues a single `sysctlbyname("kern.msgbuf", buf, 1MiB_oldlen)`. The kernel
**panics** (captured from `dfbsd-qemu/boot.log`):

```
panic: assertion "obj != NULL" failed in vm_object_hold_shared at /usr/src/sys/vm/vm_object.c:330
vm_object_hold_shared() ... vm_fault() ... trap_pfault() ... trap() ... calltrap()
--- trap 0xc, rip=ffffffff80bcaeaa, rsp=..., rbp=... ---
std_copyout() at std_copyout+0x15a 0xffffffff80bcaeaa
```

`trap 0xc` is a page fault raised inside the `copyout` source-side read (walking
off the msgbuf's mapped pages into adjacent unmapped kernel memory). Had the
adjacent memory been mapped, the same OOB read would have leaked kernel-heap
residue to userspace instead of crashing. This is decisive proof that the buggy
branch-3 length math produces an OOB read.

The `kvm_write` does not change the bug execution — it only shortcuts the
state-setup that the natural path also produces (`msgbuf_clear` sets
`msg_bufr := msg_bufx`; subsequent logging then advances `msg_bufx` to the next
`msg_size` boundary). When the kernel runs `sysctl_kern_msgbuf` in that state,
branch 3 executes identically and the underflow happens.

## 3. Why the finding's threat model is wrong (unreachable from unprivileged)

`msg_bufr` is only ever modified in two places (`sys/kern/subr_prf.c`):

- `msgaddchar` (~line 1070): bumps `msg_bufr` to `xindex - msg_size + 2048`
  **only when** `n = xindex - msg_bufr > msg_size - 1024`. So in steady state
  `msg_bufr ≈ msg_bufx - msg_size + 2048`, i.e. `rindex_modulo = 2048` and
  `n = msg_size - 2048`.
- `sysctl_kern_msgbuf_clear` (line 1214): sets `msg_bufr := msg_bufx` — a write
  that requires root (`kern.msgbuf_clear` rejects non-wheel users; verified this
  session: `sysctl kern.msgbuf_clear=1` as `maxx` → EPERM).

In steady-state geometry, branch 3 fires only when `xindex_modulo == 0`. At that
moment `rindex_modulo = 2048` and `n = msg_size - 2048`, so the buggy
`n - rindex_modulo = msg_size - 4096` — a positive, in-bounds value. The bug
becomes a 2048-byte *under-read* (returned msgbuf is 2048 bytes shorter than it
should be), not an OOB read. No leak, no panic.

The OOB underflow condition (`rindex_modulo > n`, equivalently
`rindex_modulo > msg_size/2`) requires `msg_bufr` to be "stale" at a value whose
modulo exceeds `msg_size/2`. That is **only** reachable after root writes
`kern.msgbuf_clear=1`.

### Empirical confirmation of unreachability (this session)

- `msgbuf_diag` run as `maxx` on a fresh `#0` boot: **300,000 sysctl reads,
  0 over-long reads, 0 suspect tails**, max returned length = 8679 bytes (= the
  actual boot-log size). See `run_unpriv.log`.
- `sysctl kern.msgbuf_clear=1` as `maxx` → `Operation not permitted`.

So the bug's realistic impact ceiling is a **root-triggerable local kernel OOB
read / DoS (panic)** — it does not cross a privilege boundary. The code fix is
still warranted (real latent OOB defect / wrong length math). Suggested severity
refinement: **Low** (rather than Medium) given the root-only window.

## 4. The fix

Replace `n - rindex_modulo` with `n` in the 3rd branch — matching branches 1 and
4 which already use the correct length. One-line change; see `fix.diff`
(standalone `git apply`-able unified diff against `sys/kern/subr_prf.c`).
`git apply --check` passes. This matches the finding's `## Recommended fix`
proposal exactly.

## 5. Fix validation (Phase 8) — single-fix kernel built & booted

The fix was validated end-to-end on a single-fix kernel built from the
`with-src` baseline + `fix.diff` only.

### Before / after contrast (identical buggy geometry)

| Kernel | Decisive PoC result |
|---|---|
| `#0` unpatched (`with-src`) | **kernel PANIC**: `obj != NULL` in `vm_object_hold_shared`, trap 0xc in `std_copyout+0x15a` (copyout walks off msg_ptr's mapped pages). Guest down. (`panic.txt`) |
| `#1` + `fix.diff` | `sysctl rc=0`, returned `l=424272` bytes (= `n`, exactly in-bounds), no panic, guest stays up. Deterministic across 2 runs. (`fix_run.log`, `fix_run.2.log`) |

The decisive PoC places `msg_bufx = msg_size`, `msg_bufr = msg_size/2 + 100000`
(so `xindex_modulo==0`, `rindex_modulo=624272 > msg_size/2`), then issues a
single `sysctlbyname("kern.msgbuf", buf, 1MiB_oldlen)`. The PoC prints
`bug_len=4294767296` (= `n - rindex_modulo` as u_int, the OLD buggy length) to
prove the same path is being exercised.

- On `#0` that ~4 GiB length reaches `sysctl_old_user` which clips it to
  `oldlen` (1 MiB) and `copyout`s 1 MiB starting at `msg_ptr + rindex_modulo`,
  running past `msg_ptr + msg_size` into unmapped kernel memory → page fault →
  panic.
- On `#1` the fix passes `n` (= 424272) to `sysctl_handle_opaque`; the copyout
  reads exactly `msg_ptr + rindex_modulo .. msg_ptr + msg_size` — in bounds.
  No fault, no leak.

Normal unprivileged reads are unaffected: as `maxx`, `sysctl -n kern.msgbuf`
returned 9027 bytes (the boot log) on the patched kernel — no regression.

### Build / boot details

- `fix.diff` applied with `patch -p1 --forward` cleanly (Hunk #1 at line 1180).
- Build: `cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERIC` →
  `rc=0` (full log in `fix_build.log`, 36014 lines).
- Install: `make installkernel KERNCONF=X86_64_GENERIC` → "Kernel install for
  X86_64_GENERIC completed".
- `kern.version`: `#0: Thu Jul  2 06:02:54 UTC 2026` →
  `#1: Sat Aug  8 18:13:42 UTC 2026`.
- Patched kernel sha256: `50f0d9c0df2cd24d5bc593cad206a042d45fee77f2a6a88144416fd45ca92142`.

### Fix status

**fixed.** The bad-behavior marker (panic / OOB read past msg_size) is present on
the unpatched `#0` baseline and **absent** on the single-fix `#1` kernel. The
one-line change (`n - rindex_modulo` → `n`) matches the finding's `## Recommended
fix` proposal exactly; no refinement was needed.

## 6. Files in this evidence pack

| file | role |
|---|---|
| `msgbuf_oob_decisive.c` | **DECISIVE** root-only trigger: kvm_write bad geometry + sysctl read → panic on #0, rc=0 on #1 |
| `dump_msgbuf.c` | kvm(3) reader: dumps msg_bufx/bufr and the branch-3 decision (`geometry_steady.txt`) |
| `msgbuf_diag.c` | unprivileged poll: reports over-long/suspect reads (none observed) |
| `build.sh` | builds all three binaries |
| `run.sh` | runs `decisive` / `unpriv` / `geometry` |
| `panic.txt` | tight panic signature from the decisive run on #0 (proof) |
| `leak_sample.txt` | interpretation of the panic signature + reachability note |
| `geometry_steady.txt` | steady-state msgbuf geometry + branch decision (branch 1, no bug) |
| `run_unpriv.log` | full unprivileged poll log (300k reads, 0 OOB — fresh #0 boot) |
| `env.txt` | guest uname, cc version, relevant sysctls |
| `fix.diff` | git-apply-able fix: `n - rindex_modulo` → `n` in branch 3 |
| `fix_build.log` | full `nativekernel` output for the single-fix kernel (rc=0) |
| `fix_run.log` | decisive PoC on patched #1: returns n=424272 bytes, no panic |
| `fix_run.2.log` | 2nd decisive run on #1: deterministic (rc=0, l=424272) |
| `README.md` | human-facing readme |
| `manifest.json` | machine-readable catalog |
