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

acpi_sci_test(): unchecked register_int() return passed to unregister_int() β€” kernel NULL-deref panic from crafted FADT SciInterrupt

Summary

register_int legally returns NULL on exclusive-registration collision and unregister_int derefs the descriptor unguarded: a FADT whose SciInterrupt names an irq already exclusively owned before acpi0 attaches (the 8254 'clk' on IRQ0 when the LAPIC timer is off) makes unregister_int read *(int*)0x28. Planted-ACPI rig with a cloned SeaBIOS table chain (--sci 0 + hw.lapic_timer_enable=0): 'Fatal trap 12, fault VA 0x28, Stopped at unregister_int+0x25' - deterministic boot panic; control (--sci 9) boots to full userspace. register_int-NULL check + kern_intr defense-in-depth fix validated on a rebuilt kernel (attack blob boots to ssh with 'SCI irq 0 is not available'). Fixed-offset NULL-page read, panic is the ceiling; crafted-firmware/loader threat model, DF-5430 tier.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5583 Β· 17 files
FileTypeDescriptionSize
gen_fadt_sci.py β€” 8.0 KB view raw
mindSDT.dsl β€” 597 B ↓ download
mindSDT.aml β€” 67 B ↓ download
fadt_control_sci9.bin β€” 4.1 KB ↓ download
fadt_attack_sci0.bin β€” 4.1 KB ↓ download
fadt_short_len16.bin β€” 4.1 KB ↓ download
build.sh β€” 134 B view raw
run.sh β€” 340 B view raw
run.control_boot.log β€” 29.0 KB view raw
run.control_vmstat.txt β€” 999 B view raw
run.attack_panic.log β€” 13.6 KB view raw
panic.txt β€” 448 B view raw
run.fixedkernel_attack.txt β€” 725 B view raw
run.fixedkernel_control.txt β€” 386 B view raw
env.txt β€” 271 B view raw
fix.diff β€” 1.4 KB view raw
VERDICT.md β€” 6.3 KB ↓ raw
VERDICT.md
↓ download raw

DF-5583 VERDICT β€” REPRODUCED (boot-time kernel NULL-deref panic; fix validated)

Finding: acpi_sci_test() (sys/platform/pc64/acpica/acpi_fadt.c:183-217) passes register_int()'s return value β€” which is NULL when the exclusive registration collides with an existing handler (sys/kern/kern_intr.c:276-285, goto done with rec == NULL) β€” straight into unregister_int() (acpi_fadt.c:205), which dereferences it unconditionally (sys/kern/kern_intr.c:425, intr = ((intrec_t)id)->intr;).

Attacker input: the FADT SciInterrupt field (offset 46) β€” firmware table content, delivered via the proven planted-ACPI rig (loader kenv hint.acpi.0.rsdp, accepted with no validation, acpi_sdt.c:71). The value is bounds-checked only against the legacy-irq map (machintr_legacy_intr_find, acpi_fadt.c:226; both ICU and IOAPIC implementations range-check the number but know nothing about registered handlers), so any irq that is already exclusively owned when acpi0 attaches reaches register_int(INTR_EXCL|...) and fails.

Collision provider used in the PoC: the 8254 clock handler "clk" on IRQ0, registered INTR_EXCL|INTR_CLOCK by i8254_intr_initclock() (sys/platform/pc64/isa/clock.c:1289-1298) at SI_BOOT2_CLOCKREG (kern_cputimer.c:466 cputimer_intr_initclocks) β€” i.e. before acpi0 attaches at SI_SUB_CONFIGURE, where AcpiOsInstallInterruptHandler (sys/dev/acpica/Osd/OsdInterrupt.c:69) calls acpi_sci_config(). Any boot where the i8254 is the interrupt cputimer (no LAPIC timer: PIC/ICU systems, hw.lapic_timer_enable=0, many embedded/older platforms) has this precondition naturally; the rig reproduces it deterministically on the QEMU guest.

