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

Kernel divide-by-zero panic in vm_get_pg_color via writable CPU topology sysctls (CTLFLAG_RW)

Field Value
ID DF-0941
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
CWE CWE-369 Divide By Zero
File sys/vm/vm_page.c
Lines 1225-1232
Area vm
Confidence certain
Discovered 2026-07-05
Reported pending
Known CVE none
CVE match dfly_specific

Summary

vm_get_pg_color() computes cpuscale = PQ_L2_SIZE / phys_ids / core_ids / ht_ids and then takes (pindex + object_pg_color) % cpuscale. The CPU topology variables cpu_topology_phys_ids, cpu_topology_core_ids, and cpu_topology_ht_ids are all exposed as CTLFLAG_RW sysctls (hw.cpu_topology_phys_ids etc.), so a privileged user can set them to values that make cpuscale (or an intermediate divisor) zero, causing an immediate divide-by-zero panic on the next page allocation. vm_get_pg_color is called from every vm_page_alloc(), so the panic is near-instantaneous after the sysctl write.

Root cause

In vm_get_pg_color() (vm_page.c:1195-1232), the HT-aware branch computes:

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

All three topology variables are SYSCTL_INT with CTLFLAG_RW in sys/kern/subr_cpu_topology.c:81-86, writable by root with no validation. Setting cpu_topology_phys_ids=0 causes division by zero at :1225. Setting cpu_topology_core_ids to any value > PQ_L2_SIZE / phys_ids (e.g. 2000 on a typical single-socket system where physcale=1024) makes grpscale=0, cpuscale=0, and the modulo at :1232 panics. The guard at vm_numa_organize (vm_page.c:534, if (cpu_topology_phys_ids <= 1) return) does not protect vm_get_pg_color, which only guards on cpu_topology_ht_ids != 0.

Threat model & preconditions

  • Attacker position: Root (or a process delegated PRIV_SYSCTL_WRITE).
  • Privileges gained or impact: Local denial of service β€” unconditional kernel panic with no recovery path. The sysctl node presents as informational ("# of physical packages") but is writable with no bounds checking, making this a non-obvious DoS vector for a compromised privileged service.
  • Required config or capabilities: PRIV_SYSCTL_WRITE.
  • Reachability: sysctl hw.cpu_topology_phys_ids=0 β€” the very next page allocation (within milliseconds on any active system) calls vm_page_alloc β†’ vm_get_pg_color and triggers a divide-by-zero trap.

Proof of concept

PoC source: findings/poc/DF-0941/poc.sh

Build & run

# As root on a DragonFlyBSD system with hyperthreading:
sysctl hw.cpu_topology_phys_ids=0
# Or on a 1-socket system:
sysctl hw.cpu_topology_core_ids=2000

Either command returns immediately, but the very next page allocation triggers a divide-by-zero trap.

Expected output

Fatal trap 0: divide error while in kernel mode
...
vm_get_pg_color(...) at vm_get_pg_color+0x...
vm_page_alloc(...)   at vm_page_alloc+0x...
...

Impact

Kernel panic (system-wide DoS) from a single sysctl write by root. No privilege escalation or info leak.

Two-part fix: (1) make the topology sysctls read-only to prevent runtime modification, and (2) add defensive guards in vm_get_pg_color to prevent division by zero even if the values are somehow wrong.

--- a/sys/kern/subr_cpu_topology.c
+++ b/sys/kern/subr_cpu_topology.c
@@ -78,11 +78,11 @@
 SYSCTL_INT(_hw, OID_AUTO, cpu_topology_ht_ids, CTLFLAG_RD,
       &cpu_topology_ht_ids, 0, "# of logical cores per real core");
 SYSCTL_INT(_hw, OID_AUTO, cpu_topology_core_ids, CTLFLAG_RD,
       &cpu_topology_core_ids, 0, "# of real cores per package");
 SYSCTL_INT(_hw, OID_AUTO, cpu_topology_phys_ids, CTLFLAG_RD,
       &cpu_topology_phys_ids, 0, "# of physical packages");
--- a/sys/vm/vm_page.c
+++ b/sys/vm/vm_page.c
@@ -1192,6 +1192,14 @@

    object_pg_color = object ? object->pg_color : 0;

