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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5653 Β· 11 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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_config concurrently 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 calling bus_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_trigger between 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_testable
baseline no→ patch + rebuild →patched clean

Low/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
↓ fix.diffper-fix-DF-5653

Confirmed kernel references

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).