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

elcr_read_trigger/elcr_write_trigger: irq bounds enforced only by an INVARIANTS-only KASSERT with no signed lower bound β€” negative irq executes a C-undefined shift that aliases a real ISA bit and programs the live ELCR port; irq>=16 silently corrupts the shadow on production kernels

Summary

The only validation is a strict-less-than assert with no lower bound, compiled to nothing on production kernels. Live-verified on the INVARIANTS guest: negatives PASS the assert (read(-1)/(-22) accepted, -22 aliased real bit 10 via mod-32 shift), elcr_write_trigger(-27, LEVEL) programmed the LIVE port 0x4d0 (bit5 set on real hardware), and write(16) panics on the assert while production kernels would silently execute elcr_status |= 1<<16 (shadow corruption past the 16-bit domain resurfacing in elcr_resume's byte writes). DF-5616 sibling; wild-irq source is a DF-5465-class driver bug where elcr is the final sink. signed-bound + runtime-guard fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5652 Β· 17 files
FileTypeDescriptionSize
elcrx.c β€” 9.6 KB view raw
elcrboom.c β€” 1.4 KB view raw
Makefile β€” 78 B ↓ download
build.sh β€” 136 B view raw
run.sh β€” 227 B view raw
run_boom.sh β€” 214 B view raw
build.log β€” 6.7 KB view raw
run.log β€” 2.7 KB view raw
boom.log β€” 0 B ↓ download
panic.txt β€” 896 B view raw
panic_raw.log β€” 3.1 KB view raw
wedge_v1.log β€” 2.5 KB view raw
env.txt β€” 358 B view raw
fix.diff β€” 1.3 KB view raw
VERDICT.md β€” 4.4 KB ↓ raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 5.8 KB view raw
VERDICT.md
↓ download raw

DF-5652 β€” VERDICT

Finding: elcr_read_trigger() / elcr_write_trigger() validate the caller-supplied irq only with KASSERT(irq < ISA_IRQ_CNT) β€” sys/platform/pc64/icu/elcr.c:89, elcr.c:106.

Verdict: REPRODUCED (live), impact none for an unprivileged attacker β€” defense-in-depth gap closing the family perimeter (DF-5616/5464/5465 class). Severity Info.

What was proven live (X86_64_GENERIC, INVARIANTS kernel, i440fx)

  1. No lower bound, even under INVARIANTS. KASSERT(irq < ISA_IRQ_CNT) is a strictly-less-than check on a signed int: irq=-1 passes. elcr_read_trigger(-1) and elcr_read_trigger(-22) returned normally (run.log E1). No panic occurred β€” the negative irq flowed straight into elcr_status & (1 << irq) (elcr.c:57, elcr.c:90).

  2. The UB shift aliases a real bit β€” ALIAS-PROVEN. x86 shl takes the count mod 32, so 1 << -22 materializes as 1 << 10. elcr_read_trigger(-22) returned the same value as elcr_read_trigger(10) (both L, since this rig's ELCR is 0x0c00 = irq10/11 level). The negative irq read/writes a real ISA IRQ's trigger bit.

  3. A negative irq PROGRAMS LIVE HARDWARE. elcr_write_trigger(-27, INTR_TRIGGER_LEVEL) β€” -27 < 16 passes the assert β€” set shadow bit 5 (new_status = elcr_status | (1 << -27) β†’ 1 << 5) and wrote port 0x4d0: 0x00 -> 0x20 (bit5 SET) (run.log E2). The kernel then believes irq5 is edge (stale intent) while hardware is level β€” or vice versa after a further in-range write. State restored exactly (ok=YES).

  4. The upper bound is a debug assert, not a guard. Loading elcrboom.ko on this INVARIANTS guest panics at exactly elcr.c:106: panic: elcr_write_trigger: invalid IRQ 16, trace elcr_write_trigger+0x8c <- elcrboom_modevent (panic.txt). On a production (non-INVARIANTS) kernel sys/sys/systm.h:117 compiles the KASSERT to do { } while (0): the same call silently executes elcr_status |= 1 << 16 β€” shadow corruption past the 16-bit register domain, no error, no port protection (the outb at elcr.c:115 truncates to 8 bits, so bits 16-31 poison only the shadow; they resurface in elcr_resume()'s byte writes).

Reachability (why Info)

  • In-tree callers are bounded: icu_abi_initmap() loops i=0..15 (icu_abi.c:342-344); icu_abi_legacy_intr_config() checks KKASSERT(irq >= 0 && irq < IDT_HWI_VECTORS) β€” always compiled in β€” plus KKASSERT(im_type == ICU_IMT_LEGACY) which pins irq to 0..15 in practice (icu_abi.c:373-376). No unprivileged-user path exists: the config chain is machintr_legacy_intr_config <- nexus_config_intr (BUS_CONFIG_INTR, driver probe) / acpi_fadt SCI setup / pci tunable β€” all kernel/boot/root reach.
  • The realistic in-tree route for a wild irq is a driver bug of the DF-5465 class (icu_abi_legacy_intr_config KKASSERT-only bounds on a production kernel) β€” where elcr is the final sink. Sibling finding DF-5616 (icu.c:167, same signed-lower-bound gap feeding the 8259 IMR) closes the same perimeter on the mask side.
  • No memory unsafety: no array indexing anywhere in elcr.c β€” the blast radius is the 16-bit ELCR register domain and the 32-bit shadow.

Harness

elcrx.c (shared 3-finding harness; E0/E1/E2 phases here) and elcrboom.c (upper-bound probe, run last). Build clean with -Werror (build.log). v1 of the harness wedged the guest with a spin-barrier starvation bug (documented in wedge_v1.log, poc_changes) β€” not a finding; v2 removed the barrier. Every destructive phase restores state (ok=YES, E9: coherent=YES ... guest HEALTHY); the boom panic is followed by vm.sh reset with-src (guest returned clean).

Fix

Runtime signed bounds in both functions (fix.diff): the KASSERTs gain irq >= 0 &&, and a real if (irq < 0 || irq >= ISA_IRQ_CNT) guard returns a safe value before any shift or port write β€” the write path must never touch 0x4d0/0x4d1 with an out-of-contract irq. Behavioral delta vs the live P-runs: negative/OOB irqs return EDGE / no-op instead of aliasing a real bit (E2) or corrupting the shadow (boom).

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

Info/hardening finding β€” single-fix kernel rebuild not warranted (matches DF-5616 precedent). fix.diff adds the signed lower bound to both KASSERTs plus real runtime guards (read: return INTR_TRIGGER_EDGE; write: return before touching elcr_status or the ports). Its entire behavioral delta vs the live runs is: trigger(-1)/(-22) return EDGE instead of aliasing real bits, write_trigger(-27,...) no-ops instead of setting hw bit5, and write_trigger(16,...) returns instead of panicking (INVARIANTS) or corrupting shadow bit 16 (production) β€” verified against run.log/panic.txt by inspection; no memory-corruption baseline exists to regression-test.

fix.diff
↓ fix.diffper-fix-DF-5652

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log E1 lines: 'elcr_read_trigger(-1)=E (no panic: negative irq ACCEPTED...)' and 'elcr_read_trigger(-22)=L vs elcr_read_trigger(10)=L -> ALIAS-PROVEN (1<<-22 materialized as 1<<10 on x86)'; run.log E2: 'elcr_write_trigger(-27, LEVEL): hw0x4d0 0x00 -> 0x20 (bit5 SET), shadow trigger(5)=L' then 'restored ... ok=YES'; panic.txt: 'panic: elcr_write_trigger: invalid IRQ 16' with trace 'elcr_write_trigger+0x8c <- elcrboom_modevent' (INVARIANTS-only enforcement); E0: 'hw ELCR=0x0c00 shadow LEVEL-bits=0x0c00 coherent=YES' (rig baseline); wedge_v1.log documents the v1 harness starvation bug (not a finding)

PoC changes

Harness authored from scratch (no seed). v1 wedged the guest: the two race kthreads spun on a start-barrier flag while pinning cpu0/cpu1, and the kldload thread that had to set the flag (on cpu0, per E0 mycpuid=0) was starved by the never-yielding spin thread β€” an LWKT same-priority starvation deadlock, documented in wedge_v1.log and fixed in v2 (barrier removed, 300k iters, lwkt_yield() every 16k). E1/E2 evidence from the wedged run was salvaged from the serial log and re-confirmed identically in the clean v2 run. The Makefile needed SRCS+= device_if.h bus_if.h (sys/bus.h) and machine/atomic.h (not sys/atomic.h); DEV_MODULE replaced with DECLARE_MODULE.

Verified recommended fix

Runtime signed bounds in both functions: KASSERT gains irq >= 0 && and a real 'if (irq < 0 || irq >= ISA_IRQ_CNT) return safe-value;' guard before any shift or port write (elcr.c:89, elcr.c:106)

Verdict

Live-verified on the QEMU/i440fx guest (X86_64_GENERIC, INVARIANTS kernel, ELCR=0x0c00 coherent): the ELCR trigger API validates its irq only with KASSERT(irq < ISA_IRQ_CNT) (elcr.c:89,106) β€” a strictly-less-than check on a signed int with NO lower bound, compiled out entirely on production kernels (systm.h:116-122). elcr_read_trigger(-1) and (-22) were accepted without panic even under INVARIANTS, and -22 provably aliased real bit 10 (1<<-22 materializes as 1<<10 on x86; trigger(-22)==trigger(10)==LEVEL). The write path is worse: elcr_write_trigger(-27, LEVEL) passed the assert, set shadow bit 5 via the UB shift, and PROGRAMMED THE LIVE PORT 0x4d0 (0x00 -> 0x20, bit5 SET) β€” an out-of-contract negative irq reconfigures a real ISA IRQ's trigger mode in hardware. The upper bound is assert-only: elcr_write_trigger(16, LEVEL) panics at elcr.c:106 on this INVARIANTS kernel ('panic: elcr_write_trigger: invalid IRQ 16', trace elcr_write_trigger+0x8c) β€” i.e. the sole enforcement is a debug assertion; on production kernels the same call silently does elcr_status |= 1<<16 (shadow corruption past the 16-bit register domain). Reach is kernel-code-only (root/KLD or a DF-5465-class driver bug feeding a wild irq through icu_abi_legacy_intr_config); in-tree callers are KKASSERT- or loop-bounded. No memory unsafety (no arrays in elcr.c), no unprivileged path β€” Info/hardening, closing the family perimeter with DF-5616 (icu.c:167). Every destructive phase restored state exactly (E2 ok=YES, E9 coherent=YES); boom run followed by vm.sh reset with-src.