Commit Graph
3 Commits
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 e90c5d5bcd fix(gamepad): the output-report ring absorbs haptics-rate writers (PadShm v2.2)
A field log (2026-07-30) showed a game driving the virtual DualSense's output
endpoint at >2 kHz sustained for tens of seconds. The 8-slot ring — sized on
the assumption that 2 kHz is double any real HID output rate — overflowed
every 4 ms poll, which force-silenced rumble for each storm's whole duration
and flooded the log at ~230 WARN lines/s (96 % of the user's 5000-line
web-console export, evicting the session history it was needed to diagnose).

Three legs, negotiated so every old/new host×driver pairing keeps working:

- pf-driver-proto: the ring grows in place 8 -> 56 slots; PadShm becomes
  exactly one page (4096 B), the hard ceiling that keeps cross-generation
  section views mappable. A new out_ring_len field carries the driver's side
  of the length negotiation. Deliberately NOT a GAMEPAD_PROTO_VERSION bump
  (that fails closed - no pad at all).
- pf-gamepad driver: picks its ring length from the host's out_ring_ver
  stamp (>= 2 + a full-size map -> 56) and echoes it before every ring_head
  bump (now a Release store), so an Acquire-observing drain always reads the
  modulo that indexed the slots it copies.
- host drain: follows the echo (0 = old driver = 8); on genuine overflow it
  now salvages the legacy latest-report slot - the freshest coalesced state -
  instead of total silence, and the per-poll overflow WARN is rate-limited to
  1 line/s per pad with a suppressed count.

Verified on the Windows CI runner (drivers workspace build + clippy -D
warnings against the WDK; 64 pf-inject tests incl. the new negotiation/
salvage/limiter tests; pf-inject clippy -D warnings) and on Linux via the CI
docker image (82 pf-inject tests). DriverVer needs no manual bump - the
installer stamps a strictly-increasing build timestamp per release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:36:47 +02:00
enricobuehlerandClaude Opus 5 553676282a feat(windows): the brand reads Punktfunk on every device Windows shows
Device Manager, the firewall list and the monitor name all said "punktfunk". The brand
is Punktfunk.

Renamed: the [Strings] blocks of all four driver INFs (device descriptions, install
disks, provider, manufacturer), the `description` on every SwDeviceProfile the host
creates, pf-mouse's HID manufacturer + product strings, pf-vdisplay's IddCx endpoint
friendly + manufacturer names, the EDID 0xFC display-name descriptor — so Windows now
shows `Generic Monitor (Punktfunk)` — and the netsh firewall rule names.

The EDID edit is a single byte (0x70 -> 0x50) and needs no hand-patched checksum:
Edid::generate_with already recomputes both block checksums after patching the serial.

Deliberately left lowercase, because these are IDENTITIES rather than display names and
renaming them would orphan installed state:

  * the SwDeviceCreate enumerator `w!("punktfunk")` — it IS the SWD\PUNKTFUNK\... path
    every pad instance id is built from
  * pf-paths' `join("punktfunk")` — C:\ProgramData\punktfunk
  * the CN=punktfunk-driver cert subject, which purge_driver_certs and both driver build
    scripts match by string
  * install.rs' `lo.contains("punktfunk virtual display")` probes, whose haystack is
    to_ascii_lowercase()d, so they already match the capitalised name

Nothing is orphaned by the renames that DID happen either: netsh rule names,
Get-NetFirewallRule -DisplayName and PowerShell's -match are all case-insensitive, so
the firewall delete paths and reset-pf-vdisplay.ps1's -AdapterName / -GhostMatch
defaults still reap what every release up to 0.22.1 created.

Cosmetic, with two consequences worth knowing: it takes a driver rebuild + re-sign to
appear at all, and an existing devnode keeps its cached FriendlyName until it is
recreated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:30:46 +02:00
enricobuehlerandClaude Opus 5 560e663aef fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
A LocalService principal could take over a virtual pad's shared input section and
forge HID input into the interactive desktop.

The host duplicates each pad's unnamed DATA section into the driver's WUDFHost, and
through gamepad proto v2 it learned that process from `driver_pid` in the named
bootstrap mailbox. That mailbox has to be LocalService-writable — that is what the
driver's own WUDFHost runs as — and the delivery gate, verify_is_wudfhost, only checks
that the target's IMAGE is %SystemRoot%\System32\WUDFHost.exe. That image is
world-executable. So anything running as LocalService — notably the deliberately
de-privileged plugin runner — could spawn its own WUDFHost (CREATE_SUSPENDED parks it
indefinitely with the right image path), publish that pid, and be handed
SECTION_MAP_READ|WRITE on a live section. For pf-mouse that section drives a real
absolute pointer, so it was desktop control; for the pads it was forged gamepad input
plus a read of the remote user's controller state.