+   /*
+    * Guard against zero or pathological topology values that would
+    * cause a divide-by-zero.  Fall back to the simple distribution.
+    */
+   if (cpu_topology_phys_ids <= 0 || cpu_topology_core_ids <= 0 ||
+       cpu_topology_ht_ids <= 0)
+       goto simple;
+
    if (cpu_topology_ht_ids) {
        int phys_id;
        int core_id;
@@ -1225,6 +1233,13 @@
        physcale = PQ_L2_SIZE / cpu_topology_phys_ids;
        grpscale = physcale / cpu_topology_core_ids;
        cpuscale = grpscale / cpu_topology_ht_ids;
+
+       /*
+        * If any scale collapsed to zero the topology is too wide
+        * for PQ_L2_SIZE; fall back to simple distribution.
+        */
+       if (cpuscale == 0)
+           goto simple;

References

Timeline

  • 2026-07-05 Discovered during automated audit.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0941 Β· 12 files
FileTypeDescriptionSize
poc.sh trigger-source minimal trigger: sysctl hw.cpu_topology_phys_ids=0 as root 622 B view raw
run.sh repro-script run wrapper for the trigger 641 B view raw
build.sh build-script no-op build (PoC is pure shell) 307 B view raw
panic.txt panic-signature vm_get_pg_color+0x76 idivl divide-by-zero on baseline #0 443 B view raw
env.txt environment uname, cc version, topology sysctl values 278 B view raw
fix.diff suggested-fix two-part fix: CTLFLAG_RD sysctls + vm_get_pg_color zero guards 1.7 KB view raw
fix_build.log build-log full nativekernel build output of the single-fix kernel (rc=0) 5.6 MB ↓ download
fix_run.log run-log patched-kernel PoC re-run: sysctl read-only, no panic 881 B view raw
VERDICT.md verdict full narrative: mechanism, privilege analysis, fix, validation 5.8 KB ↓ raw
README.md readme human-facing build/run/expected 2.8 KB ↓ 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 build/run/expected
↓ download raw

DF-0941 β€” Kernel divide-by-zero panic in vm_get_pg_color via writable CPU-topology sysctls

Summary

vm_get_pg_color() divides PQ_L2_SIZE (1024) by the CPU-topology counts cpu_topology_phys_ids, cpu_topology_core_ids, and cpu_topology_ht_ids (sys/vm/vm_page.c:1225-1227). Those three variables are exposed as CTLFLAG_RW sysctls (sys/kern/subr_cpu_topology.c:81-86) writable by root with no validation. Setting any to zero — or inflating one enough to collapse the derived cpuscale to zero — triggers an unconditional divide-by-zero panic on the next vm_page_alloc(). Because the write requires PRIV_SYSCTL_WRITE, this is a root→kernel DoS (Low severity); an unprivileged user gets Operation not permitted.

Build

The PoC is a pure shell script β€” no compilation required:

./build.sh     # no-op (just confirms poc.sh is present)

Run

Run as root on the guest (the sysctl requires PRIV_SYSCTL_WRITE):

./run.sh
# equivalent to:  sysctl hw.cpu_topology_phys_ids=0

Expected

On the UNPATCHED kernel (#0)

The sysctl write returns, then the next page allocation traps:

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

The guest is down (DDB debugger). This is the divide at sys/vm/vm_page.c:1225 (PQ_L2_SIZE / cpu_topology_phys_ids with phys_ids=0).

On the FIXED kernel (#1)

The sysctl is now read-only:

sysctl: oid 'hw.cpu_topology_phys_ids' is read only

No panic; the guest stays up.

Variants

  • sysctl hw.cpu_topology_phys_ids=0 β†’ div0 at vm_page.c:1225
  • sysctl hw.cpu_topology_core_ids=0 β†’ div0 at vm_page.c:1226
  • sysctl hw.cpu_topology_core_ids=2000 (1-socket guest) β†’ grpscale=0, cpuscale=0 β†’ div0 at the modulo vm_page.c:1232

Files

file purpose
poc.sh minimal trigger (root sysctl write)
run.sh run wrapper
build.sh no-op build wrapper
panic.txt divide-by-zero signature from the baseline panic
env.txt guest uname / cc / topology values
fix.diff two-part fix: CTLFLAG_RD sysctls + vm_get_pg_color guards
fix_build.log full single-fix kernel build output
fix_run.log patched-kernel PoC re-run (read-only, no panic)
VERDICT.md full analysis: mechanism, privilege, fix, validation
manifest.json machine-readable catalog
VERDICT.md verdict full narrative: mechanism, privilege analysis, fix, validation
↓ download raw

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):

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).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline panics idivl at vm_get_pg_color; patched sysctl read-only, no panic.

BEFORE: idivl guest DOWN. AFTER: read-only rc=1, guest UP.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 14:18:23 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- pure divide-by-zero, root-only sysctl write. No privilege boundary to cross.

Evidence (decisive lines)

BEFORE: sysctl phys_ids=0 succeeds -> Stopped at vm_get_pg_color+0x76 idivl, guest DOWN. AFTER: 'is read only' rc=1, no panic.

PoC changes

Authored: poc.sh, fix.diff (CTLFLAG_RW->RD + vm_get_pg_color guards incl cpuscale-collapse), VERDICT.md, manifest.json.

Verified recommended fix

(1) Change 3 topology sysctls to CTLFLAG_RD at subr_cpu_topology.c:81-86; (2) guard ht_ids>0&&core_ids>0&&phys_ids>0 + cpuscale==0 goto simple at vm_page.c:1195/1228. Supersedes finding proposal. Full diff in findings/poc/DF-0941/fix.diff.

Verdict

REPRODUCED. sysctl hw.cpu_topology_phys_ids=0 (root, SYSCTL_INT CTLFLAG_RW no validation) -> vm_get_pg_color idivl by phys_ids -> #DE trap. vm_page.c:1225 PQ_L2_SIZE/phys_ids. maxx gets EPERM (PRIV_SYSCTL_WRITE).