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

ioapic_map() of unvalidated firmware IOAPIC Address: arbitrary-physical-address kernel MMIO reads and writes at boot (DF-5365's IOAPIC twin)

Summary

Probe rejects only Address==0; enumerate maps whatever 32-bit PA the firmware names and the kernel reads AND writes it as a register file (select stores at +0x00/+0x10, 241 redirection programs, APIC-ID rewrite). Reproduced on real boots with planted guest RAM as a fake register file: kernel adopted npin=241 from the fake VER, rewrote through the page, monitor shows the kernel select-index write - arbitrary-PA kernel MMIO writes corrupting page tables/kernel text in low memory at early boot. phys_avail[]-reject-at-probe fix validated on a single-fix nativekernel (page stays pristine, falls back to mptable).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5432 Β· 13 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 5.2 KB ↓ raw
gen_madt.py β€” 4.8 KB view raw
build.sh β€” 273 B view raw
run.sh β€” 329 B view raw
run.log β€” 29.3 KB view raw
run.3.log β€” 29.3 KB view raw
run.fixed.log β€” 14.3 KB view raw
monitor_baseline.txt β€” 883 B view raw
monitor_fixed.txt β€” 883 B view raw
build_summary.txt β€” 1.4 KB view raw
env.txt β€” 2.1 KB view raw
fix.diff β€” 1.7 KB view raw

DF-5432 β€” ioapic_map() of unvalidated firmware IOAPIC address: arbitrary-physical-address kernel MMIO reads/writes (DF-5365's IOAPIC twin)

What

madt_ioapic_probe_callback rejects only Address == 0 (acpi_madt.c:619-622); madt_ioapic_enum_callback then maps whatever 32-bit physical address the firmware names β€” ioapic_map() (acpi_madt.c:733 β†’ ioapic.c:603-607 pmap_mapdev_uncacheable(pa, PAGE_SIZE), only KKASSERT(pa < 4GB)) β€” and the kernel performs MMIO reads AND writes through it (ioapic_read/ioapic_write, ioapic_ipl.s: 117-131: 32-bit store at +0x00 and +0x10) including redirection-entry programming (ioapic_pin_prog, ioapic.c:541-543) and APIC-ID rewrite (ioapic_set_apic_id, ioapic.c:312-322). This is the IOAPIC-side twin of DF-5365 (lapic_map, Medium): the LAPIC base at least has IA32_APICBASE to cross-check; the IOAPIC address is adopted with zero validation.

Demonstration (guest RAM as IOAPIC β€” reproduced on real kernel boots)

The MADT names Address = 0x40001000 β€” ordinary guest RAM, planted via QEMU -device loader with a fake register file (IOWIN=0x00F00000 β†’ VER read yields npin=241). Result (run.log:87-88, run.3.log on the with-src kernel):

ACPI MADT: IOAPIC addr 0x40001000, apic id 0, gsi base 0
IOAPIC: idx 0, apic id 2, gsi base 0, npin 241

The kernel adopted the RAM page, rewrote its "APIC ID" through it, and programmed 241 redirection entries into it; the machine then continued booting on a RAM page pretending to be the interrupt router.

Physical-memory proof (QEMU monitor, monitor_baseline.txt / run.log-era dump):

0000000040001000: 0x0000018d 0x00000000 ...   <- select-index WRITE by kernel
0000000040001010: 0x00f00000 ...              <- window (kernel-written value;
                                                   equals planted 0x00f00000 because
                                                   the written target word preserves
                                                   reserved bits read back from the
                                                   same page)

0x0000018d at +0x00 is the kernel's register-select store (planted blob had zeros there) β€” a kernel write to attacker-chosen physical memory.

Build

python3 gen_madt.py fake_ioapic /tmp/madt_fake_ioapic.bin

Run

vm.sh run_root 'printf "hint.acpi.0.rsdp=0x40000000\nboot_verbose=YES\n" >> /boot/loader.conf; sync'
vm.sh down
QEMU_EXTRA="-device loader,file=/tmp/madt_fake_ioapic.bin,addr=0x40000000,force-raw=on" vm.sh up
vm.sh log        # IOAPIC: idx 0 ... npin 241
# phys dump: QEMU monitor  xp /8wx 0x40001000

Note: the guest later stops in DDB at AcpiTbLoadNamespace β€” a rig artifact (the minimal injected RSDT has no FADT/DSDT; the healthy control table crashes there too, after completing all MADT/lapic/ioapic/SMP stages). The IOAPIC adoption happens strictly earlier (SI_BOOT2_IOAPIC).

Fix validation

fix.diff (probe-side rejection of IOAPIC addresses inside system RAM via phys_avail[]) was built as a single-fix kernel in the guest (make -j6 nativekernel KERNCONF=X86_64_GENERIC) and re-tested with the exact same blob: the patched kernel REJECTS the table ("madt_ioapic_probe: IOAPIC address 0x40001000 is in system RAM, rejecting MADT"), no RAM page is mapped/written (monitor dump all zeros), and the machine degrades to "IOAPIC disabled" instead. See run.fixed.log + monitor_fixed.txt.

VERDICT.md
↓ download raw

DF-5432 β€” VERDICT

1. Reproduced? YES β€” real kernel-boot adoption of attacker-chosen RAM as the IOAPIC, with kernel writes to that page

run kernel observation
run.log B (Jun 29 clean-install) IOAPIC: idx 0, apic id 2, gsi base 0, npin 241; monitor: 0x40001000: 0x0000018d
run.3.log A (Jul 2 with-src) β€” fix baseline identical adoption + identical 0x18d select-write

Decisive lines (run.log:87-88 / run.3.log:87-88):

ACPI MADT: IOAPIC addr 0x40001000, apic id 0, gsi base 0
IOAPIC: idx 0, apic id 2, gsi base 0, npin 241

0x40001000 is ordinary guest RAM planted via QEMU's loader device with a fake register file (IOWIN 0x00F00000 β†’ npin 241; the real QEMU IOAPIC reports 24). The kernel:

  1. mapped the page: madt_ioapic_enum_callback (acpi_madt.c:733) β†’ ioapic_map (ioapic.c:603-607, pmap_mapdev_uncacheable(pa, PAGE_SIZE), only KKASSERT(pa < 4GB));
  2. read configuration from it (ioapic_read, ioapic_ipl.s:117-120 β€” a 32-bit store of the register index at +0x00, load at +0x10) and adopted npin 241;
  3. wrote to it β€” ioapic_set_apic_id rewrote the "APIC ID" through the page (read-back matched because RAM echoes writes: ioapic.c:312-332), and ioapic_pin_prog (ioapic.c:541-543) programmed all 241 redirection entries (ioapic_write, ioapic_ipl.s:123-131: select store at +0x00, data store at +0x10).

QEMU-monitor physical dump (monitor_baseline.txt) after the run:

0000000040001000: 0x0000018d ...     <- kernel's register-select WRITE (blob planted 0)
0000000040001010: 0x00f00000 ...     <- window; final written value coincides with the
                                        planted word because the redirection target
                                        preserves reserved bits read back from the page

The value written to the window is partially attacker-influenced (vector byte = IDT_OFFSET + irq, flags from trigger/polarity, destination byte = APIC ID << 24); the select-index write at +0x00 is fully kernel-chosen but proves the arbitrary-physical-page write primitive. This is the IOAPIC twin of DF-5365 (lapic_map, Medium): same firmware-fed precondition, same arbitrary-phys map + MMIO write class; DF-5365 at least had IA32_APICBASE to cross-check against, while the IOAPIC address is adopted with literally zero validation (probe rejects only Address == 0, acpi_madt.c:619-622).

The machine continued booting (virtio on MSI-X; serial console polling) β€” a RAM page was the system's interrupt router until the rig artifact stopped it (see env.txt: late AcpiTbLoadNamespace DDB stop also present in the healthy control boot, strictly after the stage under test).

2. Fix β€” VALIDATED on a single-fix kernel

fix.diff: probe-side rejection of IOAPIC addresses that fall inside any phys_avail[] RAM segment (vm/pmap.h:149 β€” populated by MD boot long before SI_BOOT2_PRESMP; device MMIO never lives in RAM).

Baseline vs patched, identical blob (madt_fake_ioapic.bin), same loader.conf hint, kernel A lineage:

baseline (run.3.log) patched (run.fixed.log)
probe accepts (no address check) madt_ioapic_probe: IOAPIC address 0x40001000 in system RAM, rejecting MADT (run.fixed.log:87)
ioapic source RAM page, npin 241 mptable fallback: real IOAPIC npin 24 (run.fixed.log:88-90)
phys 0x40001000 after boot 0x0000018d (kernel wrote) all zeros except planted 0x00f00000 β€” kernel never touched the page (monitor_fixed.txt)

Build: guest /usr/src + fix.diff (git apply), make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ RC=0 (no warnings/errors; full log lost with the post-validation disk revert β€” see build_summary.txt for the captured tail), make installkernel RC=0, clean reboot into patched kernel. Behavior change is exactly the intended one and nothing else regressed (LAPIC stage identical: madt_lapic_probe: lapic_count=2, ACPI CPUS = 2).

3. Threat model

Firmware/bootloader-controlled MADT (demonstrated via the unvalidated hint.acpi.0.rsdp kenv vector + QEMU loader device). No unprivileged in-guest path. Impact ceiling with that control: kernel MMIO writes (partially controlled 32-bit values, fixed +0x00/+0x10 offsets) and MMIO reads of arbitrary physical pages below 4GB at early boot β€” same class and bar as accepted DF-5365 (Medium, memcorrupt).

4. Exploit chain (honest)

firmware-controlled table β†’ arbitrary-phys page mapped uncached β†’ repeated 32-bit kernel stores at page+0/+0x10 with semi-controlled data. Turning that into code execution requires the firmware attacker to also survive early boot and aim the page at security-relevant state (e.g. a page table / kernel .text in low phys); at that point the attacker already owns pre-boot, which is why this is filed Medium (matching DF-5365) and not higher. Not pursued to uid=0 β€” no unprivileged-user trigger exists.

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 reproduced→ patch + rebuild →patched clean

Single-fix kernel (fix.diff applied to guest /usr/src, make -j6 nativekernel KERNCONF=X86_64_GENERIC RC=0, installkernel RC=0, clean reboot). Same PoC blob + hint: patched kernel rejects the RAM-backed IOAPIC address ('madt_ioapic_probe: IOAPIC address 0x40001000 in system RAM, rejecting MADT', run.fixed.log:87), falls back to the real mptable IOAPIC (npin 24), and the attacker page is untouched (monitor_fixed.txt: all zeros except the planted 0x00f00000). Baseline (unpatched, same kernel lineage) adopted the page and wrote to it (run.3.log + monitor_baseline.txt). Behavior delta is exactly the intended one; LAPIC/SMP stages identical.

run.3.log vs run.fixed.log; monitor_baseline.txt vs monitor_fixed.txt; build_summary.txt; fix.diff
↓ fix.diffper-fix-DF-5432

Confirmed kernel references

Detail

Exploit chain

crafted MADT IOAPIC entry -> ioapic_map(attacker phys <4GB, uncached, PAGE_SIZE) -> repeated kernel 32-bit stores at page+0x00 (register select, kernel-chosen small ints) and page+0x10 (redirection words: vector byte IDT_OFFSET+irq, trigger/polarity flags, destination APIC ID<<24) -> corruption of whatever physical page the firmware attacker names (page tables, kernel .text in low phys). Stopped short of uid=0: no unprivileged-user trigger exists; the precondition already implies pre-boot control.

Evidence (decisive lines)

run.log:87-88 & run.3.log:87-88 (baseline adoption, npin 241); monitor_baseline.txt (0x40001000: 0x0000018d = kernel write; planted blob had 0); run.fixed.log:87-90 (patched kernel rejects, mptable fallback npin 24); monitor_fixed.txt (page pristine after patched boot); build_summary.txt (nativekernel RC=0, installkernel RC=0).

PoC changes

No seed PoC. gen_madt.py scenario fake_ioapic authored from scratch; rig = hint.acpi.0.rsdp + QEMU -device loader (discovered -acpitable is shadowed by sdt_search first-match). fix.diff authored after baseline reproduction, then kernel-build-validated in-guest.

Verified recommended fix

probe: reject IOAPIC addresses falling inside any phys_avail[] RAM segment before ioapic_map() ever maps them

Verdict

Arbitrary-physical-address kernel MMIO reads AND writes from a crafted MADT, the IOAPIC twin of DF-5365: probe rejects only Address==0 (acpi_madt.c:619-622); enumerate maps the firmware-named u32 page (ioapic_map, ioapic.c:603-607) and programs it as a register file (ioapic_ipl.s:117-131 stores at +0x00/+0x10; ioapic_pin_prog ioapic.c:541-543; ioapic_set_apic_id ioapic.c:312-332). Reproduced on two kernel builds: guest RAM at 0x40001000 (planted fake register file) was adopted as the system IOAPIC ('npin 241' from the fake VER register), its 'APIC ID' rewritten through it, and 241 redirection entries programmed into it; QEMU-monitor physical dumps show the kernel's select-index write 0x0000018d at the page. FIX VALIDATED on a single-fix kernel (phys_avail[]-based RAM rejection): same PoC now prints 'madt_ioapic_probe: IOAPIC address 0x40001000 in system RAM, rejecting MADT', falls back to the mptable IOAPIC (npin 24), and the attacker page stays byte-identical to the planted blob (monitor_fixed.txt). Firmware/bootloader-fed precondition, matching DF-5365's accepted Medium bar.