How it was reproduced (this pack)

  1. Control (fadt_control_sci9.bin, SCI_INT=9, everything else identical): planted chain accepted (ACPI SDT: RSDP in kenv hint; ACPICA core also follows it β€” ACPI: RSDP 0x0000000040000000), IOAPIC: testing 8254 interrupt delivery... success, vmstat -i shows clk live on IRQ0, ACPI FADT: SCI testing ... SCI select level/high β€” the dummy handler registers and unregisters cleanly on the free irq 9. Guest reaches full userspace + ssh. (run.control_boot.log, run.control_vmstat.txt)

  2. Attack (fadt_attack_sci0.bin, only field 46 changed 9β†’0): ACPI FADT: SCI testing interrupt mode ... ACPI FADT: SCI testing edge/high Fatal user address access from kernel mode from swapper at ffffffff80620f35 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x28 fault code = supervisor read data, page not present Stopped at unregister_int+0x25: movl 0x28(%rdi),%r12d 0x28 is exactly offsetof(struct intrec, intr) (kern_intr.c:50-59: next, info, handler, argument, name = 5Γ—8 bytes) and rdi is the NULL sci_desc. Guest sits in DDB (db>), ssh never answers β€” reproduced/panic. (run.attack_panic.log, panic.txt)

Impact assessment

