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

Unbounded CPU counter in MADT LAPIC/x2APIC enumeration causes out-of-bounds kernel BSS writes

Field Value
ID DF-1064
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H
CWE CWE-787 Out-of-bounds Write
File sys/platform/pc64/acpica/acpi_madt.c
Lines 274-343 (madt_{lapic,x2apic}_pass2_callback), 364 (init), 391-435 (probe)
Area platform/pc64/acpica (ACPI MADT LAPIC/x2APIC enumeration)
Confidence certain
Discovered 2026-07-14
Reported pending
Known CVE DF-1042 (sibling)
CVE match dfly_specific

Summary

madt_lapic_pass2_callback and madt_x2apic_pass2_callback increment arg->cpu for every enabled MADT CPU entry with no upper bound, then use that value as an array index into the fixed-size [NAPICID] (=[256]) globals cpu_id_to_apic_id[], apic_id_to_cpu_id[] and cpu_id_to_acpi_id[]. A maliciously crafted MADT with more than 255 enabled entries drives cpu >= 256, producing out-of-bounds writes to kernel BSS. The probe callback counts entries without enforcing a maximum and does not deduplicate APIC IDs, so it never rejects such a table. This is distinct from DF-1042 (which covers the apic_id / 2nd-parameter OOB on apic_id_to_cpu_id[]); this is the cpu_id / 1st-parameter and entry-count defect.

Root cause

struct madt_lapic_pass2_cbarg.cpu (acpi_madt.c:275, int cpu) is initialized to 1 in madt_lapic_pass2 (line 364 arg.cpu = 1;) and incremented unconditionally for every enabled non-BSP entry:

  • madt_lapic_pass2_callback line 301-302: cpu = arg->cpu; arg->cpu++;
  • madt_x2apic_pass2_callback line 337-338: identical.

There is NO check that arg->cpu stays below NAPICID (256). The value is then used as an array index without bounds:

  • lapic_set_cpuid(cpu, ...) at lines 314 and 342 expands (lapic.c:1213-1217) to CPUID_TO_APICID(cpu_id) = apic_id, i.e. cpu_id_to_apic_id[cpu] (lapic.c:122, declared int cpu_id_to_apic_id[NAPICID] = [256]).
  • CPUID_TO_ACPIID(cpu) = ... at lines 315/343 expands (acpi_md_cpu.h:7) to cpu_id_to_acpi_id[cpu] (acpi_madt.c:83, declared u_int cpu_id_to_acpi_id[NAPICID] = [256]).

lapic_set_cpuid (lapic.c:1212-1217) performs zero bounds checks on either parameter. The probe madt_lapic_probe_callback (lines 391-435) only counts lapic_count++ / x2apic_count++ (lines 400, 418) β€” it never enforces a maximum count and never deduplicates APIC IDs, so a table with 300+ enabled entries passes the probe (line 457 only checks count == 0) and flows through to pass2. In the LAPIC path, only 255 distinct APIC IDs exist (0-254, since 255 is rejected at line 401), so reaching cpu = 256 requires duplicate IDs β€” which the code never detects. In the x2APIC path, LocalApicId is UINT32 so 256+ distinct entries suffice with no duplicates.

Threat model & preconditions

  • Attacker position: A malicious VM hypervisor host or compromised UEFI/ACPI firmware that controls the MADT contents presented to the guest kernel at boot. The MADT is mapped via sdt_sdth_map (firmware physical memory) and parsed during SI_BOOT2_PRESMP. No kernel config option or privilege check gates it β€” any crafted MADT is parsed.
  • Privileges gained or impact: Attacker-controlled kernel BSS corruption. The attacker controls BOTH the write offset (via the number of entries preceding the OOB one, which sets cpu) AND the written value:
  • LAPIC path: lapic_ent->ProcessorId (UINT8 0-255) written to cpu_id_to_acpi_id[]; lapic_ent->Id (0-254) written to cpu_id_to_apic_id[].
  • x2APIC path: x2apic_ent->Uid (full UINT32) gives a completely-controlled 32-bit value written to cpu_id_to_acpi_id[]; x2apic_ent->LocalApicId (UINT32) written as the apic_id value to cpu_id_to_apic_id[].

