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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5502 Β· 17 files
FileTypeDescriptionSize
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 makes npin attacker-chosen.
  • guest hint.acpi.0.rsdp=0x40000000 in /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 the int_gsi == -1 sentinel.
  • negwrite β€” 16 INTSRC overrides kill every sentinel + RAM IOAPIC covering GSI[βˆ’24,βˆ’1] (GlobalIrqBase 0xFFFFFFE8, npin=24): default-branch irq = gsi = -1 OOB 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 1 then panic: 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 24 then panic: assertion "irq >= 0" failed in ioapic_set_legacy_irqmap ... ioapic_abi.c:812
  • NOINV kernel + negwrite: bootverbose IOAPIC: irq -1 -> gsi -1 edge/high printed 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.
VERDICT.md
↓ download raw

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.GlobalIrqBase is a UINT32 handed straight to the int parameter (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 walks ioapic_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 the int_gsi = -1 sentinel installed by ioapic_config itself (ioapic.c:97) and reads int_trig/int_pola from the zeroed struct = INTR_TRIGGER_CONFORM (bus.h:117) β€” forwarded via ioapic_set_legacy_irqmap(0, -1, CONFORM, CONFORM) (ioapic.c:396) into ioapic_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 passes if (irq < ISA_IRQ_CNT) (ioapic.c:370-371 β€” no lower bound!) and reads ioapic_conf.ioc_intsrc[-1].int_gsi (ioapic.c:372 β€” OOB read: ioc_intsrc is at ioapic_conf+16, so index βˆ’1 lands on the high dword of ioc_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) reads ioapic_irqmaps[cpu][-1] OOB (:780), then OOB writes ioapic_irqmaps[cpuid][-1] fields (:843, :849-853) and ioapic_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, and cpuid = 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 to ioapic_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 < 0 mask-and-return guard in ioapic_gsi_setup() ahead of the sentinel scan. git apply --check passes against the pristine tree.
  • Guest returned to the clean with-src snapshot 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-5502

Confirmed kernel references

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.