# DF-0035 — Verification verdict

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

**Verdict:** **REPRODUCED (with caveats)** — the buggy length math is real and
produces a kernel OOB read; **but the unprivileged-reachability claim in the
finding is INCORRECT.** The OOB only fires after a root-initiated
`kern.msgbuf_clear=1` opens a narrow (1-byte-per-`msg_size`) window. In normal
operation the same bug is a benign 2048-byte under-read with no leak.

## 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:1321`), 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 whatever kernel memory is adjacent.

## 2. Decisive empirical proof — kernel panic

`msgbuf_oob_decisive.c` (root-only) uses `kvm_write` to place `msg_bufx` and
`msg_bufr` in the exact geometry that the natural post-clear path produces
(`msg_bufx = msg_size`, `msg_bufr = msg_size/2 + 100000` — so
`xindex_modulo==0`, `rindex_modulo>msg_size/2`), then issues a single
`sysctlbyname("kern.msgbuf", buf, big_oldlen)`. The kernel **panics**:

```
panic: assertion "obj != NULL" failed in vm_object_hold_shared
  vm_fault -> trap_pfault -> trap -> calltrap
  --- trap 0xc, rip=ffffffff80bca5ba ---
  std_copyout() at std_copyout+0x15a
```

`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).
The panic was reproduced twice with byte-identical signatures (`panic.txt`).
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` exactly, i.e. `rindex_modulo =
  (msg_bufx + 2048) % msg_size` 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: `sysctl kern.msgbuf_clear=1` as maxx → EPERM).

In steady-state geometry, branch 3 fires only when `xindex_modulo == 0`
(see analysis in `VERDICT.md` §4). 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* (the
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" — i.e. set to
a value whose modulo exceeds `msg_size/2`. That is **only** reachable after
root writes `kern.msgbuf_clear=1` (which sets `msg_bufr := msg_bufx`). The
finding's claim that "any local user can poll kern.msgbuf to hit the window"
is **false** — the window requires root to open.

### Empirical confirmation of unreachability

- `msgbuf_oob` (original PoC) run as maxx: **2,000,000 sysctl reads, 0 hits**.
- `msgbuf_diag` (sharper diagnostic) run as maxx on a fresh boot:
  **1,500,000 sysctl reads, 0 over-long reads, 0 suspect tails**.
  Maximum returned length = 8573 bytes (= the actual boot-log size). See
  `run.unprivileged.log`.

## 4. Geometry trace (why steady state can't underflow)

In `sysctl_kern_msgbuf` (`sys/kern/subr_prf.c:1119-1200`) with no shrink
(`n <= msg_size - 1024`; the steady-state case where `msg_bufr` tracks
`msg_bufx - msg_size + 2048`):

- `rindex_modulo = (msg_bufx + 2048) % msg_size`
- `xindex_modulo = msg_bufx % msg_size`
- `n = msg_size - 2048`

Branch decision:
- branch 1 (`rindex_modulo < xindex_modulo`): holds when
  `xindex_modulo >= msg_size - 2048` (the +2048 wraps).
- branch 2 (`rindex_modulo == xindex_modulo`): impossible (would need
  `2048 ≡ 0`).
- **branch 3** (`rindex_modulo > xindex_modulo` AND `n <= msg_size - rindex_modulo`):
  the first clause holds when `xindex_modulo < msg_size - 2048`; combined
  with `n <= msg_size - rindex_modulo` ⟺ `msg_size - 2048 <= msg_size - 2048 - xindex_modulo`
  ⟺ `xindex_modulo <= 0` ⟺ **`xindex_modulo == 0`** (and then
  `rindex_modulo = 2048`).
- branch 4: otherwise (`xindex_modulo` in `[1, msg_size - 2048)`).

At the branch-3 moment in steady state:
- buggy length `= n - rindex_modulo = (msg_size - 2048) - 2048 = msg_size - 4096`
- correct length `= n = msg_size - 2048`
- difference `= 2048` bytes UNDER-read
- copyout reads `msg_size - 4096` bytes starting at `msg_ptr + 2048`, ending
  at `msg_ptr + msg_size - 2048`. **In bounds** — no leak.

For an OOB read we need `rindex_modulo > n`. With branch-3's geometry that
requires `rindex_modulo > msg_size - rindex_modulo` ⟺ `rindex_modulo > msg_size/2`.
But steady state pins `rindex_modulo = 2048`. Contradiction.

To get `rindex_modulo > msg_size/2` in branch 3, `msg_bufr` must be stale at
a value whose modulo exceeds `msg_size/2`. That staleness is produced **only**
by `sysctl_kern_msgbuf_clear` (`subr_prf.c:1213-1218`), which is root-only.

## 5. Realistic severity

The bug is a real code defect (wrong length passed to `sysctl_handle_opaque`)
and produces a genuine OOB read **when the geometry is right**. But:

- The geometry requires root to open (`kern.msgbuf_clear=1`).
- Even after root opens it, the OOB window is **exactly one `msg_bufx` value
  wide per `msg_size` (~1 MiB) bytes of new kernel log output**. Catching it
  from a polled sysctl read is a tight race; a 3,000,000-iter brute-force
  attempt with 1-byte-step console writes did not catch it within the time
  budget. (The kvm_write trigger above shortcuts this timing deterministically
  by writing `msg_bufx` and `msg_bufr` directly to the kernel struct.)
- An attacker who already has root (to write `kern.msgbuf_clear`) does not
  need an OOB read to escalate. The realistic impact ceiling of THIS bug
  alone is therefore **local kernel info-leak / DoS only after root
  msgbuf_clear** — not the unauthenticated/unprivileged leak claimed.

Suggested severity refinement: **Low** (rather than Medium) given the
root-only window. The code fix is still warranted (it's a real defect that
turns an otherwise-correct branch into a latent OOB).

## 6. The fix

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

## 7. Files in this evidence pack

| file                     | role                                                                |
|--------------------------|---------------------------------------------------------------------|
| `msgbuf_oob.c`           | original unprivileged PoC (polls kern.msgbuf; harmless)             |
| `msgbuf_diag.c`          | sharper unprivileged diagnostic (reports over-long/suspect reads)   |
| `dump_msgbuf.c`          | kvm(3) reader: dumps msg_bufx/bufr and the branch-3 decision        |
| `msgbuf_oob_decisive.c`  | **DECISIVE** root-only trigger: kvm_write bad geometry + sysctl read → panic |
| `msgbuf_trigger.c`       | earlier natural-path trigger attempt (timing-based; superseded)     |
| `msgbuf_brute.c`         | root-only natural-path brute-forcer (1-byte-step + read loop)       |
| `run_brute.sh`           | wrapper: arrange stale msg_bufr via msgbuf_clear, then brute-force  |
| `build.sh`               | builds all five binaries                                            |
| `run.sh`                 | runs unprivileged (`./run.sh unprivileged`) or decisive (`./run.sh decisive`) |
| `panic.txt`              | tight panic signature from both decisive runs (proof)               |
| `leak_sample.txt`        | explanation of the panic signature and what it proves               |
| `run.unprivileged.log`   | full unprivileged poll log (1.5M reads, 0 hits — fresh boot)        |
| `env.txt`                | guest uname, cc version, relevant sysctls                           |
| `fix.diff`               | git-apply-able fix: `n - rindex_modulo` → `n` in branch 3           |
| `README.md`              | (updated) human-facing readme                                       |
| `manifest.json`          | machine-readable catalog                                            |
