⬢ DragonFlyBSD Kernel Audit
DF-5652 / verdict.json
← back to finding ↓ download raw
{
  "finding_id": "DF-5652",
  "status": "reproduced",
  "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) \u2014 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) \u2014 an out-of-contract negative irq reconfigures a real ISA IRQs 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) \u2014 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 \u2014 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.",
  "reproduced": true,
  "impact": "none",
  "confidence": "certain",
  "exploit_chain": "",
  "evidence": "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)",
  "kernel_refs": [
    "sys/platform/pc64/icu/elcr.c:57",
    "sys/platform/pc64/icu/elcr.c:89",
    "sys/platform/pc64/icu/elcr.c:106",
    "sys/platform/pc64/icu/elcr.c:108",
    "sys/platform/pc64/icu/elcr.c:114",
    "sys/platform/pc64/icu/elcr.c:117",
    "sys/sys/systm.h:116",
    "sys/platform/pc64/icu/icu_abi.c:342",
    "sys/platform/pc64/icu/icu_abi.c:373",
    "sys/platform/pc64/x86_64/nexus.c:487"
  ],
  "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 \u2014 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.",
  "attempts": 2,
  "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul  2 06:02:54 UTC 2026     root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC  x86_64",
  "runtime_sec": 420,
  "guest_dirty": 0,
  "build_cmd": "cd /root/elcrx && sh build.sh   (bsd.kmod.mk; cc -Werror -> elcrx.ko elcrboom.ko)",
  "run_cmd": "cd /root/elcrx && sh run.sh (E0/E1/E2) ; sh run_boom.sh LAST (assert panic; then vm.sh reset with-src)",
  "code_hash": "39f7f459b92ea7e3b0202e25426ff64e974521f94aea3482396d270771cef13b (elcrx.c) + 6490c32b456a7993ec6c67ca17e794e81f3f9d184885aff0f0d460d6a92c0909 (elcrboom.c)",
  "notes": "Impact honestly none for an unprivileged attacker (that IS the Info classification): no unprivileged reach, no OOB memory access; the sink is a 16-bit port register + 32-bit shadow. The E1 alias proof and E2 hardware write are the severity-relevant facts: a wild irq does not just read garbage \u2014 it re-toggles a REAL IRQs trigger mode (level vs edge), which on real hardware means interrupt storms or lost IRQs for whichever device owns the aliased line. Same-class pass-2 cross-checks: elcr_dump (elcr.c:128-148) loop-bounded by constant ISA_IRQ_CNT \u2014 safe; elcr_probe (elcr.c:66-80) BSP-only pre-SMP (machdep.c:2774) behind NISA \u2014 no race; hw.elcr_disable is a loader.conf tunable (root-only); the 0x4d0/0x4d1 read heuristic on non-ELCR machines is FreeBSD heritage, killed as a candidate.",
  "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)",
  "fix_status": "not_applicable",
  "fix_baseline_reproduced": 0,
  "fix_patched_reproduced": 0,
  "fix_verdict": "Info/hardening finding \u2014 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) \u2014 verified against run.log/panic.txt by inspection; no memory-corruption baseline exists to regression-test.",
  "fix_evidence": "fix.diff"
}