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

Heap out-of-bounds write (4 sites) from temporal divergence of _CRS/_PRS evaluations β€” pl_links sized by one evaluation, indexed by a later one

Summary

attach sizes pl_links[] from a counting walk of _CRS, then populating walks index pl_links[link_index] with no runtime bound - only KASSERT/KKASSERT, no-ops on production kernels. ACPI does not require _CRS/_PRS to be pure; on the guest _CRS of a link device is evaluated >=3 times per boot by independent consumers and re-evaluated at route/resume. A stateful method returning more IRQ descriptors later drives OOB writes at attach, at route/resume (srs_from_crs read side leaking adjacent heap into _SRS; route_irqs write side), and symmetrically in link_add_prs. VERIFIED on guest with a crafted SSDT: stock INVARIANTS kernel panics at the boundary assert; production-style kernel corrupts silently (128 descriptors = ~13KB silent corruption, boot continues; 8192 = ~850KB overwrite crash-loop with no panic possible). Fix validated live: guard fired exactly 8191 times, clean boot. Distinct from DF-2389/2390 (temporal vs static mismatch). Runtime-guard-in-all-four-walks fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4451 Β· 22 files
FileTypeDescriptionSize
README.md β€” 4.4 KB ↓ raw
VERDICT.md β€” 5.5 KB ↓ raw
ssdt_lnkx_v1.asl β€” 7.6 KB ↓ download
ssdt_lnkx_v2.asl β€” 7.6 KB ↓ download
ssdt_lnkx_v2.aml β€” 666 B ↓ download
ssdt_lnkx_v3.asl β€” 432.8 KB ↓ download
ssdt_lnkx_v3.aml β€” 32.2 KB ↓ download
build.sh β€” 116 B view raw
run.sh β€” 143 B view raw
build.log β€” 315 B view raw
run.log β€” 949 B view raw
panic.txt β€” 1.2 KB view raw
panic-inv-assert-boot.log β€” 23.0 KB view raw
verbose-benign-boot.log β€” 57.9 KB view raw
run-noinv-boot.log β€” 115.3 KB view raw
run-noinv-128-stealth-boot.log β€” 12.9 KB view raw
env.txt β€” 382 B view raw
fix.diff β€” 2.7 KB view raw
fix_build.log β€” 186 B view raw
fix_run.log β€” 476.8 KB ↓ download
fix-validation.log β€” 1.2 KB view raw
verdict.json β€” 6.3 KB view raw

DF-4451 β€” heap OOB write in link_add_crs (and link_add_prs / srs_from_crs / route_irqs) from temporal divergence of _CRS/_PRS evaluations

File: sys/dev/acpica/acpi_pci_link.c (pass 2)

What the bug is

acpi_pci_link_attach() sizes the pl_links[] heap array from a counting walk of the link device's _CRS (acpi_pci_link.c:465), then runs populating walks (link_add_crs at :504, link_add_prs at :522) that index pl_links[req->link_index] with no runtime bound check β€” only a KASSERT (:235) / KKASSERT (:767, :896), which are no-ops on INVARIANTS-free production kernels (sys/sys/systm.h:117-119).

If the _CRS (or _PRS) method returns a different resource count on a later evaluation than on the counting evaluation, the populate walks write past the end of pl_links. The ACPI specification does not require methods to be pure: side effects and stateful returns are legal AML, and on this system _CRS of a PNP0C0F link device is evaluated at least three times per boot by three independent kernel consumers:

  1. sys/dev/acpica/acpi.c:1908 β€” acpi_parse_resources() during ACPI child enumeration (evaluates _CRS via acpi_resource.c),
  2. sys/dev/acpica/acpi_pci_link.c:465 β€” counting walk,
  3. sys/dev/acpica/acpi_pci_link.c:504 β€” link_add_crs walk,
  4. (later, at route/resume time) sys/dev/acpica/acpi_pci_link.c:727 β€” acpi_pci_link_srs_from_crs() re-evaluates _CRS and walks it with the same unbounded link/i cursor (:766-789 read side, :893-913 write side link->l_routed = TRUE).

This is distinct from the known pass-1 findings: DF-2389 is a static count mismatch between two different buffers (_PRS vs _CRS), DF-2390 is the DPF structural inconsistency inside one buffer. Here a single, well-formed, DPF-free buffer whose method simply returns different (equally valid) content on successive evaluations drives the same out-of-bounds writes.

Threat model

