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

_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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5483 Β· 19 files
FileTypeDescriptionSize
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.ko helper (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.md
↓ download raw

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)

  1. Table injection (the proven madt-batch vector, DF-5430..5433): hint.acpi.0.rsdp=0x40000000 in 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 ...).

  2. _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_any reserves 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 100 across runs (SpaceId=1 SystemIO + the named port).

  3. 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 and acpi_pst_md_gas_verify (:585-606) checks only BitOffset/width arithmetic. Nothing validates which port.

  4. The unattended boot transition: acpi_pst_postattach (acpi_cpu_pstate.c:1206-1221) ends with acpi_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_testable
baseline reproduced→ patch + rebuild →patched clean

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

Confirmed kernel references

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.