elcr_write_trigger(): unlocked read-modify-write of the global elcr_status shadow β concurrent legacy config_intr silently drops trigger-mode updates (measured 3,985 lost updates in a 2-second two-CPU run)
Summary
The shadow RMW + byte outb has no lock/atomic, and the caller serializes nothing (contrast the MSI paths in the same file which hold icu_irqmap_tok). Live-measured on the 6-vCPU guest: two pinned kthreads x 300k writes recorded 1,827+2,158 read-back mismatches - each a stale-base store silently reverting the just-written bit in BOTH shadow and hardware port. On real hardware a dropped level programming (ISA card, ACPI SCI) means storms/lost interrupts. Boot/root-gated (all in-tree paths serialized or cpu0-pinned). spinlock fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5653 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| elcrx.c | β | 9.6 KB | view raw | |
| Makefile | β | 78 B | β download | |
| build.sh | β | 124 B | view raw | |
| run.sh | β | 218 B | view raw | |
| build.log | β | 6.7 KB | view raw | |
| run.log | β | 2.7 KB | view raw | |
| env.txt | β | 358 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| VERDICT.md | β | 3.5 KB | β raw | |
| manifest.json | β | 1.0 KB | view raw | |
| verdict.json | β | 4.9 KB | view raw |
DF-5653 β VERDICT
Finding: elcr_write_trigger() performs an unlocked
read-modify-write of the global elcr_status shadow and the paired
port byte β sys/platform/pc64/icu/elcr.c:103-117 β and the caller
icu_abi_legacy_intr_config() (icu_abi.c:366-397) serializes nothing
(it does not even take icu_irqmap_tok, which the MSI paths in the
same file hold; cf. icu_abi.c:463, 529, 625).
Verdict: REPRODUCED (live, measured): 1,827 + 2,158 lost trigger-mode updates out of 300,000 writes per thread in a 2-second run. Severity Low (root/boot-gated reach; real functional consequence on hit).
Mechanism
CPU0 (irq5 -> LEVEL) CPU1 (irq7 -> LEVEL)
new = elcr_status | 0x20 (stale base S0)
new = elcr_status | 0x80 (same stale S0)
elcr_status = new (S0|0x20)
elcr_status = new (S0|0x80 β bit5 LOST)
outb(0x4d0, shadow&0xff) outb(0x4d0, shadow&0xff) (bit5 lost in HW too)
Both the shadow and the hardware byte converge to the loser's word: the
winner's LEVEL request for irq5 is silently reverted to EDGE. On
real hardware a level-triggered device (e.g. an ISA/EISA card or ACPI
SCI configured level) whose request is dropped runs edge β interrupt
storms or lost interrupts for that line.
Live proof (run.log phase R, X86_64_GENERIC, 6 vCPU)
Two kernel threads pinned to cpu0/cpu1 (kthread_create_cpu),
300,000 iterations each, each iteration = one real
elcr_write_trigger() + a shadow read-back check on the same
interrupt-mode it just wrote:
R1: done=2 waited=2s lost_irq5=1827 lost_irq7=2158
β0.6% of all writes were clobbered by the peer's stale-word store β
the race is not theoretical; it hits thousands of times per second of
contention. After the run both irqs were restored to their baseline
EDGE modes and the ELCR verified back at 0x0c00 (R2 ... ok=YES,
E9 ... coherent=YES guest HEALTHY).
Reachability (why Low, not higher)
- No in-tree path lets two CPUs call
legacy_intr_configconcurrently today: PCI probing is serialized at boot,acpi_sci_config()runs on cpu0 only (KKASSERT(mycpuid == 0), acpi_fadt.c:222), and the post-boot route (kldload of a driver callingbus_config_intr) is root-only. The unprivileged-user reach is nil. - The 0x4d0/0x4d1 byte writes do not interleave with the ICU mask
writes at 0x21/0xa1 (different registers), so there is no mask vs
trigger register-tearing hazard β the defect is purely the
shadow-word RMW, plus the same-thread variant (interrupt handler
calling
elcr_write_triggerbetween elcr.c:113 and elcr.c:115-117), which converges and is benign. - Root-gated with a concrete, measured corruption primitive of a hardware config register: Low per the rubric ("requires already-privileged user"), certain mechanism.
Fix
Serialize the shadow update + port write with a spinlock (fix.diff) β
DragonFly idiom static struct spinlock x = SPINLOCK_INITIALIZER(...)
(cf. subr_prf.c:107). The early-out (new_status == elcr_status) is
moved inside the lock so the check can't race either. elcr_resume()
may take the same lock for symmetry when it gains callers (DF-5654).
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_testableLow/hardening finding β single-fix kernel rebuild not warranted (root-gated reach, no in-tree concurrent caller today). fix.diff wraps the RMW+outb in a spinlock with the early-out moved inside; behavioral delta vs the live R1 run is 'lost counts go to zero under the identical 2-CPU hammer', which follows directly from the serialization (the measured mechanism is exactly the unlocked window). No memory-corruption baseline exists to regression-test.
fix.diff
Confirmed kernel references
- sys/platform/pc64/icu/elcr.c:103
- sys/platform/pc64/icu/elcr.c:108
- sys/platform/pc64/icu/elcr.c:111
- sys/platform/pc64/icu/elcr.c:113
- sys/platform/pc64/icu/elcr.c:115
- sys/platform/pc64/icu/elcr.c:117
- sys/platform/pc64/icu/icu_abi.c:366
- sys/platform/pc64/icu/icu_abi.c:463
- sys/platform/pc64/acpica/acpi_fadt.c:195
Detail
Evidence (decisive lines)
run.log R1 line: 'R1: done=2 waited=2s lost_irq5=1827 lost_irq7=2158 (read-back mismatches = lost trigger-mode updates)' β ~0.6% of writes lost; 'R2: restored: hw=0x0c00 (was 0x0c00) ... ok=YES' and 'E9: final hw=0x0c00 baseline=0x0c00 coherent=YES ... guest HEALTHY' (restore discipline); VERDICT.md carries the interleaving diagram and the reachability analysis
PoC changes
Harness authored from scratch (no seed). v1's spin-barrier starved the kldload thread and wedged the guest (LWKT same-priority starvation β harness bug, see DF-5652/wedge_v1.log); v2 dropped the barrier, uses natural overlap of the 300k-iteration loops plus lwkt_yield() every 16k iterations. Detection design note: the read-back check (elcr_read_trigger immediately after each write, reading the shadow the peer may have clobbered) undercounts by construction (reverts landing after the check are missed) β 3,985 counted losses is a lower bound.
Verified recommended fix
Serialize elcr_write_trigger() with a spinlock (SPINLOCK_INITIALIZER idiom) covering the shadow RMW and the port write, with the early-out check inside the lock (elcr.c:103-117)
Verdict
Live-verified and MEASURED on the QEMU/i440fx guest (6 vCPU): elcr_write_trigger() (elcr.c:103-117) performs an unlocked read-modify-write of the global elcr_status shadow followed by a byte outb β no lock, no interrupt disable, no atomic β and its caller icu_abi_legacy_intr_config() (icu_abi.c:366-397) serializes nothing either (the MSI paths in the same file take icu_irqmap_tok; the legacy config path takes no token). Two kernel threads pinned to cpu0/cpu1, each issuing 300,000 real elcr_write_trigger() calls toggling irq5/irq7 (same 32-bit shadow word, same 0x4d0 byte) between LEVEL and EDGE with a post-write shadow read-back, recorded 1,827 (irq5) + 2,158 (irq7) lost updates in a 2-second run β each mismatch is a stale-base store by the peer that silently reverted the just-written trigger bit in BOTH the shadow and the hardware port. On real hardware a dropped level-trigger programming means an edge-run level device: interrupt storms or lost interrupts. Reachability gates severity at Low: no unprivileged-user path; all in-tree config paths are boot-serialized or cpu0-pinned (acpi_fadt.c:222 KKASSERT(mycpuid==0)), so concurrency requires root (kldload racing bus_config_intr) or a future MP probe path. Post-run state restored exactly (R2 ok=YES, E9 coherent=YES guest HEALTHY).
No comments yet.