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

lapic_map() maps firmware-supplied Local APIC physical address with zero validation -> arbitrary-physical-address kernel read/write page at boot (MADT LocalApicAddr / MP apic_address never cross-checked against IA32_APICBASE)

Summary

The firmware table's LAPIC PA passes straight into pmap_mapdev_uncacheable with no range/alignment/identity check (only value 0 special-cased) and the CPU's own MSR_IA32_APICBASE - which cannot lie - is never consulted; lapic_init then does fixed-offset stores (eoi=0, icr_timer=0xffffffff, dcr, RMWs) and enumeration reads into the mapped page. Crafted MADT/MP = boot-time read of attacker-chosen physical memory + persistent kernel corruption (zero a function pointer, RMW-forge pointer halves). Stronger than DF-5341 (that was read-only); boot-gated, no unpriv runtime trigger. Guest-reproduced via KLD harness calling PRODUCTION lapic_map with (A) a marker RAM page - 'READ-PRIMITIVE CONFIRMED' + 7/7 register cells written - and (B) phys(&cpu_id_to_apic_id[3])-0x380 - BSS cell 0x3->0xffffffff 'KERNEL-BSS CORRUPTION CONFIRMED'; MSR-cross-check fix validated on rebuilt kernel (rejects both, 0/7 cells, genuine path clean).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5365 Β· 13 files
FileTypeDescriptionSize
df5365_harness.c β€” 9.8 KB view raw
Makefile β€” 78 B ↓ download
gen_madt.py β€” 1.8 KB view raw
build.sh β€” 126 B view raw
run.sh β€” 168 B view raw
build.log β€” 851 B view raw
run.log β€” 896 B view raw
run.2.log β€” 432 B view raw
fix_run.log β€” 1.0 KB view raw
fix_run.2.log β€” 376 B view raw
env.txt β€” 208 B view raw
fix.diff β€” 1.0 KB view raw
VERDICT.md β€” 6.5 KB ↓ raw
VERDICT.md
↓ download raw

DF-5365 β€” lapic_map() adopts firmware LAPIC physical address with no validation β†’ boot-time arbitrary-physical-address kernel read/write

Verdict

REPRODUCED (boot-time, firmware-data-gated kernel memory corruption; not userspace-reachable). Fix VALIDATED on a single-fix kernel: with fix.diff, lapic_map() cross-checks the firmware value against IA32_APICBASE and falls back to the hardware page; the identical harness run then writes 0/7 cells of the attacker page and leaves cpu_id_to_apic_id[3] untouched, while printing LAPIC: bogus LAPIC address 0x… in firmware table, using 0xfee00000 from APIC base MSR.

Mechanism (every hop cited)

lapic_map() at sys/platform/pc64/apic/lapic.c:1081-1085:

void
lapic_map(vm_paddr_t lapic_addr)
{
    lapic_mem = pmap_mapdev_uncacheable(lapic_addr, sizeof(struct LAPIC));
}

lapic_addr is firmware-supplied on both enumeration paths:

  • MADT: madt_lapic_probe seeds arg.lapic_addr = madt->Address (acpi_madt.c:451); madt_lapic_pass1 returns it, honoring LOCAL_APIC_ADDRESS_OVERRIDE entries (acpi_madt.c:~250-270); the only special-cased value anywhere on the path is 0 (acpi_madt.c:479-487 β€” and only when the CPU lacks x2APIC). madt_lapic_enumerate then calls lapic_map(lapic_addr) (acpi_madt.c:528-531).
  • MP table: lapic_addr = cth->apic_address (mptable.c:761, KKASSERT(lapic_addr != 0) only) β†’ lapic_map(lapic_addr) (mptable.c:805).

The CPU's own MSR_APICBASE (0x1b, specialreg.h:533) always contains the real page base (0xFEE00000 on this guest) and is never consulted.

After the mapping, lapic_init() (lapic.c:272-408) performs RMW stores to lvt_lint0/lint1/error/pcint/timer, tpr, svr, plus three stores of 0 to eoi; lapic_timer_calibrate() arms icr_timer with APIC_TIMER_MAX_COUNT (0xffffffff, lapic.c:451/:493/:542/:591) and writes the divisor to dcr_timer (lapic.c:440). Every store lands at a fixed offset (eoi +0xB0, svr +0xF0, icr_timer +0x380, dcr_timer +0x3E0, lvt_* +0x320..0x370) inside the page the firmware table selected. Reads (LAPIC_READID at enumeration, LAPIC_READ(version) at lapic.c:330/380) return page contents the attacker also controls (info side-channel / logic steering).

