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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2846 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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: 6hw.cpu_topology.members: cpus(0)β root members; must becpus(0-5)hw.cpu_topology.cpuN.physical_siblings: cpus(0)for every N β the whole guest is one chip, must becpus(0-5)hw.cpu_topology.cpuN.core_siblings: cpus()(empty) for N=1..5 β each core is single-threaded, must becpus(N)sysctl -n hw.cpu_topology.treeβ rendered at READ time β correctly showsPACKAGE 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)
sys/kern/subr_cpu_topology.c:823-824βSYSINIT(cpu_topology, SI_BOOT2_CPU_TOPOLOGY, SI_ORDER_FIRST, init_cpu_topology).sys/sys/kernel.h:156-158βSI_BOOT2_CPU_TOPOLOGY = 0x1a58000sorts BEFORESI_BOOT2_START_APS = 0x1a60000.sys/platform/pc64/x86_64/machdep.c:2687βncpus = 1at early boot; it is only set tonaps + 1insidestart_all_aps()(sys/platform/pc64/x86_64/mp_machdep.c:544), which runs frommp_start_aps()βSYSINIT(startaps, SI_BOOT2_START_APS, ...)(mp_machdep.c:384).init_pcpu_topology_sysctl()(subr_cpu_topology.c:563-610) and themembersrendering (:656-660) callsbuf_print_cpuset(), whoseCPUSET_FOREACH(i, *mask)(subr_cpu_topology.c:727) expands tofor (i = 0; i < ncpus; i++) if (CPUMASK_TESTBIT(mask, i))(sys/sys/cpu_topology.h:63-65). Withncpus == 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_testableConfirmed kernel references
- sys/kern/subr_cpu_topology.c:563-610
- sys/kern/subr_cpu_topology.c:656-660
- sys/kern/subr_cpu_topology.c:727
- sys/kern/subr_cpu_topology.c:823-824
- sys/kern/subr_cpu_topology.c:285
- sys/sys/cpu_topology.h:63-65
- sys/sys/kernel.h:156-158
- sys/platform/pc64/x86_64/machdep.c:2687
- sys/platform/pc64/x86_64/mp_machdep.c:384
- sys/platform/pc64/x86_64/mp_machdep.c:544
- sys/kern/lwkt_ipiq.c:855-856
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.
No comments yet.