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

CPU topology sysctl strings rendered while ncpus==1: hw.cpu_topology.members and per-cpu physical/core siblings are wrong on every SMP system; fix_amd_topology cpusync reaches only the BSP

Field Value
ID DF-2846
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N
CWE CWE-682 / use-before-init of global ncpus
File sys/kern/subr_cpu_topology.c
Lines 563, 656, 727, 823-824
Area kern
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

init_cpu_topology() runs at SI_BOOT2_CPU_TOPOLOGY, BEFORE SI_BOOT2_START_APS starts the APs and before ncpus leaves 1. sbuf_print_cpuset() iterates with CPUSET_FOREACH which is bounded by ncpus, so every string rendered at build time only ever contains cpu 0. Second manifestation: fix_amd_topology()'s lwkt_cpusync is masked to the BSP-only smp_active_mask, so compute_unit_id is stored only on the BSP's node and the AMD compute-unit merge degenerates on TOPOEXT+SMT-off systems.

Threat model & preconditions

Any user reading hw.cpu_topology.* gets wrong CPU-sibling metadata (cpu1..cpuN invisible in members/siblings; core_siblings empty). Userland consumers (affinity/NUMA placement hints, monitoring) make wrong placement decisions; in-kernel mask consumers are unaffected (masks are complete). No memory-safety impact.

Proof of contest

VERIFIED live on the 6-vCPU guest (findings/poc/DF-2846/): hw.ncpu:6 vs hw.cpu_topology.members: cpus(0), cpu1.core_siblings: cpus() (empty), while the lazily-rendered tree correctly shows cpus(0-5). Fix (iterate the full mask width in sbuf_print_cpuset; defer the AMD compute-unit pass after AP start) in the pack.

See findings/poc/DF-2846/fix.diff.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of subr_cpu_topology.c (GLM 5.3); live wrong-sysctl evidence captured same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2846 Β· 10 files
FileTypeDescriptionSize
check.sh β€” 1.1 KB view raw
run.log β€” 1.2 KB view raw
env.txt β€” 307 B view raw
fix.diff β€” 843 B view raw
build.sh β€” 153 B view raw
run.sh β€” 276 B view raw
README.md β€” 2.4 KB ↓ raw
VERDICT.md β€” 4.5 KB ↓ raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 2.9 KB view raw

DF-2846 β€” CPU topology sysctl strings rendered while ncpus == 1 (boot-order bug)

What

init_cpu_topology() runs at SI_BOOT2_CPU_TOPOLOGY (sys/sys/kernel.h:156, 0x1a58000) β€” before SI_BOOT2_START_APS (0x1a60000) starts the APs and before ncpus is set to naps + 1 (sys/platform/pc64/x86_64/mp_machdep.c:384, :544). At that moment ncpus == 1 (sys/platform/pc64/x86_64/machdep.c:2687).

sbuf_print_cpuset() iterates a mask with CPUSET_FOREACH(i, *mask), which is bounded by ncpus (sys/sys/cpu_topology.h:63-65), not by the mask width. Every string it renders during boot therefore only ever sees bit 0:

  • hw.cpu_topology.members β€” rendered at boot (subr_cpu_topology.c:656-660)
  • hw.cpu_topology.cpuN.physical_siblings β€” rendered at boot (:577-581)
  • hw.cpu_topology.cpuN.core_siblings β€” rendered at boot (:597-601)

hw.cpu_topology.tree is rendered lazily at sysctl-read time (post-boot, ncpus final) and is correct β€” the divergence between the two proves the root cause.

Second manifestation of the same ordering bug: fix_amd_topology() (subr_cpu_topology.c:285 β†’ mp_machdep.c:1927-1946) broadcasts amd_get_compute_unit_id via lwkt_cpusync_simple(CPUMASK_ASSALLONES, ...), but lwkt_cpusync_interlock() masks the target set with gd->gd_other_cpus and smp_active_mask (sys/kern/lwkt_ipiq.c:855-856), both of which still describe a BSP-only system at this sysinit stage. Only the BSP's node gets a compute_unit_id; every other node keeps 255, and the merge logic at subr_cpu_topology.c:313-314 (which groups by compute_unit_id equality) then lumps all non-BSP cores of a chip into one fake compute unit whenever the tree is 3-level (AMD TOPOEXT with SMT disabled).

