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)
PoC verification
Evidence pack
findings/poc/DF-4451 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
sys/dev/acpica/acpi.c:1908βacpi_parse_resources()during ACPI child enumeration (evaluates_CRSviaacpi_resource.c),sys/dev/acpica/acpi_pci_link.c:465β counting walk,sys/dev/acpica/acpi_pci_link.c:504βlink_add_crswalk,- (later, at route/resume time)
sys/dev/acpica/acpi_pci_link.c:727βacpi_pci_link_srs_from_crs()re-evaluates_CRSand walks it with the same unboundedlink/icursor (:766-789read side,:893-913write sidelink->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_CRSreturns 1 IRQ descriptor on the first two evaluations (acpi.c:1908 pre-parse + counting walk) and 128 IRQ descriptors afterwards (thelink_add_crswalk):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_EXTRAthrough 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 backrun.shβ boot the guest with the table injectedpanic-inv-assert-boot.logβ full serial log, INVARIANTS-kernel panic bootpanic.txtβ decisive excerptverbose-benign-boot.logβ control boot (threshold not yet reached): shows pci_link5 happily attaching 128 links when both walks see the same bufferrun-noinv-boot.logβ no-INVARIANTS kernel, same SSDT (raw OOB write)fix.diffβ bounds guards for all four unbounded cursor walksfix-validation.logβ patched no-INVARIANTS kernel, same SSDT: no panic
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
- Control boot (no SSDT): stock kernel from the
with-srcsnapshot, 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). - Trigger build:
ssdt_lnkx_v2.aslcompiled with the guest's base/usr/sbin/iasl(version 20251212) βssdt_lnkx.aml(666 bytes). Injected withQEMU_EXTRA="-acpitable file=ssdt_lnkx.aml" ./vm.sh up(vm.sh passes$QEMU_EXTRAthrough to qemu-system-x86_64; two-line infrastructure hook added to vm.sh, default-empty). - 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-bytestruct link), thenlink_add_crsgets eval #3 which returns BUF2 (128 IRQ descriptors) β walks 128 links into the 1-element array. Atlink_index = 1the 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.
-
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) showspci_link5with links 0..127 β direct evidence that (a) the driver consumes stateful_CRScontent verbatim and (b)_CRSof 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). -
Production-style kernel (INVARIANTS removed β config NOINV4451):
X86_64_GENERICminusoptions INVARIANTS, built withmake nativekernel KERNCONF=NOINV4451. With the same SSDT the KASSERT/KKASSERT family compiles to no-ops (sys/sys/systm.h:117-119), solink_add_crsperforms the writes: * v3 SSDT (8192 extra descriptors β 850 KB overwrite walk): the machine hard-resets β no panic, no trap, nodb>(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 inrun-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
_CRSdecides 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_doattachof 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
fixedfix.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)
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.
No comments yet.