Boot-time kernel NULL-page read at a fixed offset; the loaded value is only used as a comparison key in the reclist walk and range-checked afterwards, so with page 0 unmapped (always on this platform, and DFly does not let userland map page 0) the result is the panic/DoS, not controllable corruption. Threat model: attacker-controlled firmware or boot-loader environment (loader.conf / cloud image / physical DMA), the same model under which the acpi_madt family findings (DF-5430..5433) were filed. Severity Low, CWE-476, per family calibration (boot panics = Low; DF-5432's arbitrary- physical MMIO was the Medium outlier).

Fix validation (fix.diff, applied in guest /usr/src, kernel #1 Oct 8 04:41:58)

  • make -j6 nativekernel KERNCONF=X86_64_GENERIC: rc=0, -Werror clean.
  • Attack blob on patched kernel: boots to ssh; decisive lines ACPI FADT: SCI testing edge/high β†’ ACPI FADT: SCI irq 0 is not available, disable; clk ticking at ~250 Hz in vmstat -i; no panic, no DDB. (run.fixedkernel_attack.txt)
  • Control blob on patched kernel: ACPI FADT: SCI select level/high β€” the normal mode-testing path is unaffected. (run.fixedkernel_control.txt)
  • Baseline (stock kernel, same blob): Fatal trap 12 @ 0x28 (run.attack_panic.log).

fix_status: fixed (fix_baseline_reproduced=true, fix_patched_reproduced=false β€” the panic itself is gone on the patched kernel).

Negative results recorded during this audit (so the clean parts have weight)

  • FADT field reads are bounds-safe in this file: SciInterrupt (offset 46) is only read after Header.Length >= ACPI_FADT_V1_SIZE (128, acpi_fadt.c:113), BootFlags (offset 109) only after Length >= ACPI_FADT_V3_SIZE && Revision >= 3 (acpi_fadt.c:120-121). No V2..V6-specific field is consumed here at all.
  • kgetenv/kfreeenv pairing on both trigger/polarity paths is balanced (acpi_fadt.c:134-162); no leak.
  • The machintr_legacy_intr_find(acpi_sci_irq, ...)-before-< 0-check order wart (acpi_fadt.c:226-235) is cosmetic only: both ICU (icu_abi.c:664) and IOAPIC (ioapic_abi.c:967) implementations reject negative/out-of-range irq numbers, so a disabled SCI just logs a spurious "irq -1 is invalid" line.
  • A short-FADT (Length < 36) KKASSERT panic candidate at acpi_fadt.c:103 was refuted live: sdt_search() maps each candidate via sdt_sdth_map(), which returns NULL for Length<36 (acpi_sdt.c:147-148), so sdt_search skips such tables β€” fadt_probe prints "can't locate FADT" and the KKASSERT is unreachable from static table content.
  • Reset-register and PM1a/b plumbing named in the task brief live in the ACPICA core (contrib scope, divergence-note only); this DFly file consumes only SciInterrupt, BootFlags, Revision and Length.
  • Side observation (not this file, no finding filed): with lapic_enable on but no MADT, start_all_aps() spins forever in the CPUMASK_CMPMASKNEQ(smp_lapic_mask, smp_startup_mask) wait (mp_machdep.c:595-600) β€” a crafted-table boot hang in mp_machdep.c, noted for the acpi_madt/mp_machdep owners.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src (patch -p0, hunks at acpi_fadt.c:198-205 and kern_intr.c:425), make -j6 nativekernel KERNCONF=X86_64_GENERIC rc=0 -Werror clean, installkernel, reboot. Attack blob (SciInterrupt=0) on the patched kernel: NO panic, boots to ssh, prints 'ACPI FADT: SCI irq 0 is not available, disable', clk interrupt alive at ~250Hz in vmstat -i. Control blob on the patched kernel: 'ACPI FADT: SCI select level/high' -- the normal SCI mode-testing path is unchanged. Baseline on the stock kernel with the same blob: Fatal trap 12 at 0x28 in unregister_int.

run.fixedkernel_attack.txt (uname #1 Oct 8 04:41:58 + graceful disable line + vmstat clk + root shell) and run.fixedkernel_control.txt (SCI select level/high), versus run.attack_panic.log (stock kernel Fatal trap 12); diff in fix.diff; build was -Werror clean (df5583_build.log on guest, env.txt).
↓ fix.diffper-fix-DF-5583

Confirmed kernel references

Detail

Evidence (decisive lines)

run.attack_panic.log + panic.txt: 'ACPI FADT: SCI testing edge/high' immediately followed by 'Fatal trap 12: page fault while in kernel mode', 'fault virtual address = 0x28' (= offsetof(struct intrec, intr)), 'Stopped at unregister_int+0x25: movl 0x28(%rdi),%r12d' with rdi==NULL sci_desc; run.control_boot.log + run.control_vmstat.txt: same rig with SCI_INT=9 boots, vmstat -i shows clk ticking on IRQ0 (the collision owner) and 'SCI select level/high'; run.fixedkernel_attack.txt: patched kernel boots to ssh, prints 'ACPI FADT: SCI irq 0 is not available, disable', no panic; run.fixedkernel_control.txt: patched kernel still does normal SCI mode selection.

PoC changes

No seeded PoC existed (pass-2 finding). Built gen_fadt_sci.py from the proven DF-5483 chain generator with three deltas: (1) MADT reduced to 1 LAPIC but keeping the real QEMU intsrc overrides so ioapic+MSI-X keep working, (2) loader.conf adds hw.lapic_timer_enable=0 so the i8254 (not the LAPIC timer) registers its INTR_EXCL clk handler on IRQ0 before acpi0 attaches (a first attempt with no MADT/PIC mode failed twice: mp_machdep.c:595 smp_lapic_mask wait hangs forever; a second attempt with hw.lapic_enable=0 died in 'panic: MSI-X is not setup yet' in pci_ht_map_msi), (3) the FADT SCI_INT field is the only difference between control and attack blobs. Minimal inert DSDT (mindSDT.dsl) compiled with the guest's iasl 20251212.

Verified recommended fix

In acpi_sci_test(), check register_int()'s return: on NULL print 'ACPI FADT: SCI irq %d is not available, disable', set acpi_sci_irq=-1 and return TRUE (stop mode testing, never unregister the NULL descriptor); defense-in-depth NULL guard at the top of unregister_int() (kern_intr.c).

Verdict

REPRODUCED on the stock guest kernel: a crafted FADT (planted via the proven hint.acpi.0.rsdp + QEMU-loader rig) whose SciInterrupt=0 names the legacy IRQ of the already-registered INTR_EXCL 8254 'clk' handler makes acpi_sci_test() call register_int(0, ..., INTR_EXCL|...) which returns NULL (kern_intr.c:276 goto done), and that NULL is passed to unregister_int() which dereferences it at kern_intr.c:425 -- boot dies with 'Fatal trap 12: page fault ... fault virtual address = 0x28 / Stopped at unregister_int+0x25: movl 0x28(%rdi),%r12d' and sits in DDB; ssh never answers. Identical rig with SciInterrupt=9 boots to full userspace (differential control). fix.diff (NULL-check in acpi_sci_test + defense-in-depth guard in unregister_int) applied in guest /usr/src, kernel rebuilt -Werror-clean and rebooted: the same attack blob now boots to ssh with the graceful 'ACPI FADT: SCI irq 0 is not available, disable' and clk alive; control blob still mode-selects normally. Impact ceiling is the boot-time kernel NULL-page read/panic (CWE-476, DoS) under the crafted-firmware/loader threat model -- firmware-consistent family calibration Low, same class tier as DF-5430/DF-5431.