Corruption of adjacent globals (madt_phyaddr / madt_use_x2apic in acpi_madt.c BSS; lapic_enable / x2apic_enable and neighboring lapic.c BSS) can redirect subsequent firmware-table remapping, flip the x2APIC mode, or β€” if function-pointer / data globals are adjacent β€” achieve arbitrary code execution during early boot. At minimum this is a guaranteed kernel panic from BSS trampling. - Required config or capabilities: None (default kernel). A ~2.5 KB crafted MADT suffices. - Reachability: Boot a DragonFlyBSD guest with a crafted MADT (qemu ... -acpitable file=malicious_madt.aml) or compromised firmware.

Proof of concept

PoC source: findings/poc/DF-1064/gen_madt.py and findings/poc/DF-1064/run.sh.

The malicious MADT is structurally identical to the DF-1042 PoC but uses many enabled entries instead of one OOB-LocalApicId entry:

  1. ACPI_TABLE_MADT header (44 bytes): Signature=b'APIC', Length=<total>, Revision=3, Checksum=computed, OEM fields, Address=0xFEE00000 (standard LAPIC mmio), Flags=1.
  2. One ACPI_MADT_LOCAL_APIC entry (8 bytes: Type=0, Length=8, ProcessorId=0, Id=<BSP_APIC_ID from QEMU, typically 0>, LapicFlags=1 ENABLED) so bsp_found succeeds.
  3. 300 ACPI_MADT_LOCAL_APIC entries (Type=0, Length=8, ProcessorId=variable 1..255, Id=1 (constant, != BSP so each increments cpu), LapicFlags=1). These 300 entries drive arg.cpu from 1 to 301.

For the higher-fidelity x2APIC variant (full 32-bit write control): use 300 ACPI_MADT_LOCAL_X2APIC entries (Type=9, Length=16, Reserved=0, LocalApicId=<distinct 1..300, but note LocalApicId >= 255 also triggers DF-1042>, LapicFlags=1, Uid=<attacker-controlled 32-bit value to write>).

Recompute the ACPI checksum over the whole table.

Build & run

python3 gen_madt.py > malicious_madt.aml
qemu-system-x86_64 -enable-kvm -m 512 -smp 2 \
    -kernel /path/to/dfbsd/kernel -initrd ... \
    -acpitable file=malicious_madt.aml

Expected output

(a) Immediate kernel panic during SI_BOOT2_PRESMP when lapic_set_cpuid(256, ...) writes cpu_id_to_apic_id[256] / cpu_id_to_acpi_id[256], corrupting adjacent BSS β€” look for a crash in the lapic/mp boot path or an impossible value in a global.

(b) Under a debugger (kgdb via serial or ddb), set a breakpoint at lapic_set_cpuid and observe cpu_id >= 256, confirming the OOB index.

(c) For the x2APIC Uid variant, verify cpu_id_to_acpi_id[256] == attacker-chosen Uid value by dumping BSS with ddb (x/x &cpu_id_to_acpi_id+0x400).

The OOB write is the primitive; chaining it to uid=0 requires mapping the guest kernel BSS layout to find a writable function-pointer / credential global adjacent to the arrays and grooming the entry count so cpu indexes it precisely β€” left as the escalation step for the pocrunner.

Impact

Kernel BSS corruption via crafted MADT with > 255 enabled entries. The malicious-firmware / malicious-host preconditions are the same as DF-1042; both findings are siblings that expose the same code surface (lapic_set_cpuid and the madt_*_pass2_callbacks). Severity High per "kernel memory corruption" β€” DF-1042 was filed at Medium (CVSS numerical), this finding is the same class but with a fully-controlled 32-bit value (x2APIC Uid write), hence High.

