โฌข DragonFlyBSD Kernel Audit
โ† triage ยท dashboard
DF-1042

lapic_set_cpuid missing bounds validation allows OOB write to APIC ID mapping arrays via crafted MADT

Field Value
ID DF-1042
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:H
CWE CWE-787 Out-of-bounds Write
File sys/platform/pc64/apic/lapic.c
Lines 1212-1217 (called from acpi_madt.c:342 and mptable.c:566/572)
Area platform/pc64/apic (x86 LAPIC init)
Confidence certain
Discovered 2026-07-14
Reported pending
Known CVE none
CVE match dfly_specific

Summary

lapic_set_cpuid() uses its cpu_id and apic_id parameters as raw indices into the 256-element global arrays cpu_id_to_apic_id[NAPICID] and apic_id_to_cpu_id[NAPICID] (lapic.c:122-123) with no bounds check. The x2APIC MADT enumeration callback (acpi_madt.c:342) passes x2apic_ent->LocalApicId (a UINT32 that can exceed 255) directly as apic_id, and the cpu counter increments without any MAXCPU cap. This causes out-of-bounds writes to kernel BSS during boot when the firmware-provided MADT contains x2APIC entries with LocalApicId >= 256 or more than 256 enabled CPU entries.

Root cause

lapic_set_cpuid() at sys/platform/pc64/apic/lapic.c:1212-1217:

void
lapic_set_cpuid(int cpu_id, int apic_id)
{
    CPUID_TO_APICID(cpu_id) = apic_id;   /* cpu_id_to_apic_id[(cpu_id)] */
    APICID_TO_CPUID(apic_id) = cpu_id;   /* apic_id_to_cpu_id[(apic_id)] */
}

Both arrays are declared at lapic.c:122-123 as int[NAPICID] where NAPICID=256 (apicvar.h:41). Neither parameter is validated against [0, NAPICID).

The x2APIC MADT pass2 callback at sys/platform/pc64/acpica/acpi_madt.c:342 calls:

lapic_set_cpuid(cpu, x2apic_ent->LocalApicId);

where LocalApicId is UINT32 (ACPI spec / actbl2.h:1760) and can be any 32-bit value. The probe phase (acpi_madt.c:413) filters entries with LocalApicId >= APICID_MAX (255) and only counts those < 255, but the enumeration phase (acpi_madt.c:330-342) processes all enabled x2APIC entries regardless of LocalApicId value. This probe/enumerate inconsistency is the core defect.

Additionally, the cpu counter in madt_x2apic_pass2_callback (acpi_madt.c:337-338) increments arg->cpu++ for every enabled non-BSP entry without any cpu < MAXCPU check โ€” unlike the mptable path which guards with else if (cpu < MAXCPU) at mptable.c:571. If the MADT contains more than 256 enabled entries, cpu reaches 256 and cpu_id_to_apic_id[256] is an OOB write.

The naps cap (lapic.c:1185-1193) happens after the enumeration iteration completes, so it does not prevent the OOB writes during iteration.

Threat model & preconditions

  • Attacker position: Control of firmware-provided ACPI MADT tables. Realistic scenarios: 1. A VM hypervisor (e.g. QEMU/KVM host) crafts a malicious MADT presented to a DragonFlyBSD guest โ€” the host controls all guest ACPI tables. 2. Compromised or buggy UEFI firmware producing sparse x2APIC IDs โ‰ฅ 256 (x2APIC was designed to support IDs > 255 on large multi-socket systems, so this is realistic). 3. A MADT with >256 enabled CPU entries.
  • Privileges gained or impact: Out-of-bounds write to kernel BSS with a small positive integer value (CPU ID 0-255) at an attacker-controllable offset (via LocalApicId). The arrays at lapic.c:122-123 are immediately followed in source order by lapic_enable (124), lapic_usable (125), x2apic_enable (126) โ€” a small overflow corrupts these globals; a large LocalApicId (up to 2ยณยฒ) can write to arbitrary kernel memory, causing panic (DoS) or potentially corrupting security-relevant state.
  • Required config or capabilities: Default kernel booting with ACPI MADT (hw.acpi.enable=1, the default on x86-64). Attacker must control the MADT itself.
  • Reachability: Path is reached during SI_BOOT2_LAPIC (lapic.c:1290) โ†’ lapic_config() โ†’ madt_lapic_enumerate() โ†’ madt_lapic_pass2() โ†’ madt_x2apic_pass2_callback() โ†’ lapic_set_cpuid().

Proof of concept