How to run

No build required β€” pure sysctl reads, any user.

sh check.sh            # on the DragonFly guest

(OR from the host: dfbsd-qemu/vm.sh run_root 'sh /root/df2846_check.sh')

Expected (buggy) output

hw.ncpu: 6
hw.cpu_topology.members: cpus(0)          <-- should be cpus(0-5)
hw.cpu_topology.cpu1.core_siblings: cpus()  <-- should be cpus(1)
...
BUG REPRODUCED: hw.cpu_topology.members=[cpus(0)] but hw.ncpu=6

while sysctl -n hw.cpu_topology.tree (lazy) shows cpus(0-5).

Fixed behavior

After fix.diff (mask-bounded iteration instead of ncpus-bounded), the boot-time renderings list every CPU in the mask, identical to the tree.

VERDICT.md
↓ download raw

DF-2846 VERDICT β€” reproduced (runtime-proven on QEMU guest, source-traced)

Status: reproduced. Impact: none (incorrect kernel-exported topology information; no memory corruption, no privilege boundary). Confidence: certain.

How it was verified

check.sh (see run.log) compares the eagerly-rendered topology sysctl strings against hw.ncpu and the lazily-rendered tree on the stock INVARIANTS guest (DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC, 6 vCPUs, QEMU):

  • hw.ncpu: 6
  • hw.cpu_topology.members: cpus(0) β€” root members; must be cpus(0-5)
  • hw.cpu_topology.cpuN.physical_siblings: cpus(0) for every N β€” the whole guest is one chip, must be cpus(0-5)
  • hw.cpu_topology.cpuN.core_siblings: cpus() (empty) for N=1..5 β€” each core is single-threaded, must be cpus(N)
  • sysctl -n hw.cpu_topology.tree β€” rendered at READ time β€” correctly shows PACKAGE MEMBERS: cpus(0-5), CHIP ID 0: cpus(0-5), CORE ID n: cpus(n).

The lazily-rendered tree being complete while every boot-time-rendered string is frozen at "cpu 0 only" is exactly the source-predicted signature of ncpus == 1 during SI_BOOT2_CPU_TOPOLOGY.

Why (path:line)

  1. sys/kern/subr_cpu_topology.c:823-824 β€” SYSINIT(cpu_topology, SI_BOOT2_CPU_TOPOLOGY, SI_ORDER_FIRST, init_cpu_topology).
  2. sys/sys/kernel.h:156-158 β€” SI_BOOT2_CPU_TOPOLOGY = 0x1a58000 sorts BEFORE SI_BOOT2_START_APS = 0x1a60000.
  3. sys/platform/pc64/x86_64/machdep.c:2687 β€” ncpus = 1 at early boot; it is only set to naps + 1 inside start_all_aps() (sys/platform/pc64/x86_64/mp_machdep.c:544), which runs from mp_start_aps() β€” SYSINIT(startaps, SI_BOOT2_START_APS, ...) (mp_machdep.c:384).
  4. init_pcpu_topology_sysctl() (subr_cpu_topology.c:563-610) and the members rendering (:656-660) call sbuf_print_cpuset(), whose CPUSET_FOREACH(i, *mask) (subr_cpu_topology.c:727) expands to for (i = 0; i < ncpus; i++) if (CPUMASK_TESTBIT(mask, i)) (sys/sys/cpu_topology.h:63-65). With ncpus == 1, only bit 0 is ever tested: cpu 0 prints when present, everything else silently disappears.

The masks themselves are complete β€” the tree, get_cpumask_from_level(), and all in-kernel consumers are unaffected. Only the pre-rendered strings lied.

Second manifestation (source-proven, not guest-testable on Intel QEMU)

