# 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**:

```c
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:

```c
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:

- `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 by `cpu < 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`.
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).