PoC source: findings/poc/DF-1042/malicious_madt.aml (generator script) and findings/poc/DF-1042/run.sh

Build & run

# Generate a malicious MADT ACPI table where one x2APIC entry has
# LocalApicId = 0x100 (>= 256) so the lapic_set_cpuid OOB write triggers.
python3 findings/poc/DF-1042/gen_madt.py > malicious_madt.aml

# Boot DragonFlyBSD under QEMU with the custom table.
qemu-system-x86_64 -enable-kvm -m 512 -smp 2 \
    -acpitable file=malicious_madt.aml \
    -kernel /path/to/dfbsd-kernel -initrd /path/to/initrd

Entry 0 in the crafted MADT: Type=LOCAL_X2APIC, LocalApicId=0, Uid=0, LapicFlags=ENABLED (BSP, passes probe). Entry 1: Type=LOCAL_X2APIC, LocalApicId=256, Uid=1, LapicFlags=ENABLED (triggers OOB write at apic_id_to_cpu_id[256]).

Alternative trigger without x2APIC: a MADT with 257+ enabled LOCAL_APIC entries causes cpu to reach 256, writing to cpu_id_to_apic_id[256] (OOB on the cpu_id index). The mptable path is safe (mptable.c:571 guards cpu < MAXCPU); the MADT path is not.

Expected output

[early boot]
MADT: cpu id 0, acpi uid 0, apic id 0
MADT: cpu id 1, acpi uid 1, apic id 256
Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0x<address past apic_id_to_cpu_id>
lapic_set_cpuid(...) at lapic.c:1216
madt_x2apic_pass2_callback(...) at acpi_madt.c:342
...

With LocalApicId = 0x1000, the write lands 4096 ints (16 KB) past the array, likely hitting other critical data and producing an immediate panic. Smaller overflows may instead corrupt adjacent globals (lapic_enable, lapic_usable, x2apic_enable) and produce later malfunction rather than an immediate panic.

Impact

Local DoS (and potentially integrity impact) at boot when firmware/ACPI tables are attacker-controlled. The high-privilege prerequisite (control of the MADT/boot environment) keeps this at Medium rather than High per the rubric, but the OOB write is real, the path is the default x86-64 boot flow, and the defense-in-depth fix is small and obvious. Real-world exposure includes compromised hypervisor hosts (cloud tenants cannot trust the host) and malicious UEFI firmware.

Add bounds validation to lapic_set_cpuid() so it cannot write past either array, and skip invalid entries. The defensive fix belongs in lapic_set_cpuid itself since it is the single function performing the unchecked array writes:

--- a/sys/platform/pc64/apic/lapic.c
+++ b/sys/platform/pc64/apic/lapic.c
@@ -1212,7 +1212,15 @@ lapic_enumerator_register(struct lapic_enumerator *ne)
 void
 lapic_set_cpuid(int cpu_id, int apic_id)
 {
+   if (cpu_id < 0 || cpu_id >= NAPICID ||
+       apic_id < 0 || apic_id >= NAPICID) {
+       kprintf("lapic_set_cpuid: invalid cpu_id %d apic_id %d, skipping\n",
+           cpu_id, apic_id);
+       return;
+   }
    CPUID_TO_APICID(cpu_id) = apic_id;
    APICID_TO_CPUID(apic_id) = cpu_id;
 }

Additionally, the MADT pass2 callbacks in acpi_madt.c should skip entries with LocalApicId >= APICID_MAX (matching the probe filter at acpi_madt.c:413) and cap the cpu counter at MAXCPU, but the lapic.c fix is the authoritative defense-in-depth boundary.

References

Timeline

  • 2026-07-14 Discovered during automated audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1042 ยท 15 files