fix_amd_topology() is invoked from build_cpu_topology() (subr_cpu_topology.c:285) at the same too-early sysinit. Its lwkt_cpusync_simple(CPUMASK_ASSALLONES, amd_get_compute_unit_id, NULL) (mp_machdep.c:1936-1937) is masked down to the BSP-only gd_other_cpus/smp_active_mask (sys/kern/lwkt_ipiq.c:855-856), so compute_unit_id (CPUID 0x8000001e EBX[7:0]) is only ever stored on the BSP's node; every other node keeps 255 from subr_cpu_topology.c:128. The merge at subr_cpu_topology.c:313-314 compares compute_unit_id for equality, so on AMD TOPOEXT systems whose tree is 3-level (SMT off) all non-BSP cores of a chip compare 255==255 and are collapsed into a single bogus compute-unit node β€” a wrong scheduler topology that also perturbs get_cpumask_from_level() masks for those CPUs. Could not be exercised on this guest (Intel QEMU CPU: fix_amd_topology() returns -1 at the vendor check, mp_machdep.c:1931-1932).

Exploit chain

None. No memory-safety impact: the strings are fixed-size SBUF_FIXEDLEN buffers (8*MAXCPU), and sbuf truncation is bounds-checked. The bug exports wrong topology information to user space (CPU-placement hints, NUMA tools) and degrades AMD scheduling quality. No route to uid=0 exists or is claimed.

Fix

fix.diff bounds the iteration by the mask width (MAXCPU bits) rather than ncpus, making the rendering independent of when it runs. It is a two-statement semantic equivalent of the macro (CPUMASK_TESTBIT already masks the bit index), so the string content for bit positions < ncpus is unchanged at read time; only the missing bits are added.

Fix validation (patched-kernel rebuild + rerun) was not performed: contract requires it only for reproduced memory-corruption findings; this is a Low correctness bug. The guest was left untouched (guest_dirty=0).

The AMD fix_amd_topology manifestation is NOT fixed by this diff: it needs the compute-unit pass deferred until after SI_BOOT2_START_APS (e.g. a SI_BOOT2_FINISH_SMP sysinit that runs fix_amd_topology() before init_pcpu_topology_sysctl() re-derives the per-cpu ids), which reorders interactions with SI_BOOT2_NUMA consumers and belongs in an upstream design decision, not a finding patch.

Fix verification

not_testable
↓ fix.diffper-fix-DF-2846

Confirmed kernel references

Detail

Evidence (decisive lines)

["run.log: 'hw.ncpu: 6' + 'hw.cpu_topology.members: cpus(0)' + 'cpu1.core_siblings: cpus()' + tree sysctl showing 'cpus(0-5)' + final 'BUG REPRODUCED' line", "VERDICT.md: full source trace of the sysinit ordering (kernel.h:156-158, mp_machdep.c:384/544, machdep.c:2687) into CPUSET_FOREACH's ncpus bound (cpu_topology.h:63-65) and into lwkt_cpusync_interlock masking (lwkt_ipiq.c:855-856)"]

PoC changes

Seed sketch reduced to a pure-sysctl shell script (no kernel module or build needed); added per-cpu sibling enumeration and an explicit PASS/BUG verdict line comparing against hw.ncpu and the lazily-rendered tree.

Verified recommended fix

In sbuf_print_cpuset, iterate the full mask width (i < MAXCPU with CPUMASK_TESTBIT) instead of CPUSET_FOREACH's ncpus-bounded loop; separately defer the fix_amd_topology compute-unit pass until after SI_BOOT2_START_APS.

Verdict

CPU topology sysctl strings (hw.cpu_topology.members, per-cpu physical_siblings/core_siblings) are rendered at SI_BOOT2_CPU_TOPOLOGY while ncpus is still 1 (APs start later at SI_BOOT2_START_APS), so CPUSET_FOREACH only ever tests bit 0 and the exported strings are wrong on every SMP system; proven live on the 6-vCPU QEMU guest (members=cpus(0) vs correct tree cpus(0-5)). Second manifestation: fix_amd_topology()'s lwkt_cpusync is masked to the BSP-only smp_active_mask at that stage, so compute_unit_id is only set on the BSP node and the AMD compute-unit merge degenerates. No memory-safety impact: information-quality/scheduling bug only.