Cross-CPU MTRR reprogram race: amd64_mrstore() fire-and-forget IPI vs amd64_mrfetch() immediate rewrite of the shared mr_desc[] array leaves a persistent per-CPU memory-type desync (torn descriptor generation)
Summary
mrset dispatches storeone via the ASYNCHRONOUS lwkt_send_ipiq_mask (queue-and-return) then immediately refetches/rewrites every mr_desc[] entry - a remote CPU still executing storeone reads torn base/len/flags per slot and programs an MTRR pair matching neither generation; nothing repairs it (whole-PA-space UC on one CPU possible). mem_lock only serializes ioctls. Root-gated /dev/mem ioctl; machine-check/corruption class on real HW; also undermines securelevel-locked MTRR state. 9-protocol ~500k-SET campaign with a per-CPU MSR-snapshot KLD (detector self-test proven) never landed the tear under KVM (phase-locked ~1.1us/exit cadence) - honest not_reproduced, defect code-certain (FreeBSD uses the full barrier; in-tree XXX comments admit the gap). lwkt_cpusync_simple fix (both call sites).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5442 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.6 KB | β raw | |
| VERDICT.md | β | 6.0 KB | β raw | |
| mtrr_race.c | β | 10.7 KB | view raw | |
| mtrrdump.c | β | 7.5 KB | view raw | |
| Makefile | β | 69 B | β download | |
| build.sh | β | 243 B | view raw | |
| run.sh | β | 165 B | view raw | |
| build.log | β | 278 B | view raw | |
| run.log | β | 10.6 KB | view raw | |
| fix.diff | β | 645 B | view raw | |
| logs/final_run.log | β | 13.5 KB | view raw | |
| logs/race.log | β | 15.4 KB | view raw | |
| logs/race2.log | β | 15.5 KB | view raw | |
| logs/env.txt | β | 606 B | view raw |
DF-5442 β /dev/mem MEMRANGE_SET cross-CPU MTRR race in amd64_mem.c
What the finding is
amd64_mrstore() (sys/platform/pc64/x86_64/amd64_mem.c:280-290) dispatches the
MTRR reprogram function amd64_mrstoreone() to the other CPUs with the
asynchronous, fire-and-forget lwkt_send_ipiq_mask() β it returns as soon
as the remote IPIs are queued, without waiting for any remote CPU to execute
them (sys/kern/lwkt_ipiq.c, lwkt_send_ipiq3(): queues to the target FIFO
and returns; only the sending CPU's own invocation runs inline).
Its only caller amd64_mrset() (amd64_mem.c:529-599) then immediately
rewrites the shared sc->mr_desc[] descriptor array via amd64_mrfetch()
(amd64_mem.c:597 β :172-243) β while a remote CPU may still be reading that
same array inside amd64_mrstoreone() (amd64_mem.c:298-400, reads
mrd->mr_flags/mr_base/mr_len per slot). A remote CPU whose storeone
interleaves with the caller's refetch reads a torn descriptor generation
(base from one generation, flags/len from another) and programs a permanent,
per-CPU MTRR pair that matches neither β a cross-CPU memory-type desync
(nothing repairs it: that storeone is the last one queued). Consequences on
real hardware: one CPU with, e.g., UC or WC where all others have WB over a
physical range β machine check / hang / silent memory corruption.
Nothing else synchronizes the two: the ioctl path holds mem_lock
(kern_memio.c:493), but that lock serializes ioctls against each other, not
against remote-CPU IPI work. FreeBSD's counterpart uses the full barrier
smp_rendezvous_cpus(); DragonFly's own XXX comments at amd64_mem.c:283-287
and :720-725 admit the missing rendezvous ("We should use ipi_all_but_self()
to call other CPUs into a locking gate").
Reachability: ioctl("/dev/mem", MEMRANGE_SET, ...) β root/kmem-gated at open
(devfs 0640 root:kmem + caps_priv_check(SYSCAP_RESTRICTEDROOT|
__SYSCAP_NOROOTTEST), kern_memio.c:152-153). The missing FWRITE/securelevel
gate on the ioctl itself is the already-filed DF-0080 and is NOT re-reported
here.
What is in this pack
| file | what it is |
|---|---|
mtrr_race.c |
userland PoC (root): control SET/GET/REMOVE roundtrip + race hammer with arrival-time scan |
mtrrdump.c |
diagnostic KLD: debug.mtrr_snap (per-CPU variable-MTRR snapshot via lwkt_cpusync_simple rendezvous + cross-CPU divergence comparator), debug.mtrr_busy (busy-IPI load injector), debug.mtrr_time (store/fetch cost-center measurement) |
Makefile |
stock kmod.mk Makefile (guest tree needs hand-linking, see build.sh) |
build.sh / run.sh |
exact guest-side build + run |
build.log / run.log |
compiler + runtime output |
logs/final_run.log |
decisive 60-cycle run (canonical) |
logs/race.log, logs/race2.log |
earlier protocol iterations |
logs/env.txt |
guest uname, cpu, sysctls, cost measurements |
fix.diff |
the one-line-semantics fix (full rendezvous) |
VERDICT.md |
full narrative |
Build (on the guest, as root)
See build.sh. The guest's /usr/src include dirs are trimmed, so the host
pushes machine.tgz/cpu.tgz (pc64 + x86_64 include trees) and the module is
compiled with the kernel CFLAGS from sys/platform/pc64/conf/kern.mk
(-mcmodel=kernel -mno-red-zone -msoft-float ...) and linked per
sys/conf/kmod.mk:207 (cc -nostdlib -Wl,--hash-style=sysv -r -Wl,-d).
Run (on the guest, as root)
./run.sh β expected: control roundtrip OK (A active at desc 89,
after REMOVE ... = -1), then 60 race cycles each printing
MTRR-SNAP: cpus=6 divergent_slots=0 ..., then the detector self-test
which MUST print divergent_slots=1 (cpu3 deliberately perturbed) to prove
the comparator works.
Result on this rig
not_reproduced β ~500k MEMRANGE_SET executions across 9 protocol variants
(idle; 2-3 concurrent setter threads; continuous 10-80Β΅s busy-IPI jitter;
caller pinned to cpu0; per-cycle changing final generation; 0-150Β΅s and
0-1000Β΅s arrival-time scans) produced zero post-drain cross-CPU MTRR
divergence, with the detector itself proven to fire (self-test) and the phase
costs measured (var_wrmsr=18 wbinvd=1 var_rdmsr=18 usec): under KVM the
store/fetch slot loops are phase-locked at the same ~1.1Β΅s/exit cadence, so
the caller's writes and the remote's reads stay per-slot ordered; the tear
needs sub-Β΅s alignment inside a ~2Β΅s band that KVM's exit jitter never lands
in. On real hardware β where wbinvd costs 10-100Β΅s on large-memory boxes,
making the self-store phase an order of magnitude longer than the fetch phase
β the window is proportionally wider. The defect itself is code-certain.
Expected output of the decisive run: see logs/final_run.log.
DF-5442 VERDICT β not_reproduced (on this KVM guest); defect code-certain
The defect (path:line)
sys/platform/pc64/x86_64/amd64_mem.c:289βamd64_mrstore()dispatchesamd64_mrstoreone()to all CPUs with the asynchronouslwkt_send_ipiq_mask(); remote CPUs merely get a FIFO entry (sys/kern/lwkt_ipiq.clwkt_send_ipiq3(), queue-and-return; only the sender's own invocation runs inline).sys/platform/pc64/x86_64/amd64_mem.c:594-597βamd64_mrset()callsamd64_mrstore(sc)and then immediatelyamd64_mrfetch(sc), which rewrites every entry of the sharedsc->mr_desc[]array (:187-241) from the caller's MSRs β with zero synchronization against a remote CPU still executingamd64_mrstoreone()(:306-386), which walks and reads that same array slot-by-slot (mrd->mr_flags,mrd->mr_base,mrd->mr_len).- The
mem_lockheld by the ioctl path (sys/kern/kern_memio.c:493) only serializes ioctls against each other; it says nothing about remote-CPU IPI execution. - The same pattern in
amd64_mrreinit()(:726) for AP re-init, andamd64_mrAPinit()reached frommem_range_AP_init()(sys/platform/pc64/x86_64/mp_machdep.c:1521) during AP boot. - FreeBSD's counterpart reprograms MTRRs under
smp_rendezvous_cpus()β a full barrier that returns only after every CPU finished. DragonFly's own XXX comments at :283-287 and :720-725 admit the missing gate.
A remote CPU whose storeone interleaves with the caller's refetch reads a torn
(base, flags, len) triple and programs an MTRR pair that matches neither
generation β and because that storeone is the last one queued, nothing ever
repairs it: the desync persists on that CPU (e.g. slot programmed with
base=0 + mask=VALID only = whole physical address space UC, from mixing
an inactive slot's stale base/len with the new ACTIVE flags). On real
hardware that is machine-check / hang / silent-corruption material for
whatever physical ranges end up mistyped on one CPU only.
Entry: ioctl("/dev/mem", MEMRANGE_SET, ...) β root/kmem-gated at open
(kern_memio.c:140-160). The missing FWRITE/securelevel gate on the ioctl
itself is already filed as DF-0080 and is not re-reported.
Verification effort (9 protocol variants, ~500k SETs)
Rig: 6-vCPU KVM guest (DragonFly 6.5-DEVELOPMENT, kernel of Jul 2 2026),
ndesc=96 (88 fixed + 8 variable MTRRs), MEMRANGE_SET/GET/REMOVE roundtrip
verified (control: A active at desc 89, REMOVE β -1).
Observation tool: mtrrdump.ko β
debug.mtrr_snap reads all 8 variable-MTRR base/mask MSR pairs plus
MTRRdefType on every CPU inside a lwkt_cpusync_simple() rendezvous (which
also drains each CPU's pending IPI FIFO before sampling), then compares
across CPUs; debug.mtrr_busy fires a Β΅s-spin IPI at all CPUs (load
injector); debug.mtrr_time measures the cost centers.
Detector validated: a deliberately perturbed build (cpu3 slot0 ^= 0x1000)
reports divergent_slots=1 with the per-CPU table; the clean build reports 0.
Protocols tried: 1. single-threaded alternation, idle system (550 SETs) β 0 divergence; 2. 2-3 concurrent setter threads (~110k SETs) β 0; 3. continuous 60-120Β΅s busy-IPI jitter (~68k SETs) β 0; 4. caller pinned to cpu0 + per-cycle changing final generation (~67k SETs) β 0; 5. busy injectors live through the final SET (~62k SETs) β 0; 6. arrival-time scan, busy 0-150Β΅s in 10Β΅s steps before each final SET (~104k SETs) β 0; 7. arrival-time scan 0-1000Β΅s in 50Β΅s steps (~81k SETs) β 0; 8. final canonical run (60 cycles, ~14k SETs) β 0, guest healthy throughout; 9. plus the ENOSPC-triggering variant (slots exhausted) that proved the ENOSPC path at amd64_mem.c:515 behaves as read.
Why it does not reproduce under KVM (measured)
debug.mtrr_time: var_wrmsr=18 wbinvd=1 var_rdmsr=18 usec
(tsc_frequency=3.6GHz). So T_s (storeone) β 32Β΅s and T_f (mrfetch)
β 30Β΅s, with both slot loops advancing at the same ~1.1Β΅s/MSR-exit cadence.
Caller-side writes of slot j and remote-side reads of slot j are therefore
offset by the constant (T_s + fixed_f + jΒ·c) β (Ξ΅ + fixed_r + jΒ·c) = ~30Β΅sβΞ΅:
either the whole remote read pass happens before the caller's write pass or
after it. A tear requires the sub-Β΅s field-store/load interleaving inside a
~2Β΅s-wide arrival band, under KVM exit jitter that randomizes each trial past
the band. ~500k trials, zero hits β with the detector proven to fire.
On real hardware the geometry differs materially: wbinvd costs 10-100Β΅s on
large-memory systems (vs 1Β΅s here), which lengthens the caller's self-store
phase and widens the absolute misalignment distribution (real interrupt
latency, SMIs, other IPI work), making the overlap reachable. The defect is
unconditional in the code; only its timing window is guest-hostile here.
Verdict
status: not_reproduced on this virtualization stack (expected marker β
post-drain divergent_slots>0 β absent across ~500k SETs and 9 protocols;
guest stayed up; detector self-test proves the instrumentation). The finding
itself stands on code analysis with confidence likely: unsynchronized
shared-state access is present by construction; the manifestation needs
timing this KVM rig does not deliver.
Fix validation
Not performed (fix_status: not_testable): with no reproducible baseline on
this guest there is no observable behavior to compare a patched kernel
against. fix.diff swaps the two fire-and-forget dispatches for
lwkt_cpusync_simple() β the exact primitive the in-tree XXX comments ask
for β verified git apply --check clean against the audit tree; a patched
kernel additionally makes each MEMRANGE_SET wait for all CPUs (measurable as
a latency change, not a behavioral fix observable by this rig).
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
not_testableNo fix-kernel build was performed: the race did not reproduce on this KVM guest, so there is no observable baseline behavior for a patched kernel to eliminate. fix.diff is complete and applies cleanly (git apply --check) - it substitutes lwkt_cpusync_simple() for the asynchronous dispatch in both amd64_mrstore() and amd64_mrreinit(), which is the exact rendezvous the in-tree XXX comments request; on hardware where the window exists this removes the torn-read possibility entirely, and it additionally makes each MEMRANGE_SET measurable slower (waits for all CPUs), which is the correct semantics.
findings/poc/DF-5442/fix.diff (git apply --check clean against sys/); VERDICT.md 'Fix validation' section
Confirmed kernel references
Detail
Evidence (decisive lines)
findings/poc/DF-5442/run.log (race_exit=0, all snapshots divergent_slots=0, then the detector self-test printing 'DIVERGENT SLOTS ... cpu3 base=...c0001000 ... divergent_slots=1' proving the comparator works); findings/poc/DF-5442/logs/final_run.log (canonical 60-cycle run); logs/env.txt (uname, 6 cpus, debug.mtrr_time=var_wrmsr=18 wbinvd=1 var_rdmsr=18 usec); VERDICT.md for the full 9-protocol negative result and the code-line argument
PoC changes
No seed PoC existed (authored from scratch). Iterations that mattered: (1) module initially hand-linked without -mcmodel=kernel -> kldload panicked the guest (link_elf_obj_reloc_local); fixed with the full sys/platform/pc64/conf/kern.mk flag set and the sys/conf/kmod.mk:207 link rule. (2) raw rdmsr of var-MTRR slots 8..11 #GP'd into ddb (KVM VCNT=8) -> rdmsr_safe + SNAP_SLOTS=8 and error-masked comparison. (3) single-threaded alternation never overlaps (timing analysis: self-store runs inline in lwkt_send_ipiq3_mask before/after queueing by cpuid order) -> concurrent setter threads. (4) F range exhausted the 8 variable slots (ENOSPC at amd64_mem.c:515) -> per-cycle REMOVE and a changing final base so slot values actually differ between generations. (5) random busy-IPI jitter replaced by a deterministic arrival-time scan (single-shot busy-IPI of swept duration before each final SET, caller pinned to cpu0 via usched_set).
Verified recommended fix
Replace the two fire-and-forget lwkt_send_ipiq_mask() dispatches in amd64_mrstore()/amd64_mrreinit() with lwkt_cpusync_simple(smp_active_mask, ...) so amd64_mrset() only rewrites mr_desc[] after every CPU completed its store (fix.diff in this pack).
Verdict
The unsynchronized shared-state access is present by construction: amd64_mrstore() (amd64_mem.c:289) dispatches amd64_mrstoreone() with the asynchronous lwkt_send_ipiq_mask() (queue-and-return, sys/kern/lwkt_ipiq.c:192 only runs the sender's own invocation inline), and amd64_mrset() immediately rewrites the shared sc->mr_desc[] array via amd64_mrfetch() (amd64_mem.c:597->:172-241) while a lagging remote CPU may still be reading it inside amd64_mrstoreone() (:306-386); mem_lock (kern_memio.c:493) only serializes ioctls against each other. FreeBSD uses a full smp_rendezvous_cpus() barrier and the in-tree XXX comments (:283-287, :720-725) admit the missing gate. On the 6-vCPU KVM guest this could not be manifested: ~500k MEMRANGE_SET executions across 9 protocols (idle; 2-3 concurrent setters; 10-80us busy-IPI jitter; caller pinned to cpu0; per-cycle changing final generation; 0-150us and 0-1000us arrival-time scans) produced zero post-drain cross-CPU MTRR divergence, with the divergence detector itself proven to fire (perturbed self-test reports divergent_slots=1) and the phase cost centers measured (var_wrmsr=18 wbinvd=1 var_rdmsr=18 usec): under KVM both slot loops advance at the same ~1.1us/MSR-exit cadence, so the caller's writes and a remote's reads stay per-slot ordered and the tear needs sub-us alignment inside a ~2us band that exit jitter never delivers. On real hardware (wbinvd 10-100us on large-memory systems, real interrupt latency spread) the window is proportionally wider; the defect itself is code-certain, root-gated (open /dev/mem required), impact ceiling is per-CPU memory-type desync -> machine check / hang / silent corruption.
No comments yet.