ci / rust (push) Failing after 12s
windows-drivers / probe-and-proto (push) Successful in 48s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Failing after 10s
decky / build-publish (push) Successful in 47s
windows-drivers / driver-build (push) Successful in 1m40s
apple / swift (push) Successful in 3m6s
ci / bench (push) Successful in 7m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
android / android (push) Successful in 12m28s
deb / build-publish (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 12m31s
arch / build-publish (push) Successful in 12m40s
deb / build-publish-host (push) Successful in 12m17s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 23m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m24s
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>
101 lines
5.9 KiB
Markdown
101 lines
5.9 KiB
Markdown
# pf-gamepad — the virtual-gamepad UMDF2 HID minidriver
|
|
|
|
> Renamed from **pf-dualsense** (2026-07-28). One driver has always served four identities —
|
|
> DualSense, DualShock 4, DualSense Edge and Steam Deck — so the old name read as if the other three
|
|
> lived somewhere else. Only the PACKAGE identity moved (crate, INF, CAT, DLL, UMDF service); the
|
|
> four **hardware ids** (`pf_dualsense`, `pf_dualshock4`, `pf_dualsenseedge`, `pf_steamdeck`) are
|
|
> deliberately unchanged — they bind every devnode the host creates and every installed system.
|
|
> `driver install --gamepad` retires the pre-rename store package so the two can't both claim them.
|
|
|
|
A self-authored **Rust UMDF2 HID minidriver** that presents a virtual Sony **DualSense**
|
|
(VID `054C` / PID `0CE6`) to Windows, so games drive adaptive triggers / lightbar / rumble —
|
|
capabilities ViGEm structurally cannot deliver. It's how the punktfunk Windows host gives a client's
|
|
DualSense a near-native feel with **no external gamepad dependencies** (no ViGEmBus).
|
|
|
|
Shipping: the driver is one member of the in-tree driver workspace
|
|
([`packaging/windows/drivers/`](../../README.md)), built from source in CI, and bundled +
|
|
`pnputil`-installed by the Windows host [installer](../../README.md). The host feeds it over a shared
|
|
memory channel from `crates/punktfunk-host/src/inject/windows/dualsense_windows.rs`. The same UMDF driver also
|
|
serves the **DualShock 4** identity per a `device_type` byte the host stamps.
|
|
|
|
This README captures the driver-authoring lore — the bugs and the signing recipe that make a
|
|
self-signed UMDF HID driver actually load. The authoritative build/sign/package flow (CI + Inno Setup)
|
|
lives in the [Windows host packaging README](../../README.md).
|
|
|
|
## Build workspace
|
|
|
|
This crate builds as a member of the [`packaging/windows/drivers/`](../../drivers) workspace, which
|
|
uses the published **crates.io `wdk`/`wdk-sys`/`wdk-build`** (0.4/0.5) — not the old dev-box
|
|
`windows-drivers-rs` path-deps. It's a separate cargo workspace from the main tree because driver
|
|
crates are cdylibs built with the WDK toolchain on Windows only; it path-deps the shared ABI crate
|
|
[`crates/pf-driver-proto`](../../../../crates/pf-driver-proto/README.md).
|
|
|
|
## Build / sign / install recipe (the one that actually loads)
|
|
|
|
Prereqs on the Windows box: **WDK 26100**, **LLVM** (the current default; bindgen 0.72 builds on clang
|
|
22), Rust MSVC. Built as a member of the `packaging/windows/drivers/` workspace (plain `cargo build`, no
|
|
cargo-make). A self-signed CodeSigning cert in `CurrentUser\My` + `LocalMachine\Root` +
|
|
`TrustedPublisher`.
|
|
|
|
Every build needs:
|
|
|
|
```powershell
|
|
$env:LIBCLANG_PATH = 'C:\Program Files\LLVM\bin'
|
|
$env:Version_Number = '10.0.26100.0' # else wdk-build picks 10.0.28000.0 (no km/crt) and bindgen fails
|
|
```
|
|
|
|
The shipping flow is `build-gamepad-drivers.ps1` (one level up): workspace `cargo build --release`
|
|
plus the sign steps below, staged for the installer. The original manual dev-box recipe, kept as
|
|
lore (paths reflect that era's cargo-make layout):
|
|
|
|
```powershell
|
|
cargo make # -> target\debug\pf_gamepad_package\ (.inf/.cat/.dll)
|
|
|
|
# *** CRITICAL: clear the PE FORCE_INTEGRITY bit ***
|
|
# windows-drivers-rs links the DLL with /INTEGRITYCHECK, which forces a CI-trusted page-hash
|
|
# signature a self-signed cert cannot satisfy (CodeIntegrity 3004 "hash not found" /
|
|
# 3089 VerificationError 7). SudoVDA.dll (third-party VDD prior art, not used by punktfunk) has
|
|
# this bit OFF. Clear bit 0x80 at PE-header offset +0x5e:
|
|
$f = 'target\debug\pf_gamepad_package\pf_gamepad.dll'
|
|
$b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e
|
|
$dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F))
|
|
$b[$off]=$bb[0]; $b[$off+1]=$bb[1]; [IO.File]::WriteAllBytes($f,$b)
|
|
|
|
signtool sign /fd SHA256 /sha1 <cert-thumbprint> $f
|
|
Remove-Item target\debug\pf_gamepad_package\pf_gamepad.cat
|
|
Inf2Cat /driver:target\debug\pf_gamepad_package /os:10_x64
|
|
signtool sign /fd SHA256 /sha1 <cert-thumbprint> target\debug\pf_gamepad_package\pf_gamepad.cat
|
|
|
|
pnputil /add-driver target\debug\pf_gamepad_package\pf_gamepad.inf /install
|
|
devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node
|
|
```
|
|
|
|
`devgen` (under `Windows Kits\10\Tools\<ver>\x64\`) is only for manual testing — the shipping
|
|
install is `punktfunk-host.exe driver install --gamepad`, and the host SwDeviceCreate's the device
|
|
per session (no persistent devnode). SWD devgen devices clear on reboot. TODO: drop the post-build
|
|
PE patch by stopping wdk-build emitting `/INTEGRITYCHECK`.
|
|
|
|
## The three bugs that made it work (porting a WDK C sample to Rust)
|
|
|
|
`WDF_*_CONFIG_INIT` / `WDF_OBJECT_ATTRIBUTES_INIT` macros set **non-zero** defaults — `mem::zeroed()`
|
|
silently breaks them:
|
|
|
|
1. **FORCE_INTEGRITY** (above) — the load wall.
|
|
2. **Timer `ExecutionLevel`** — zeroed = Invalid → `WdfTimerCreate` 0xC0200209. Set
|
|
`ExecutionLevel/SynchronizationScope = InheritFromParent` + `AutomaticSerialization = TRUE`
|
|
(the working vhidmini2 shape).
|
|
3. **Queue `Settings.Parallel.NumberOfPresentedRequests`** — zeroed = 0 → a parallel queue presents
|
|
zero requests → `EvtIoDeviceControl` never fires → no HID handshake → ~5 s timeout →
|
|
`CM_PROB_FAILED_START`. Set to `u32::MAX`.
|
|
|
|
## Notes
|
|
|
|
- **Multi-pad** works via `UmdfHostProcessSharing=ProcessSharingDisabled` — each pad gets its own
|
|
WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the
|
|
device Location (`WdfDeviceAllocAndQueryProperty`) to poll its own `*-boot-<index>` bootstrap
|
|
mailbox (the DATA section itself is unnamed — the sealed pad channel,
|
|
punktfunk-planning: `gamepad-channel-sealing.md` — and its `pad_index` is validated against this
|
|
index on attach).
|
|
- Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature
|
|
blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/proto/dualsense_proto.rs`.
|