Security is a first-class goal for hp-thermal: a privileged SYSTEM service that performs BIOS/WMI calls, driven by an unprivileged tray over a local pipe, has to be built to be safe by design.
Report privately via GitHub Security Advisories ("Report a vulnerability" on the Security tab), not a public issue. Please state clearly if you believe a finding escapes the bounded-impact ceiling described below — those are prioritized.
Every release is built by a public GitHub Actions workflow and carries a SLSA build
provenance attestation and an SBOM attestation, so you can prove the .exe was built
from this repo's source by that workflow and was not altered in transit.
With the GitHub CLI, pinning the signer to this repo's release workflow (the identity check is what makes verification meaningful):
gh attestation verify hp-thermal.exe \
--repo bgonz808/hp-thermal \
--signer-workflow bgonz808/hp-thermal/.github/workflows/release.ymlThe attestation is the real integrity anchor. SHA256SUMS is attached as a convenience, but
a bare checksum only proves a file matches itself; the attestation proves it came from this
build. Since v0.3.0 releases are also Authenticode-signed
(keyless OIDC to an HSM-backed signing service; the pipeline verifies the signature before
publishing), so the UAC prompt shows a verified publisher. Note that SmartScreen reputation
is a separate system and accrues slowly: a warning wall on a fresh release is expected and is
not a signature failure.
The single hp-thermal.exe runs in two roles: a tray (interactive user, Medium
integrity) and a service (SYSTEM). They communicate over a local named pipe.
Principle: trust originates at the SYSTEM service (tamper-resistant to non-admins); authentication is mutual; the service exposes only bounded-impact operations — so no client, even a fully compromised one, can escalate. Security comes from making the pipe not worth tampering with, not from making it un-tamperable.
- Network —
PIPE_REJECT_REMOTE_CLIENTS: local-only. - DACL — pipe access limited to
BUILTIN\Users+ SYSTEM + Admins. - First-instance —
FILE_FLAG_FIRST_PIPE_INSTANCE: the service refuses to run if the pipe name was already registered (anti-squat). - Wire framing — a 2-byte magic prefix. This is a framing marker, not authentication (it's published in the source and rejects accidental/scanning traffic); real authorization is the DACL + client validation, never these bytes.
- Client → server — the tray confirms the pipe server is our exe in Program Files (admin-only-write, so a user-level impostor can't be there).
- Server → client — the service confirms the caller's exe path and reads its token
via
ImpersonateNamedPipeClientto reject a confirmed below-Medium integrity level. - Input allowlist — a pure,
unsafe-free, fixed-size parser; a closed command set with range-checked payloads; unknown commands rejected. Unit-tested. - Anti-injection — process mitigation policies (
ExtensionPointDisablePolicy) on both roles.
Path/identity and caller integrity-level checks fail closed (#159): a wrong client path, or a caller whose integrity level can't be confirmed, is denied. Only the client→server check fails open (inability to determine the server ≠ rejection), so a transient API failure never breaks the legitimate tray ↔ service path; the pipe's mandatory-label SACL is the kernel backstop behind it.
- Escalation through the pipe: no path by construction. The most a fully compromised client can make SYSTEM do is toggle a thermal mode or brightness level — range-checked, no code execution, no dangerous parameter. And BIOS/EC writes are token-bucket rate-limited (#159), so a compromised Medium-IL tray can't flood the firmware with mode changes. (Stated as "no path by construction," not formally proven.)
- Tamper-proof: no — and that's the correct posture. The SYSTEM service is tamper-resistant to non-admins; the tray cannot be tamper-proofed against same-user injection, and the design does not depend on it.
- Tampering the input allowlist requires the privilege it protects — patching it on disk needs Program Files write (admin); in memory needs SYSTEM's memory. A non-admin can't; an admin gains nothing new.
- Runtime integrity vs. load-time authenticity — two independent axes. The self-checks
(footing check, CIG, image-load policy, pipe integrity-level checks,
require_hp, anti-rollback) harden runtime integrity: they resist in-memory tampering of a genuine process, applied at startup before injected code could act. But they are self-imposed, so they hold only assuming the as-built binary is what ran — a tampered build omits them, and the footing check verifies location / ACL / privilege, not authenticity. That assumption — load-time authenticity — is discharged by Authenticode signing (in place since v0.3.0): the OS won't run a tampered image as our publisher. So the self-checks are tamper-resistant; signing is what makes them tamper-evident.
Weaknesses addressed (CWE)
Design decisions mapped to the weakness classes they hold down — illustrative, not exhaustive.
A CWE is a standing invariant, not a one-time fix: a new feature can reintroduce any of these,
so each is only as durable as what enforces it (by construction > CI lint > convention; see #28).
Status is current posture — held = "no known instances, mechanism in place," not "solved
forever"; a ticket points at a known open gap. Residual risk is bounded by the OS privilege
boundary and the pipe's blast radius (a compromised client can only toggle a thermal mode), so
reachability alone does not imply impact.
| Weakness | Closed by | Status |
|---|---|---|
| CWE-20 Improper input validation | Bounded 2-byte pipe command set, range-checked | held |
| CWE-269 Improper privilege management | SYSTEM service + least-privilege tray; startup footing check | held |
| CWE-367 TOCTOU race | Verify image path on the process handle, not the snapshot PID | held |
| CWE-426 Untrusted search path | Absolute System32 paths for sc / icacls / runas |
held |
| CWE-427 Uncontrolled DLL search path | SetDefaultDllDirectories(System32) + image-load policy + /DEPENDENTLOADFLAG |
held |
| CWE-494 Download of code without integrity check | Build attestation; signing + verify-before-promote planned | #21, #23 |
| CWE-732 Incorrect permission assignment | Program Files admin-only ACL + service SDDL; data-dir ACL | partial · #27 |
Open work per weakness class is tracked on issues — filter by the CWE-### label
(e.g. CWE-732).
The labels are the live ledger; this table is the curated class-level summary.
Exploit-mitigation flags on the shipped .exe (verify with cargo xtask verify-hardening):
| Mitigation | Status |
|---|---|
| ASLR / high-entropy ASLR | ✅ |
| DEP / NX | ✅ |
| Control Flow Guard | ✅ (-C control-flow-guard=checks) |
| Stack canaries | ✅ (-Z stack-protector=strong) |
Windows resolves a non-KnownDLL by
search order —
the application directory before System32 — so a DLL loaded by name from a writable run
directory can be planted and hijacked (MITRE T1574.001
/ .002). Installed to Program Files (admin-only
ACL) this is a non-issue; the surface that matters is the portable/installer .exe run elevated
from a writable dir (e.g. Downloads), where a standard user can pre-plant a DLL and escalate.
Controls (verify with cargo xtask audit-dll-closure / audit-dll-planting):
| Load | Control |
|---|---|
| Our static imports | /DEPENDENTLOADFLAG:0x800 — the loader pins them to System32 at process init |
Runtime LoadLibrary (nvml, uxtheme) |
explicit LoadLibraryExW(…, LOAD_LIBRARY_SEARCH_SYSTEM32) per call |
| Process-wide default | SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32) as main's first line |
Deferred cold-path DLLs (rstrtmgr, powrprof) |
delay-load (build.rs /DELAYLOAD) — load past the pin (measured +4.5 KB for delayimp.lib) |
The subtlety: /DEPENDENTLOADFLAG pins our own static imports but not a dependency's own
resolution (docs). rstrtmgr
and powrprof (used only on the install and Fn+F12 cold paths) each pulled a non-KnownDLL
transitive dep (ncrypt; umpdc/wmiclnt) at process init — before the runtime pin — a real
pre-main window (confirmed exploitable for rstrtmgr→ncrypt by the plant harness). Both are now
delay-loaded, so they stay declared imports (honest, visible to static analysis — unlike a
manual LoadLibrary+GetProcAddress, which reads as dynamic-API-resolution obfuscation,
T1027.007) but load at first use, after the pin,
resolving from System32. Net: zero pre-main plant surface.
Gated in CI (.github/workflows/dll-plant-audit.yml):
audit-dll-closurewalks the regular + delay import closure and fails on any new pre-maintransitive dep (allowlist empty); delay/runtime deps are reported, not gated (pinned).audit-dll-planting— a user-mode plant harness (no admin, no kernel driver): builds a proxy under a dependency's name beside a throwaway exe copy and detects a run-dir win via a load marker (dynamic load) or the startupNTSTATUS(static bind failure). Blocking; validated with a known-plantable and a known-fixed control.capabilities— the import allowlist is exact-match: it fails if the concrete-DLL surface grows or shrinks without updatingALLOWED, ratcheting the reduced surface so it can't silently regress.
Residual: a system DLL dynamically LoadLibrary-ing a non-KnownDLL pre-main is OS-level behavior
we can't close in-process for a parent we don't control (an elevated installer's parent is
consent.exe). Backstops: the Program Files ACL (installed app) and Authenticode signing
(roadmap); the plant harness surfaces any such case.
- Runtime third-party surface = the Microsoft
windowscrate family only. Build-only crates are not in the binary. - Source scanning: Dependabot (GitHub Advisory Database, incl. RustSec) continuously and
off-workflow;
cargo-deny+cargo-audit(RustSec) in the release attestation; andosv-scanner(OSV.dev — RustSec-complete) in the CIscanjob. Each scans the full pinned lockfile. - Artifact scanning: release binaries are built with
cargo-auditable, embedding the dependency manifest so the shipped.exeitself is scannable (cargo audit bin, trivy). - Pinning:
Cargo.lockis committed — exact version + SHA-256 checksum per crate;cargo-denyrestricts sources to crates.io.
On "0 CVEs": we report 0 known advisories against the pinned Cargo.lock, confirmed by
two independent databases — RustSec (cargo-audit / cargo-deny) and OSV.dev
(osv-scanner) — over the crates.io / Cargo ecosystem, every pinned crate (a superset of
what's shipped and reachable, so a clean superset implies clean at binary and reachability
scope). The shipped .exe is independently scannable via cargo-auditable. Freshness &
scope: the result is only as current as the advisory-DB snapshot at each run (re-scanned on
every push/PR; Dependabot re-checks continuously off-workflow), and it excludes the Rust
std / toolchain, native C libraries behind *-sys crates (not in Cargo.lock / RustSec —
only trivy's OS scan reaches those), and any not-yet-published vulnerability. So: "no known
advisories against these pinned versions," not "no vulnerabilities exist."
hp-thermal is a signed but still low-reputation Rust binary that does legitimately privileged, hardware-specific work — the exact combination heuristic/ML antivirus engines and sandboxes over-flag. As of v0.3.0 (first signed release) the "Unknown Publisher" root cause is gone and the two most impactful engines have cleared; the residual detections are generic and decay with download reputation. If a scanner flags it, here is what the detections are and are not.
They are generic / reputation signals, not behavior. Every detection to date has been a
generic/ML name — no specific malware family — and the count inflates because several
products license one engine's database. A no-argument run is the bare tray (Medium integrity)
doing reads only — no file writes, no persistence, no network; the privileged install
behaviors are gated behind --install and never fire in a bare detonation. Behavioral sandboxes
agree (Zenbox: Non-Malicious).
Recorded runs (we track every release so the reputation trend is visible):
| Release | Signed | Score | Engines (all generic/ML, no family) | Notable |
|---|---|---|---|---|
| pre-0.3.0 (representative) | no | handful | Gen:Variant.Yogi, Wacatac.B!ml, MalwareX-gen, …Agent |
Microsoft and BitDefender firing |
v0.3.0 — add64e322…ec149dc |
yes | 5/71 | Avast/AVG Win64:MalwareX-gen, Avira/WithSecure TR/W64.Agent, ESET Win64/Agent_AGen.PRN |
family label misc; Microsoft + BitDefender now clean |
The v0.3.0 five collapse to three independent engines (AVG re-uses Avast's engine; WithSecure
re-uses Avira's — identical detection strings prove the shared verdict), all generic. The decisive
signal: signing flipped Microsoft (Wacatac.B!ml) and BitDefender (Yogi) from firing
to clean on byte-identical code — detections that respond to signing metadata rather than code
are the mechanical signature of reputation FPs, not behavior. The VT sample hash was verified equal
to the published SHA256SUMS entry and the attested build digest, so the scoreboard describes the
real release, not a stray upload.
What trips the heuristics, node by node:
| Flag | Cause | Assessment |
|---|---|---|
resolved in v0.3.0 — Azure Trusted Signing, WVT S_OK, durable-identity EKU |
the root cause, now removed; Microsoft + BitDefender cleared as a direct result | |
reads HKLM\...\BIOS\SystemManufacturer / SystemProductName |
HP-model detection (hwinfo.rs) — the tool is HP-specific |
trips the anti-VM heuristic (the same read malware uses to detect sandboxes); irreducible for a hardware tool |
.dep-v0 / non-standard PE section names |
cargo-auditable dependency manifest (enables cargo audit bin) |
irreducible without dropping auditable |
| "requires command line arguments" | monolithic role-by-flag design (--install / --service) |
benign; the risky paths are consent-gated, not evasion |
| dual-use API surface | service create (T1543.003), token adjust (T1134), WMI (T1047), WER opt-out (T1562.001) | legitimate installer behavior; a base-rate false positive |
The fix is identity, not code. These are the structural false positives of a low-reputation, dual-use binary; changing code to lower the count is whack-a-mole against classifiers that can't read intent. Signing supplied the identity half (removing "Unknown Publisher" and, with it, Microsoft + BitDefender); the remaining half is accrued download reputation, which collapses the residual generic cluster over days-to-weeks. We record every VirusTotal run above so the trend is visible — and we do not alter behavior to game the number.
SmartScreen is a separate system from both AV and Authenticode, and this is the most
misunderstood part: a valid signature does not clear the red warning. SmartScreen gates on
reputation — the publisher identity's and the specific file hash's download prevalence — not on
signature validity. v0.3.0 verifies as Valid (WVT S_OK) and still shows the red
"Windows protected your PC" screen on first run, because the signing identity has not accrued
reputation yet. This is expected for a new publisher, not a defect.
What does and does not move it:
- Do not buy an EV certificate to fix this. EV code-signing certs used to grant instant SmartScreen reputation, but Microsoft removed that behavior in 2024 and its docs (current as of mid-2026) state plainly it no longer exists. An EV cert would not clear the warning and would be wasted money; OV, EV, and Trusted Signing now all earn reputation the same way.
- Reputation is earned by download volume + time (days to a few weeks). It accrues to the
durable publisher identity (the
1.3.6.1.4.1.311.97.*EKU we pin), so it compounds across releases and survives the ~72 h cert rotation — one reason we pin the durable-identity EKU rather than a spoofable subject name. - Submit the binary to Microsoft for analysis (Microsoft Security Intelligence, aka.ms/wdsi → "I believe this is clean"). Now that it is signed, Microsoft can attribute the file to the identity; this can pre-clear Defender and feed SmartScreen.
- Sign every release with the same identity and drive legitimate downloads. Consistency is the whole mechanism; re-signing obsessively does not help (a new hash resets file reputation, though publisher reputation carries).
Known 2026 Trusted Signing caveat (may be amplifying our warning). v0.3.0 chains through the
intermediate CA Microsoft ID Verified CS EOC CA 03. In ~March 2026 Microsoft silently migrated
Trusted Signing customers onto new intermediate CAs (AOC / EOC CA 03), which reset or broke
SmartScreen reputation for affected customers — identical publishers that were trusted under the
prior CA began tripping "Windows protected your PC," with some still warned weeks later. So part of
our red wall may be this Microsoft-side migration, not organic warm-up alone. Tracked via Microsoft
Q&A and the Azure/artifact-signing-action issue tracker; if reputation has not built after a few
weeks of real downloads, that CA issue is the likely cause and warrants a Microsoft
engineering-review request rather than any change on our side.
A CI runner is a privileged trust boundary: every action and installed tool executes with the job's environment (token, secrets, source). We treat the runner as hostile-adjacent and bound the blast radius rather than assume the toolchain is trustworthy.
- Read-only, secret-less workflows. Both
ci.ymlandrelease.ymlsetpermissions: contents: readand expose no secrets — a compromised step cannot push, publish, reach other repos, or exfiltrate credentials. - The frequent path installs nothing third-party. PR CI is toolchain-only (fmt / clippy / test via rustup). Continuous dependency scanning runs off-workflow via Dependabot — on GitHub's infrastructure, not a runner, not with our token.
- Actions pinned by full commit SHA. A tag can be re-pointed by an upstream compromise
(cf. the
tj-actions/changed-filesincident, 2025); a SHA is immutable. - Attestation tools installed by immutable git-rev, and only in the rare release workflow. A maintainer-account compromise can publish a new version but cannot rewrite history at a pinned commit.
- Release publishing is isolated. Uploading a GitHub Release needs
contents: write; that would be a separate, elevated job — kept apart from the read-only build/attest job.
Honest limit: there is no self-verifiable digest pin for cargo install today, and
building any tool from source executes its build scripts in the runner. --locked --version
(an immutable crates.io version) and --git --rev (an immutable commit) reduce what runs,
but neither is a digest we independently verify. The real defense is therefore not perfect
verification but bounded reward: least privilege + no secrets + minimal in-runner code.
- Every
unsafeblock carries a// SAFETY:justification, enforced by#![deny(clippy::undocumented_unsafe_blocks)]. - The input parser and IPC framing are
unsafe-free and unit-tested. - (Miri UB-checking is not run today — the minimal-binary
build-stdconfig conflicts with Miri's sysroot build. See roadmap.)
- Same-user in-memory tampering of the tray — Windows' security boundary is user + integrity level, not process-to-process within a user. Impact is bounded to the thermal command set, which is why this is acceptable.
std/toolchain advisories (not in the lockfile) and unknown/unpublished vulnerabilities.
/CETCOMPAT, formal (Kani) proof of the input parser, and Miri
UB-checking of the pure-logic tests — the last unblocked by extracting the
pure logic into a stable core crate (which also removes the build-std sysroot conflict).