ioapic_add() accepts unvalidated firmware GSI base/npin: negative-GSI chain OOB-reads ioc_intsrc[] (ioapic.c:372), forwards negative IRQs to ioapic_set_legacy_irqmap() for silent OOB writes on production kernels, and aliases the int_gsi==-1 sentinel for a deterministic production panic (ioapic.c:520)
Summary
MADT GlobalIrqBase is UINT32 handed to an int parameter: bases >=0x80000000 become negative GSI bases and the overlap scan never collides them with positive ranges. GSI -1 aliases the int_gsi sentinel (reads CONFORM -> panic on production kernels); with sentinels overwritten the identity mapping forwards irq=-1 past the upper-bound-only checks into ioapic_set_legacy_irqmap whose KKASSERT compiles out - OOB-writes ioapic_irqmaps[cpuid][-1] and ioapic_irqs[-1], and odd negative GSIs make cpuid negative, moving writes 4.6KB*|cpuid| below ioapic_irqmaps. Injection-rig-proven on all 4 combos (stock+negpanic/negwrite panics at the exact asserts; production NOINV+negpanic panics pin_prog conform; production+negwrite executes the OOB-write path SILENTLY and boots); validate+full-interval fix validated (rejects both, legit IOAPIC byte-identical).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5502 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| gen_madt.py | β | 5.1 KB | view raw | |
| run_scenario.sh | β | 832 B | view raw | |
| fix.diff | β | 2.6 KB | view raw | |
| run_control.log | β | 14.3 KB | view raw | |
| run_negpanic.log | β | 8.4 KB | view raw | |
| run_negwrite.log | β | 2.6 KB | view raw | |
| run_negpanic_noinv.log | β | 8.3 KB | view raw | |
| run_negwrite_noinv.log | β | 14.0 KB | view raw | |
| run_negpanic_fixed.log | β | 14.3 KB | view raw | |
| run_negwrite_fixed.log | β | 14.0 KB | view raw | |
| build.log | β | 1.4 KB | view raw | |
| build_fixed.log | β | 690 B | view raw | |
| panic.txt | β | 2.0 KB | view raw | |
| env.txt | β | 1.1 KB | view raw | |
| VERDICT.md | β | 8.1 KB | β raw | |
| README.md | β | 3.3 KB | β raw | |
| verdict.json | β | 7.2 KB | view raw |
DF-5502 β ioapic.c negative-GSI chain (pass-2 verify rig)
Finding: ioapic_add() performs zero validation of the firmware-supplied
GSI base / pin count; a crafted ACPI MADT IO_APIC entry with
GlobalIrqBase >= 0x80000000 (u32 β int wrap) plants a negative GSI space
that drives ioapic_gsi_setup() into an OOB read of
ioapic_conf.ioc_intsrc[] (ioapic.c:372) and forwards negative IRQs to
ioapic_set_legacy_irqmap() β OOB writes to ioapic_irqmaps[] /
ioapic_irqs[] on production (no-INVARIANTS) kernels, and a
sentinel-aliasing CONFORM panic otherwise.
Rig (same injection model as the accepted DF-5431 rig)
gen_madt.py <scenario> <out.bin>β crafts RSDPβRSDTβMADT at 0x40000000 plus, at +0x1000, a RAM-backed fake IO-APIC "register window" (select@+0x00, data@+0x10) whose VER preload makesnpinattacker-chosen.- guest
hint.acpi.0.rsdp=0x40000000in /boot/loader.conf (hinted RSDP is accepted with no signature/checksum validation, acpi_sdt.c:71). QEMU_EXTRA="-device loader,file=<blob>,addr=0x40000000,force-raw=on".run_scenario.sh <scenario>appends the hint, boots, captures serial.
Scenarios
controlβ 2 CPUs + real IOAPIC base 0 (must configure ioapic cleanly; boot later stops in ACPICA because the minimal table set has no DSDT β rig artifact, happens after the ioapic stage in every run).negpanicβ adds a RAM IOAPIC covering GSI β1 only (GlobalIrqBase 0xFFFFFFFF, npin=1): gsi β1 aliases theint_gsi == -1sentinel.negwriteβ 16 INTSRC overrides kill every sentinel + RAM IOAPIC covering GSI[β24,β1] (GlobalIrqBase 0xFFFFFFE8, npin=24): default-branchirq = gsi = -1OOB read at ioapic.c:372 β negative IRQ forwarded.
Build / run
python3 gen_madt.py control /tmp/madt_control.bin # on host python3 gen_madt.py negpanic /tmp/madt_negpanic.bin python3 gen_madt.py negwrite /tmp/madt_negwrite.bin sh run_scenario.sh negpanic # stock INVARIANTS kernel β panic :809/:812 sh run_scenario.sh negwrite # stock INVARIANTS kernel β panic :812 # production-behaviour kernel (stock GENERIC ships WITHOUT INVARIANTS): # guest: cd /usr/src/sys/config; sed -e 's/^options INVARIANTS/#&/' # -e 's/^ident.*/ident X86_64_NOINV/' X86_64_GENERIC > X86_64_NOINV # guest: cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_NOINV sh run_scenario.sh negwrite # NOINV kernel β silent OOB writes # fix validation: # guest: cd /usr/src && patch < fix.diff && make -j6 nativekernel ... sh run_scenario.sh negwrite # fixed kernel β rejected, no OOB, no panic
Expected decisive output
- stock kernel + negpanic:
IOAPIC: idx 0, apic id 2, gsi base -1, npin 1thenpanic: assertion "trig == INTR_TRIGGER_EDGE || trig == INTR_TRIGGER_LEVEL" failed in ioapic_set_legacy_irqmap ... ioapic_abi.c:809 - stock kernel + negwrite:
IOAPIC: idx 0, apic id 2, gsi base -24, npin 24thenpanic: assertion "irq >= 0" failed in ioapic_set_legacy_irqmap ... ioapic_abi.c:812 - NOINV kernel + negwrite: bootverbose
IOAPIC: irq -1 -> gsi -1 edge/highprinted from inside the OOB-write path (plus any other negative irq lines), ioapic stage completes with no panic β out-of-bounds writes executed. - fixed kernel + negwrite:
ioapic_add: invalid I/O APIC gsi base -24, npin 24, ignored, single ioapic configured, no negative-irq lines, no ioapic panic.
DF-5502 β VERDICT
1. Reproduced? YES β on three kernel configurations
All runs use the DF-5431 injection model: crafted RSDPβRSDTβMADT blob placed at
0x40000000 by -device loader, selected with loader kenv
hint.acpi.0.rsdp=0x40000000 (hinted RSDP accepted with no signature or
checksum validation, sys/platform/pc64/acpica/acpi_sdt.c:71). A RAM page at
0x40001000 acts as a fake IO-APIC register window (select@+0x00, data@+0x10):
the ID readback in ioapic_set_apic_id() echoes, and the VER read returns the
preloaded dword, so npin is attacker-chosen (ioapic.c:735-738 caller side:
acpi_madt.c:733-738).
Run matrix (full serial logs in this directory)
| log | kernel | result |
|---|---|---|
| run_control.log | stock GENERIC (INVARIANTS) | clean ioapic stage (46 legacy mappings); later stops in AcpiTbLoadNamespace β rig artifact (minimal table set has no DSDT), identical in healthy runs |
| run_negpanic.log | stock GENERIC | IOAPIC: idx 0, apic id 2, gsi base -1, npin 1 β panic: assertion "trig == INTR_TRIGGER_EDGE ..." ioapic_abi.c:809 |
| run_negwrite.log | stock GENERIC | gsi base -24, npin 24 β panic: assertion "irq >= 0" ioapic_abi.c:812 |
| run_negpanic_noinv.log | X86_64_NOINV (production behaviour; stock GENERIC ships without INVARIANTS) | IOAPIC: irq 0 -> gsi -1 conform/conform β panic: ioapic_pin_prog: trig conform is not supported (ioapic.c:520) |
| run_negwrite_noinv.log | X86_64_NOINV | IOAPIC: irq -1 -> gsi -1 edge/high β the OOB-write path executed; NO panic; ioapic stage completed ("legacy irq max 24"), SMP came up, boot continued (silent out-of-bounds writes) |
| run_negwrite_fixed.log | X86_64_NOINV + fix.diff | ioapic_add: invalid I/O APIC gsi base -24, npin 24, ignored; single legit ioapic; no negative-irq line, no panic; boot proceeds exactly like control |
| run_negpanic_fixed.log | X86_64_NOINV + fix.diff | ... gsi base -1, npin 1, ignored; no panic; full normal legacy mapping (46 lines, identical to control) |
2. Root cause (path:line)
ioapic_add(void *addr, int gsi_base, int npin) (ioapic.c:229-267) validates
nothing about the firmware-supplied GSI base or pin count:
ACPI_MADT_IO_APIC.GlobalIrqBaseis a UINT32 handed straight to theintparameter (acpi_madt.c:738). A base β₯ 0x80000000 becomes a negative GSI base;gsi_end = gsi_base + npin - 1(ioapic.c:234) also overflows for large positive bases. The overlap scan (ioapic.c:235-246) compares against positive existing ranges and never fires for negative ones β the entry is accepted and sorted to the head of the list.ioapic_config()βioapic_setup()(ioapic.c:553-554) then walksioapic_gsi_setup(info->io_gsi_base + i)with negative GSIs:- negpanic path: the intsrc scan
gsi == int_src->int_gsi(ioapic.c:358) aliases GSI β1 against theint_gsi = -1sentinel installed by ioapic_config itself (ioapic.c:97) and readsint_trig/int_polafrom the zeroed struct =INTR_TRIGGER_CONFORM(bus.h:117) β forwarded viaioapic_set_legacy_irqmap(0, -1, CONFORM, CONFORM)(ioapic.c:396) intoioapic_pin_prog()which panics on CONFORM (ioapic.c:519-521). On INVARIANTS builds the KKASSERT at ioapic_abi.c:809 fires first. Console proof:irq 0 -> gsi -1 conform/conform. - negwrite path (memory corruption): with the 16 sentinels overwritten
by INTSRC overrides, GSI β1 falls to the default identity mapping:
irq = gsi= β1 passesif (irq < ISA_IRQ_CNT)(ioapic.c:370-371 β no lower bound!) and readsioapic_conf.ioc_intsrc[-1].int_gsi(ioapic.c:372 β OOB read:ioc_intsrcis at ioapic_conf+16, so index β1 lands on the high dword ofioc_list.tqh_first, a KVA pointer β always negative) β the>= 0"mapped elsewhere" guard fails β EDGE/HIGH βioapic_set_legacy_irqmap(-1, -1, EDGE, HIGH)(ioapic.c:396) β ioapic_abi.c:KKASSERT(irq >= 0)(:812) is INVARIANTS-only and absent on production builds;ioapic_is_legacy_irqmap_used(-1)readsioapic_irqmaps[cpu][-1]OOB (:780), then OOB writesioapic_irqmaps[cpuid][-1]fields (:843, :849-853) andioapic_irqs[-1]{.io_addr pointer, .io_idx, .io_flags} (:865-871). The bootverbose print at :855-860 βIOAPIC: irq -1 -> gsi -1 edge/highβ is emitted from inside the OOB path and appears in run_negwrite_noinv.log:107. - For other negative GSIs g β [β24,β2] the :372 OOB read sweeps the 260
bytes below
ioapic_conf(lapic/tsc variables at 0x818da520-0x818da53f on the audited link layout); any negative dword forwards irq=g the same way, andcpuid = gsi % ncpus(ioapic_abi.c:1180) goes negative for odd negative GSIs (e.g. β23 % 6 = β5 on the 6-CPU host), moving the writes toioapic_irqmaps[-5][...]β 23 KB below the array, into foreign .bss.
3. Impact
Firmware/boot-vector (crafted ACPI tables β same accepted threat model as DF-5431/DF-1042; no unprivileged in-guest path):
- Production kernels: silent kernel-memory OOB writes during boot (firmware-controlled negative offsets/row indices, semi-controlled values: small enums, GSI values, an MMIO KVA pointer) plus a deterministic production panic via the sentinel path (ioapic.c:520).
- INVARIANTS/debug kernels: two distinct assertion panics (ioapic_abi.c:809, :812).
Concrete damage on the audited link layout (nm -S, stock kernel):
ioapic_irqs[-1] writes land in the 16-byte gap after ioapic_conf
(0x818da610) and ioapic_irqmaps[0][-1] in the padding after
ioapic_abi_legacy_irq_max (0x818db228) β layout-dependent, a different
kernel config or link order places live objects there; the odd-negative-GSI
row variant hits arbitrary earlier .bss unconditionally.
Classification: impact=panic (deterministic hard stop, incl. the production ioapic.c:520 panic), with the silent OOB write documented above β consistent with DF-1042/DF-5351 precedent.
4. Exploit chain (firmware threat model)
crafted MADT β unvalidated ioapic_add(base=0xFFFFFFE8, npin=24@RAM-window) β ioapic_gsi_setup(-1) β OOB read ioc_intsrc[-1] (negative KVA dword) β ioapic_set_legacy_irqmap(irq=-1) β OOB writes ioapic_irqmaps[0][-1] + ioapic_irqs[-1] β boot continues. Root escalation is not in scope of this vector (pre-SMP boot phase, firmware-supplied values only); the corruption is boot-time .bss damage whose blast radius depends on link layout.
5. Fix validation (fix.diff β applied in-guest, kernel rebuilt, re-run)
- Baseline (X86_64_NOINV, unfixed): negwrite β OOB print irq β1; negpanic β panic ioapic.c:520.
- Patched (X86_64_NOINV + fix.diff): both scenarios β
ioapic_add: invalid I/O APIC gsi base -24/-1, npin 24/1, ignored; negative-irq line gone; no panic; legacy mapping byte-identical to the healthy control run (46 "IOAPIC: irq" lines). - fix.diff also closes the containment hole in the overlap test (a new range
fully swallowing an existing one previously passed β see DF-5503) and adds a
defensive
gsi < 0mask-and-return guard in ioapic_gsi_setup() ahead of the sentinel scan.git apply --checkpasses against the pristine tree. - Guest returned to the clean
with-srcsnapshot afterwards.
6. Honesty notes
- The stock-kernel run logs are the console tail (59 lines) captured by the rig script; each contains the complete decisive section (MADT entries, gsi base lines, panic line, DDB stop). NOINV/fixed logs are full boot.log copies.
- Every boot that survives the ioapic stage later stops in AcpiTbLoadNamespace (kernel page fault, DDB) because the minimal crafted table set has no DSDT β a rig artifact present in control and fixed runs alike; all ioapic evidence precedes it.
- One earlier negwrite attempt booted the wrong kernel (nativekernel does not install; fixed by running installkernel explicitly).
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
fixedfix.diff applied inside the guest /usr/src, X86_64_NOINV rebuilt and installed. Baseline: negwrite -> silent OOB-write path executed ('IOAPIC: irq -1 -> gsi -1 edge/high', no panic); negpanic -> panic ioapic.c:520. Patched: negwrite -> 'ioapic_add: invalid I/O APIC gsi base -24, npin 24, ignored', no negative-irq line, no panic, boot proceeds like control; negpanic -> '... gsi base -1, npin 1, ignored', no panic, full normal legacy mapping byte-identical to the control run. The previously-observed bad behavior is GONE on the patched kernel.
findings/poc/DF-5502/run_negwrite_fixed.log (line ~105 warning; no OOB marker), run_negpanic_fixed.log (warning; 46 normal 'IOAPIC: irq' lines), fix.diff (git apply --check passes), VERDICT.md section 5
Confirmed kernel references
- sys/platform/pc64/apic/ioapic.c:229
- sys/platform/pc64/apic/ioapic.c:234
- sys/platform/pc64/apic/ioapic.c:235
- sys/platform/pc64/apic/ioapic.c:277
- sys/platform/pc64/apic/ioapic.c:354
- sys/platform/pc64/apic/ioapic.c:370
- sys/platform/pc64/apic/ioapic.c:372
- sys/platform/pc64/apic/ioapic.c:396
- sys/platform/pc64/apic/ioapic.c:519
- sys/platform/pc64/apic/ioapic.c:553
- sys/platform/pc64/apic/ioapic_abi.c:809
- sys/platform/pc64/apic/ioapic_abi.c:812
- sys/platform/pc64/apic/ioapic_abi.c:843
- sys/platform/pc64/apic/ioapic_abi.c:865
- sys/platform/pc64/acpica/acpi_madt.c:738
Detail
Exploit chain
crafted RSDP->RSDT->MADT (hint.acpi.0.rsdp, no validation) -> MADT IO_APIC entry GlobalIrqBase=0xFFFFFFE8 + RAM-backed fake IO-APIC window (attacker-chosen npin) -> ioapic_add(-24, 24) accepted -> ioapic_gsi_setup(-1) -> OOB read ioc_intsrc[-1] (negative) -> ioapic_set_legacy_irqmap(irq=-1, gsi=-1) -> OOB writes ioapic_irqmaps[0][-1] + ioapic_irqs[-1] (flags/idx/MMIO-KVA pointer) on production kernels -> silent corruption, boot continues. Sentinel variant (base 0xFFFFFFFF, npin 1) -> deterministic production panic at ioapic.c:520. Boot-time firmware vector only (pre-SMP); no unprivileged in-guest trigger, so no uid=0 chain from this bug.
Evidence (decisive lines)
panic.txt (all four decisive signatures); run_negwrite_noinv.log lines 105-107 (OOB-path print 'IOAPIC: irq -1 -> gsi -1 edge/high') and 156 (ioapic stage completed); run_negpanic_noinv.log lines 92-93 ('irq 0 -> gsi -1 conform/conform' + 'panic: ioapic_pin_prog: trig conform'); run_negwrite.log line 48 (stock assertion 'irq >= 0' ioapic_abi.c:812); run_negpanic.log (stock assertion at :809); run_negwrite_fixed.log / run_negpanic_fixed.log (rejection warnings, no OOB, no panic); VERDICT.md sections 2/3/5; env.txt (kernels); fix.diff
PoC changes
Rig authored from scratch on the DF-5431 gen_madt.py pattern: added RAM-backed fake-IOAPIC register window (npin fully attacker-chosen via VER preload), negpanic/negwrite scenarios, 16 sentinel-killing INTSRC overrides, idempotent loader-hint handling. One build-flow fix during verification: nativekernel does not install -- explicit 'make installkernel KERNCONF=X86_64_NOINV' added (first negwrite-on-NOINV attempt accidentally booted stock GENERIC); one source fix in fix.diff (INT_MAX -> 0x7fffffff, not available in kernel C).
Verified recommended fix
Validate gsi_base >= 0, npin > 0, no base+npin overflow in ioapic_add() (reject-and-warn, not panic), replace the two-sided membership overlap test with the full interval test, and guard ioapic_gsi_setup() against gsi < 0 before the sentinel scan (see fix.diff)
Verdict
ioapic_add() (ioapic.c:229-267) validates nothing about the firmware-supplied gsi_base/npin. A crafted MADT IO_APIC entry with GlobalIrqBase >= 0x80000000 (u32 truncated to int at acpi_madt.c:738) plants a negative GSI space that passes the overlap scan (ioapic.c:235-246, negative ranges never collide with positive ones) and is walked by ioapic_gsi_setup(): GSI -1 aliases the int_gsi==-1 sentinel installed at ioapic.c:97, forwarding zeroed-struct CONFORM enums into ioapic_pin_prog() -> production panic 'ioapic_pin_prog: trig conform is not supported' (ioapic.c:520; reproduced on X86_64_NOINV, run_negpanic_noinv.log:92-93; INVARIANTS builds stop earlier at ioapic_abi.c:809, run_negpanic.log). With the sentinels overwritten (16 INTSRC overrides), the default identity mapping 'irq = gsi' has no lower bound (ioapic.c:370-371), OOB-reads ioc_intsrc[-1] at ioapic.c:372 (lands on ioc_list.tqh_first's high dword -- always negative) and forwards irq=-1 to ioapic_set_legacy_irqmap(): on the production kernel the KKASSERT(irq>=0) (ioapic_abi.c:812) is compiled out and the function performs out-of-bounds writes to ioapic_irqmaps[0][-1] (:843,:849-853) and ioapic_irqs[-1] including a kernel pointer (:865-871) -- proven by the bootverbose line 'IOAPIC: irq -1 -> gsi -1 edge/high' printed from inside the OOB path with NO panic and a boot that continued normally (run_negwrite_noinv.log:107,156). Stock-kernel runs panic at the two assertions (run_negpanic.log, run_negwrite.log). fix.diff (validate gsi_base/npin in ioapic_add + proper interval-overlap test + defensive gsi<0 guard before the sentinel scan) was built into X86_64_NOINV and both scenarios then degrade to 'ioapic_add: invalid I/O APIC gsi base ..., ignored' with clean, control-identical ioapic configuration and no panic.
No comments yet.