# DF-0941 — Verdict

## Verdict: REPRODUCED → FIX VALIDATED

**Finding:** Kernel divide-by-zero panic in `vm_get_pg_color()` via writable CPU-topology sysctls.
**Severity:** Low (root-only DoS; no privilege boundary crossed).
**Status after fix:** FIXED — panic eliminated, sysctl made read-only, defensive guard added.

---

## Mechanism (confirmed by source trace + live panic)

`vm_get_pg_color()` (`sys/vm/vm_page.c:1195-1263`) is called from every
`vm_page_alloc()`. Its HT-aware branch computes page-coloring scales by
dividing the topology counts into `PQ_L2_SIZE` (1024):

```c
physcale = PQ_L2_SIZE / cpu_topology_phys_ids;   /* vm_page.c:1225 */
grpscale = physcale / cpu_topology_core_ids;     /* vm_page.c:1226 */
cpuscale = grpscale / cpu_topology_ht_ids;       /* vm_page.c:1227 */
...
pg_color += (pindex + object_pg_color) % cpuscale;/* vm_page.c:1232 */
```

The three topology variables are exposed as `SYSCTL_INT(..., CTLFLAG_RW, ...)`
in `sys/kern/subr_cpu_topology.c:81-86` — writable by root with **no
validation**. The branch guard at `:1195` is only `if (cpu_topology_ht_ids)`,
which does NOT protect against `phys_ids==0` or `core_ids==0` (those divide-by-
zeros happen *inside* the already-entered branch). The `vm_numa_organize()`
guard at `vm_page.c:534` (`if (cpu_topology_phys_ids <= 1 || core_ids == 0)
return;`) does **not** cover `vm_get_pg_color`, which only checks `ht_ids`.

**Trigger:** `sysctl hw.cpu_topology_phys_ids=0` (as root). The sysctl write
itself returns cleanly, but the very next page allocation (milliseconds later
on any active system) executes `idivl` with a zero divisor and traps.

## Live reproduction (unpatched #0 baseline)

On `DragonFly 6.5-DEVELOPMENT #0` (build Thu Jul  2 06:02:54 UTC 2026):

```
# as root:
sysctl hw.cpu_topology_phys_ids=0
```

Result: kernel drops into DDB with a divide-error trap (captured in
`dfbsd-qemu/boot.log`):

```
Stopped at      vm_get_pg_color+0x76:   idivl   0x61ef6c(%rip),%eax
db>
```

The `idivl` at `vm_get_pg_color+0x76` is exactly the integer divide at
`vm_page.c:1225` (`PQ_L2_SIZE / cpu_topology_phys_ids` with `phys_ids=0`).
Guest is **down** after the trap. See `panic.txt`.

## Privilege analysis (no escalation)

The sysctl requires `PRIV_SYSCTL_WRITE`, i.e. **root**. Confirmed as
unprivileged user `maxx` (uid 1001, not in wheel):

```
$ sysctl hw.cpu_topology_phys_ids=0
sysctl: hw.cpu_topology_phys_ids=0: Operation not permitted
```

Per the audit's bright-line rule, a write reachable **only from an already-root
context** has no privilege boundary to cross (root→kernel is game-over by
definition). There is therefore **no escalation chain** — this is a
**root→kernel hardening gap** (a compromised privileged service, a buggy
monitoring script, or a malicious admin can panic the kernel via an
unvalidated sysctl that presents as informational topology data). Severity Low
(CVSS `AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H`) is correct. No memory corruption
is involved (pure integer divide-by-zero trap), so Phase 6 escalation does not
apply.

The other two topology counts are equally unguarded:
- `sysctl hw.cpu_topology_core_ids=0` → div0 at `:1226`.
- `sysctl hw.cpu_topology_core_ids=2000` (on this 1-socket/6-core guest,
  `physcale=1024`, so `grpscale=1024/2000=0`, `cpuscale=0`) → div0 at the
  modulo `:1232`.

## Fix (authored in `fix.diff`, matches finding proposal, hardened)

Two-part defense-in-depth fix:

1. **`sys/kern/subr_cpu_topology.c:81-86`** — change all three topology sysctls
   from `CTLFLAG_RW` to `CTLFLAG_RD`. These variables are only ever assigned
   once, during boot topology detection (`subr_cpu_topology.c:605-618`); there
   is no legitimate runtime writer. Making them read-only closes the
   user-facing attack surface completely. (Verified: `rg` finds no runtime
   assignment outside boot detection.)

2. **`sys/vm/vm_page.c:1195`** — extend the branch guard from
   `if (cpu_topology_ht_ids)` to also require `core_ids > 0` and
   `phys_ids > 0`, so any zero/negative topology falls back to the simple
   even-distribution branch. **`sys/vm/vm_page.c:1228`** — add
   `if (cpuscale == 0) goto simple;` after the scale computation so a
   "too-wide" topology (where `PQ_L2_SIZE / phys / core / ht` collapses to 0)
   also falls back instead of dividing by zero in the modulo. A `simple:` label
   is added at the top of the `else` block (with a null `;` statement to
   satisfy C's label-must-precede-a-statement rule).

This supersedes the finding markdown's recommended fix by also adding the
`cpuscale == 0` collapse guard (the finding's proposal only guarded the
zero-operand case, not the modulo-after-collapse case where e.g.
`core_ids=2000` makes `cpuscale` zero without any single operand being zero).

## Fix validation (Phase 8 — built + booted single-fix kernel)

- **Baseline (#0, unpatched):** `sysctl hw.cpu_topology_phys_ids=0` succeeds →
  divide-by-zero panic `vm_get_pg_color+0x76: idivl` → guest down. (before)
- **Patched (#1, build `Tue Jul 14 14:18:23 UTC 2026`, sha256
  `1dbfa610...`):** applied `fix.diff` to clean `/usr/src`, built with
  `make -j6 nativekernel KERNCONF=X86_64_GENERIC` (rc=0), overwrote
  `/boot/kernel/kernel` with `kernel.stripped`, rebooted into #1. Re-ran the
  PoC: `sysctl: oid 'hw.cpu_topology_phys_ids' is read only` (rc=1), **no
  panic, guest stays up**. (after)

Before/after contrast saved in `fix_run.log`; full kernel build log in
`fix_build.log`.

**fix_status: fixed** — bad behavior (panic) is gone on the patched kernel AND
present on the unpatched baseline.

## PoC changes

The provided `poc.sh` was correct as written (no compile, root sysctl write).
No source changes were needed for reproduction. Added `build.sh`/`run.sh`
repro wrappers and this `VERDICT.md`; authored `fix.diff` (corrected from the
finding's proposal to also cover the modulo-collapse case and to add the
required null statement after the C label so the kernel compiles).
