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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5442 Β· 14 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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() dispatches amd64_mrstoreone() to all CPUs with the asynchronous lwkt_send_ipiq_mask(); remote CPUs merely get a FIFO entry (sys/kern/lwkt_ipiq.c lwkt_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() calls amd64_mrstore(sc) and then immediately amd64_mrfetch(sc), which rewrites every entry of the shared sc->mr_desc[] array (:187-241) from the caller's MSRs β€” with zero synchronization against a remote CPU still executing amd64_mrstoreone() (:306-386), which walks and reads that same array slot-by-slot (mrd->mr_flags, mrd->mr_base, mrd->mr_len).
  • The mem_lock held 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, and amd64_mrAPinit() reached from mem_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_testable
baseline no→ patch + rebuild →patched clean

No 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
↓ fix.diffper-fix-DF-5442

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.