FileTypeDescriptionSize
df1042_harness.c trigger-source kld harness: calls real lapic_set_cpuid(1,256) and observes BSS corruption (sanctioned primitive-proof for a boot-only path) 3.6 KB view raw
Makefile trigger-source kld module build (bsd.kmod.mk) 78 B โ†“ download
gen_madt.py trigger-source emits the conceptual attacker MADT (LocalApicId=256) a malicious hypervisor/UEFI would supply 2.4 KB view raw
malicious_madt.aml trigger-source prebuilt 88-byte malicious MADT (BSP id=0 + malicious id=256) 88 B โ†“ download
build.sh build-script build the kld harness + regenerate the MADT 1.1 KB view raw
run.sh run-script kldload the harness (root), dump the OOB/corruption dmesg 1.1 KB view raw
harness_unpatched_dmesg.txt run-log UNPATCHED #0: cpu_id_to_apic_id[0] 0->1, OOB WRITE CONFIRMED 1.1 KB view raw
harness_patched_dmesg.txt run-log PATCHED #1: 'skipping' + cpu_id_to_apic_id[0] unchanged (run twice) 1.2 KB view raw
fix_build.log build-log full single-fix nativekernel build output, NK_DONE rc=0 5.6 MB โ†“ download
fix.diff suggested-fix bounds check in lapic_set_cpuid + MADT pass2 cpu/apic_id guards (supersedes finding proposal) 1.2 KB view raw
env.txt environment uname / kern.version (#1) / cc 8.3 433 B view raw
VERDICT.md verdict full narrative: mechanism, reachability, dynamic proof, fix validation 8.8 KB โ†“ raw
README.md readme original reviewer README (trigger description) 2.5 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 reviewer README (trigger description)
โ†“ download raw

DF-1042 PoC โ€” lapic_set_cpuid OOB write via crafted MADT

Trigger

Crafted ACPI MADT table containing x2APIC entries that exercise the missing bounds check in lapic_set_cpuid(). Two variants:

Variant A โ€” large LocalApicId

Generate a MADT with one BSP entry that has LocalApicId=0 (passes probe phase) and one entry with LocalApicId=256 (triggers OOB write at apic_id_to_cpu_id[256]).

Variant B โ€” many enabled entries

Generate a MADT with 257+ enabled LOCAL_APIC entries; the cpu counter reaches 256 and cpu_id_to_apic_id[256] is written OOB.

Generate the MADT

gen_madt.py emits a minimal MADT table with the header, one LAPIC address override, the BSP x2APIC entry, and one malicious x2APIC entry with LocalApicId=256. Output is ready for QEMU's -acpitable file=....

The MADT subtable layout (ACPI_MADT_LOCAL_X2APIC): - Type (u8) = 9 (LOCAL_X2APIC) - Length (u8) = 16 - Reserved (u16) - LocalApicId (u32) โ† attacker-controlled - Uid (u32) - LapicFlags (u32) bit 0 = ENABLED

Run:

python3 gen_madt.py > malicious_madt.aml

Run under QEMU

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

Expected output (Variant A)

[early boot]
MADT: cpu id 0, acpi uid 0, apic id 0
MADT: cpu id 1, acpi uid 1, apic id 256
Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0x<address past apic_id_to_cpu_id>
lapic_set_cpuid(...) at lapic.c:1216
madt_x2apic_pass2_callback(...) at acpi_madt.c:342
madt_lapic_pass2(...) at acpi_madt.c:...
lapic_config(...) at lapic.c:1290

Notes

  • This is a boot-time firmware-data trigger, not a userspace exploit. The "attacker" must control the firmware/ACPI tables (malicious VM host, malicious UEFI, or a SeaBIOS patch).
  • An alternate proof without QEMU is a unit test that constructs a fake ACPI_MADT_LOCAL_X2APIC and calls madt_x2apic_pass2_callback directly with LocalApicId >= 256, asserting that apic_id_to_cpu_id[256] was written.

Kernel references

VERDICT.md verdict full narrative: mechanism, reachability, dynamic proof, fix validation
โ†“ download raw

DF-1042 โ€” lapic_set_cpuid missing bounds validation โ†’ OOB BSS write via crafted MADT

Verdict

REPRODUCED (boot-time firmware-data OOB write; not userspace-reachable). Fix VALIDATED on a single-fix kernel (#1): the OOB is gone.

Mechanism (confirmed, path:line at every hop)

lapic_set_cpuid(int cpu_id, int apic_id) at sys/platform/pc64/apic/lapic.c:1212-1217 writes its two parameters directly as indices into the 256-element globals with no bounds check:

void lapic_set_cpuid(int cpu_id, int apic_id) {
    CPUID_TO_APICID(cpu_id) = apic_id;   /* cpu_id_to_apic_id[cpu_id]   */
    APICID_TO_CPUID(apic_id) = cpu_id;   /* apic_id_to_cpu_id[apic_id]  */
}
  • CPUID_TO_APICID/APICID_TO_CPUID expand to raw array indexing (sys/platform/pc64/apic/lapic.h:37-38).
  • The arrays are int[NAPICID], NAPICID = 256 (apicvar.h:41), declared at lapic.c:122-123. Index 256 is out of bounds.

The x2APIC MADT pass2 callback madt_x2apic_pass2_callback (acpi_madt.c:320-346) is the reachable sink:

cpu = arg->cpu; arg->cpu++;                              /* acpi_madt.c:337-338  -- UNcapped   */
...
lapic_set_cpuid(cpu, x2apic_ent->LocalApicId);           /* acpi_madt.c:342     -- u32, unchecked */
CPUID_TO_ACPIID(cpu) = x2apic_ent->Uid;                  /* acpi_madt.c:343     -- also unbounded */
  • x2apic_ent->LocalApicId is a UINT32 (ACPI spec) and can be any 32-bit value; it is passed straight through as apic_id. โ†’ apic_id-dimension OOB.
  • arg->cpu starts at 1 (acpi_madt.c:364) and is incremented for every enabled non-BSP entry with no cpu < MAXCPU check, unlike the mptable path which guards with else if (cpu < MAXCPU) at mptable.c:571. With

    256 enabled entries cpu reaches 256 โ†’ cpu-dimension OOB (and the adjacent CPUID_TO_ACPIID(cpu) write to cpu_id_to_acpi_id[NAPICID] (acpi_madt.c:83) is likewise unbounded).

  • MAXCPU == SMP_MAXCPU == NAPICID == 256 (sys/cpu/x86_64/include/param.h:71-72).
  • The probe phase filters LocalApicId < APICID_MAX (acpi_madt.c:413) and only sets madt_use_x2apic when no legacy LOCAL_APIC entries exist (acpi_madt.c:460-476); pass2 then processes every enabled x2APIC entry regardless of LocalApicId. This probe/enumerate inconsistency is the core defect. The naps cap (lapic.c:1183-1193) runs after iteration, so it does not prevent the OOB writes during iteration.

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

A full call-site audit (grep -rn 'lapic_set_cpuid(' sys/) 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. 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. This is a legitimate but high-bar precondition; it is correctly scoped Medium, not High.

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 (DoS) 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).

Dynamic proof (harness on the real kernel)

Because the path is boot-only, and because QEMU's -acpitable appends a second MADT while the kernel's sdt_search() (acpi_sdt.c:196-214) returns the first matching signature, 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). findings/poc/DF-1042/gen_madt.py is still shipped as the conceptual attacker data (a valid 88-byte MADT with a BSP LocalApicId=0 entry and a malicious LocalApicId=256 entry).

