icu_abi MSI/MSI-X allocation count validated only by INVARIANTS-only KASSERTs β OOB write past caller's 32-int array, negative-index scan-walk panic, and count==0 livelock holding icu_irqmap_tok on production kernels
Summary
The ICU twin of DF-5351: count validated only by KASSERTs that compile out on production kernels - count=64 returns rc=0 writing intrs[j] past the caller's on-stack int irqs[32] (canary-proven 32/96 words), count=-2600000 walks the scan ~41MB below icu_irqmaps (Fatal trap 12 at the im_type load), count=0 livelocks 'i += count' holding icu_irqmap_tok (proven: kldload never returned and the guest then refused ALL new ssh until reset). ICU row fragmentation blocks the IDT-smash variant. Runtime-validation fix validated (EINVAL, no corruption, no hang).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5464 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| icu_msiabi_probe.c | β | 3.2 KB | view raw | |
| icu_msiabi_neg.c | β | 1.5 KB | view raw | |
| icu_msiabi_livelock.c | β | 1.7 KB | view raw | |
| build.sh | β | 188 B | view raw | |
| run.sh | β | 213 B | view raw | |
| build.log | β | 11.2 KB | view raw | |
| run.log | β | 2.3 KB | view raw | |
| run_invkernel.log | β | 653 B | view raw | |
| run_fixed.log | β | 478 B | view raw | |
| panic_neg_count.txt | β | 435 B | view raw | |
| env.txt | β | 2.3 KB | view raw | |
| VERDICT.md | β | 5.7 KB | β raw | |
| fix.diff | β | 2.2 KB | view raw | |
| manifest.json | β | 1.2 KB | view raw | |
| verdict.json | β | 5.0 KB | view raw |
DF-5464 VERDICT β reproduced (OOB write past contract array + panic + livelock), driver-facing MSI ABI, Low severity
Bottom line
REPRODUCED on the guest, on a kernel built exactly like production
(X86_64_GENERIC minus options INVARIANTS; stock GENERIC ships without
INVARIANTS too): icu_abi_msi_alloc_intern() (icu_abi.c:452-513)
performs no runtime validation of the driver-supplied count. A KLD
(the driver-facing ABI, identical in reach to PCIB_ALLOC_MSI β
nexus_alloc_msi β MachIntrABI.msi_alloc, active on ICU-ABI configs
where lapic_enable && !ioapic_enable) passing count=64 gets a clean
success return and 32 kernel words written past the caller's 32-int
contract array (icu_abi.c:498 intrs[j] = intr; the reference caller's
array is the on-stack int irqs[32] at sys/bus/pci/pci.c:1952/2072).
count=-2600000 walks the scan index below the kernel image:
Fatal trap 12 ... fault virtual address = 0xffffffff7f1bf020 (below
KERNBASE 0xffffffff80200000), Stopped at icu_abi_msi_alloc_intern+0x5a:
movl (%r13,%rax,1),%edi β the im_type load at icu_abi.c:476.
count=0 never advances i and live-locks the kernel while holding
icu_irqmap_tok (icu_abi.c:463/:473): kldload never returned and the
guest then stopped servicing new ssh sessions entirely for minutes
("Connection timed out during banner exchange" on every attempt) β a
system-wide wedge needing a hardware reset. On the INVARIANTS kernel
(stock X86_64_GENERIC) the same trigger panics panic: invalid count 64
at the KASSERT (icu_abi.c:460) β the KASSERT is the only guard.
How it was reproduced (baseline X86_64_NOINV, full logs in run.log)
RUN 1 β safe canary demo (icu_msiabi_probe.ko):
icu_msiabi_probe: msix_alloc(cpu0) rc=0 irq=16 <- legal path OK icu_msiabi_probe: msi_alloc rc=0 <- count=64 ACCEPTED icu_msiabi_probe: zone[0..7] = 128 129 ... 135 <- alloc at i=128 icu_msiabi_probe: 32/96 canary words past zone[31] overwritten icu_msiabi_probe: zone[32]=160 <-- OOB write past 32-int contract icu_msiabi_probe: msi_release(64) done / SAFE PHASE COMPLETE
Guest healthy afterwards β this is a clean OOB-write primitive, not a blanket failure.
RUN 2 β negative count (icu_msiabi_neg.ko) β panic_neg_count.txt:
Fatal trap 12: page fault while in kernel mode fault virtual address = 0xffffffff7f1bf020 (below KERNBASE) Stopped at icu_abi_msi_alloc_intern+0x5a: movl (%r13,%rax,1),%edi
RUN 3 β zero count (icu_msiabi_livelock.ko): kldload never returned (caller-side timeout expired); afterwards the guest refused all new ssh sessions (banner timeout) until a QEMU-monitor hardware reset.
ICU-vs-IOAPIC note: unlike the ioapic twin (DF-5351), ICU map rows are fragmented ([0..15] LEGACY/RESERVED, [96] SYSCALL, next row's head RESERVED), so count=128 cannot complete a row-crossing window here; the demonstrated primitives are the contract-array stack smear, the negative scan walk, and the token-holding livelock.
Primitive characterization (why Low severity, not higher)
Writes: for each j in [0,count): intrs[j] = i+j (semi-controlled small
ints, past the caller's array β kernel stack in the real caller) and
icu_irqmaps[cpuid][i+j] = {im_type=MSI/MSIX, im_msi_base=i} (16-byte
strided map corruption). Reach requires a driver calling the ICU MSI ABI
with a bad count β already-privileged code (kldload) or a malicious PCIe
device paired with a driver that forwards the device-advertised MMC
count. No unprivileged entry point exists (no sysctl/ioctl/dev-node
surface in this file). Hence Low severity β but the primitive is real
memory corruption past an attacker-influenced bound plus a trivial
system-wide livelock.
Fix validation (fix.diff, applied to guest /usr/src, incremental rebuild)
Baseline reproduced β apply fix.diff (runtime count/powerof2 checks returning EINVAL/early-return in msi_alloc_intern/msi_release_intern) β rebuild X86_64_NOINV β reboot β identical PoCs (run_fixed.log):
probe: msi_alloc rc=22 (EINVAL), "alloc failed, no canary test",
legal msix count=1 round trip still OK
neg: SURVIVED, rc=22 (was: fatal trap)
livelock: returned rc=22 (was: permanent hang + ssh wedge)
Bad behavior gone; no regression on legal allocations.
What I had to fix vs a naive PoC (poc_changes)
- Same KLD plumbing lessons as DF-5351: in-tree kmod.mk recipe, generated device_if.h/bus_if.h, moduledata_t-based DECLARE_MODULE.
- The count=-2600000 value was chosen so the second loop iteration reads ~41 MB below icu_irqmaps (0xffffffff81a00420) β below KERNBASE, guaranteed unmapped β instead of stepping through mapped BSS first.
- The livelock rig expects the caller side to time out; the guest wedge afterwards (all ssh dead) is stronger than predicted and documented.
Kernel references
- sys/platform/pc64/icu/icu_abi.c:460-461 (KASSERT-only count checks)
- sys/platform/pc64/icu/icu_abi.c:473-507 (scan loop, intrs[j]=intr, msi_setup)
- sys/platform/pc64/icu/icu_abi.c:524-533 (release count check + intrs[i] read)
- sys/sys/systm.h:94-122 (KASSERT/KKASSERT compiled out without INVARIANTS)
- sys/bus/pci/pci.c:1952,2072 (caller's 32-int stack arrays)
- sys/platform/pc64/x86_64/nexus.c:591-596 (nexus_alloc_msi gating)
- sys/config/GENERIC (no INVARIANTS) vs sys/config/X86_64_GENERIC:56
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 to guest /usr/src/sys/platform/pc64/icu/icu_abi.c, X86_64_NOINV rebuilt (completed 23:35:53 UTC) and rebooted; identical PoCs re-run in the same session: msi_alloc(count=64) and count=-2600000 and count=0 all return EINVAL(22) immediately, zero canary words overwritten, no fault, no livelock, and the legal count=1 MSI-X round trip still succeeds (run_fixed.log). Bad behavior gone; no regression.
run_fixed.log; guest dmesg captured live during the fixed run (rc=22 lines for probe/neg/livelock; SAFE PHASE COMPLETE)
Confirmed kernel references
Detail
Exploit chain
driver/KLD calls MachIntrABI.msi_alloc(count=64) (reachable in ICU-ABI configs via PCIB_ALLOC_MSI -> nexus.c:595, lapic_enable && !ioapic_enable) -> icu_abi.c:473 loop accepts -> icu_abi.c:498 intrs[j]=intr smears 32 ints past the caller's on-stack int irqs[32] (kernel stack in the real caller; values are allocated intr numbers 128..191) plus 16-byte-strided icu_irqmap writes (im_type/im_msi_base); count=0 -> permanent loop at icu_abi.c:473 holding icu_irqmap_tok -> system-wide ssh wedge (DoS); count<0 -> OOB read walk below the kernel image -> fatal trap. No unprivileged entry point; primitive requires already-privileged code or a malicious PCIe device forwarding its MMC count.
Evidence (decisive lines)
run.log (RUN1 canary 'zone[32]=160 <-- OOB write', RUN2 trap, RUN3 livelock + ssh banner timeouts); panic_neg_count.txt (fault VA 0xffffffff7f1bf020, Stopped at icu_abi_msi_alloc_intern+0x5a); run_invkernel.log ('panic: invalid count 64'); run_fixed.log (rc=22 x3, SAFE PHASE COMPLETE)
PoC changes
Rigs written fresh for the ICU ABI (family pattern from DF-5351): export-based access via 'extern struct machintr_abi MachIntrABI_ICU'; in-tree kmod.mk recipe with generated device_if.h/bus_if.h; moduledata_t DECLARE_MODULE; count=-2600000 chosen so the 2nd loop iteration lands ~41MB below icu_irqmaps (below KERNBASE, guaranteed unmapped); livelock rig relies on caller-side timeout; capture of module output via dmesg/serial (kprintf does not reach the ssh pipe).
Verified recommended fix
Replace the INVARIANTS-only KASSERT count checks in icu_abi_msi_alloc_intern()/icu_abi_msi_release_intern() with runtime validation returning EINVAL / early-return (see fix.diff).
Verdict
REPRODUCED on a production-behaviour kernel (X86_64_NOINV = X86_64_GENERIC minus INVARIANTS; stock GENERIC equally ships without INVARIANTS): icu_abi_msi_alloc_intern()/icu_abi_msi_release_intern() perform no runtime validation of the driver-supplied count (KASSERT-only, icu_abi.c:460-461/524). count=64 returns rc=0 and writes 32 ints past the caller's 32-int contract array (zone[32]=160 in the canary rig; the real caller's array is the on-stack int irqs[32] at pci.c:1952/2072); count=-2600000 walks the scan index below KERNBASE and dies with 'Fatal trap 12 ... Stopped at icu_abi_msi_alloc_intern+0x5a: movl (%r13,%rax,1),%edi' (the im_type load, icu_abi.c:476); count=0 live-locks the kernel holding icu_irqmap_tok β kldload never returned and the guest then stopped servicing all new ssh sessions until a hardware reset. INVARIANTS kernels panic at 'panic: invalid count 64' instead (captured) β the KASSERT is the only guard. Fix (runtime count checks -> EINVAL/early-return) validated on a rebuilt kernel: rc=22 for all bad counts, zero canary writes, legal msix(1) round trip unaffected.
No comments yet.