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

Uninitialised heap read in k8_powernow_setperf tail leaks a heap word into world-readable sysctl machdep.powernow.frequency.current

Summary

k8_get_curfreq's -1 sentinel becomes UINT_MAX in the unsigned parameter, the match loop exits at i==n_states, and the tail reads state_table[n_states] (kmalloc'd without M_ZERO; decode fills only n_states of 16) into the world-readable sysctl - a 4-byte residual heap word to any local user on a K8 whose boot state is outside the PST. Distinct from DF-1960 (decode succeeded here). Logic-verified on guest as uid=1001 with the exact struct replica; kernel path AMD-gated. EINVAL-on-no-match + validated-init fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4903 Β· 11 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 3.6 KB ↓ raw
sim_tailread.c β€” 4.4 KB view raw
build.sh β€” 98 B view raw
run.sh β€” 95 B view raw
build.log β€” 161 B view raw
run.log β€” 305 B view raw
env.txt β€” 314 B view raw
fix.diff β€” 834 B view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 3.3 KB view raw

DF-4903 β€” Uninitialised heap read in k8_powernow_setperf() tail leaks one heap word into world-readable sysctl

File: sys/dev/powermng/powernow/powernow.c Severity: Medium Β· Bucket: kernleak Β· Confidence: likely (code path certain; hardware precondition)

Root cause (path:line)

  1. k8_get_curfreq() (powernow.c:204-224) returns -1 ("Not reached", powernow.c:222-223) when the CPU's current fid/vid (MSR 0xc0010042) is not among the decoded PST states β€” e.g. a BIOS PST that does not list the boot state, a state changed after boot by other means, or a trimmed OEM table.
  2. powernow_init() (powernow.c:392) calls k8_powernow_setperf(k8_get_curfreq()). The callee takes unsigned int freq (powernow.c:227): -1 becomes 0xFFFFFFFF.
  3. The state-match loop (powernow.c:245-251) compares state_table[i].freq >= freq with freq unsigned β€” no MHz value can match UINT_MAX, so the loop runs to completion: i == cstate->n_states, fid == vid == 0.
  4. The transitions then drive the CPU toward fid 0 / vid 0 (a valid K8 state: 800 MHz), and the tail at powernow.c:310-311 fires: cpuspeed = cstate->state_table[i].freq; with i == n_states.
  5. struct k8pnow_cpu_state is allocated with kmalloc(..., M_DEVBUF, M_WAITOK) (powernow.c:366) β€” not zeroed β€” and k8pnow_decode_pst() (powernow.c:139-164) initialises only the first n_states of the 16 state_table[] entries. For n_states < 16 the read at step 4 therefore returns an uninitialised heap word (still inside the allocation). For n_states == 16 it reads the n_states member itself (benign value).
  6. cpuspeed is handed to any local user via the world-readable sysctl machdep.powernow.frequency.current (SYSCTL_OUT at powernow.c:337, leaf registered at powernow.c:417-420, CTLFLAG_RD; DragonFly gates only sysctl writes on privilege β€” kern/kern_sysctl.c:1446).

Net effect: one 4-byte kernel-heap disclosure per load/trigger, readable by an unprivileged user. Distinct from known DF-1960 (no-matching-PST β‡’ whole-table uninit): here the PST decode succeeded, but the -1 sentinel walks the index past the initialised prefix.

Trigger preconditions

  • AMD K8 (PowerNow!/Cool'n'Quiet, CPUID 0x80000007 EDX bit 6) β€” the driver's gate at powernow.c:358-363.
  • Boot (or current) fid/vid βˆ‰ decoded PST states β‡’ k8_get_curfreq()==-1.
  • n_states < 16 so state_table[n_states] is uninitialised heap.

What was run (see VERDICT.md)

  • Guest is Intel/QEMU: kldload powernow prints module_register_init: MOD_LOAD (powernow) error 2 (CPUID gate) β€” the vulnerable path cannot execute on this guest, so no kernel-level reproduction was possible (status: untested).
  • A userland logic harness (sim_tailread.c) replicating the exact struct layout and expressions was compiled and run as uid=1001 on the guest: confirms -1 β†’ UINT_MAX β†’ i==n_states β†’ uninit read β†’ cpuspeed == 0xA5A5A5A5. Logic-level proof only; not a kernel leak.

Build / run

cc -Wall -O2 -o sim_tailread sim_tailread.c && ./sim_tailread

Expected output

k8_get_curfreq() = -1   (boot cfid/cvid not in table)
cpuspeed after init = 0xa5a5a5a5 (-1515870811)
LEAK CONFIRMED (logic): ...

Treat "no matching state" as an error instead of falling through the loop tail, and never feed -1 into the unsigned parameter β€” see fix.diff.

VERDICT.md
↓ download raw

DF-4903 β€” VERDICT

status: untested Β· reproduced: 0 Β· impact: leak (kernel-heap info leak, 4 bytes, unpriv-readable sysctl) Β· confidence: likely

Narrative

