Multi-agent follow-up audit of the privileged streaming host: 18 attack surfaces, every finding adversarially double-verified, plus a coverage critic. Records 15 confirmed + 9 partial findings and a prior-fix re-verification of the 2026-06-21 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 KiB
punktfunk host — security audit (2026-06-28, follow-up)
Status: AUDIT COMPLETE (2026-06-28). Follow-up to the 2026-06-21 whole-project review (
security-review.md), scoped to the privileged streaming host (crates/punktfunk-host) — re-verifying the prior 12 findings and hunting the code added since (library.rs+ store providers,stats_recorder.rs,kwin_fake_input.rs, session-watch / Desktop↔Game follow, "launch apps on Windows/Linux non-gamescope hosts", "driver/web install into the host exe"). Method: a multi-agent fan-out over 18 attack surfaces (13 in pass 1 + 5 gap-driven in pass 2), every candidate finding adversarially double-verified from two independent lenses (reachability/attacker-control + existing-mitigation/correctness), plus a coverage-gap critic. 15 confirmed + 9 partial issues carried; 8 refuted recorded for completeness. No memory-unsafety or RCE on attacker wire bytes was found; the residual risk is in dependency hygiene, the opt-in GameStream surface, and Windows local-privilege ACLs.
Consolidated overview & top priorities
The host's core trust architecture remains sound: native SPAKE2 pairing (single-use
disarm-before-verify, CSPRNG PIN, sanitized device names, atomic+rollback persist), post-pair
cert-pinning that verifies the real CertificateVerify signature, the management API authn/authz
split (read-only-cert allowlist vs. bearer-gated mutations), uniformly bounds-checked client→host
wire decoders (no reachable parse panic/OOB), memory-safe client-geometry→encoder/FFI paths, a clean
driver-IPC ABI, and a fail-closed app-layer pairing gate. The new library/launch surface is notably
well-defended against the network adversary (client ids resolve against the host's own catalog,
argv-only, no shell, no SSRF). Most prior fixes are present and not regressed.
The real risk clusters in three places: (1) a vulnerable QUIC dependency on the always-on
default listener, (2) the opt-in GameStream/Moonlight compatibility surface (two pre-auth
boundary bypasses), and (3) Windows %ProgramData% ACLs (the prior secret-file fix did not cover
the directory or two newer writers).
Fix promptly (priority order):
| P | Finding | Sev | Auth | Surface |
|---|---|---|---|---|
| 1 | S1 quinn-proto 0.11.14 (RUSTSEC-2026-0185) → pre-auth remote memory-exhaustion DoS on the default serve QUIC listener |
High | pre-auth | dep / native QUIC |
| 2 | #1 Unauthenticated GameStream GET /pin → full pre-auth self-pairing (consent bypass) → capture + input injection |
High | pre-auth | GameStream (opt-in) |
| 3 | #2 Windows mgmt bearer token written without DACL — any local user reads the admin credential | High | local | secrets |
| 4 | #3 %ProgramData%\punktfunk dir + host.env not DACL-locked → local user → SYSTEM env/arg injection (LPE) |
High | local | Windows service |
| 5 | #4 Pre-auth RTSP/UDP media plane has no pairing gate → desktop disclosure (portal) + stream-slot DoS | High→Med | pre-auth | GameStream (opt-in) |
Medium: #5 Windows gamepad/IDD shared sections Everyone:GENERIC_ALL (local input-inject /
screen read) · #6 gamescope EIS socket via predictable /tmp relay (local keylog / input DoS) ·
#7 process-global env retargeting unsound under default concurrent sessions (set_var/getenv
data-race UB → host-wide DoS; the live form of deferred prior-fix #7) · S2 malformed client Opus
frame tears down the shared host-lifetime virtual mic (cross-session DoS).
Low / info: #8 web-password write-then-icacls TOCTOU · #9 pairing-window-burn DoS ·
#10 ENet control-flood warn-log spam · #11 SYSTEM host.log link-redirection (sub-case of
#3) · #12 legacy pairing no rate-limit · #13 pending-approval queue flood · S3 unbounded
held-button/key Vec growth · S4 unbounded read of Epic launcher caches · S5 refresh/fps
lower-bound unvalidated on the Hello path (self-inflicted single-session panic) · S6 unbounded
mpsc into the shared mic service · S7 rsa 0.9 Marvin advisory on the opt-in GameStream signing
path (not practically reachable).
Highest-leverage remediations (each closes a cluster): (a) cargo update -p quinn-proto --precise 0.11.15 + wire cargo audit into CI as a failing gate; (b) delete the unauthenticated
nvhttp /pin and bind RTSP/PLAY to a paired /launch session; (c) DACL-lock the Windows config
directory and route all config/secret writes through write_secret_file; (d) thread per-session
launch/compositor/input env through SessionContext instead of process-global std::env.
The two passes' full verified detail follows verbatim (pass 1 = the 13-surface report; pass 2 = the supplement completing the native-protocol/unsafe-FFI surfaces + coverage-critic gaps), then the coverage-gap appendix.
Pass 1 — 13-surface report
punktfunk host — security audit (2026-06-28, follow-up)
Status: Follow-up audit of the privileged streaming host (crates/punktfunk-host), focused on code added since the 2026-06-21 review (library.rs + store providers, stats_recorder.rs, kwin_fake_input.rs, session-watch/Desktop-Game follow, the "launch apps on Windows/Linux non-gamescope hosts" path, and the "move driver/web install into the host exe" path), plus a regression re-verification of the prior twelve findings. Thirteen surface areas reviewed; every candidate finding was adversarially double-verified. 9 confirmed + 4 partial issues are carried; 6 refuted items are recorded for completeness.
Executive summary
The host's core trust architecture remains sound: the native SPAKE2 pairing ceremony, the post-pair mTLS cert-pinning model, the management API authn/authz split (read-only cert allowlist vs. bearer-gated mutations), and the RTSP/input/gamepad wire parsers are all carefully hardened and, where re-verified, the prior fixes are present and not regressed. The new game-library/launch surface is notably well-defended against the network adversary — client-supplied launch ids are resolved against the host's own scanned catalog, numeric/charset-validated, and spawned argv-based (no shell) on every non-operator path.
The real risks cluster in two places. First, the opt-in GameStream/Moonlight compatibility surface (serve --gamestream) deviates from its own trust boundary in two pre-auth ways: the legacy nvhttp GET /pin endpoint is completely unauthenticated, letting an unpaired LAN peer drive the entire pairing ceremony with no operator consent and obtain a persistent paired identity with full capture + input injection (Finding 1, the single highest-leverage issue); and the RTSP/UDP media plane performs no pairing/launch check at all, so an unpaired peer can start capture/encode and receive the desktop stream (Finding 4). Both are gated only by the opt-in --gamestream flag and the documented "trusted-LAN-only" posture — but within that supported mode they are genuine pre-auth bypasses of the pairing boundary that /launch otherwise enforces.
Second, the Windows LocalSystem service has three local-privilege gaps rooted in one cause — the prior fix #1 hardened secret files but not the %ProgramData%\punktfunk directory or two newer files written into it. The management bearer token is written with no Windows DACL (Finding 2), and host.env — which feeds the SYSTEM service's environment and command-line arguments — is neither DACL-locked nor is its directory (Finding 3). These give a local unprivileged user a path to the admin management plane and, via directory pre-creation / env injection, toward SYSTEM. On Linux/gamescope, a world-readable /tmp EIS-socket relay lets a second local user keylog or deny the remote session's input (Finding 6). The remaining items are lower-severity local IPC ACL over-breadth (gamepad shared memory), a concurrency-introduced std::env::set_var data race that is now reachable because concurrent native sessions became the default (Finding 7, the live form of deferred prior-fix #7), and pre-auth DoS edges.
Overall posture is good and improving; the GameStream pairing/media pre-auth bypasses and the Windows config-directory ACL gap are the items that warrant prompt remediation.
Findings
| # | Severity | Surface | Title | Status |
|---|---|---|---|---|
| 1 | High | GameStream pairing | Unauthenticated nvhttp GET /pin → full pre-auth GameStream self-pairing (consent bypass) |
Confirmed |
| 2 | High | Secrets / mgmt | Windows mgmt bearer token written without DACL — local-user disclosure of host admin credential | Confirmed |
| 3 | High | Windows service / config | %ProgramData%\punktfunk directory + host.env not DACL-locked → local user → SYSTEM env/arg injection |
Confirmed (apps.json sub-vector: Partial) |
| 4 | High→Med | GameStream RTSP/media | Pre-auth RTSP ANNOUNCE+PLAY starts capture/encode with no pairing gate (desktop disclosure + stream-slot DoS) | Partial |
| 5 | Medium | Input injection | Windows host↔UMDF gamepad shared sections are Everyone:GENERIC_ALL — local cross-session input injection/tamper |
Confirmed |
| 6 | Medium | Session lifecycle (gamescope) | EIS socket path relayed via predictable world-accessible /tmp file — local keylog / input DoS |
Confirmed |
| 7 | Medium→Low | Session lifecycle | Process-global env retargeting unsound under now-default concurrent native sessions (data race + cross-session confusion) | Confirmed |
| 8 | Low | Secrets | web-password written world-readable then icacls'd — brief TOCTOU disclosure |
Confirmed |
| 9 | Low | Native pairing | Unpaired LAN peer can burn the operator's single-use pairing window (pairing-ceremony DoS) | Confirmed |
| 10 | Low | GameStream control | ENet control flood → unbounded per-packet warn-log spam (+ transient CPU) | Confirmed |
| 11 | Low→Info | Windows service | SYSTEM host.log predictable name in Users-writable dir (link-redirection of SYSTEM appends) |
Partial |
| 12 | Low→Info | GameStream pairing | Legacy pairing has no rate-limit; parks unbounded 300 s waiters | Partial |
| 13 | Info | Native pairing | Pending-approval queue floodable by LAN cert flood (eviction of a genuine knock) | Confirmed |
Finding details (confirmed & partial)
1. [High] Unauthenticated nvhttp GET /pin enables full pre-auth GameStream self-pairing — Confirmed
- Surface: GameStream pairing ceremony / nvhttp.
- Refs:
gamestream/nvhttp.rs:61,nvhttp.rs:85-96(h_pin, plain-HTTP router),gamestream/pairing.rs:40-43(PinGate::submit),pairing.rs:102-150(getservercert),pairing.rs:226-234(phase 4 /save_paired),crypto.rs:35-40(pin_key). - Threat actor: Malicious network client, pre-auth (#1). Requires
serve --gamestream. - Mechanism: The GameStream PIN is the sole proof of operator consent (
aes_key = SHA-256(salt ‖ pin)), and the host has no independent knowledge of the correct PIN — it derives the key from whatever is delivered toPinGate::submit. The operator-channel (mgmtPOST /api/v1/pair/pin) is bearer-gated for exactly this reason, but the host also exposesGET /pin?pin=NNNNon the unauthenticated nvhttp router with no auth and noawaiting_pinguard, on0.0.0.0:47989(plain HTTP) and:47984. Because the attacker controls both thegetservercertrequest (its own salt + cert) and can submit the PIN itself, it supplies both sides of the ceremony. There is no operator "arm pairing" gate for the legacy GameStream path (unlike native SPAKE2). - Attack scenario: (1) Attacker sends
GET /pair?phrase=getservercert&uniqueid=X&salt=<32hex>&clientcert=<own-cert-hex>→ parks onpin.take(300s). (2) Attacker sends unauthenticatedGET /pin?pin=4242→ the parkedtake()returns it; host computesaes_key = SHA-256(attacker_salt ‖ "4242"), which the attacker also knows. (3) Attacker completesclientchallenge/serverchallengeresp/clientpairingsecret(all derivable — it knows the key and owns its cert); phase 4 pins the attacker cert viasave_paired. (4) Attacker reconnects over HTTPS:47984 with its now-pinned cert;peer_is_paired()is true →/launch+/applistsucceed → desktop capture and keyboard/mouse/gamepad injection on the privileged host. No operator action at any step. - Existing mitigations: GameStream is opt-in and documented "trusted-LAN only"; default
servedoes not start nvhttp. The post-pair launch surface is correctly gated bypeer_is_paired— it just gets satisfied because the attacker self-pairs. None of these is a control on/pin. - Verifier adjudication: Both verifiers confirmed reachable + attacker-controlled, downgrading the original critical to high only because the surface is the opt-in, documented-weaker
--gamestreammode (smaller affected population than the always-on native listener). This is not subsumed by accepted-risk #9 (which covers/pairbeing plain HTTP / a MITM brute-force, not unauthenticated PIN self-delivery). - Recommendation: Remove the unauthenticated nvhttp
GET /pinendpoint entirely; PIN delivery must come only from the bearer-gated mgmt API. If a nvhttp delivery path must remain, require an explicit operator "arm GameStream pairing" step (mirror nativenative_pairingarm-on-demand) and bind the submitted PIN to that armed window. Ideally have GameStream pairing display a host-generated PIN the operator confirms, rather than accepting an arbitrary client-side PIN.
2. [High] Windows mgmt bearer token written without DACL lockdown — Confirmed
- Surface: Secret-file permissions / management authz. (Reported independently by two surface auditors; same defect.)
- Refs:
mgmt_token.rs:59-71(write_token),mgmt_token.rs:40-44(dir viafs::create_dir_all),gamestream/mod.rs:251-261(config_dir=%ProgramData%\punktfunk),gamestream/mod.rs:282-285(create_private_diris a no-op for ACLs on Windows),gamestream/mod.rs:293-347(write_secret_file/restrict_to_system_admins). - Threat actor: Local unprivileged user (#4). Windows host only (Unix is correctly
O_CREAT 0600). - Mechanism: The mgmt bearer token grants full admin authority over the management API. It is persisted by
write_token, the only host secret writer that does not route throughwrite_secret_file → restrict_to_system_admins; it applies a Unix0600mode but has no#[cfg(windows)]arm. On the LocalSystem service,config_dir()is%ProgramData%\punktfunk, whose inherited default DACL grantsBUILTIN\Usersread;create_private_dirapplies no DACL on Windows and explicitly relies on each secret file being individually locked bywrite_secret_file. The token file is therefore left Users-readable. (The host key, cert, and both trust stores are locked — the token is the regressed outlier; thewrite_secret_filedoc comment ironically claims it "Mirrors the mgmt-token hardening.") - Attack scenario: A local unprivileged user reads
C:\ProgramData\punktfunk\mgmt-token, then presentsAuthorization: Bearer <token>to the loopback mgmt HTTPS API (default127.0.0.1:47990; self-signed cert trivially ignored). They now hold full admin authority: arm native pairing and read the PIN, approve their own device into the paired trust store, unpair/add clients, control sessions, andPOST /library/customwith acommandLaunchSpec that the host subsequently executes — a plausible path to code execution beyond the user's own privileges. - Existing mitigations: Default bind is loopback; API still requires HTTPS+bearer — but that bearer is exactly what leaks. The sibling
web-passwordisicacls-hardened (install.rs:280-289), confirming this is a missed file, not a design choice. - Verifier adjudication: Both verifiers (across two surfaces) confirmed at high; this is the same class/severity as prior HIGH #1 (host key readable by any local user) and a genuine regression of that principle.
attacker_controlled=falsecorrectly reflects that this is a credential disclosure, not value injection. - Recommendation: Route the mgmt-token write through
gamestream::write_secret_file(or callrestrict_to_system_adminson the path after writing) and create the dir withcreate_private_dir's Windows DACL. Re-tighten any pre-existing token file on startup.
3. [High] Windows config directory and host.env are not DACL-locked → local user → SYSTEM env/arg injection — Confirmed (apps.json sub-vector Partial)
- Surface: Windows LocalSystem service / config & discovery. (Merges the
host.envfinding and the config-directory finding — same root cause.) - Refs:
windows/service.rs:681-713(ensure_default_host_envplainstd::fs::write, skips if fileexists()),service.rs:159-180(load_host_envset_vars every KEY=VALUE, not justPUNKTFUNK_*),service.rs:301-302(format!("\"{}\" {host_cmd}", exe)fromPUNKTFUNK_HOST_CMD),gamestream/mod.rs:264-286(config dir never DACL-locked;create_private_dirno-op on Windows),gamestream/apps.rs:40-95+stream.rs:140-145(apps.jsoncmd). - Threat actor: Local unprivileged user (#4). Windows host only.
- Mechanism: Secret files are individually
icacls-locked, but the%ProgramData%\punktfunkdirectory is never DACL-restricted andhost.envis written with a barestd::fs::write. Under the default%ProgramData%ACL,BUILTIN\Usersinherit a container "create folders" right (and becomeCREATOR OWNERof subfolders they create). A non-admin who pre-creates thepunktfunksubfolder before the elevated installer/service populates it owns it with full control and can planthost.env/apps.json;ensure_default_host_envthen skips writing because the file alreadyexists(). On service start,load_host_envinjects every line ofhost.envinto the SYSTEM process environment, andsupervise()builds the SYSTEM child command line verbatim fromPUNKTFUNK_HOST_CMD. - Attack scenario / impact: The surviving primitives (after verifier scrutiny) are: (a) arbitrary SYSTEM-process environment injection — e.g. set
PATH/DLL-search vars inhost.envto an attacker-writable directory and plant a hijackable DLL the SYSTEM host loads by name; (b) attacker-controlled SYSTEM argv to the fixed signedpunktfunk-host.exe; (c) config-dir/trust-store tampering. Each independently sustains a local privilege escalation toward NT AUTHORITY\SYSTEM. The planted-apps.jsoncmdvector is weaker than originally stated:launch_gamestream_command→interactive::spawn_in_active_sessionruns the cmd under the interactive console user token (WTSQueryUserToken+CreateProcessAsUserW), not SYSTEM — so apps.json planting yields code execution as the interactive user, not SYSTEM. - Verifier corrections: The literal
PUNKTFUNK_HOST_CMD=... & malware.exeshell-injection payload does not work —spawn_hostusesCreateProcessAsUserWwith no shell, so&is an inert argv token. Exploitation is gated on directory pre-creation (the punktfunk subfolder must be absent at attack time — fresh install before first launch, or a removed dir); on a normally installed box the elevated installer/SYSTEM service owns the dir and the default ACL grants Users create-subdirectory but not create-file, blocking overwrite of an existing admin-ownedhost.env. One verifier adjusted to medium on these grounds; the other held high. Carried at high because the env/arg-injection LPE primitives are real and the directory is genuinely never re-secured. - Existing mitigations: Secret files are DACL-locked individually; the elevated installer creates the dir in normal flows. GameStream/apps.json launch is opt-in and additionally needs a launch to occur.
- Recommendation: Apply a restrictive DACL to the config directory at creation on Windows (
SYSTEM/Administratorsfull +CREATOR OWNER, strip inheritance) insidecreate_private_dir; writehost.envthroughwrite_secret_file; and refuse to loadhost.env/honorPUNKTFUNK_HOST_CMD(and trustapps.jsoncmd) unless the file/dir is owned by SYSTEM/Administrators.
4. [High→Medium] Pre-auth RTSP/UDP media plane has no pairing gate — Partial
- Surface: GameStream RTSP / video stream.
- Refs:
gamestream/rtsp.rs:91(handle_conn, no auth),rtsp.rs:204-216(ANNOUNCE writesstate.streamunauthenticated),rtsp.rs:218-239(PLAY starts video onSome(cfg) && !streaming.swap(true), never checksstate.paired/state.launch),gamestream/stream.rs:90-108(UDP 47998 binds andconnect()s the first pinger),gamestream/mod.rs:214(rtsp::spawnonly under--gamestream). - Threat actor: Malicious network client, pre-auth (#1). Requires
serve --gamestream. - Mechanism: nvhttp gates
/launch//applist//resume//cancelonpeer_is_paired(), but the RTSP listener (TCP 48010) and the UDP media planes are unauthenticated.ANNOUNCEstores a client-chosenStreamConfig(width/height/fps/codec/packetSize) with no auth;PLAYstarts the video stream consulting neitherstate.pairednorstate.launch(only the optional audio sub-stream requires the launchgcm_key). Video is sent in plaintext, so no key is needed. There is no per-launch session token and no binding between the paired nvhttp client and the RTSP/UDP peer (unlike Sunshine, which validates the launch session). - Attack scenario: Unpaired attacker sends a minimal ANNOUNCE SDP → host stores a config; sends PLAY → host spawns the video pipeline, detects the compositor, creates a virtual output / opens the encoder; sends any UDP datagram to 47998 → host
connect()s there and streams. Net effects: (a) pre-auth desktop disclosure — full real-monitor leak on thePUNKTFUNK_VIDEO_SOURCE=portalpath; on the recommendedvirtualsource the attacker captures a fresh blank virtual output (no app, since/launchis pairing-gated), and the default source is a synthetic test pattern; (b) unconditional pre-auth resource consumption (forces virtual-output creation + GPU encode); (c) stream-slot DoS —streaming.swapallows only one stream, so an attacker can grab and hold the slot against legitimate clients (an in-progress legit session cannot be concurrently hijacked). - Existing mitigations: Opt-in
--gamestream; documented trusted-LAN-only;streaming.swapsingle-stream lock; packetSize bounded[64,2048];encode::validate_dimensionsbounds ANNOUNCE width/height. None is an authentication check on the media plane. - Verifier adjudication: Both verifiers confirmed the bypass is real and unconditional; severity split high vs. medium turning on the capture source (portal = real-desktop leak → high; virtual/default = blank/test-pattern, leaving DoS + boundary bypass → medium). Carried at high→medium: the pairing authz boundary is unconditionally bypassed and the portal path leaks the real desktop, but the most-common
virtualconfiguration limits disclosure. - Recommendation: Require a valid recent
/launchsession (set by a paired HTTPS client) before ANNOUNCE/PLAY will start a stream, and bind the RTSP/UDP peer to the launching client's address / a per-launch session secret (as Sunshine does). At minimum, refuse PLAY whenstate.launchisNoneand no paired client has an active session.
5. [Medium] Windows host↔UMDF gamepad shared sections are world-writable (Everyone:GENERIC_ALL) — Confirmed
- Surface: Input injection (Windows virtual-pad IPC).
- Refs:
inject/windows/gamepad_raii.rs:43(SDDL literalD:(A;;GA;;;WD)),gamepad_raii.rs:37-81(Shm::create),inject/windows/dualsense_windows.rs:239,dualshock4_windows.rs:40,gamepad_windows.rs:158; same SDDL atcapture/windows/idd_push.rs:245(Global\pfvd-*frame textures). - Threat actor: Local unprivileged user (#4). Windows host only.
- Mechanism: Every virtual-pad backend creates its host↔driver section in the kernel
Global\namespace with a SECURITY_ATTRIBUTES built fromD:(A;;GA;;;WD)—WD= Everyone (S-1-1-0),GA= GENERIC_ALL — and no mandatory integrity label (so the SYSTEM-created object defaults to medium IL /NO_WRITE_UPonly). The host writes the live HID input report intoOFF_INPUT; the privileged UMDF driver streams those exact bytes to games as virtual-controller input. The DACL grants full access to Everyone, so any interactive medium-IL local user canOpenFileMapping("Global\pfds-shm-0", FILE_MAP_WRITE)while a session has a pad active. - Attack scenario: A separate unprivileged local account (different session / fast-user-switch / RDP) opens the named section and overwrites
OFF_INPUTwith attacker-chosen button/stick/trigger values → the driver injects them into the streaming user's game. It can also corrupt the magic/device_type(DoS / device confusion) and observe the streaming user's input. The identical SDDL onidd_push.rsadditionally lets any local user read captured screen frames. - Existing mitigations:
Global\creation needsSeCreateGlobalPrivilege, preventing pre-creation/squatting — but opening an existing object only needs DACL access. The section exists only while a pad is active; keyboard/mouse useSendInput(not this channel), so injection is gamepad-only. - Verifier adjudication: Both verifiers confirmed at medium — genuine cross-session/cross-privilege input injection + IPC tamper + (via the shared SDDL) screen-content disclosure; bounded below high by being local-only, needing a concurrent local account and a live pad.
- Recommendation: Scope the section DACL to exactly the principal the WUDFHost runs as (grant SYSTEM and the specific WUDF/driver service SID) instead of
Everyone, and add a mandatory label / deny lower-IL writers (e.g. replaceWDwith the WUDFHost service account SID +S:(ML;;NW;;;ME)). Apply the identical fix to theGlobal\pfvd-*frame-texture sections incapture/windows/idd_push.rs.
6. [Medium] Gamescope EIS socket path relayed through a predictable, world-accessible /tmp file — Confirmed
- Surface: Session lifecycle / libei input injection (gamescope backend).
- Refs:
vdisplay/linux/gamescope.rs:778(EI_SOCKET_FILE = /tmp/punktfunk-gamescope-ei),gamescope.rs:797(remove_file, error ignored),gamescope.rs:807(printf %s "$LIBEI_SOCKET" > /tmp/...),gamescope.rs:677(fs::write),inject/linux/libei.rs:298-345(connect_socket_file:read_to_string+UnixStream::connect, no ownership/symlink/stat check),libei.rs:193(wiring). - Threat actor: Local unprivileged user (#4). Gamescope hosts only (Steam Deck / Bazzite gaming mode, or
PUNKTFUNK_COMPOSITOR=gamescope). KWin/Mutter/Sway use D-BusConnectToEISand are unaffected. - Mechanism: The nested session writes gamescope's
LIBEI_SOCKETpath to the fixed world-readable/tmp/punktfunk-gamescope-ei. The libei injector reads that file andUnixStream::connects to whatever absolute path it contains — with no verification that the file or target socket is owned by the host uid — then streams the remote client's keyboard/mouse events to it as a libei client. EIS has no peer authentication, so a fake server captures the input stream. On sticky/tmp(1777), if a different uid pre-creates the relay file (owner=attacker, mode 0644), the host'sremove_fileand> file/fs::writetruncate both fail (EPERM/EACCES, errors ignored), so the attacker's content survives and the host connects to the attacker's socket.stop_sessionremoves the host-owned file on each teardown, giving a recurring re-plant window. - Attack scenario: Local attacker runs
echo /home/attacker/evil.sock > /tmp/punktfunk-gamescope-ei(0644) and listens onevil.sockas an EIS server. When a remote client streams, the injector connects there instead of gamescope's real EIS; every keystroke/pointer event the remote user sends (game/Steam input, typed credentials) is delivered to the attacker, and gamescope receives no input (input DoS). - Existing mitigations: The real EIS socket lives under
XDG_RUNTIME_DIR(0700) — but only its path is leaked/overridable via the/tmprelay.protected_symlinksdoes not help (regular file, not symlink). The injector retries onlyConnectionRefused/NotFound; a live attacker socket returnsOkand is trusted. - Verifier adjudication: Both verifiers confirmed at medium. Impact is high (full remote keystroke capture incl. credentials, plus input DoS) but local-only, gamescope-backend-only, and most gamescope deployments are single-user, capping practical likelihood.
- Recommendation: Relay the EIS path through a host-private location (a file in
XDG_RUNTIME_DIR, 0700, createdO_EXCL) instead of/tmp, and/orstatthe relay file and reject it unless owned by the host uid, mode ≤0644, not a symlink, before reading. Apply the same hardening to the predictable world-readable/tmp/punktfunk-gamescope.log.
7. [Medium→Low] Process-global env retargeting is unsound under now-default concurrent native sessions — Confirmed
- Surface: Session lifecycle / library-launch. (Merges the "native concurrent launch-env race" and the "apply_session_env/apply_input_env" findings — one root cause; the live, generalized form of deferred prior-fix #7.)
- Refs:
punktfunk1.rs:150(DEFAULT_MAX_CONCURRENT=4),punktfunk1.rs:254(Semaphore),punktfunk1.rs:612(std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)),punktfunk1.rs:1871/1885(apply_session_env/apply_input_envcalls),vdisplay.rs:367-397/457-485(env setters),vdisplay/linux/gamescope.rs:791-794(reads the global env),punktfunk1.rs:600/vdisplay.rs:363-365(stale "ONE-session-at-a-time" comments). - Threat actor: Malicious network client, post-auth (#2, paired/trusted-tier).
- Mechanism: The native host now serves up to 4 concurrent sessions by default, yet the per-session handshake mutates process-global environment via
std::env::set_var(resolved launch id intoPUNKTFUNK_GAMESCOPE_APP; plusWAYLAND_DISPLAY/XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS/PUNKTFUNK_INPUT_BACKEND/etc. viaapply_session_env/apply_input_env). These run insidespawn_blockingfor each concurrent handshake and are then read by backends/injectors at open time. The in-code invariant ("the host serves one session at a time, so a process-global write is sound") is now false. Two effects: (1) concurrentset_varwhile another threadgetenvs is documented UB in Rust (glibcenvironrealloc) → potential host-wide crash taking down all live sessions; (2) session B's handshake overwrites the env session A's gamescope-spawn/injector is about to read → A launches B's (operator-approved) title or routes input to B's backend. - Attack scenario: Two paired clients connect concurrently (or one reconnects in a tight loop while another session is active). The racing
set_var/getenvcan abort the host (DoS affecting all sessions); concurrently A's session can be mispointed. - Verifier adjudication: Both confirmed the technical defect; severity split medium vs. low. The cross-session launch/input misrouting grants no new authority (both peers are already authorized to view/inject on the shared desktop; the
uidfilter prevents cross-user selection), so under "only NEW authority counts" it is a correctness bug. The surviving security impact is theset_var/getenvdata-race UB → non-deterministic host-wide DoS, triggerable by an already-paired device. Carried at medium→low accordingly. - Existing mitigations: Pairing gate runs before
resolve_compositor(post-auth).detect_active_sessionfilters/procby the host's own uid (no cross-user selection). Most env writes are gated to auto-detect mode (skipped whenPUNKTFUNK_COMPOSITORis set). No lock serializes the env writes, and there is no per-session config object for these knobs (unlike the Windows/GameStreamSessionContext.launch). - Recommendation: Stop using process-global env on the per-session path. Thread launch command, compositor, input-backend, and session env into the per-session
VirtualDisplay/SessionContext(as GameStream already does viaset_launch_command) and pass them as explicit args to backend/injector open calls. At minimum serialize allset_varwrites + dependent backend-open under one mutex, or forcemax_concurrent=1while the auto env-retargeting state machine is active.
8. [Low] web-password written world-readable then icacls'd — brief TOCTOU disclosure — Confirmed
- Surface: Secret-file permissions (Windows install).
- Refs:
windows/install.rs:273-290. - Threat actor: Local unprivileged user (#4). Windows, install/upgrade time only.
- Mechanism:
set_web_passwordwrites the cleartextPUNKTFUNK_UI_PASSWORD=<pw>viastd::fs::write(creating the file at the inherited Users-readable%ProgramData%ACL) and only afterward strips inheritance withicacls. Between the write and theicaclschild-process completion (a full process spawn = a race-winnable window) the web-console login password is readable by any local user. - Attack scenario: A local user polling
%ProgramData%\punktfunkduring a fresh install readsweb-passwordbeforeicaclsapplies, obtaining the web-console login credential. - Existing mitigations: Window is fresh-install-only (on upgrade the existing file's locked DACL is preserved across a truncating write, so no window reopens — the "upgrade rewrites the password" sub-claim does not hold); install is operator-initiated and one-time;
icaclslocks immediately after; impact limited to web-console access. - Verifier adjudication: One verifier confirmed low; the other adjusted to info, noting the write-then-
icaclspattern is the established Windows secret pattern (used bywrite_secret_filefor far higher-value secrets), so the "anomalously non-atomic" framing is overstated and this is the lowest-value secret affected. Carried at low. - Recommendation: Create the file with a restrictive DACL atomically (
CreateFilewith a SECURITY_DESCRIPTOR, or write to a per-process temp under an already-locked dir then rename), or write empty +icaclsbefore writing the secret bytes.
9. [Low] Unpaired LAN peer can burn the operator's single-use pairing window — Confirmed
- Surface: Native SPAKE2 pairing.
- Refs:
punktfunk1.rs:459(np.disarm()before proof verification),punktfunk1.rs:438(pake.finishaccepts a wrong-PIN message),punktfunk1.rs:517-531(cooldown /current_pin()),native_pairing.rs:216-218(disarm),quic.rs:1581(AcceptAnyClientCert). - Threat actor: Malicious network client, pre-auth (#1). Native path, while pairing is armed.
- Mechanism: The single-use design disarms the PIN on any well-formed pairing attempt, before verifying the guess (the disarm-before-verify behavior is exactly prior-fix #2, which gives the single-online-guess guarantee).
pake.finish()does not reject a wrong-PINspake_a(only malformed messages), so an unpaired peer with a self-signed cert and a SPAKE2 message built from any random PIN guess reachesdisarmand consumes the window without knowing the PIN. - Attack scenario: Operator arms pairing; an attacker polling the QUIC port every ~2 s (the
PAIRING_COOLDOWN) lands an attempt inside the ~120 s armed window; the host disarms. The legitimate device then submits the real PIN and is told "pairing not armed." Repeat indefinitely. - Existing mitigations: Availability-only (1/10000 chance a blind guess actually pairs — the documented single online guess). The attack only works while a window is armed (outside it,
current_pin()isNoneand the handshake bails before touching disarm), so it cannot permanently disable pairing — it races an open window. The delegated-approval flow (knock → console approve) is structurally immune and remains usable on hostile LANs. - Verifier adjudication: One verifier confirmed low; the other adjusted to info as a self-acknowledged, in-code-documented design tradeoff with an immune fallback. Carried at low.
- Recommendation: Prefer the delegated-approval flow on hostile LANs (already immune). Document that PIN arming should be brief. If retaining PIN arming, consider only consuming the window on a key-confirmation match when the failure is observable (trading some brute-force resistance for availability).
10. [Low] ENet control flood → unbounded per-packet warn-log spam — Confirmed
- Surface: GameStream ENet control plane.
- Refs:
gamestream/control.rs:84/161(on_receive),control.rs:186(per-packettracing::warn!, no throttle),control.rs:316/347-378(decrypt + scheme sweep),control.rs:79(detectedreset on Disconnect). - Threat actor: Malicious network client, pre-auth (#1). Requires
--gamestreamand an active paired session. - Mechanism: The ENet control host (UDP 47999,
peer_limit=4) accepts unauthenticated connections. Once a paired client has launched (globalgcm_keyset), any0x0001-prefixed packet with a ≥16-byte payload that fails to authenticate emits onetracing::warnper packet with no rate limit or sampling. The full ~72-candidate GCM scheme-sweep runs only whiledetectedisNone(a transient window; the attacker can reset it via its own Disconnect but steady state is one GCM op + one warn per packet). - Attack scenario: With a paired session active, an attacker ENet-connects and floods junk
0x0001packets → unbounded warn-log lines (disk/observability pressure) + intermittent CPU. - Existing mitigations:
peer_limit=4; the expensive sweep isdetected-gated; AES-GCM open on tiny buffers is microseconds; input injection itself stays cryptographically gated on the HTTPS-deliveredgcm_key(no forgery). Opt-in, trusted-LAN. - Verifier adjudication: Confirmed; the "per-packet GCM brute-force" framing is largely neutralized by the
detectedfast-path, but the unthrottled per-packet warn log is genuinely unmitigated. Low severity (DoS/observability only, no injection or memory unsafety). - Recommendation: Throttle/aggregate the "GCM decrypt failed" warning (sampled, not per-packet) and drop a peer after N consecutive auth failures; optionally skip the scheme-sweep for a peer that has produced no authenticating packet.
11. [Low→Info] SYSTEM host.log opened with predictable name in a Users-writable directory — Partial
- Surface: Windows service / logging.
- Refs:
windows/service.rs:121-125(logs dir via plaincreate_dir_all),service.rs:574-602(open_log_handle,OPEN_ALWAYS, append-only, inheritable,FILE_SHARE_READ|WRITE). - Threat actor: Local unprivileged user (#4). Windows.
- Mechanism: The SYSTEM service opens
%ProgramData%\punktfunk\logs\host.logand redirects the host child's stdout/stderr to it. The logs dir lives under the non-DACL-locked config tree (Finding 3). A local user able to create files there could pre-createhost.logas an NTFS hardlink to an attacker-chosen target, causing SYSTEM's appends to land on that target. - Impact: Limited integrity: SYSTEM appends attacker-uncontrolled log text (append-only handle — no truncation, no chosen-offset writes) to an attacker-chosen file. No content control → no realistic code-exec path; a log-tamper/nuisance/DoS primitive at most.
- Verifier adjudication: Both verifiers found the redirect-target control hinges on a non-admin holding
FILE_ADD_FILEon a SYSTEM-created subdir, which the default%ProgramData%ACL does not grant (Users get create-subfolder, not create-file). The only residual is the same pre-install directory-squatting edge as Finding 3, and even then the writes are append-only uncontrolled text. One verifier partial/low, one partial/info. Effectively a sub-case of Finding 3. - Recommendation: Fixing Finding 3 (DACL-lock the config/logs dir to SYSTEM+Administrators) fixes this. Optionally open the log rejecting reparse points / create the dir with a restrictive DACL before first write.
12. [Low→Info] Legacy GameStream pairing has no rate-limit and parks unbounded 300 s waiters — Partial
- Surface: GameStream pairing.
- Refs:
gamestream/pairing.rs:102-127(getservercertparkspin.take(300s)),pairing.rs:50-60(WaiterGuard),nvhttp.rs:215-244(unauthenticated/pairroute, no rate limit / connection cap). - Threat actor: Malicious network client, pre-auth (#1). Requires
--gamestream. - Mechanism:
/pair?phrase=getservercertis reachable pre-auth with an attacker-chosenuniqueidand no per-IP/global rate limit; each parks a tokio task for up to 300 s and keepsawaiting_pinasserted. The HTTP server has no connection cap (bareaxum_server::bind). - Verifier adjudication: Both verifiers confirmed the no-rate-limit/parked-waiter core but refuted the alarming "unbounded never-evicted HashMap" sub-claim — the
sessionsinsert is downstream of a successfulpin.take(), which requires an operator-delivered PIN, so the map grows at most one entry per PIN submission (not attacker-driven). The residual is a bounded (300 s self-heal, cheap tasks), opt-in slow-loris +awaiting_pinnuisance on a surface already covered by accepted-risk #5/#9, plus a minor enlargement of the Finding-9-class PIN race. Both adjusted to info. - Recommendation: Add a per-source-IP / concurrent-handshake cap on pairing attempts and evict the per-
uniqueidsession on success/timeout (not only on failure).
13. [Info] Pending-approval queue floodable by a LAN cert flood — Confirmed
- Surface: Native pairing / delegated-approval queue.
- Refs:
native_pairing.rs:336-357(note_pending,PENDING_CAP=32eviction of least-recently-active),native_pairing.rs:81-83(cap + 10-min TTL),punktfunk1.rs:566(called per unpaired knock, no per-source rate limit). - Threat actor: Malicious network client, pre-auth (#1).
- Mechanism:
note_pendingis called for every unpaired-but-identified knock with no per-source rate limit; past 32 entries the least-recently-active is evicted. An attacker minting >32 distinct self-signed certs can churn the queue, potentially evicting a quiet legitimate knock before the operator approves it. - Verifier adjudication: One confirmed info, one refuted — the in-place refresh resets
requested_aton every same-fingerprint re-knock, so an actively-retrying legitimate device is structurally non-evictable; only a one-shot knock-and-wait device is at risk and it recovers instantly by re-knocking. Each junk slot costs a full QUIC handshake; no trust-store/PIN/key impact. Carried at info (transient self-healing availability nuisance on the convenience queue only). - Recommendation: Optionally cap pending entries per source IP/subnet, or surface a "pending overflow" indicator. Low priority.
Prior-fix verification (#1–#12)
- #1 — HIGH (secret-file perms 0600/0700 Unix; SYSTEM+Admins DACL Windows): PRESENT but INCOMPLETE — regressed for two newer files. The core helpers
create_private_dir(0700 Unix) andwrite_secret_file/restrict_to_system_admins(Unix 0600 + WindowsicaclsSYSTEM/Admins/OWNER) are correct and used forkey.pem,cert.pem, GameStreampaired.json, nativepunktfunk1-paired.json, andweb-password(all atomic temp+rename, no world-readable window, never logged). Gaps: the mgmt-token writer (mgmt_token.rs:write_token) hardens onlycfg(unix)and never applies the Windows DACL (Finding 2);host.envis written with a barestd::fs::writeand the Windows config directory is never DACL-locked (Finding 3);web-passwordhas a brief write-then-icaclsTOCTOU window (Finding 8). Non-secret files (uniqueid,library.json, art cache, stats captures) carry no key material — acceptable. - #2 — HIGH (native SPAKE2 PIN single-use): VERIFIED INTACT.
np.disarm()runs unconditionally before reading the client proof (punktfunk1.rs:459); a malformedspake_aerrors earlier but makes no guess. The globalPAIRING_COOLDOWN(2 s) + per-attemptcurrent_pin()close the concurrency TOCTOU; CSPRNG PIN; CLI arm-at-start is also consumed. No path leaves a static reusable PIN. (The single-use design's only side effect is the availability edge of Finding 9.) Caveat: the legacy GameStreamPinGateis a separate mechanism —PinGate::take()consumes the PIN and the mgmt path guardsawaiting_pin(), but the nvhttp/pinpath does not guard and is unauthenticated (Finding 1). - #3 — MED (RTSP packetSize clamp + saturating packetizer): VERIFIED PRESENT.
rtsp.rs:330-339rejects packetSize outside64..=2048;video.rs:63clampspayload_per_shardso all divisors are ≥1 (regression testdegenerate_packet_size_does_not_panic). - #4 — LOW (mgmt mTLS cert restricted to read-only allowlist): VERIFIED COMPLETE.
cert_may_access(mgmt.rs:514-528) is GET-only over an exact-path set excluding every state-changing/pairing/stats route; all/api/v1routes shareroute_layer(require_auth); cert branch additionally requiresnative.is_paired(fp). No streaming cert can read the PIN, self-approve, mutate the library, or reach/stats/*. Not regressed by any newly-added route. - #5 — LOW ACCEPTED (legacy control-stream GCM nonce reuse): UNCHANGED. Still legacy/Moonlight-compat (
control.rs:108-117); not reachable on the defaultservepath. Not re-flagged. - #6 — LOW (RTSP header/Content-Length caps + read timeout + connection cap): VERIFIED PRESENT.
MAX_RTSP_CONNS=8,RTSP_READ_TIMEOUT=15s, 16K header / 64K body / 128K message caps enforced;ConnGuardreleases the slot on panic. - #7 — LOW PARTIAL (per-session launch command; native path used a process-global env): STILL UNRESOLVED and now REGRESSED IN IMPACT. The native path still does
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", cmd)and the gamescope backend reads that global; the in-code "ONE-session-at-a-time" justification is invalidated byDEFAULT_MAX_CONCURRENT=4. The GameStream/Windows path correctly threads launch into a per-sessionSessionContext. This is now Finding 7 (generalized to the whole env-retargeting state machine + aset_var/getenvdata race). - #8 — INFO (GameStream phase-4 hash compare constant-time): VERIFIED PRESENT.
pairing.rs:228usescrypto::ct_eq, a proper no-early-exit fold;hash_okandsig_okare both computed before branching. Mgmttoken_eqsimilarly SHA-256-hashes both sides. - #9 — INFO ACCEPTED (/pair over plain HTTP): UNCHANGED as a transport matter. Note: the unauthenticated
/pinself-delivery (Finding 1) is a distinct, newly-surfaced defect, not subsumed by #9. - #10 — INFO (fixed ALPN
pkf1on QUIC): VERIFIED PRESENT. - #11 — INFO (FEC reconstruct failure = drop not fatal): VERIFIED PRESENT. Host encode uses
encode(...).unwrap_or_default(); audio returnsNoneto skip a block; no fatal path. - #12 — LOW DEFERRED (web
NODE_TLS_REJECT_UNAUTHORIZED): out of host scope, not examined.
Refuted / investigated — not vulnerabilities
- PinGate PIN not bound to uniqueid/cert (confused-deputy PIN theft) — refuted. The global PIN-slot race is real (enables a pairing DoS, folded into Finding 9's class), but the escalation is cryptographically impossible: in GameStream the PIN is generated and displayed by the Moonlight client and the host never echoes it, so a racing attacker consumes the PIN-submission event but never learns the PIN value; without it the phase-2/4 hash + RSA checks fail closed. No paired identity gained.
- Attacker-chosen device name in the approval queue (trusted-device impersonation) — refuted. The unpaired knock is hard-rejected; the fingerprint (the value actually pinned) is displayed alongside the sanitized name, and bidi/control/homoglyph chars are stripped. Approval requires a bearer-authenticated human; "approving on the label without reading the fingerprint" is social engineering inherent to any human-in-the-loop pairing, with the standard mitigation already present.
- Lutris cover-art slug path traversal — refuted. The
..-joined read is real, butslugoriginates from the host user's own~/.local/share/lutris/pga.db(a same-user local file), not controllable by any in-scope network/MITM/local-unpriv adversary; the disclosure recipient is an already-paired client with strictly greater authority, and the read is.jpg-only, ≤1 MiB. Charset-validating the slug is worthwhile defense-in-depth. - Privileged install invokes system tools by bare name (PATH/CWD hijack) — refuted. Premise is wrong for the Rust toolchain:
std::process::Commandresolves the executable itself, searchesSystem32before the CWD, and never searches the spawning process's directory. All cited tools are System32 binaries, so a planted CWD copy loses. Using absolute%SystemRoot%\System32\…paths is reasonable consistency hardening but addresses no reachable threat. uniqueid/mgmt-token create the config dir withcreate_dir_all(brief 0755) — refuted. Every secret file is written 0600/DACL-locked regardless of directory mode; the only non-secret file (uniqueid) is a public serverinfo identifier; on Linux the dir is under the owning user's per-user home;create_private_dirlater tightens it to 0700. Code-consistency cleanup, no disclosure.- Unbounded on-disk stats capture files — refuted. Every
/stats/*route is bearer-token-gated (excluded from the cert allowlist); the captures dir is 0700; the file id is host-generated. No pre-auth, post-auth, MITM, or local-unpriv path can create captures — only the trusted operator over their own disk. Pruning/streamedlist()parsing is a reasonable operational improvement, not a security fix.
Cross-cutting themes
- GameStream/Moonlight compatibility is the soft underbelly. Both pre-auth bypasses (Findings 1, 4) and the control-plane DoS (Finding 10) live exclusively on the opt-in
--gamestreamsurface, whose authz model is weaker by design (accepted-risk #5/#9). The native punktfunk/1 plane is markedly stronger. The two genuinely new pre-auth issues — unauthenticated/pinself-pairing and the ungated RTSP media plane — are bypasses of GameStream's ownpeer_is_pairedboundary, not inherent-protocol weaknesses, and are fixable without breaking stock-Moonlight compatibility. - Prior-fix #1 hardened secret files but not the Windows config directory or two files added since. Findings 2, 3, 8, 11 all trace to the
%ProgramData%\punktfunkACL gap plus the bespokewrite_token/std::fs::writepaths that bypasswrite_secret_file. A single remediation — DACL-lock the config directory and route all config writes throughwrite_secret_file— closes most of the Windows local-privilege surface. - Concurrency outgrew single-session assumptions. Finding 7 (and the regressed prior-fix #7) is the codebase shipping default
max_concurrent=4while per-session state still uses process-globalstd::envmutation written under a one-session invariant. TheSessionContext/set_launch_commandpattern already used on the Windows/GameStream path is the correct fix to generalize. - Local IPC and temp-file trust. The Windows gamepad/IDD shared sections (
Everyone:GENERIC_ALL, Finding 5) and the Linux gamescope EIS/tmprelay (Finding 6) both trust a local channel that a second unprivileged account can read/write. Scope DACLs to the consuming principal and move relays into owner-private runtime dirs.
Security controls done right (positives)
- Native SPAKE2 pairing is well-hardened: single-use disarm-before-verify, global cooldown, atomic+rollback persist, fail-closed load, CSPRNG PIN, device-name sanitization (C0/C1 + bidi/format stripped, 64-char cap) at every sink, with regression tests. No path lets an unpaired peer self-approve, read the PIN, or poison the trust store.
- Post-pair cert-pinning is sound: the TLS layer verifies the
CertificateVerifysignature (key ownership) even though it "accepts any" cert at handshake, andpeer_is_pairedpins SHA-256(DER) against the saved cert — a stolen public cert cannot impersonate a paired client. - Management authz is solid: every
/api/v1route gated (even on loopback),runrefuses to start without a token, loopback-default bind, constant-time (SHA-256-hashed) token compare, 256-bit token entropy, no cookie/CSRF surface, and a correct read-only-cert vs. bearer-mutation split. - The new library/launch surface is strong against the network adversary: client ids resolve against the host's own scanned catalog (never client-supplied launch strings), Steam appids are digit-validated, Heroic/Epic/AUMID values charset-validated, all non-operator spawns are argv-based with no shell, and the only
cmd.exe /c/sh -csinks consume operator-typed input only. No SSRF in the cover-art warmer (fixed trusted hosts, ids in the path component only). XML/JSON/VDF parsers are entity-expansion-safe. - Wire parsers are memory-safe: RTSP has connection caps, read timeouts, header/body/message caps, and clamps every attacker-controlled numeric; the video packetizer is structurally panic-proof; input/gamepad decoders are fully
.get()-bounded withidx < MAX_PADSchecks; DualSense/DS4 output-report parsers bounds-check before indexed reads. - The stats-capture surface is clean: bearer-only routes, host-generated path-safe ids with traversal rejection (tested), 0700 captures dir, bounded samples, lock-serialized hot-path feed, and host-derived (non-free-form) metadata fields.
- Session/cross-user isolation holds: the Desktop↔Game follow watcher and
detect_active_sessionfilter/procstrictly by the host's own uid, so a session can never follow or expose a different user's compositor; per-session virtual-output/encoder teardown is sound RAII (no monitor/FD/zombie leaks);--max-concurrentgenuinely caps concurrency. - Windows service launch hygiene: fully-quoted
current_exebinPath with fixed args (no unquoted-service-path), correct token scoping (drops to the user token for store launchers/WGC, retains SYSTEM only for our own streamer), anonymous inherited pipes for the host↔helper channel, and no command line built from network input.
Supplement (2026-06-28, follow-up pass 2 — completed surfaces + coverage-critic gaps)
(a) Summary
This pass closes the two finders that failed in the main audit (native protocol; unsafe FFI — here split into control-plane, data-plane, encode/capture, and driver-IPC) and the three coverage-critic gaps (mic/Opus → virtual mic + cross-session isolation; main.rs default-security posture + dependency RUSTSEC; cover-art outbound egress/SSRF). The headline answers: the native control plane is fail-closed for unpaired peers at the application layer — serve_session rejects anonymous/unpaired clients before any session machinery (punktfunk1.rs:544-573) — but the QUIC transport underneath is not, and it is the only genuinely pre-auth crown-jewel-adjacent exposure found here: quinn-proto 0.11.14 (RUSTSEC-2026-0185, CVSS 7.5 unbounded out-of-order reassembly) is reachable by any unpaired peer who completes the 1-RTT handshake with a throwaway cert before the pairing gate runs → remote memory-exhaustion DoS of the always-on default listener. Client geometry is well bounds-checked (W/H caps applied on Hello, Reconfigure, and ANNOUNCE; Opus mic buffer math is exact; gamepad/touchpad indices clamped) with one consistent gap: the refresh/fps lower bound is unvalidated on the initial Hello path (the Reconfigure path guards it), yielding at worst a self-inflicted single-session divide-by-zero panic. Cover-art egress is SSRF-safe against every in-scope adversary (hardcoded hosts, id only in the path segment, TLS verification on); the only residual is an out-of-scope supply-chain redirect-follow. The rsa 0.9 Marvin oracle is not practically reachable — it is a signing path (not the classic PKCS#1v1.5 decryption oracle), on the opt-in trusted-LAN-only GameStream plane. The mic/Opus surface adds one real cross-session defect: a malformed Opus frame tears down the single host-lifetime virtual mic shared by all concurrent sessions. The driver-IPC surface is memory-safe and clean (the only weakness is the already-reported world-writable section ACL).
(b) Confirmed and partial findings
S1 — Pre-auth remote memory exhaustion via vulnerable quinn-proto 0.11.14 on the always-on native QUIC control plane (RUSTSEC-2026-0185) — CONFIRMED, severity HIGH
- Surface: cli-posture-deps / native QUIC transport. Files:
Cargo.lock(quinn-proto 0.11.14, line ~2966),crates/punktfunk-core/src/quic.rs:1540-1589,1580-1581,1723-1740,crates/punktfunk-host/src/punktfunk1.rs:176-181,503. - Threat actor / auth: malicious network client, pre-auth (unpaired, unauthenticated).
- Mechanism:
serve(the secure default) always builds the native QUIC listener bound to0.0.0.0:9777. The rustlsServerConfigusesAcceptAnyClientCertand defers all identity/pairing verification to a post-handshake app-layer fingerprint check. An unpaired peer therefore presents any self-signed cert, completes the QUIC 1-RTT handshake, and reachesquinn-proto's stream-reassembly path before the--require-pairinggate. RUSTSEC-2026-0185: unbounded out-of-order STREAM-frame buffering → remote memory exhaustion. - Scenario: attacker on the LAN sends a ClientHello, finishes the handshake with a throwaway cert, opens a stream, floods out-of-order STREAM frames with large gaps; the privileged host buffers unboundedly → OOM, killing streaming for all paired clients and possibly the box.
- Existing mitigations:
--max-concurrentbounds session count but not per-connection reassembly memory; the pairing gate runs after the vulnerable transport layer;stream_transport()sets only idle-timeout/keep-alive, not receive-window limits. None neutralize this. - Recommendation:
cargo update -p quinn-proto --precise 0.11.15(or bumpquinn), and wirecargo auditinto CI as a failing gate on the QUIC path. - Verifiers: both confirmed, adjusted_severity HIGH (availability-only — no key/trust-store impact — so high, not critical). Exploit path corroborated end-to-end:
main.rs:503always-on default →server_with_identity→AcceptAnyClientCertaccepts any cert → handshake reaches quinn-proto reassembly pre-pairing.
S2 — Malformed client Opus mic frame tears down the shared host-lifetime virtual mic (cross-session DoS) — CONFIRMED, severity LOW–MEDIUM
- Surface: audio-mic-decode. Files:
crates/punktfunk-host/src/punktfunk1.rs:1231-1280(esp. 1266-1277),:221,:292/:300;crates/punktfunk-core/src/quic.rs:1210. - Threat actor / auth: malicious paired client, post-auth.
- Mechanism:
mic_service_threadtreats anyopus::Decoder::decode_floaterror as a backend failure: it setsmic=None; decoder=None; last_failed=now, tearing down the PipeWire/WASAPI virtual mic and forcing a 2sINJECTOR_REOPEN_BACKOFF. The Opus payload is raw attacker bytes (decode_mic_datagramchecks onlylen>=13and forwardsb[13..]verbatim), and libopus returnsOPUS_INVALID_PACKETon a malformed TOC, so a single crafted ≥14-byte datagram triggers it. Critically, theMicServiceis one host-lifetime resource shared by every concurrent session (created once inserve(), sender cloned per session). - Scenario: paired client #2 (a second concurrent session) sends one garbage Opus frame every ~2s; the shared mic thread repeatedly drops the virtual mic and re-enters backoff, keeping the microphone unavailable for session #1's recording/voice-chat app — a cross-session denial of an optional feature beyond the offender's own tier.
- Existing mitigations: pairing-gated; 2s backoff bounds reopen churn; DTX/empty frames skipped; no memory blow-up. None prevent the cross-session denial because there is no per-session decoder/mic isolation.
- Recommendation: treat a codec decode error as a per-frame drop (rate-limited log), keeping decoder+mic open; only tear down on an actual backend
pusherror; reset (not destroy) decoder state; ideally use a per-session decoder. - Verifiers: both confirmed; adjusted_severity split MEDIUM / LOW — medium because a low-effort paired client denies an honest concurrent session's mic (genuine new authority via the shared resource); low because the impact is confined to one optional feature, churn-bounded, no crash/disclosure/exec, and all paired clients already share one desktop at a high mutual-trust tier. Net: treat as LOW–MEDIUM, fix is cheap and warranted.
S3 — Unbounded held-button/held-key tracking Vec grows on attacker-chosen input codes (per-session DoS) — CONFIRMED, severity LOW
- Surface: native-data-plane. Files:
crates/punktfunk-host/src/punktfunk1.rs:1457-1483(esp. 1476-1483);crates/punktfunk-core/src/input.rs:136-149. - Threat actor / auth: malicious paired client, post-auth.
- Mechanism: every
MouseButtonDown/KeyDownwhose 32-bitev.code(read straight off the wire atinput.rs:144, no range/validity check) is not already present is pushed into the per-sessionheld_buttons/held_keysVec, with no cap and a linearVec::containspresence test (O(n) per event, O(n²) over a run). Entries are removed only by a matching Up. The upstream mpsc is also unbounded with no per-packet throttle. - Scenario: paired client floods
MouseButtonDown/KeyDownwith monotonically increasingcodes and never sends Up → theVecgrows unbounded and the quadratic scan spikes the session's input-thread CPU for the session lifetime. - Existing mitigations: per-session
Vecs dropped on disconnect; input injection is in-scope-by-design (the only new harm is the unbounded tracking state); QUIC intake is receive-buffer bounded. - Recommendation: bound the held-state sets with a
HashSetkeyed bycode(removes the O(n²) scan) and/or reject codes outside valid button/key ranges before tracking; cap the number of distinct held codes. - Verifiers: both confirmed, adjusted_severity LOW — self-confined to one session thread, no host crash, inverted amplification (wire bytes > memory), but a real unnecessary unbounded-growth defect.
S4 — Unbounded read of local launcher caches (Epic catcache.bin / .item manifests) — memory-exhaustion DoS — CONFIRMED, severity LOW
- Surface: cover-art-egress / library enumeration. Files:
crates/punktfunk-host/src/library.rs:657-665(esp.std::fs::readat ~660 + base64 decode ~663),:580(read_to_string). - Threat actor / auth: local unprivileged user (Windows host), post-auth N/A (local).
- Mechanism:
epic_art_indexreads the entire%ProgramData%\Epic\EpicGamesLauncher\Data\Catalog\catcache.binwith no size cap, then base64-decodes it (a second ~0.75× allocation), thenserde_jsonparses — stacked unbounded allocations in the LocalSystem host. Each.itemmanifest is likewise read whole. Default ProgramData ACLs commonly let a standard user create/replace files in app subfolders (Epic itself grants Users modify so its user-mode launcher can rewrite the cache). - Scenario: local user plants a multi-GB
catcache.bin; the next library enumeration (mgmt list / GameStream serverinfo-applist / art warmerall_games()) loads it plus its decoded copy into the privileged host → OOM. - Existing mitigations: best-effort (failures return empty map, no crash); triggered per-enumeration, not continuously; Windows-only. Notably the Linux
lutris_imagereader (library.rs:372-377) already caps at 1 MiB — the pattern is known and simply not applied here. - Recommendation:
fs::metadatasize check or atake()-limited reader (a few MB forcatcache.bin, tens of KB per.item) before read/decode; skip oversize files. - Verifiers: both confirmed, adjusted_severity LOW — DoS only, ACL-precondition reduces exploitability but not the verdict; the author's own Linux cap proves the omission.
S5 — Client refresh/fps lower bound not validated before encoder open (Hello path; folded across two finders) — PARTIAL, severity LOW→INFO
- Surface: native-control-plane + unsafe-encode-capture (these two finders are the same defect at different depths; reported once here). Files:
crates/punktfunk-host/src/encode.rs:195-211(validate_dimensions),crates/punktfunk-host/src/punktfunk1.rs:574-579,804,3659-3663,crates/punktfunk-host/src/encode/linux/mod.rs:247-248,474,crates/punktfunk-host/src/encode/linux/vaapi.rs:98,184. - Threat actor / auth: malicious paired client, post-auth (pre-auth only on opt-in
--open/--allow-tofu). - Mechanism:
validate_dimensionscaps W/H but ignores refresh. The mid-stream Reconfigure path explicitly checksreq.mode.refresh_hz > 0(punktfunk1.rs:804) — proving the invariant is known — but the initial Hello path does not. On the common Linux backends (gamescope/wlroots/mutter)preferred_modeechoes the requested refresh, soeffective_hz's.filter(|hz| hz>0).unwrap_or(mode.refresh_hz)collapses a requestedrefresh_hz=0back to 0, reachingopen_video(fps=0)→time_base = Rational(1,0)and the uncheckedpts * 1e9 / self.fpsdivide atencode/linux/mod.rs:474(andvaapi.rs:184). - Scenario: a paired client sends
Hello{mode: WxHx0}; on a Mutter/wlroots/gamescope host eitheravcodec_open2rejects the1/0time_base (clean Err) or the first packet triggers a divide-by-zero panic on the encode thread. - Impact / mitigations: at worst a single-session-thread panic isolated by
spawn_blocking/panic=unwind(surfaces as a JoinError atpunktfunk1.rs:1092-1094; the persistent listener and sibling sessions survive). KWin reports a real achieved Hz and dodges it. The GameStream half is refuted:rtsp.rs:340-342floorsmaxFPSwith.filter(|&f| f>0).unwrap_or(60), socfg.fpsis never 0. - Recommendation: fold a refresh lower-bound (
>0, ideally clamp1..=480) intovalidate_dimensionsso Hello and Reconfigure enforce the same invariant; defensively useself.fps.max(1)at the two division sites. - Verifiers: all four lenses PARTIAL; adjusted_severity INFO–LOW — a real validation asymmetry and reachable divide-by-zero, but the outcome is a self-inflicted teardown of the attacker's own isolated session granting no new authority (post-auth) or reducing to the already-accepted stream-slot DoS (on opt-in
--openhosts). Worth the trivial fix; not a boundary crossing.
S6 — Unbounded mpsc into the host-lifetime shared MicService (0xCB) — PARTIAL (leaning info), severity LOW→INFO
- Surface: native-data-plane / audio. Files:
crates/punktfunk-host/src/punktfunk1.rs:905-911,1200,1231-1280; sinksaudio/linux/mod.rs:151-153,audio/windows/wasapi_mic.rs:107-120. - Threat actor / auth: malicious paired client, post-auth.
- Mechanism (as filed): each session forwards every 0xCB frame into an unbounded host-lifetime
std::sync::mpscshared across all sessions, with no backpressure/cap; the single consumer does an Opus decode + virtual-mic push per iteration. - Verifier correction: the filed DoS mechanism — "the push blocks on the audio backend, so the queue grows without bound" — is factually wrong. Both
VirtualMic::pushimpls are non-blocking and self-bounded: Linux usestry_send(drops when behind); Windows takes a quick mutex with a drop-oldestMAX_QUEUE_BYTEScap. The consumer is therefore CPU-throughput-limited (decode-only), runs on its own thread, and never stalls; the producer is QUIC-receive-rate bounded doing comparable per-item work. Items are only the ~sub-1KB Opus payload. - Residual: a genuine but minor robustness gap — an unbounded shared channel with no explicit cap/rate-limit; under a sustained near-line-rate flood exceeding decode throughput, a small producer>consumer gap could accumulate.
- Recommendation: use a bounded (drop-oldest) channel for the mic forward, or rate-limit/coalesce per-session before the shared service.
- Verifiers: both PARTIAL, adjusted_severity INFO–LOW — structural claim holds, stated stall mechanism refuted by the non-blocking sinks.
S7 — GameStream RSA pairing uses rsa 0.9 (RUSTSEC-2023-0071 Marvin timing side-channel) — PARTIAL (leaning info), severity LOW→INFO
- Surface: cli-posture-deps. Files:
Cargo.toml(rsa 0.9.10),crates/punktfunk-host/src/gamestream/cert.rs:54-55,crates/punktfunk-host/src/gamestream/pairing.rs:200. - Threat actor / auth: network adversary on the GameStream pairing flow, pre-auth (the pairing flow itself; the consent bypass is already tracked in the main audit).
- Mechanism: the host's persistent RSA-2048 identity (the trust root: pinned TLS cert + pairing signer) is loaded into a PKCS1v15
SigningKeyand used tosign(&serversecret)during the unauthenticated nvhttp pairing ceremony.rsa 0.9.10carries RUSTSEC-2023-0071 (variable-time private-key op, no fixed upstream release), so signing-response timing is data-dependent on the secret key. Recovery would defeat client cert-pinning (host impersonation). - Existing mitigations: GameStream is off by default and documented trusted-LAN-only (#5/#9 inherent caveat); the native plane uses Ed25519/SPAKE2 and is unaffected. Crucially this is the signing path, not the PKCS#1v1.5 decryption oracle Marvin classically targets, and
serversecretis host-generated random (not attacker-chosen) — so a remote network-timed RSA-2048 key recovery over a jittery LAN is theoretical, requiring enormous high-precision sampling. - Recommendation: track the advisory; when a blinded/constant-time
rsarelease lands, upgrade; consider migrating the GameStream identity to ECDSA/Ed25519; keep GameStream gated off by default. - Verifiers: both PARTIAL, adjusted_severity INFO–LOW — claim technically accurate and no code-level fix exists upstream, but the off-by-default posture, signing-not-decryption distinction, and lack of any demonstrated practical remote key recovery reduce this to a transitive-advisory exposure.
(c) Refuted / not vulnerabilities
- Single shared virtual mic + stateful Opus decoder across concurrent sessions (no isolation) — refuted (downgraded to info). Concurrent sessions are co-tenancy of ONE desktop by design (
punktfunk1.rs:244-246); a paired client already injects keystrokes/captures that desktop via the identically-shared input service, so sharing the mic grants no new authority. Decoder "corruption" is self-healing (reopen) audio-quality, not security. Document the limitation alongside the known gamescope multi-user gap. - Cover-art warmer follows HTTP redirects → blind SSRF — refuted under the in-scope threat model. URLs are hardcoded
https://api.gog.com/https://displaycatalog.mp.microsoft.comconstants reached over verified TLS (ureq 2.x → rustls + webpki-roots); the id is only a path segment. No in-scope adversary (network client, on-path MITM with no host key, local user) can emit the 30xLocation— that requires a genuine compromise/cert-hijack of those domains (supply-chain, out of scope). A local user can only poison the path segment of a request still sent to the real upstream over TLS. Defense-in-depth: still set.redirects(0). - GameStream RSA signing direct attacker-control — partial-leaning: the adversary observes a timing side-channel, not a value flowing to a sink; see S7.
(d) Positives confirmed on these surfaces
- Native control plane is fail-closed at the app layer:
serve_session(punktfunk1.rs:544-573) rejects unpaired/anonymous clients beforevalidate_dimensions, compositor resolution, thecan_encode_444GPU probe, encoder open, and vdisplay create. - Client→host wire decoders are uniformly bounds-checked, no reachable parse panic/OOB:
Hello.decodeuses checked.get()for every trailing field (the oneu32atis gated bylen>=20);RichInput(quic.rs:1271),InputEvent(input.rs:136), anddecode_mic_datagramall length-check before indexed reads; unknown datagram tags are a non-fatal drop. - No client field reaches HDR SEI: the 0xCE/HDR, 0xCA rumble, 0xCD HidOut datagrams are host→client only; the SEI builders are fed only host-derived values.
- Geometry → unsafe FFI is memory-safe: W/H caps applied on Hello, Reconfigure, and ANNOUNCE; CPU upload paths re-derive
src_rowfrom the encoder's own width and bound-checkbytes.len() >= src_row*hbeforesws_scale/copy; encoder fully rebuilds on size change (no stale-size OOB); CUDA pitch math driver-bounded; Drop SAFETY contracts hold (no UAF/double-free); pf_vdisplay/SudoVDA ioctls usesize_of-sized buffers with no attacker-controlled length. - Driver-IPC ABI is clean:
pf-driver-protopins all offsets/sizes via compile-timeoffset_of!/size_ofasserts; gamepad output reports, XUSB rumble, IDD-push publish token, and the WGC AU pipe all bounds-check before indexed reads and never use an attacker byte as offset/length/index; the only residual is the already-reported world-writable section ACL. - Opus mic buffer math exact: 5760×2 f32 = the 120 ms max stereo frame; the safe
opuscrate returnsBufferTooSmallrather than overflowing;(samples×2).min(pcm.len()). - Gamepad accumulation clamped at every layer:
idx < MAX_WIRE_PADS(16),idx >= MAX_PADS(4)rejects, finger/touchpad/stick/trigger clamps. - No production GameStream client→host Opus decode path: the only
MSDecoder::new(..., client_mapping)call sites are inside#[cfg(test)](the prompt's G1 premise corrected) — that attack surface does not exist in shipped code. - CLI default-security posture sound:
require_pairing/openuse exact-string scans (malformed/quoted args can't flip them); the mgmt token is mandatory on every bind including loopback (mgmt.rs:86-92,471-507); empty--mgmt-tokenrejected; dev subcommands expose no weaker-trust default listener. - Cover-art direct SSRF safe: hardcoded hosts, id only in path, TLS verification on, body capped at ureq's 10 MB; catalog art URLs flow only to clients, never re-fetched by the host.
- Concurrency/probe bounds:
max_concurrentvia owned semaphore permit beforeaccept(); probe duration/rate clamped (MAX_PROBE_MS=5s,MAX_PROBE_KBPS=10Gbps);ClockProbeanswered 1:1 (no amplification).
Appendix — coverage-gap critic (pass 1) and how pass 2 addressed it
Coverage gaps & follow-up
I enumerated all 82 host source files and mapped them to the 13 audit surfaces. Below are files / data-paths / cross-cutting concerns that no surface clearly owns, ranked for a follow-up pass.
Gaps in per-file coverage
G1 — Client mic-uplink Opus decode → privileged virtual mic (MED)
Files: src/audio.rs, src/audio/linux/mod.rs, src/audio/windows/wasapi_cap.rs+wasapi_mic.rs, decode sinks at punktfunk1.rs:1233-1266 and gamestream/audio.rs:610-732.
The native-protocol surface covers the demux (0xCB → mic_tx) and gamestream-wire covers RTP framing, but the Opus decode itself and the PCM injection into a host-wide virtual microphone is owned by no surface. This is an attacker-controlled byte stream (opus::Decoder::decode_float on raw network bytes, punktfunk1.rs:1266) decoded into a system-visible recording device. Worse on the GameStream path: gamestream/audio.rs:637/724 builds an opus::MSDecoder from a client-derived channel mapping/layout (layout.streams, layout.coupled, client_mapping) — verify those are bounds-checked before reaching libopus, and that decode errors can't panic/DoS the host-lifetime mic thread. Native path is post-auth; the GameStream mic path rides weaker GameStream trust. No audio-decode surface existed.
G2 — Shared host-lifetime mic/input services across concurrent sessions (MED)
punktfunk1.rs:219-300 (mic_service / mic_tx shared, host-lifetime). With --max-concurrent sessions sharing one virtual mic and input service, a paired client's mic stream / input can bleed into a different concurrent session's desktop. This spans audio + session-lifecycle + input-injection and no single surface would catch the cross-session isolation question. Adversary: post-auth client #2 against session #1 (multi-user isolation, explicitly listed as "remaining piece" in CLAUDE.md for gamescope).
G3 — main.rs CLI parsing & default-security posture (MED)
src/main.rs (734 LOC) is owned by no surface. It decides the crown-jewel default: require_pairing: !args.iter().any(|a| a == "--allow-tofu") (main.rs:388) — a substring/exact-match flag scan that gates whether unpaired clients are accepted. Also hosts the spike and punktfunk1-host dev subcommands shipped in the production binary, and the --mgmt-bind parse (main.rs:516, non-loopback requires a token — good, but verify the loopback check can't be bypassed by 0.0.0.0/IPv6-mapped forms). A default-posture/flag-parsing regression here silently disables pairing. Cross-cutting; no surface re-derives it.
G4 — Cover-art warmer outbound egress + parse (LOW-MED)
library.rs:1004-1090 (fetch_gog_art, fetch_xbox_art, host-lifetime warmer over ureq) and Epic catcache.bin base64 decode. library-launch likely covered launch-command construction, but the outbound HTTP egress (host as SSRF client fetching URLs influenced by on-disk store files / operator custom entries, library.rs:481-697) and the base64/JSON parse of attacker-influenceable launcher caches are a distinct trust boundary. Confirm library-launch actually traced the fetch side, not just launch exec.
G5 — hdr.rs metadata path (LOW)
src/hdr.rs (168 LOC) — HDR/color-info construction. If any field derives from client ColorInfo (0xCE / connect_ex5 caps), it's attacker-influenced metadata fed to the encoder SEI. No surface names it.
G6 — Glue/init files unmapped (LOW)
pipeline.rs, pwinit.rs, session_tuning.rs, linux/dmabuf_fence.rs, linux/drm_sync.rs — mostly internal glue, but the dmabuf/drm-sync FFI files border unsafe-ffi; confirm that surface's scope included them (they were not in its cited list of zerocopy/encode/capture).
Cross-cutting concerns no per-surface review can catch
X1 — Dependency / RUSTSEC posture (MED)
Cargo.toml is owned by no surface. Notable: rsa = "0.9" is subject to RUSTSEC-2023-0071 (Marvin timing side-channel) and is used directly by the GameStream RSA pairing ceremony — a network-adjacent oracle concern for gamestream/crypto.rs+pairing.rs. ureq = "2" backs the cover-art egress (G4). Run cargo audit against the workspace lock as a follow-up; per-surface reviewers won't.
X2 — Secret-file create→chmod TOCTOU across modules (LOW)
secrets-perms verifies final perms, but the create-then-restrict ordering window is implemented independently in gamestream/cert.rs, mgmt_token.rs, native_pairing.rs, and the captures/art writers (stats_recorder.rs, library.rs). A single helper vs N call-sites is a cross-module check: confirm every secret is created with restrictive perms atomically (O_CREAT mode), not world-readable-then-chmod, on every path including ones added since the prior audit.
X3 — On-disk capture / cache write paths (LOW)
stats_recorder.rs captures (~/.config/punktfunk/captures/*.json) and library.rs art cache are operator-readable artifacts; stats-capture covered the endpoints but confirm the filename derivation for saved captures can't be influenced by a network field (path traversal into the captures dir).
X4 — windows/install.rs driver/web install moved into host exe (MED — verify owned)
windows/install.rs + windows/interactive.rs should be under windows-service-priv, but given commit 125a51d is new, explicitly confirm that surface traced: the source of bundled driver paths (pnputil install), any download/verify of the web bundle, and that CreateProcessAsUserW/scheduled-task launch can't be redirected by an unprivileged local user (adversary #4) writing into a host-readable staging dir.
Net: G1 (mic decode → virtual mic) and G3 (main.rs default posture) are the most likely real-blind-spots; X1 (rsa 0.9 in GameStream pairing) is the cleanest cross-cutting follow-up.