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_callbackline 301-302:cpu = arg->cpu; arg->cpu++;madt_x2apic_pass2_callbackline 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) toCPUID_TO_APICID(cpu_id) = apic_id, i.e.cpu_id_to_apic_id[cpu](lapic.c:122, declaredint cpu_id_to_apic_id[NAPICID]=[256]).CPUID_TO_ACPIID(cpu) = ...at lines 315/343 expands (acpi_md_cpu.h:7) tocpu_id_to_acpi_id[cpu](acpi_madt.c:83, declaredu_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 duringSI_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 tocpu_id_to_acpi_id[];lapic_ent->Id(0-254) written tocpu_id_to_apic_id[]. - x2APIC path:
x2apic_ent->Uid(full UINT32) gives a completely-controlled 32-bit value written tocpu_id_to_acpi_id[];x2apic_ent->LocalApicId(UINT32) written as theapic_idvalue tocpu_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:
- ACPI_TABLE_MADT header (44 bytes):
Signature=b'APIC',Length=<total>,Revision=3,Checksum=computed, OEM fields,Address=0xFEE00000(standard LAPIC mmio),Flags=1. - One
ACPI_MADT_LOCAL_APICentry (8 bytes:Type=0,Length=8,ProcessorId=0,Id=<BSP_APIC_ID from QEMU, typically 0>,LapicFlags=1 ENABLED) sobsp_foundsucceeds. - 300
ACPI_MADT_LOCAL_APICentries (Type=0,Length=8,ProcessorId=variable 1..255,Id=1(constant,!= BSPso each incrementscpu),LapicFlags=1). These 300 entries drivearg.cpufrom 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.
Recommended fix
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
- DF-1042 β sibling finding (the
apic_idparameter OOB inlapic_set_cpuid) sys/platform/pc64/acpica/acpi_madt.c:274-343βmadt_{lapic,x2apic}_pass2_callbacksys/platform/pc64/acpica/acpi_madt.c:364βarg.cpu = 1initialisersys/platform/pc64/acpica/acpi_madt.c:83βcpu_id_to_acpi_id[NAPICID]sys/platform/pc64/apic/lapic.c:122-123βcpu_id_to_apic_id[NAPICID],apic_id_to_cpu_id[NAPICID]sys/platform/pc64/apic/lapic.c:1212-1217βlapic_set_cpuid(no bounds check)sys/platform/pc64/acpica/acpi_madt.c:391-435β probe (no max-count, no dedup)- ACPI Specification, MADT subtables
- CWE-787 Out-of-bounds Write
Timeline
- 2026-07-14 Discovered during automated audit.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1064 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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= |
| 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):
- Confirm
madt_lapic_pass2_callbackatacpi_madt.c:301-302incrementsarg->cpu++with no upper-bound check. - Confirm
madt_x2apic_pass2_callbackatacpi_madt.c:337-338does the same. - Confirm
lapic_set_cpuidatlapic.c:1212-1217has no bounds check on either parameter. - Confirm the arrays
cpu_id_to_apic_id/apic_id_to_cpu_id/cpu_id_to_acpi_idare all sized[NAPICID]=[256].
All four line references are confirmed in the finding markdown.
Kernel references
sys/platform/pc64/acpica/acpi_madt.c:274-343β the buggy callbackssys/platform/pc64/acpica/acpi_madt.c:301-302, 337-338β unboundedarg->cpu++sys/platform/pc64/acpica/acpi_madt.c:83βcpu_id_to_acpi_id[NAPICID]sys/platform/pc64/apic/lapic.c:122-123βcpu_id_to_apic_id[NAPICID],apic_id_to_cpu_id[NAPICID]sys/platform/pc64/apic/lapic.c:1212-1217βlapic_set_cpuid(no bounds)- DF-1042 β sibling finding (apic_id parameter OOB)
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_callbackacpi_madt.c:301-302cpu = arg->cpu; arg->cpu++;madt_x2apic_pass2_callbackacpi_madt.c:337-338identical.
That cpu value is then used as a raw array index in two unbounded
writes per entry:
lapic_set_cpuid(cpu, ...)atacpi_madt.c:314/:342. The sink atlapic.c:1212-1217performs zero bounds checking on either parameter and expands tocpu_id_to_apic_id[cpu] = apic_id+apic_id_to_cpu_id[apic_id] = cpu.CPUID_TO_ACPIID(cpu) = ...atacpi_madt.c:315/:343, which (acpi_md_cpu.h:7) expands tocpu_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->cpureaches 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:
sys/platform/pc64/acpica/acpi_madt.c:314(MADT legacy LOCAL_APIC pass2)sys/platform/pc64/acpica/acpi_madt.c:342(MADT LOCAL_X2APIC pass2)sys/platform/pc64/x86_64/mptable.c:566,572,728,730(MP-table; safe β guarded bycpu < MAXCPU)
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):
- Primitive A β
CPUID_TO_ACPIID(256) = sentinel(the adjacent-write half of the bug,acpi_madt.c:315/343). Writes0xDF1064tocpu_id_to_acpi_id[256]via the same array access the callback uses. - Primitive B β
lapic_set_cpuid(256, 1)(the canonical bug-path call,acpi_madt.c:314/342βlapic.c:1215-1216). Writescpu_id_to_apic_id[256] = 1andapic_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):
- Authoritative bounds check in
lapic_set_cpuid(lapic.c): rejectcpu_id/apic_idoutside[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. - Defense-in-depth guards in the MADT pass2 callbacks (
acpi_madt.c): addif (cpu >= MAXCPU) return 0;(legacy LAPIC) /if (cpu >= MAXCPU || x2apic_ent->LocalApicId >= APICID_MAX) return 0;(x2APIC) BEFORE bothlapic_set_cpuidAND the adjacentCPUID_TO_ACPIID(cpu)write. This keeps the unboundedcpu_id_to_acpi_idwrite safe even if a future caller bypasseslapic_set_cpuid, and mirrors the probe filter atacpi_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/srcwith the audit commit +findings/poc/DF-1064/fix.diffapplied:make -j6 nativekernel KERNCONF=X86_64_GENERICβNK_DONE rc=0(full output infix_build.log). - Installed as the bare
/boot/kernel/kernel(sha256cec3ba4f36205d7c9bdb43389b7a66c9b6b3ef92383d48ff416c9a1d1d2c7ba2). kern.version:DragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 21:07:29 UTC 2026(vs the#0baselineThu 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 reallapic_set_cpuidsymbol with the exact argument the crafted MADT forces the pass2 callback to pass whenarg->cpureaches 256, plus a direct-write test of the adjacentCPUID_TO_ACPIIDsink). The sanctioned primitive-proof pattern for a boot-only bug, since the path is unreachable post-boot and-acpitableboot repro is defeated bysdt_searchfirst-match. - Added
build.sh/run.sh. - Kept the shipped
gen_madt.py/malicious_madt.amlas 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 adjacentCPUID_TO_ACPIIDwrite via callback guards, and hardens thelapic_set_cpuidsink itself).
Fix verification
fixedVALIDATED: baseline clobbers lapic_mem; patched 'invalid cpu_id 256 skipping' no clobber.
BEFORE: lapic_mem 0->1. AFTER: unchanged.
Confirmed kernel references
- sys/platform/pc64/acpica/acpi_madt.c:274-278
- sys/platform/pc64/acpica/acpi_madt.c:301-302
- sys/platform/pc64/acpica/acpi_madt.c:314-315
- sys/platform/pc64/acpica/acpi_madt.c:337-338
- sys/platform/pc64/acpica/acpi_madt.c:342-343
- sys/platform/pc64/acpica/acpi_madt.c:364
- sys/platform/pc64/acpica/acpi_madt.c:83
- sys/platform/pc64/acpica/acpi_madt.c:391-435
- sys/platform/pc64/apic/lapic.c:122-123
- sys/platform/pc64/apic/lapic.c:1212-1217
- sys/platform/pc64/apic/apicvar.h:41
- sys/cpu/x86_64/include/param.h:71-72
- sys/platform/pc64/acpica/acpi_md_cpu.h:7
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.
No comments yet.