Pass-2 audit of sys/dev/powermng/powernow/powernow.c found that the "current frequency" plumbing leaks an uninitialised kernel-heap word when the CPU's live fid/vid does not match any decoded PST entry:

  • k8_get_curfreq() returns -1 on mismatch (powernow.c:223).
  • powernow_init() passes that -1 straight into k8_powernow_setperf(unsigned int freq) (powernow.c:227, call at :392), where it becomes 0xFFFFFFFF.
  • Every state_table[i].freq >= freq comparison (powernow.c:246) compares an int against that unsigned value β€” all false β€” so i exits the loop equal to n_states with fid=vid=0.
  • The tail cpuspeed = cstate->state_table[i].freq; (powernow.c:310-311) then reads state_table[n_states]: for n_states < 16 that entry was never initialised (kmalloc without M_ZERO at powernow.c:366; decode fills only n_states entries, powernow.c:143-162).
  • cpuspeed is world-readable at machdep.powernow.frequency.current (powernow.c:337, registered :417-420). DragonFly only restricts sysctl writes (kern_sysctl.c:1446), so any local user can read the leak.

Not DF-1960: DF-1960 is the "PSB present but no PST matches β‡’ whole table uninitialised" case. DF-4903 is a different root cause β€” the table is properly decoded; the -1 sentinel type-confuses the unsigned match loop and indexes one past the initialised prefix.

Verification attempts

  1. Guest gate check β€” the audit guest is QEMU Virtual CPU version 2.5+ (Intel-class). kldload powernow β†’ console: module_register_init: MOD_LOAD (powernow, ...) error 2 β€” the CPUID 0x80000007 gate (powernow.c:361-363) refuses the module, no sysctls are created, and no MSR/PSB code executes. Runtime reproduction of an AMD-K8-only path is impossible on this guest β‡’ honest untested (kernel side).
  2. Logic harness (sim_tailread.c) β€” exact replica of the struct layout, the -1 sentinel, the unsigned comparison, and the tail read; heap poisoned with 0xA5 to emulate kmalloc(M_DEVBUF) reuse. Compiled with guest cc 8.3 [DragonFly] and run as uid=1001(maxx):

k8_get_curfreq() = -1 (boot cfid/cvid not in table) cpuspeed after init = 0xa5a5a5a5 (-1515870811) LEAK CONFIRMED (logic): uninitialised heap word read at state_table[n_states=3] landed in the world-readable sysctl machdep.powernow.frequency.current

This proves the code path and the type confusion concretely; it is a userland simulation and is not counted as a kernel reproduction.

Exploit chain

Passive 4-byte heap-info leak, once per module load (or per any future setperf caller passing an out-of-table frequency). Value is residual slab data from a prior M_DEVBUF allocation; not attacker-positioned reliably β‡’ info value limited; no escalation path from this primitive alone.

Fix validation

Not run β€” kernel path not executable on the Intel/QEMU guest (fix_status: not_testable). fix.diff is authored from line-accurate root-cause knowledge: bail out of k8_powernow_setperf() when the match loop ran off the end, and only call it from powernow_init() with a validated frequency.

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 no→ patch + rebuild →patched clean

Fix validation not run: the vulnerable kernel path requires AMD K8 hardware (CPUID 0x80000007 EDX bit 6) absent from the audit guest; fix.diff is line-accurate (bail out when i==n_states; guard the powernow_init call site) and verified to apply cleanly with patch(1).

fix.diff applies clean: 'checking file powernow.c' / DF4903_APPLIES_CLEAN
↓ fix.diffper-fix-DF-4903

Confirmed kernel references

Detail

Exploit chain

Passive 4-byte kernel-heap disclosure readable by any local user (sysctl read needs no privilege, kern_sysctl.c:1446 gates writes only). Requires AMD K8 with the boot-time fid/vid not present in the BIOS PST β€” value is residual slab data, limited info value, no escalation primitive by itself.

Evidence (decisive lines)

run.log (harness output: cpuspeed = 0xa5a5a5a5 as uid=1001), env.txt (guest uname + kldstat showing powernow.ko loaded-but-inert after MOD_LOAD error 2), sim_tailread.c (flow replica), VERDICT.md

PoC changes

No seed PoC existed; harness authored fresh from the kernel source with identical struct layout, sentinel and comparison expressions; MSR transition phases stubbed.

Verified recommended fix

In k8_powernow_setperf return EINVAL when the match loop exits with i==n_states, and in powernow_init only call setperf when k8_get_curfreq() > 0.

Verdict

Kernel path is certain from source but cannot execute on the Intel/QEMU audit guest (kldload powernow fails at the CPUID 0x80000007 gate, console: MOD_LOAD error 2), so no kernel-level reproduction was possible. A userland harness replicating the exact struct layout and expressions (run as uid=1001 on the guest, run.log) demonstrates the full flow: k8_get_curfreq()'s -1 sentinel becomes UINT_MAX in the unsigned freq parameter, the match loop exits with i==n_states, and the tail at powernow.c:311 reads state_table[n_states] β€” an uninitialised kmalloc(M_DEVBUF) word for n_states<16 β€” which lands in the global cpuspeed exposed world-readable via sysctl machdep.powernow.frequency.current. Distinct root cause from DF-1960 (there the whole table is uninitialised because no PST matched; here decode succeeded but the sentinel indexes past the initialised prefix).