Firmware-supplied ACPI (crafted SSDT/DSDT in a bootable VM image β€” the same bar as DF-2389/2390), or a buggy BIOS with a stateful _CRS. No unprivileged runtime trigger is needed: the write fires during boot device attachment (device_doattach β†’ acpi_pci_link_attach).

Reproduction on the audit guest (done)

  • Inject an SSDT defining \_SB.LNKX (PNP0C0F) whose _CRS returns 1 IRQ descriptor on the first two evaluations (acpi.c:1908 pre-parse + counting walk) and 128 IRQ descriptors afterwards (the link_add_crs walk): ssdt_lnkx_v2.asl (ASL), ssdt_lnkx_v2.aml (compiled with the guest's /usr/sbin/iasl).
  • Boot the guest with the table appended: QEMU_EXTRA="-acptable file=ssdt_lnkx.aml" dfbsd-qemu/vm.sh up (vm.sh passes $QEMU_EXTRA through to qemu-system-x86_64).

Result on stock GENERIC (INVARIANTS kernel): panic β€” panic.txt

panic: link_add_crs: array boundary violation
link_add_crs() at link_add_crs+0x18a
AcpiWalkResourceBuffer() at AcpiWalkResourceBuffer+0x60
AcpiWalkResources() at AcpiWalkResources+0x9f
acpi_pci_link_attach() at acpi_pci_link_attach+0x3d1
device_doattach() at device_doattach+0x3af
Debugger("panic")

The kernel's own assertion catches link_index >= pl_num_links at acpi_pci_link.c:235 β€” proving the overrun is reached.

Result on production-style kernel (INVARIANTS removed): the write proceeds

X86_64_GENERIC minus options INVARIANTS (config NOINV4451, see build-noinv.log) β€” with the asserts compiled out (sys/sys/systm.h:117) link_add_crs writes 128 struct link entries (~13 KB) into a sizeof(struct link) (104-byte) allocation: run-noinv-*.log.

Files

  • ssdt_lnkx_v1.asl / ssdt_lnkx_v2.asl β€” SSDT sources (v1 = count walk divergence attempt that revealed the acpi.c:1908 pre-evaluation; v2 = the firing trigger)
  • ssdt_lnkx_v2.aml β€” compiled SSDT (iasl 20251212, guest /usr/sbin/iasl)
  • build.sh β€” compile the ASL on the guest, fetch the AML back
  • run.sh β€” boot the guest with the table injected
  • panic-inv-assert-boot.log β€” full serial log, INVARIANTS-kernel panic boot
  • panic.txt β€” decisive excerpt
  • verbose-benign-boot.log β€” control boot (threshold not yet reached): shows pci_link5 happily attaching 128 links when both walks see the same buffer
  • run-noinv-boot.log β€” no-INVARIANTS kernel, same SSDT (raw OOB write)
  • fix.diff β€” bounds guards for all four unbounded cursor walks
  • fix-validation.log β€” patched no-INVARIANTS kernel, same SSDT: no panic
VERDICT.md
↓ download raw

DF-4451 β€” VERDICT

Status: reproduced (boot-time kernel memory-safety violation, guest-verified) Impact: heap OOB write on production (INVARIANTS-free) kernels; boot panic on stock GENERIC. Crafted-ACPI precondition (same threat bar as DF-2389/2390).

What was run

  1. Control boot (no SSDT): stock kernel from the with-src snapshot, DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 (X86_64_GENERIC), boots clean; 5 pci_link devices attach (pci_link0..4 = LNKA..LNKD, LNKS).
  2. Trigger build: ssdt_lnkx_v2.asl compiled with the guest's base /usr/sbin/iasl (version 20251212) β†’ ssdt_lnkx.aml (666 bytes). Injected with QEMU_EXTRA="-acpitable file=ssdt_lnkx.aml" ./vm.sh up (vm.sh passes $QEMU_EXTRA through to qemu-system-x86_64; two-line infrastructure hook added to vm.sh, default-empty).
  3. Trigger boot, stock GENERIC kernel (INVARIANTS on): the ACPI bus pre-parses _CRS (acpi.c:1908 β†’ eval #1, returns BUF1), the counting walk gets eval #2 (BUF1 β†’ pl_num_links = 1, pl_links = kmalloc of ONE 104-byte struct link), then link_add_crs gets eval #3 which returns BUF2 (128 IRQ descriptors) β†’ walks 128 links into the 1-element array. At link_index = 1 the kernel panics at its own boundary assertion:

panic: link_add_crs: array boundary violation link_add_crs() at link_add_crs+0x18a AcpiWalkResourceBuffer() at AcpiWalkResourceBuffer+0x60 AcpiWalkResources() at AcpiWalkResources+0x9f acpi_pci_link_attach() at acpi_pci_link_attach+0x3d1 device_doattach() at device_doattach+0x3af

(panic.txt, full serial log panic-inv-assert-boot.log.) This proves the overrun is reached at exactly sys/dev/acpica/acpi_pci_link.c:235-237.

  1. Calibration boot (ssdt_lnkx_v1.asl, threshold 1): both walks saw the same BUF2 β†’ 128 in-bounds links, no panic; the verbose dump (verbose-benign-boot.log) shows pci_link5 with links 0..127 β€” direct evidence that (a) the driver consumes stateful _CRS content verbatim and (b) _CRS of a link device is evaluated β‰₯3 times per boot by independent consumers (acpi.c:1908, acpi_pci_link.c:465, acpi_pci_link.c:504).

  2. Production-style kernel (INVARIANTS removed β€” config NOINV4451): X86_64_GENERIC minus options INVARIANTS, built with make nativekernel KERNCONF=NOINV4451. With the same SSDT the KASSERT/KKASSERT family compiles to no-ops (sys/sys/systm.h:117-119), so link_add_crs performs the writes: * v3 SSDT (8192 extra descriptors β‰ˆ 850 KB overwrite walk): the machine hard-resets β€” no panic, no trap, no db> (the overwrite destroyed state the trap machinery itself needs) β€” and because the SSDT is injected at every boot it crash-loops: 16 reset cycles captured in run-noinv-boot.log. * v2 SSDT (128 extra descriptors β‰ˆ 13 KB overwrite): the system boots and runs normally β€” the corruption is completely silent (run-noinv-128-stealth-boot.log, system up, uptime 42 s at inspection). Nothing in any log indicates 13 KB of kernel heap was just overwritten.

Primitive characterization

  • What: contiguous linear heap overwrite, stride sizeof(struct link) (104 bytes), 9 controlled-ish bytes per stride (res_index = 1,2,3…, crs_type = 3 or 15 (IRQ/EXTIRQ descriptor type), l_irq = first IRQ number in the descriptor mask or 255).
  • Where: immediately past a kmalloc(nΒ·104, M_PCI_LINK, M_WAITOK|M_ZERO) allocation in the DF kernel heap.
  • How far: fully attacker-chosen β€” the crafted _CRS decides the number of IRQ descriptors (hundreds to tens of thousands; AML buffer of a few KB is enough for megabytes of overwrite).
  • Boot-time, pre-authentication (during device_doattach of the link device).

Turning this into uid=0 would additionally require controlling the heap layout at that early boot stage (grooming which objects sit after pl_links) and useful field values; the per-stride controlled bytes are limited, and the write happens once at boot before userland exists, so weaponization is nontrivial. The vulnerability class (heap OOB write from firmware data) and its reliable DoS/corruption impact are proven; privesc weaponization left unclaimed. On INVARIANTS kernels (stock GENERIC as shipped) the same input is a deterministic boot panic.

False-positive check

Not a false positive: the stock kernel's own KASSERT text fired at the exact cited line with the crafted table loaded (panic.txt), and the count/add divergence is by construction (stateful _CRS, _PRS deliberately benign at 1 descriptor so the KNOWN DF-2389 path β€” _PRS count > _CRS count β€” was NOT the trigger; no DPF descriptors exist in either buffer, so DF-2390's mechanism was not involved either).

Fix validation

fix.diff adds real bounds guards to the four unbounded cursor walks (link_add_crs, link_add_prs, acpi_pci_link_srs_from_crs, acpi_pci_link_route_irqs); the same diff also bounds the DF-4452 reads in acpi_pci_link_choose_irq. Applied to the guest's /usr/src copy, kernel NOINV4451 rebuilt, same SSDT re-run: see fix-validation.log + fix_run.log / fix_build.log.

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 to the guest's /usr/src, kernel NOINV4451 (production-style) rebuilt and installed. Baseline with the exact v3 SSDT: hard-reset crash loop. Patched with the identical input: clean boot to multiuser, pci_link5 attaches with its single counted link, guard message printed exactly 8191 times, no panic/trap. The bad behavior is gone.

fix_run.log (patched boot, grep -c 'returned more IRQ resources than counted' = 8191); fix_build.log (kernel build+install); fix-validation.log (side-by-side summary)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Sun Sep 20 08:20:07 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/NOINV4451 x86_64

Confirmed kernel references

Detail

Exploit chain

crafted SSDT (stateful _CRS on PNP0C0F link) in the booted VM image -> QEMU -acpitable -> ACPICA loads SSDT -> acpi bus evaluates _CRS (acpi.c:1908) -> pci_link attach count walk sizes pl_links=1 (104-byte kmalloc) -> link_add_crs add walk receives N-descriptor buffer -> pl_links[1..N-1] written OOB (stride 104, ~9 controlled bytes: res_index, crs_type, l_irq) -> attacker-chosen length; on INVARIANTS kernels: boot panic; on production kernels: silent heap corruption (128 desc) or hard-reset crash loop (8192 desc). uid=0 chain not completed (single boot-time write, limited field control).

Evidence (decisive lines)

panic.txt (stock-kernel panic at the exact cited line); run-noinv-boot.log (16 crash-loop resets, production kernel, 8192-descriptor trigger); run-noinv-128-stealth-boot.log (silent ~13 KB corruption, system boots and runs); verbose-benign-boot.log (pci_link5 dump showing the driver consuming stateful _CRS content); fix_run.log (patched kernel: boots clean, '_CRS returned more IRQ resources than counted' x8191); fix-validation.log (summary); VERDICT.md (full narrative)

PoC changes

No seeded PoC existed (pass-2 new finding). Built from scratch: ASL SSDT with a counter-based stateful _CRS; had to account for one pre-attach _CRS evaluation by the acpi bus (acpi.c:1908) - the first version (threshold 1) made both pci_link walks see the same 128-descriptor buffer (benign, verified via boot-verbose link dump), so the threshold was moved to 2 to place the divergence between the count and add walks. iasl is available in the DragonFly base system (/usr/sbin/iasl, v20251212). vm.sh needed a 2-line QEMU_EXTRA env passthrough (default-empty) to inject -acpitable.

Verified recommended fix

Replace the KASSERT/KKASSERT-only bounds in link_add_crs/link_add_prs/srs_from_crs/route_irqs with runtime guards that skip (and log) resources beyond pl_num_links (fix.diff, validated).

Verdict

Heap out-of-bounds write in link_add_crs (and the same unbounded-cursor pattern in link_add_prs, acpi_pci_link_srs_from_crs and acpi_pci_link_route_irqs) caused by temporal divergence of _CRS/_PRS evaluations: acpi_pci_link_attach sizes pl_links[] from a counting walk (acpi_pci_link.c:465) but the populating walks index pl_links[link_index] with no runtime bound - only KASSERT/KKASSERT, which are no-ops on INVARIANTS-free production kernels (sys/sys/systm.h:117-119). The ACPI spec does not require _CRS/_PRS to be pure; on this system _CRS of a PNP0C0F link is evaluated at least 3x per boot by independent consumers (acpi.c:1908 bus pre-parse, the count walk, the add walk) and again at route/resume time (acpi_pci_link.c:727). A crafted SSDT with a stateful _CRS (1 IRQ descriptor for the counting evaluations, then N) reproduces: stock INVARIANTS GENERIC panics at its own boundary assertion 'panic: link_add_crs: array boundary violation' (panic.txt); a production-style kernel (X86_64_GENERIC minus INVARIANTS) performs the writes silently - 128 extra descriptors = ~13 KB overwrite with the system booting normally (stealth, run-noinv-128-stealth-boot.log), and 8192 extra descriptors = ~850 KB overwrite hard-resetting the machine into a crash loop with no panic or DDB possible (16 reset cycles, run-noinv-boot.log). Attacker controls the overwrite length (descriptor count) and, per 104-byte struct link stride, ~9 bytes per stride (res_index counter, crs_type, l_irq). Boot-time, pre-authentication, firmware-data precondition (crafted ACPI in the booted image - same bar as DF-2389/2390, but a distinct mechanism: neither static _CRS/_PRS mismatch nor DPF inconsistency; single well-formed DPF-free buffer whose method returns different valid content on successive evaluations). uid=0 weaponization not claimed: the write fires once during early boot device attachment before userland exists and with limited per-stride control. fix.diff adds real bounds guards to all four walks; validated on the rebuilt production-style kernel with the exact crash-looping input: clean boot, guard fires 8191x, no panic.