df1042_harness.c is a kld module that calls the real exported lapic_set_cpuid() symbol with the exact argument the crafted MADT forces the pass2 callback to pass โ€” lapic_set_cpuid(1, 256) โ€” and observes the resulting corruption on the live kernel BSS. From nm /boot/kernel/kernel.debug the linker placed the two mapping arrays back-to-back in BSS:

0xffffffff818d9d20  B apic_id_to_cpu_id     (256 ints, 0x400 bytes)
0xffffffff818da120  B cpu_id_to_apic_id     (256 ints, 0x400 bytes)  <-- [256]==0x818da520 == lapic_mem

So apic_id_to_cpu_id[256] (offset +0x400 from 0x818d9d20 = 0x818da120) aliases cpu_id_to_apic_id[0] โ€” the BSP's CPUโ†’APIC mapping. (The finding's specific claim that the small overflow hits lapic_enable/lapic_usable/ x2apic_enable is slightly off: those live in .data at 0x8113e650/654, not in the adjacent BSS. The actual adjacent victims are cpu_id_to_apic_id[0] and, one array further, lapic_mem โ€” the LAPIC MMIO base pointer โ€” which is equally critical.)

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

DF1042: lapic_set_cpuid=0xffffffff80bdd180 cpu_id_to_apic_id=0xffffffff818da120 apic_id_to_cpu_id=0xffffffff818d9d20
DF1042: &apic_id_to_cpu_id[256]=0xffffffff818da120  &cpu_id_to_apic_id[0]=0xffffffff818da120  (same addr => index 256 aliases cpu_id_to_apic_id[0])
DF1042: BEFORE lapic_set_cpuid(1,256): cpu_id_to_apic_id[0]=0 [1]=1 (BSP apic id is 0)
DF1042: AFTER  lapic_set_cpuid(1,256): cpu_id_to_apic_id[0]=1 [1]=256
DF1042: OOB WRITE CONFIRMED -- apic_id_to_cpu_id[256] OOB write clobbered cpu_id_to_apic_id[0]