The module docs claimed mailbox tampering "yields at worst a gamepad DoS, never a read
or an injection". That was wrong, and the reasoning behind it — that a LocalService
token is DACL-denied OpenProcess on a UMDF WUDFHost — only covers the REAL host, not
one the attacker spawned itself.

The pid now comes from the device stack (ChannelProof, proto 2 -> 3). The host asks the
devnode it SwDeviceCreate'd who is serving it, looked up by the instance id PnP handed
back, so a planted look-alike devnode is not a candidate and the kernel — not anything
the attacker supplies — does the routing. Only the driver PnP actually bound to that
device can answer. `driver_pid` survives as a liveness hint; a tamperer can still deny a
pad, which squatting the name always allowed, but can no longer choose the recipient.
Two rules keep the state machine honest around it: a delivery stands until its target
process EXITS (judged on a retained SYNCHRONIZE handle, so a recycled pid cannot fake
it, and UMDF's restart-after-driver-crash still re-attaches), and a pad with no
SwDeviceCreate devnode refuses to deliver rather than fall back — unless an operator
sets PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX, which says so loudly.

Three transports, because Windows carries different things to different driver shapes,
and the obvious two did not survive contact with hidclass. Measured on .173 (Win11
26200): HidD_GetIndexedString is NOT forwarded to a UMDF HID minidriver at all — it
failed for every index including ones the driver demonstrably serves through the named
wrappers; and a private device interface registers and enumerates but cannot be OPENED
(ERROR_GEN_FAILURE), because hidclass owns IRP_MJ_CREATE on a devnode it is the FDO for.
That is exactly why pf-xusb was never affected: it is not a HID minidriver, so nothing
sits above it. What works:

  * pf-xusb   — a private IOCTL on its own GUID_DEVINTERFACE_XUSB.
  * pf-mouse  — the HID serial string. Verified: PFCP:3:0:7296, and 7296 was a genuine
                service-spawned WUDFHost.exe. Safe here alone: nothing reads the virtual
                mouse's serial, whereas a pad's is SDL/Steam dedup material.
  * pf-gamepad — a HID feature report, and it cost NO report-descriptor change. The
                captured descriptors already declare far more Feature ids than the driver
                ever served: 0x85 is declared on DualSense, DualShock 4 and Edge alike and
                used to fail with STATUS_INVALID_PARAMETER, so hidclass lets it through and
                nothing can have depended on the old failure. The Deck's one feature report
                is unnumbered and Steam drives it command->response, so its proof rides that
                existing contract via a private two-byte command. Verified: feature 0x85
                returned magic "PFCP", proto 3, pad_index 0, wudf_pid 18456 — and 18456 was
                a WUDFHost — with the product string still 'DualSense Wireless Controller'.

Also renamed pf-dualsense -> pf-gamepad. One driver has always served four identities, so
the old name read as if the other three lived elsewhere. ONLY the package identity moved
(crate, INF/CAT/DLL, UMDF service, build script, CI lines, log file, env var). The four
HARDWARE IDS are deliberately unchanged — they bind every devnode the host creates and
every installed system — as are the Global\pfds-boot-<i> mailbox and PAD_MAGIC, which are
wire contract. `driver install --gamepad` now retires the pre-rename store package first,
matched on pf_dualsense.dll because that string appears only in the OLD inf; matching on
the hardware ids would delete what we are about to install. On .173 that separated 14
stale packages from the 1 new one with 0 ambiguous, and the renamed package binds the old
hwid (devgen root\pf_dualsense -> oem143.inf = pf_gamepad.inf).

The repo's own pre-commit/pre-push rustfmt hooks named the old crate, so they caught the
rename before the commit did — they now check pf-gamepad, and pf-mouse alongside it, which
they had been missing relative to the CI line.

Host and drivers MUST ship together: v2<->v3 fails closed in both directions by design,
with the existing "update host + drivers together" diagnostic.

The rename moved files that also carry the security change, so splitting this into two
commits would mean reconstructing an intermediate state that was never gated. It is one
commit on purpose.

Gated on the windows-amd64 runner with cargo clean first (the box's clock lags, so stale
artifacts would read as a vacuous green): clippy -D warnings clean for pf-inject,
pf-capture and pf-driver-proto, drivers workspace build + the CI clippy line clean,
cargo check --release -p punktfunk-host clean, 19 + 58 tests green. Also fixes pf-mouse
still writing its debug log to world-writable C:\Users\Public, which the 2026-07-17
review moved for the other three drivers and missed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:54:40 +02:00