What was demonstrated on the guest (stock kernel #0, Jul 2 snapshot)

KLD harness df5365.ko (root kldload; the established evidence class for boot-path bugs on this rig β€” same as DF-1064, since -acpitable cannot override QEMU's built-in MADT, sdt_search() returns the first match):

  • Test A β€” lapic_map(phys_of_fresh_RAM_page) (production call), page pre-filled 0xAAAAAAAA: LAPIC_MEM_READ(version) returned 0xAAAAAAAA (READ primitive), and the lapic_init() store set rewrote 7/7 observed register cells (run.log): eoi=0, icr_timer=0xffffffff, dcr_timer=0x00, svr=0xaaaaa9ff (fill|APIC_SVR_ENABLE|vector), lint0/lint1/timer RMWed.
  • Test B β€” lapic_map(phys(&cpu_id_to_apic_id[3]) - 0x380) so the oneshot-arm store aliases the live BSS array: cpu_id_to_apic_id[3] went 0x00000003 β†’ 0xffffffff (KERNEL-BSS CORRUPTION CONFIRMED), then restored.

The guest stayed healthy through load/unload in both baseline runs (run.log, run.2.log).

Threat model / impact ceiling

Precondition is control of the firmware tables presented at boot (crafted MADT via patched BIOS/pflash; MADT injection with -acpitable does NOT reach the sink because the kernel picks the first APIC-signature table). This is the same premise already accepted for DF-1042 (Medium) and DF-1064 (High). Within that premise the primitive is stronger than DF-5341 (mptable arbitrary-PA read): it is an arbitrary-PA write page whose fixed-offset stores include full-control values 0 and 0xffffffff and preserving RMWs β€” enough to corrupt kernel text/data/heap in physical memory at boot (e.g., zero a syscall table entry, forge pointer halves with RMW bit-sets) and to read boot-time kernel memory through LAPIC_READs (version/ID steering). Requires memory-mapped LAPIC mode (x2apic_enable == 0).

Fix validation (kernel #1, fix.diff)

fix.diff adds the missing cross-check in lapic_map(): derive hw_addr = rdmsr(MSR_APICBASE) & ~0xFFF and fall back to it (with a kprintf) whenever the firmware value is 0, misaligned, or differs. Applied to the guest /usr/src, make -j6 nativekernel KERNCONF=X86_64_GENERIC, installed, rebooted (clean boot: normal LAPIC: enter X2APIC mode, no warning on the genuine path). Identical harness run (fix_run.log, fix_run.2.log):

  • Test A: LAPIC: bogus LAPIC address 0xd9d8000 … using 0xfee00000 from APIC base MSR; 0/7 cells written; attacker page still 0xAAAAAAAA; version read returns 0xffffffff (real MMIO, architecturally ignored in x2APIC mode β€” not the attacker fill).
  • Test B: LAPIC: bogus LAPIC address 0x18d9dac …; cpu_id_to_apic_id[3] stays 0x00000003 β€” not-confirmed.

Harness debugging notes (poc_changes)

The first harness revision passed the modevent function as DECLARE_MODULE's data argument (must be a moduledata_t struct). That made module_register() treat function bytes as a moduledata_t and strcmp a code-bytes "pointer" β†’ GPF strcmp+0x10 (dump captured on the serial log). Fixed by declaring static moduledata_t df5365_mod = {"df5365", df5365_modevent, NULL}; exactly as DF-1064's harness does. Test B was also strengthened after the first successful run (initial variant aliased an already-zero cell; final variant aliases cpu_id_to_apic_id[3] (value 3) with the 0xffffffff store for an unambiguous 3 β†’ 0xffffffff flip).

Artifacts

  • df5365_harness.c β€” KLD harness (production lapic_map() + production store set; saves/restores state)
  • gen_madt.py β€” conceptual malicious MADT (LocalApicAddr = 0x01000000)
  • build.sh / run.sh β€” exact commands
  • build.log β€” full successful build (baseline)
  • run.log, run.2.log β€” baseline reproductions (stock kernel #0)
  • fix_run.log, fix_run.2.log β€” patched-kernel runs (fix rejected both attacker addresses; no corruption)
  • env.txt β€” guest uname/tunables/compiler
  • fix.diff β€” validated fix (git-apply-able vs this tree; never applied to the read-only audit sys/)

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, clean reboot) rejects both attacker-chosen addresses with 'LAPIC: bogus LAPIC address ... using 0xfee00000 from APIC base MSR'; identical harness run writes 0/7 attacker-page cells (fill 0xaaaaaaaa intact) and cpu_id_to_apic_id[3] stays 0x00000003 (was 0xffffffff on baseline). Genuine boot path unaffected (normal LAPIC bring-up, no warning).

findings/poc/DF-5365/fix_run.log and fix_run.2.log (decisive lines quoted in VERDICT.md); fix.diff sha256 79b93263069493f023ed04f9dd51b46317e43607faaae06a802bf34bf9552023
↓ fix.diffper-fix-DF-5365

Confirmed kernel references

Detail

Exploit chain

attacker controls firmware table at boot (crafted MADT LocalApicAddr / MP apic_address) -> lapic_map() maps chosen PA as lapic_mem (xAPIC mode) -> enumeration reads (LAPIC_READID/version) read attacker-chosen kernel/physical memory -> lapic_init()+calibration stores write fixed offsets (+0xB0 eoi=0, +0x380 icr_timer=0xffffffff, +0x3E0 divisor, svr/lvt RMWs) into that page -> e.g. point the base so a chosen kernel text/data word receives 0 / 0xffffffff / bit-set RMW -> persistent kernel-memory corruption at boot; harness demonstrated the BSS flip on a live kernel

Evidence (decisive lines)

run.log lines 'DF5365: A: 7/7 ... ARBITRARY-PA WRITE CONFIRMED' and 'DF5365: B: cpu_id_to_apic_id[3] after =0xffffffff -> KERNEL-BSS CORRUPTION CONFIRMED' (baseline, stock kernel #0); fix_run.log/fix_run.2.log lines 'LAPIC: bogus LAPIC address 0x... using 0xfee00000 from APIC base MSR' + '0/7' + 'not-confirmed' (patched kernel #1); VERDICT.md has the full path:line trace

PoC changes

initial harness passed the modevent function as DECLARE_MODULE's data arg (moduledata_t struct required) -> module_register() strcmp'd function bytes -> GPF strcmp+0x10 at kldload; fixed with static moduledata_t df5365_mod = {"df5365", df5365_modevent, NULL} (DF-1064 pattern). Test B initially aliased an already-zero BSS cell; strengthened to alias cpu_id_to_apic_id[3] (value 3) with the 0xffffffff icr_timer store for an unambiguous 3 -> 0xffffffff flip. gen_madt.py header-length bug fixed (9-byte OEM table id). fix.diff hunk regenerated mechanically after two hand-counted hunks were malformed.

Verified recommended fix

In lapic_map(), cross-check the firmware address against rdmsr(MSR_APICBASE) & ~0xFFF and fall back to the hardware page (warn) when the firmware value is 0, misaligned, or mismatched.

Verdict

lapic_map() (lapic.c:1081-1085) maps the firmware-supplied Local APIC register-page physical address (MADT LocalApicAddr via acpi_madt.c:451/528-531, or MP cth->apic_address via mptable.c:761/805) with no validation whatsoever -- not even a cross-check against IA32_APICBASE -- so a crafted firmware table gives lapic_init()'s fixed-offset, partially-controlled 32-bit stores (eoi=0 x3 at lapic.c:406-408, icr_timer=0xffffffff at lapic.c:451, dcr_timer divisor at lapic.c:440, svr/lvt/tpr RMWs at lapic.c:272-400) an arbitrary-physical-address kernel read/write page at boot. Proven on the stock guest kernel with a root KLD harness (the established evidence class for boot-path bugs on this rig): production lapic_map(attacker_pa) + the production store set rewrote 7/7 observed register cells of an attacker-chosen RAM page and flipped live kernel BSS cpu_id_to_apic_id[3] from 0x3 to 0xffffffff via the icr_timer oneshot-arm store; state restored, guest healthy. Fix validated on a rebuilt kernel: with fix.diff's IA32_APICBASE cross-check the same harness run redirects to the real 0xfee00000 page, writes 0/7 attacker cells, leaves the BSS word untouched, and warns. Precondition is firmware-table control (same class as filed DF-1042/DF-1064) plus memory-mapped LAPIC mode (x2apic_enable==0: default QEMU CPU models / most bare metal / hw.x2apic_enable=0); no runtime userspace reachability.