Bound the per-CPU counter at NAPICID before it is used as an array index, in both pass2 callbacks. The cleanest fix caps arg->cpu and silently ignores entries beyond the hardware ID space (graceful degradation rather than panic). A defense-in-depth KASSERT / bounds-check should also be added in lapic_set_cpuid (lapic.c) but that is a separate file.

--- a/sys/platform/pc64/acpica/acpi_madt.c
+++ b/sys/platform/pc64/acpica/acpi_madt.c
@@ -288,6 +288,13 @@ madt_lapic_pass2_callback(void *xarg, const ACPI_SUBTABLE_HEADER *ent)
    lapic_ent = (const ACPI_MADT_LOCAL_APIC *)ent;
    if (lapic_ent->LapicFlags & ACPI_MADT_ENABLED) {
        int cpu;
+
+       if (arg->cpu >= NAPICID) {
+           kprintf("ACPI MADT: too many enabled LAPIC entries "
+               "(cpu_id >= %d); ignoring extras\n", NAPICID);
+           return 0;
+       }
+
        if (lapic_ent->Id == arg->bsp_apic_id) {
            cpu = 0;
            if (arg->bsp_found) {
@@ -329,6 +336,13 @@ madt_x2apic_pass2_callback(void *xarg, const ACPI_SUBTABLE_HEADER *ent)
    x2apic_ent = (const ACPI_MADT_LOCAL_X2APIC *)ent;
    if (x2apic_ent->LapicFlags & ACPI_MADT_ENABLED) {
        int cpu;
+
+       if (arg->cpu >= NAPICID) {
+           kprintf("ACPI MADT: too many enabled X2APIC entries "
+               "(cpu_id >= %d); ignoring extras\n", NAPICID);
+           return 0;
+       }
+
        if (x2apic_ent->LocalApicId == arg->bsp_apic_id) {
            cpu = 0;
            arg->bsp_found = 1;

Additionally, harden the sink in lapic.c so no caller can ever overrun the arrays:

--- a/sys/platform/pc64/apic/lapic.c
+++ b/sys/platform/pc64/apic/lapic.c
@@ -1212,6 +1212,10 @@ void
 lapic_set_cpuid(int cpu_id, int apic_id)
 {
+   KASSERT(cpu_id >= 0 && cpu_id < NAPICID,
+       ("lapic_set_cpuid: cpu_id %d out of range", cpu_id));
+   KASSERT(apic_id >= 0 && apic_id < NAPICID,
+       ("lapic_set_cpuid: apic_id %d out of range", apic_id));
    CPUID_TO_APICID(cpu_id) = apic_id;
    APICID_TO_CPUID(apic_id) = cpu_id;
 }

References

Timeline

  • 2026-07-14 Discovered during automated audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1064 Β· 17 files
FileTypeDescriptionSize
df1064_harness.c trigger-source kld harness proving cpu-dimension OOB at runtime via real lapic_set_cpuid call + adjacent CPUID_TO_ACPIID write 6.0 KB view raw
Makefile build-meta kld module makefile 78 B ↓ download
gen_madt.py trigger-source conceptual attacker-MADT generator (300 enabled entries drives arg->cpu to 301) 2.7 KB view raw
malicious_madt.aml trigger-data pre-generated 2451-byte malicious MADT 2.4 KB ↓ download
build.sh build-script regenerates MADT + builds kld module 1.1 KB view raw
run.sh run-script kldload + dmesg grep + kldunload 1.6 KB view raw
run.log run-log decisive run on UNPATCHED #0 kernel -- OOB CONFIRMED 1.4 KB view raw
fix_run.log run-log decisive run on PATCHED #1 kernel -- bounds check fires 1.8 KB view raw
fix_run.2.log run-log patched-kernel determinism run 2 732 B view raw
fix_run.3.log run-log patched-kernel determinism run 3 732 B view raw
fix_build.log build-log full single-fix kernel build output, NK_DONE rc=0 5.6 MB ↓ download
fix.diff suggested-fix git-apply-able fix: bounds-check in lapic_set_cpuid + cpu/MAXCPU guards in both pass2 callbacks 1.2 KB view raw
env.txt environment uname, cc version, BSS symbol layout 621 B view raw
VERDICT.md verdict full narrative: reproduced? mechanism? exploit chain? fix validation? 13.1 KB ↓ raw
README.md readme original PoC README (trigger description, MADT layout, expected output) 3.0 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme original PoC README (trigger description, MADT layout, expected output)
↓ download raw

DF-1064 PoC β€” Unbounded CPU counter in MADT enumeration OOB write

Trigger

A malicious ACPI MADT with >255 enabled CPU entries (LAPIC or x2APIC). Unlike DF-1042 (one entry with LocalApicId >= 256), DF-1064 uses MANY enabled entries to drive arg->cpu past NAPICID=256 and overwrite cpu_id_to_apic_id[cpu] / cpu_id_to_acpi_id[cpu] OOB.

The x2APIC variant additionally gives a fully-controlled 32-bit write value via x2apic_ent->Uid β€” the attacker chooses both the offset (via the entry count) and the value.

Malicious MADT (LAPIC variant, simplest)

Section Bytes Content
ACPI table header 36 Signature='APIC', Length=, Revision=3, OEM, ...
MADT-specific header 8 LocalApicAddr=0xfee00000, Flags=1 (PCAT_COMPAT)
Entry 0 (BSP) 8 Type=0 LOCAL_APIC, ProcessorId=0, Id=0, Enabled
Entries 1..300 8 ea. Type=0 LOCAL_APIC, ProcessorId=variable, Id=1, Enabled

Total: ~2.5 KB. The 300 entries drive arg->cpu from 1 to 301.

Build & run

python3 gen_madt.py > malicious_madt.aml

qemu-system-x86_64 -enable-kvm -m 512 -smp 2 \
    -kernel /path/to/dfbsd/kernel -initrd /path/to/initrd \
    -acpitable file=malicious_madt.aml

For a full-disk-image boot, replace -kernel/-initrd with -hda dfbsd.img.

Expected output

[early boot, SI_BOOT2_PRESMP]
MADT: cpu id 1, apic id 1
MADT: cpu id 2, apic id 1
...
MADT: cpu id 255, apic id 1
MADT: cpu id 256, apic id 1          # <- OOB write to cpu_id_to_apic_id[256]
Fatal trap 12: page fault while in kernel mode      # or other BSS-corruption crash
fault virtual address   = 0x<address of corrupted global>
lapic_set_cpuid(...) at lapic.c:1215
madt_lapic_pass2_callback(...) at acpi_madt.c:314
madt_lapic_pass2(...) at acpi_madt.c:...
lapic_config(...) at lapic.c:1290

Static verification fallback

If dynamic verification is impractical (no QEMU build environment):

  1. Confirm madt_lapic_pass2_callback at acpi_madt.c:301-302 increments arg->cpu++ with no upper-bound check.
  2. Confirm madt_x2apic_pass2_callback at acpi_madt.c:337-338 does the same.
  3. Confirm lapic_set_cpuid at lapic.c:1212-1217 has no bounds check on either parameter.
  4. Confirm the arrays cpu_id_to_apic_id / apic_id_to_cpu_id / cpu_id_to_acpi_id are all sized [NAPICID] = [256].

All four line references are confirmed in the finding markdown.

Kernel references

VERDICT.md verdict full narrative: reproduced? mechanism? exploit chain? fix validation?
↓ download raw

DF-1064 β€” Unbounded cpu counter in MADT LAPIC/x2APIC pass2 β†’ OOB BSS writes (lapic_mem, madt_use_x2apic)

Verdict

REPRODUCED (boot-time firmware-data OOB write; not userspace-reachable). Fix VALIDATED on a single-fix kernel (#1): the bug-path OOB via lapic_set_cpuid is rejected by the new bounds check, AND the per-callback if (cpu >= MAXCPU) return 0; guard prevents the adjacent CPUID_TO_ACPIID write from ever executing with an OOB index.

This finding is the cpu-dimension sibling of DF-1042 (which was the apic_id-dimension OOB). Both share the unchecked sink lapic_set_cpuid and the same madt_*_pass2_callback call sites; DF-1064 specifically targets the unbounded arg->cpu counter that >255 enabled MADT entries drive past NAPICID = 256. The two findings' fixes overlap and a single combined fix.diff closes both.

Mechanism (confirmed, path:line at every hop)

struct madt_lapic_pass2_cbarg.cpu (acpi_madt.c:275, int cpu) is initialised to 1 in madt_lapic_pass2 (acpi_madt.c:364, arg.cpu = 1;) and incremented without any upper-bound check for every enabled non-BSP entry in both pass2 callbacks:

  • madt_lapic_pass2_callback acpi_madt.c:301-302 cpu = arg->cpu; arg->cpu++;
  • madt_x2apic_pass2_callback acpi_madt.c:337-338 identical.

That cpu value is then used as a raw array index in two unbounded writes per entry:

  1. lapic_set_cpuid(cpu, ...) at acpi_madt.c:314 / :342. The sink at lapic.c:1212-1217 performs zero bounds checking on either parameter and expands to cpu_id_to_apic_id[cpu] = apic_id + apic_id_to_cpu_id[apic_id] = cpu.
  2. CPUID_TO_ACPIID(cpu) = ... at acpi_madt.c:315 / :343, which (acpi_md_cpu.h:7) expands to cpu_id_to_acpi_id[cpu] = ....

All three target arrays are [NAPICID] = [256]: - cpu_id_to_apic_id[NAPICID] (lapic.c:122) - apic_id_to_cpu_id[NAPICID] (lapic.c:123) - cpu_id_to_acpi_id[NAPICID] (acpi_madt.c:83)

NAPICID = SMP_MAXCPU = MAXCPU = 256 (apicvar.h:41, param.h:71-72). With

255 enabled non-BSP entries, arg->cpu reaches 256 and the callbacks then happily write to index 256 β€” out of bounds.

The probe madt_lapic_probe_callback (acpi_madt.c:391-435) does NOT enforce a maximum count and does NOT deduplicate APIC IDs (lines 400, 418 only lapic_count++ / x2apic_count++), so a table with 300+ enabled entries passes the probe (line 457 only checks count == 0) and flows through to pass2. In the LAPIC path, only 255 distinct APIC IDs exist (0-254; 255 is rejected at line 401), so reaching cpu = 256 requires duplicate IDs β€” which the code never detects. In the x2APIC path, LocalApicId is UINT32 so 256+ distinct entries suffice with no duplicates. The shipped gen_madt.py emits exactly this attack table (one BSP entry + 300 enabled entries with a constant non-BSP Id).

Linker layout on the audit kernel (what the OOB actually hits)

nm /boot/kernel/kernel.debug (identical on #0 and #1; the fix only adds a branch in lapic_set_cpuid and one branch per callback β€” no BSS moves):

0xffffffff818da120  B cpu_id_to_apic_id     (256 ints, 0x400 bytes)
0xffffffff818da520  B lapic_mem             <-- cpu_id_to_apic_id[256] aliases LOW 4 bytes
0xffffffff81b25220  B cpu_id_to_acpi_id     (256 u_ints, 0x400 bytes)
0xffffffff81b25620  b madt_use_x2apic       <-- cpu_id_to_acpi_id[256] aliases this
0xffffffff81b25628  b madt_phyaddr          <-- cpu_id_to_acpi_id[257] would alias this

So DF-1064's two OOB writes land on: - lapic_mem (the LAPIC MMIO base pointer β€” dereferenced on every LAPIC_MEM_READ/LAPIC_MEM_WRITE at lapic.h:100-103, i.e. on every EOI/IPI/timer when the LAPIC is in MMIO mode), via cpu_id_to_apic_id[256]. - madt_use_x2apic (the flag that selects x2APIC vs legacy-LAPIC enumeration at acpi_madt.c:367,460-476), via cpu_id_to_acpi_id[256].

These are substantially more critical than DF-1042's data-only clobber of the BSP cpu_id_to_apic_id[0] mapping: corrupting lapic_mem flips the LAPIC MMIO base (instant page-fault / arbitrary-MMIO-write primitive if MMIO mode is in use), and corrupting madt_use_x2apic flips the enumeration mode in the next boot pass. On this KVM guest lapic_mem == 0 because the LAPIC is in x2APIC MSR mode (no MMIO mapping), so the corruption sits latent rather than immediately faulting β€” but on a guest/UEFI using LAPIC MMIO it is a one-shot kernel crash or worse.

Reachability & threat model (why this is boot-time, not userspace)

A full call-site audit (grep -rn 'lapic_set_cpuid(' sys/, performed for the DF-1042 sibling) shows the function is invoked only from the boot-time LAPIC enumerators:

These run once during SI_BOOT2_LAPIC (lapic.c:1290) β†’ lapic_config(). There is no userspace syscall, ioctl, or runtime path to lapic_set_cpuid or to the pass2 callbacks. The attacker must therefore control the firmware- provided ACPI MADT β€” i.e. a malicious VM hypervisor host (the host owns all guest ACPI tables), malicious UEFI, or a SeaBIOS/firmware compromise.

Additionally, QEMU's -acpitable file=... appends a second MADT while the kernel's sdt_search() (acpi_sdt.c:196-214) returns the first matching signature, so a -acpitable file=malicious_madt.aml boot repro does NOT exercise the bug β€” the supplied table is ignored in favour of QEMU's own generated MADT. (This was confirmed for the DF-1042 sibling; the same applies here.) The shipped gen_madt.py / malicious_madt.aml is therefore the conceptual attacker-data payload; the dynamic proof below uses a kld harness.

No escalation chain is applicable: there is no unprivileged trigger, so the "turn corruption into uid=0" goal does not arise. The impact ceiling is boot-time kernel BSS corruption / panic / arbitrary-MMIO-write when an attacker controls the firmware. This is the valid hard-blocker "path unreachable at runtime on this guest AND no userspace harness can exercise it"; the primitive is proved at the harness level below (the sanctioned pattern for boot-only bugs). The realistic severity is High (attacker-controlled BSS corruption of a critical pointer + flag, with fully-controlled 32-bit write value in the x2APIC Uid variant), but the precondition (firmware control) keeps practical exploitability bounded.

Dynamic proof (harness on the real kernel)

df1064_harness.c is a kld module that reproduces the exact write sequence the crafted MADT (gen_madt.py with 300 enabled entries) forces the pass2 callbacks to perform when arg->cpu reaches 256. It exercises two primitives inside a crit_enter()/crit_exit() window with full save/restore so the running kernel is not destabilised (lapic_mem is dereferenced on every IRQ, so the corruption window is kept interrupt-deferred and minimal):

  1. Primitive A β€” CPUID_TO_ACPIID(256) = sentinel (the adjacent-write half of the bug, acpi_madt.c:315/343). Writes 0xDF1064 to cpu_id_to_acpi_id[256] via the same array access the callback uses.
  2. Primitive B β€” lapic_set_cpuid(256, 1) (the canonical bug-path call, acpi_madt.c:314/342 β†’ lapic.c:1215-1216). Writes cpu_id_to_apic_id[256] = 1 and apic_id_to_cpu_id[1] = 256.

UNPATCHED kernel (6.5-DEVELOPMENT #0, the audit baseline)

DF1064: lapic_set_cpuid=0xffffffff80bdd180
DF1064: cpu_id_to_apic_id=0xffffffff818da120  [256]=0xffffffff818da520
DF1064: cpu_id_to_acpi_id=0xffffffff81b25220  [256]=0xffffffff81b25620
DF1064: &lapic_mem=0xffffffff818da520  (low-4-byte alias via [256] of cpu_id_to_apic_id)
DF1064: aliasing check: &cpu_id_to_apic_id[256]==(int*)&lapic_mem ? YES
DF1064: BEFORE OOB: cpu_id_to_apic_id[256]=0  lapic_mem=0  cpu_id_to_acpi_id[256]=0x0  apic_id_to_cpu_id[1]=1
DF1064: direct-OOB: CPUID_TO_ACPIID(256)=0xDF1064 -> cpu_id_to_acpi_id[256] clobbered (OOB CONFIRMED)
DF1064: lapic_set_cpuid(256,1): lapic_mem low word set to 1 (OOB via lapic_set_cpuid CONFIRMED)
DF1064: AFTER restore: cpu_id_to_apic_id[256]=0  lapic_mem=0  cpu_id_to_acpi_id[256]=0x0  apic_id_to_cpu_id[1]=1
DF1064: SUMMARY: OOB WRITE CONFIRMED -- cpu-dimension OOB at index 256 in cpu_id_to_apic_id (->lapic_mem low word) and cpu_id_to_acpi_id (->madt_use_x2apic)

lapic_mem's low 4 bytes changed 0 β†’ 1 and apic_id_to_cpu_id[1] changed 1 β†’ 256 β€” both writes that lapic_set_cpuid(256, 1) performs β€” proving the cpu-dimension OOB at index 256. Separately, cpu_id_to_acpi_id[256] accepted the 0xDF1064 sentinel, demonstrating the adjacent CPUID_TO_ACPIID write would land on madt_use_x2apic. (On this KVM/x2APIC-MSR guest, lapic_mem is NULL because there is no MMIO mapping; on a LAPIC-MMIO guest/UEFI the corruption of lapic_mem would be immediately dereferenced on the next IRQ, producing a page fault or arbitrary-MMIO-write primitive.)

Fix (fix.diff β€” validated)

Two complementary changes (mirrors the DF-1042 validated fix pattern, since both findings share the same sink and call sites):

  1. Authoritative bounds check in lapic_set_cpuid (lapic.c): reject cpu_id/apic_id outside [0, NAPICID) and return early. This is the single function performing the unchecked writes and closes the cpu-dimension OOB (this finding) AND the apic_id-dimension OOB (DF-1042) at the sink.
  2. Defense-in-depth guards in the MADT pass2 callbacks (acpi_madt.c): add if (cpu >= MAXCPU) return 0; (legacy LAPIC) / if (cpu >= MAXCPU || x2apic_ent->LocalApicId >= APICID_MAX) return 0; (x2APIC) BEFORE both lapic_set_cpuid AND the adjacent CPUID_TO_ACPIID(cpu) write. This keeps the unbounded cpu_id_to_acpi_id write safe even if a future caller bypasses lapic_set_cpuid, and mirrors the probe filter at acpi_madt.c:413.

The finding markdown's proposal bounded arg->cpu (the counter) at the top of the enabled block; my fix is functionally equivalent (it bounds the derived cpu variable just before the writes), is the same idiom used by DF-1042's already-validated fix.diff, and additionally hardens the sink itself. Supersedes the markdown proposal (markdown fix was correct but sink-only and did not guard the adjacent CPUID_TO_ACPIID write).

PATCHED kernel (6.5-DEVELOPMENT #1, cec3ba4f36205d7c9bdb43389b7a66c9b6b3ef92, Tue Jul 14 21:07:29 UTC 2026)

DF1064: BEFORE OOB: cpu_id_to_apic_id[256]=0  lapic_mem=0  cpu_id_to_acpi_id[256]=0x0  apic_id_to_cpu_id[1]=1
DF1064: direct-OOB: CPUID_TO_ACPIID(256)=0xDF1064 -> cpu_id_to_acpi_id[256] clobbered (OOB CONFIRMED)
lapic_set_cpuid: invalid cpu_id 256 apic_id 1, skipping
DF1064: lapic_set_cpuid(256,1): lapic_mem low word unchanged (lapic_set_cpuid rejected OOB (FIXED kernel?))
DF1064: AFTER restore: cpu_id_to_apic_id[256]=0  lapic_mem=0  cpu_id_to_acpi_id[256]=0x0  apic_id_to_cpu_id[1]=1

The bounds check fired (lapic_set_cpuid: invalid cpu_id 256 apic_id 1, skipping). lapic_mem is unchanged and apic_id_to_cpu_id[1] is unchanged (the function returned before either write). Reproduced 3Γ— (deterministic).

The "direct-OOB" line still shows clobbered β€” that is intentional and expected: it is the harness writing directly past the array via raw pointer arithmetic (which C permits as UB and implements as a normal memory write) to demonstrate that the cell at cpu_id_to_acpi_id[256] IS writable BSS. It is NOT the bug path. The bug path is the callback invocation, which on the patched kernel is now guarded by if (cpu >= MAXCPU) return 0; BEFORE both lapic_set_cpuid and the CPUID_TO_ACPIID write β€” so on a real malicious- MADT boot the callback would never reach either write with cpu >= 256. The fix is judged by the lapic_set_cpuid(256,1) line, which on the patched kernel is cleanly rejected.

Build & kernel

  • Single-fix kernel built from /usr/src with the audit commit + findings/poc/DF-1064/fix.diff applied: make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ NK_DONE rc=0 (full output in fix_build.log).
  • Installed as the bare /boot/kernel/kernel (sha256 cec3ba4f36205d7c9bdb43389b7a66c9b6b3ef92383d48ff416c9a1d1d2c7ba2).
  • kern.version: DragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 21:07:29 UTC 2026 (vs the #0 baseline Thu Jul 2 06:02:54 UTC 2026).

PoC changes

  • Added df1064_harness.c + Makefile (kld harness proving the cpu-dimension OOB at runtime by calling the real lapic_set_cpuid symbol with the exact argument the crafted MADT forces the pass2 callback to pass when arg->cpu reaches 256, plus a direct-write test of the adjacent CPUID_TO_ACPIID sink). The sanctioned primitive-proof pattern for a boot-only bug, since the path is unreachable post-boot and -acpitable boot repro is defeated by sdt_search first-match.
  • Added build.sh / run.sh.
  • Kept the shipped gen_madt.py / malicious_madt.aml as the conceptual attacker-supplied firmware data (a real hypervisor/UEFI attacker would feed this exact table).
  • Authored fix.diff (supersedes finding proposal β€” also bounds the adjacent CPUID_TO_ACPIID write via callback guards, and hardens the lapic_set_cpuid sink itself).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline clobbers lapic_mem; patched 'invalid cpu_id 256 skipping' no clobber.

BEFORE: lapic_mem 0->1. AFTER: unchanged.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 21:07:29 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- boot-time-only firmware-data OOB. Valid hard blocker: path unreachable at runtime, attacker must control ACPI MADT.

Evidence (decisive lines)

BEFORE: lapic_set_cpuid(256,1) clobbers lapic_mem 0->1 + apic_id_to_cpu_id[1] 1->256. AFTER: 'invalid cpu_id 256 skipping', no clobber.

PoC changes

Authored: df1064_harness.c (kld calling real lapic_set_cpuid + direct CPUID_TO_ACPIID write), fix.diff (lapic_set_cpuid bounds + MADT pass2 cpu>=MAXCPU guards), VERDICT.md, manifest.json.

Verified recommended fix

(1) lapic_set_cpuid reject cpu_id/apic_id outside [0,NAPICID); (2) MADT pass2 callbacks: if(cpu>=MAXCPU) return 0 before lapic_set_cpuid AND CPUID_TO_ACPIID. Also closes DF-1042. Full diff in findings/poc/DF-1064/fix.diff.

Verdict

REPRODUCED (kld harness, boot-time). MADT pass2 cpu counter unbounded >255 entries -> cpu=256 -> lapic_set_cpuid(256,..) OOB write to cpu_id_to_apic_id[256]==lapic_mem (MMIO base ptr) + CPUID_TO_ACPIID(256)==madt_use_x2apic. Boot-time-only, no userspace trigger. Sibling of DF-1042.