cpu_id_to_apic_id[0] (the BSP mapping) changed 0 โ†’ 1: the out-of-bounds write to apic_id_to_cpu_id[256] landed on it. On a real malicious-MADT boot this corrupts the BSP/AP mappings and (for the cpu-dimension OOB) lapic_mem, producing a boot panic or corrupted LAPIC state.

Fix (fix.diff โ€” validated)

Two complementary changes:

  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 both the apic_id-dimension OOB (x2APIC LocalApicId >= 256) and the cpu_id-dimension OOB.

  2. Defense-in-depth guards in the MADT pass2 callbacks (acpi_madt.c): skip the entry before both lapic_set_cpuid and the adjacent CPUID_TO_ACPIID(cpu) write when cpu >= MAXCPU (legacy + x2APIC) or x2apic_ent->LocalApicId >= APICID_MAX (x2APIC, mirroring the probe filter at acpi_madt.c:413). This keeps the cpu counter and the unbounded cpu_id_to_acpi_id write safe even if lapic_set_cpuid is ever called from a future caller.

PATCHED kernel (6.5-DEVELOPMENT #1, fix.diff applied)

lapic_set_cpuid: invalid cpu_id 1 apic_id 256, skipping
DF1042: BEFORE lapic_set_cpuid(1,256): cpu_id_to_apic_id[0]=0 [1]=1 (BSP apic id is 0)
DF1042: AFTER  lapic_set_cpuid(1,256): cpu_id_to_apic_id[0]=0 [1]=1
DF1042: no corruption -- apic_id_to_cpu_id[256] OOB write did not reach cpu_id_to_apic_id[0]

cpu_id_to_apic_id[0] is unchanged; the bounds check fired and returned early. Reproduced twice (deterministic). The fix supersedes the finding markdown's proposal (which fixed only lapic.c): the markdown fix is correct and necessary, but insufficient alone because the adjacent CPUID_TO_ACPIID(cpu) write in the same callback is equally unbounded on the cpu dimension; fix.diff closes that too.

Build & kernel

  • Single-fix kernel built from /usr/src with the audit commit + findings/poc/DF-1042/fix.diff applied: make -j6 nativekernel KERNCONF=X86_64_GENERIC โ†’ NK_DONE rc=0.
  • Installed as the bare /boot/kernel/kernel (sha256 bc94555c466f0c9bf478edb64d81d3201638e2341e23f9060f0ce0eaebb4ac7b).
  • kern.version: DragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 08:26:18 UTC 2026 (vs the #0 baseline Thu Jul 2 06:02:54 UTC 2026).

PoC changes

  • Added df1042_harness.c + Makefile (kld harness driving the real lapic_set_cpuid symbol โ€” the sanctioned primitive-proof 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).

Fix verification

fixed
baseline reproducedโ†’ patch + rebuild โ†’patched clean

VALIDATED: baseline OOB write confirmed; patched bounds check fires (skipping), no corruption. 2x deterministic.

BEFORE: cpu_id_to_apic_id[0]=1 (corrupted). AFTER: cpu_id_to_apic_id[0]=0 (no corruption).
โ†“ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 08:26:18 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- boot-time-only BSS corruption (attacker must control firmware MADT). No userspace privilege boundary crossed. Valid hard blocker: path unreachable at runtime.

Evidence (decisive lines)

BEFORE: cpu_id_to_apic_id[0] 0->1, OOB WRITE CONFIRMED. AFTER: 'invalid cpu_id 1 apic_id 256 skipping', cpu_id_to_apic_id[0]=0, no corruption.

PoC changes

Authored from scratch: df1042_harness.c + Makefile (kld calling real lapic_set_cpuid), gen_madt.py (conceptual attacker MADT), fix.diff (bounds check in lapic_set_cpuid + MADT pass2 cpu/apic_id guards), VERDICT.md, manifest.json.

Verified recommended fix

(1) Bounds check in lapic_set_cpuid rejecting cpu_id/apic_id outside [0,NAPICID); (2) skip cpu>=MAXCPU in MADT pass2 callbacks + LocalApicId>=APICID_MAX for x2APIC. Supersedes finding proposal (also bounds adjacent CPUID_TO_ACPIID write). Full diff in findings/poc/DF-1042/fix.diff.

Verdict

REPRODUCED (kld harness). lapic_set_cpuid (lapic.c:1212-1217) no bounds check on cpu_id/apic_id vs NAPICID=256. Harness lapic_set_cpuid(1,256) corrupted cpu_id_to_apic_id[0] (apic_id_to_cpu_id[256] aliases it at 0xffffffff818da120). Boot-time-only path (MADT/mptable enumerators), no userspace trigger.