_PCT/_PSS firmware-ABI trust: kernel writes firmware-chosen 32/16-bit values to firmware-chosen IO ports unattended at boot (ACPI-S5 machine poweroff demonstrated); world-readable members sysctl turns the status port into an unprivileged read oracle
Summary
The Intel path accepts any SystemIO _PCT GAS: the port is reserved (only rman availability checked), width arithmetic validated, then the unattended boot-time global_set_pstate(0) pushes the _PSS-controlled value to that port. SSDT-injection-proven with the madt-batch vector: control boot (port 0x80) attaches with live sysctls and uid-1001 members read making the kernel read port 0x81; destructive boot (port 0x604 = PM1a_CNT, cval 0x2000) -> outw(SLP_EN|S5) POWERED THE GUEST OFF mid-boot. IO-space-only ceiling (0xcf9/0x92 reset real HW). 16-bit-IO-range-check fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5483 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.4 KB | β raw | |
| VERDICT.md | β | 5.9 KB | β raw | |
| gen_pstate.py | β | 5.9 KB | view raw | |
| dsdt_template.dsl | β | 5.7 KB | β download | |
| dsdt_control.dsl | β | 5.5 KB | β download | |
| dsdt_control.aml | β | 595 B | β download | |
| dsdt_poweroff.dsl | β | 5.5 KB | β download | |
| dsdt_poweroff.aml | β | 601 B | β download | |
| pstate_control.bin | β | 4.6 KB | β download | |
| pstate_poweroff.bin | β | 4.6 KB | β download | |
| estforce.c | β | 1.5 KB | view raw | |
| mon_type.py | β | 1.3 KB | view raw | |
| build.sh | β | 128 B | view raw | |
| run.sh | β | 125 B | view raw | |
| run.control_output.txt | β | 2.2 KB | view raw | |
| run.poweroff_console.log | β | 26.4 KB | view raw | |
| run.nomd_control.log | β | 320 B | view raw | |
| env.txt | β | 741 B | view raw | |
| fix.diff | β | 1.6 KB | view raw |
DF-5483 β ACPI P-state _PCT/_PSS: kernel writes firmware-chosen values to
firmware-chosen IO ports, unattended at boot
What this pack contains
A table-injection rig (the proven madt-batch vector) plus the stock DragonFly
kernel driver cpu_pst doing exactly what the audited code says:
_PCT (DSDT) ββacpi_pst_alloc_resource()ββ> rman IO-port reservation
(no identity validation of the port)
_PSS (DSDT) ββacpi_pst_postattach()ββ> acpi_pst_global_set_pstate(0) [boot, unattended]
ββ> acpi_pst_intel_set_pstate() (acpi_pstate_machdep.c:520)
ββ> acpi_pst_md_res_write() (acpi_pstate_machdep.c:631)
ββ> bus_space_write_N(port, _PSS control value)
Scenarios (all boot the SAME stock kernel; only the planted tables differ)
| scenario | ctrl GAS | _PSS cval | expected / observed |
|---|---|---|---|
| control | SystemIO 0x80/8 | 0xAA | boots; px_dom0.* sysctls; boot write invisible (POST port); unpriv user (uid 1001) reads members -> kernel reads the firmware-chosen status port; root re-fires the write via px_global |
| poweroff | SystemIO 0x604/16 (PM1a_CNT) | 0x2000 (SLP_EN | S5) |
| nomd | control blob, no CPUID substitution | 0xAA | negative control: no P-state md -> no write, no px sysctls (run.nomd_control.log) |
Prerequisites
- QEMU guest, DragonFly 6.5-DEVELOPMENT X86_64_GENERIC (this repo's rig)
- the
estforce.kohelper (estforce.c) preloaded via loader.conf β this QEMU masks CPUID.01H:ECX.EST under both KVM and TCG, so the Intel md would never engage. The helper substitutes ONLY the missing CPUID advertisement (DF-5155 "honest gap" methodology); every subsequent instruction is the genuine stock driver. - iasl (on the guest: /usr/sbin/iasl) to compile dsdt_template.dsl variants
- TCG CPU model (
-machine accel=tcg -cpu SandyBridge) so MSR 0x1a0 (EST enable dance in acpi_pst_intel_init) is virtualized
Build
# 1) compile the scenario DSDTs (on the guest)
scp dsdt_control.dsl dsdt_poweroff.dsl dfbsd-maxx:/tmp/
vm.sh run_root 'cd /tmp && iasl dsdt_control.dsl && iasl dsdt_poweroff.dsl'
scp dfbsd-maxx:/tmp/dsdt_control.aml dfbsd-maxx:/tmp/dsdt_poweroff.aml /tmp/opencode/
# 2) assemble the planted table chain (on the host)
python3 gen_pstate.py /tmp/opencode/dsdt_control.aml /tmp/opencode/pstate_control.bin
python3 gen_pstate.py /tmp/opencode/dsdt_poweroff.aml /tmp/opencode/pstate_poweroff.bin
# 3) one-time guest setup: kenv RSDP hint + estforce preload
vm.sh run_root 'mkdir -p /boot/modules && cp estforce.ko /boot/kernel/
printf "hint.acpi.0.rsdp=0x40000000\nboot_verbose=YES\nestforce_load=YES\n" >> /boot/loader.conf'
Run
# control (survives; shows the full driver surface) vm.sh down QEMU_EXTRA="-machine accel=tcg -cpu SandyBridge \ -device loader,file=/tmp/opencode/pstate_control.bin,addr=0x40000000,force-raw=on" vm.sh up 240 vm.sh run_root 'dmesg | grep -a -E "cpu_pst|control reg|check failed"; sysctl hw.acpi.cpu.px_dom0.available hw.acpi.cpu.px_dom0.members hw.acpi.cpu.px_global' vm.sh run_user 'sysctl hw.acpi.cpu.px_dom0.members' # uid 1001 -> kernel port read vm.sh run_root 'sysctl -w hw.acpi.cpu.px_global=3000' # re-fires the port write # poweroff (destructive: guest dies at boot) vm.sh down QEMU_EXTRA="-machine accel=tcg -cpu SandyBridge \ -device loader,file=/tmp/opencode/pstate_poweroff.bin,addr=0x40000000,force-raw=on" vm.sh up 120 # -> TIMEOUT: ssh never answers; vm.sh status -> down; boot.log ends at the # postattach "P-State configuration check failed" lines (the write fires # microseconds later on the surviving domain, before another printf)
Expected decisive evidence
- run.poweroff_console.log β console output stops forever right after
ACPI: domain1 P-State configuration check failed; the surviving domain's first transition (acpi_pst_global_set_pstate -> acpi_pst_md_res_write -> outw(0x604, 0x2000)) ACPI-powers-off the machine. ssh never comes up. - run.dword_accesswidth.log / run.nomd_control.log β differential controls.
VERDICT β DF-5483
Reproduced? YES β arbitrary firmware-chosen IO-port write by the stock
kernel driver, unattended at boot; demonstrated as a boot-time machine
poweroff (DoS)
The chain, every link observed on the guest
-
Table injection (the proven madt-batch vector, DF-5430..5433):
hint.acpi.0.rsdp=0x40000000in loader.conf +-device loader,file=pstate_*.bin,addr=0x40000000,force-raw=on. The planted chain (RSDPβRSDTβ[FACPβFACS+DSDT, APIC]) is accepted by sdt_probe() with zero signature/checksum validation. The DSDT defines\_SB.C000..C005, each with_PCT(two SystemIO GAS buffers) and_PSS(one P-state, control value = cval). dmesg confirms adoption:ACPI: DSDT 0x0000000040001000 ... (v02 TST DSDT ...). -
_PCT parse + port allocation, no identity validation:
acpi_pst_alloc_resource(acpi_cpu_pstate.c:1495-1518) βacpi_bus_alloc_gas(acpi.c:1251-1293) βbus_alloc_resource_anyreserves whatever port the firmware named (only "is it free in rman" is checked). bootverbose dmesg:cpu_pst0: control reg 1 80/control reg 1 f9/control reg 1 100across runs (SpaceId=1 SystemIO + the named port). -
check_csr accepts any SystemIO GAS whose width arithmetic parses:
acpi_pst_intel_check_csr(acpi_pstate_machdep.c:430-485) verifies only pr_res != NULL andacpi_pst_md_gas_verify(:585-606) checks only BitOffset/width arithmetic. Nothing validates which port. -
The unattended boot transition:
acpi_pst_postattach(acpi_cpu_pstate.c:1206-1221) ends withacpi_pst_global_set_pstate(acpi_pstate_start)β executed at every boot with no user involvement βacpi_pst_intel_set_pstate(acpi_pstate_machdep.c:520-536) βacpi_pst_md_res_write(:631-653) βbus_space_write_N(ctrl->pr_bt, ctrl->pr_bh, reg, pstate->st_cval).
The three runs
| run | tables | outcome |
|---|---|---|
| control (run.control_output.txt) | ctrl=port 0x80 (8-bit), status=0x81, cval=0xAA | boots; px_dom0.available: 3000, px_dom0.select: 3000, px_global: 3000; uid 1001 user reading px_dom0.members got cpu0(3000) β i.e. the kernel read port 0x81 for an unprivileged user and the sysctl output encodes the comparison ((3000) vs (*)) β a world-readable 1-bit-per-read oracle over a firmware-chosen port; root sysctl -w hw.acpi.cpu.px_global=3000 returned 0 β the write re-fires on demand |
| poweroff (run.poweroff_console.log) | ctrl=port 0x604 (PM1a_CNT, 16-bit), cval=**0x2000 (SLP_EN | SLP_TYP=S5)** |
| nomd (run.nomd_control.log) | same control blob, no CPUID substitution | no P-state md β no transition, no px sysctls β differential control proving the write comes from this driver |
Also observed (noted, not filed β different file): the first blob iteration
accidentally encoded the port at GAS Address+4 (0x8000000000);
acpi_bus_alloc_gas happily reserved "IO port 0x8000000000" (rman accepts
64-bit IO starts) and the write would have truncated to port 0x0000 via the
16-bit out instructions β silent truncation of an out-of-range port address,
covered by this finding's fix.diff.
Preconditions / threat model
Crafted ACPI tables reach the guest exactly as in the madt batch: host-level
QEMU flags (physical/host compromise β game over anyway), or anything that
can edit the guest bootloader environment (/boot/loader.conf kenv is root
on the guest). This is the standard firmware-ABI trust posture the audit
family files: the kernel treats DSDT/_PSS/_PCT as trusted TCB input while
the proven injection vector makes that input attacker-reachable. There is
no unprivileged-user trigger for the write; the read oracle
(px_dom0.members, CTLFLAG_RD) is unprivileged but only over a
firmware-chosen port.
Impact ceiling
- Demonstrated: unattended boot-time arbitrary-port write with fully firmware-controlled 16/32-bit value β machine poweroff (DoS).
- Ceiling (not demonstrated): a single writable port per _PCT. Port 0xcf9 (reset) and 0x92 (fast reset) are ignored by this QEMU machine model, but on real hardware both reset the box; the PCI config pair 0xcf8/0xcfc needs two writes to be useful (only ctrl is written; status is read-only), so one-shot PCI config corruption requires luck with the stale 0xcf8 value. Ports 0x60/0x64 (KBC) and 0x70/0x71 (CMOS/NMI) are usually rman-owned and therefore rejected. Realistic class: hardware pokes + reboot/poweroff DoS from crafted tables β the IO-space twin of DF-5432 (which reached arbitrary-physical MMIO and was filed Medium). IO ports cannot directly write kernel RAM, hence Low here.
fix.diff
Authored (fix.diff): explicit 16-bit IO-space range validation of the _PCT
control/status GAS in acpi_pst_intel_check_csr β kills the silent
truncation class and the 0x8000000000 reservation. It does NOT (and cannot,
without a port whitelist upstream will not accept) prevent in-range ports
such as 0x604; the real systemic defense is validating ACPI table provenance
at sdt_probe (tracked by the madt-batch findings). Not rebuild-validated:
the demonstrated DoS is by-design reachability of an in-range port, so the
patch would not change the poweroff run; fix_status=not_testable.
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
not_testablefix.diff (IO-range validation) authored against the read-only sys/ tree and not applied: the demonstrated poweroff uses the in-range PM1a_CNT port 0x604, which no range check can (or should) distinguish β rebuilding would not change the destructive run. The out-of-range/truncation half (port 0x8000000000 accepted and reserved today) is what the diff kills; not rebuild-validated for time budget.
fix.diff; VERDICT.md 'fix.diff' section
Confirmed kernel references
- sys/platform/pc64/acpica/acpi_pstate_machdep.c:520
- sys/platform/pc64/acpica/acpi_pstate_machdep.c:631
- sys/platform/pc64/acpica/acpi_pstate_machdep.c:430
- sys/platform/pc64/acpica/acpi_pstate_machdep.c:585
- sys/dev/acpica/acpi_cpu_pstate.c:1206
- sys/dev/acpica/acpi_cpu_pstate.c:1495
- sys/dev/acpica/acpi.c:1251
Detail
Exploit chain
planted RSDP/RSDT/FACP/DSDT via hint.acpi.0.rsdp=0x40000000 (sdt_probe accepts with no validation) -> DSDT _SB.C000..C005 with _PCT(SystemIO GAS: firmware-chosen port/width) + _PSS(control value) -> acpi_pst_attach -> acpi_pst_alloc_resource -> acpi_bus_alloc_gas reserves the named port (only rman availability checked) -> acpi_pst_intel_check_csr accepts (only width arithmetic) -> boot-time acpi_pst_global_set_pstate(0) -> acpi_pst_intel_set_pstate -> acpi_pst_md_res_write -> bus_space_write_N(firmware port, _PSS cval). estforce.ko substituted only the CPUID2_EST advertisement (QEMU masks it under KVM and TCG); every subsequent instruction is the stock driver.
Evidence (decisive lines)
run.poweroff_console.log (console stops forever right after 'ACPI: domain1 P-State configuration check failed' β the surviving domain0/cpu0 executes outw(0x604,0x2000) microseconds later -> ACPI S5, machine off); run.control_output.txt ('control reg 1 80', px_dom0.available/select/members/px_global sysctls, uid-1001 members read, px_global rewrite rc=0); run.nomd_control.log (no md -> no write, differential control); VERDICT.md for the full narrative
PoC changes
Built the full firmware-ABI rig from scratch: hand-written DSDT ASL (iasl on guest), RSDP/RSDT/FACP/FACS/MADT/DSDT chain generator (FADT PM blocks cloned from the live SeaBIOS FADT via /dev/mem so AcpiEnableSubsystem succeeds), estforce.ko CPUID-substitution helper (DF-5155 methodology), and three scenario differentials. GAS buffer layout had to match DragonFly's acpi_PkgRawGas +3 quirk with a packed struct (empirically derived from bootverbose 'control reg' prints).
Verified recommended fix
Validate _PCT GAS IO addresses against the 16-bit IO space in acpi_pst_intel_check_csr (reject Address+len > 0x10000 instead of silently truncating); systemic provenance validation of injected tables belongs to sdt_probe (madt-batch findings).
Verdict
Stock cpu_pst driver, fed only planted ACPI tables (proven madt-batch injection vector: kenv hint.acpi.0.rsdp + QEMU loader blob), allocated the firmware-named _PCT IO port and, at the unattended boot P-state transition (acpi_pst_postattach -> acpi_pst_global_set_pstate -> acpi_pst_intel_set_pstate:520 -> acpi_pst_md_res_write:631), wrote the _PSS-controlled value to that port. Demonstrated three ways: control run (port 0x80: sysctls live, boot write executed, uid-1001 world-readable 'members' sysctl makes the kernel READ the firmware-named status port, root px_global write re-fires the write), poweroff run (port 0x604 = PM1a_CNT, cval 0x2000 = SLP_EN|S5 -> kernel outw(0x604,0x2000) powered the machine off mid-boot; ssh never answered; vm.sh status down; console ends at the postattach check lines), and a no-md differential control. No port-identity validation exists anywhere on the path; gas_verify only checks width arithmetic. IO-space ceiling (no kernel-RAM write), firmware-tables precondition -> Low/dos, same family calibration as the madt batch.
No comments yet.