fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
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>
This commit is contained in:
2026-07-28 16:54:40 +02:00
co-authored by Claude Opus 5
parent 9b3ec9204c
commit 560e663aef
32 changed files with 1561 additions and 144 deletions
+2 -2
View File
@@ -153,9 +153,9 @@ jobs:
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
+25 -4
View File
@@ -245,10 +245,31 @@ impl Drop for KeyedMutexGuard<'_> {
/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's
/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a
/// tampered mailbox could name an arbitrary process to receive the channel, so this is the
/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL
/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid)
/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`);
/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
/// confused-deputy gate. `what` names the channel in the error (e.g. `"frame-channel"`); shared with
/// the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
///
/// # What this does and does NOT prove (security-review 2026-07-28)
///
/// It proves the target's image is the system WUDFHost binary. It does **not** prove the target is
/// *the* WUDFHost hosting our devnode, and it is not an authorization check: `WUDFHost.exe` is
/// world-executable, so anyone who can name a pid to a broker can first spawn their own copy (e.g.
/// `CREATE_SUSPENDED`, which parks it indefinitely with the right image path) and pass this check.
/// It therefore only screens out *non-WUDFHost* pids — an attacker exe, a stale/wrong devnode.
///
/// Whether that is sufficient depends entirely on who can name the pid, so it must be judged at each
/// caller, NOT here:
/// - **Frame channel** — sufficient. The pid is `AddReply::wudf_pid`, which the driver fills with its
/// own `GetCurrentProcessId()` and returns over the pf-vdisplay control device, whose DACL is
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)`. Only SYSTEM/Administrators can speak on that channel, and both are
/// out of scope by SECURITY.md.
/// - **Gamepad/mouse channel** — NOT sufficient on its own. The pid comes from a `Global\` mailbox
/// that LocalService can write, so this check does not identify the caller; see the corrected
/// analysis and the one-delivery rule in `pf-inject/src/inject/windows/gamepad_raii.rs`.
///
/// Do not add a token/session/account check here expecting it to fix the gamepad case: a genuine
/// UMDF host and a LocalService attacker's spawned WUDFHost are both session 0 and both LocalService,
/// so such checks discriminate nothing while risking a false negative that would silently kill
/// display capture and every virtual pad.
///
/// # Safety
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
+358 -11
View File
@@ -681,7 +681,7 @@ pub mod frame {
};
}
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_gamepad`).
///
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
@@ -699,12 +699,12 @@ pub mod gamepad {
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
pub const XUSB_MAGIC: u32 = 0x5558_4650;
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
/// Pad section magic — the exact u32 the shipped host + `pf_gamepad` driver compare (loosely
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
/// only the u32 value is the contract.)
pub const PAD_MAGIC: u32 = 0x5046_4453;
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
/// `device_type` selector the `pf_gamepad` driver reads to pick its HID identity. The section is
/// zeroed, so `0` = DualSense is the default; one driver serves every identity.
pub const DEVTYPE_DUALSENSE: u8 = 0;
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
@@ -730,7 +730,235 @@ pub mod gamepad {
/// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery.
/// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates
/// the mailbox a v2 driver polls, so a mixed pairing fails closed either way.
pub const GAMEPAD_PROTO_VERSION: u32 = 2;
///
/// v3: the **channel proof** ([`ChannelProof`]) — the host stopped trusting the mailbox's
/// `driver_pid` and now learns the duplication target over the DEVICE STACK instead. A v2 driver
/// answers no proof, so a v3 host refuses to deliver to it; a v2 host never asks, and a v3 driver
/// refuses the v2 handshake on the `host_proto` check. Mixed pairings fail closed both ways, with
/// the existing "update host + drivers together" diagnostic.
pub const GAMEPAD_PROTO_VERSION: u32 = 3;
// ── the channel proof (v3): who to hand the DATA section to ──────────────────────────────────
//
// WHY THIS EXISTS. Through v2 the host took the duplication target from the mailbox's
// `driver_pid`. The mailbox has to be openable by LocalService (that is what the driver's own
// WUDFHost runs as), and the delivery gate — `verify_is_wudfhost` — only checks that the named
// process's IMAGE is `%SystemRoot%\System32\WUDFHost.exe`, which is world-executable. So any
// LocalService principal, notably the deliberately de-privileged plugin runner, could spawn its
// own WUDFHost, publish that pid, and be handed the pad's DATA section: forged HID input into the
// interactive desktop (the mouse section drives a real absolute pointer) and a read of the remote
// user's controller state (security-review 2026-07-28).
//
// WHY IT HAS TO COME FROM THE DEVICE STACK. That race cannot be closed on the host side alone.
// Everything the real driver can read at LocalService — the devnode's Location, its
// `Device Parameters` key, the object namespace — an attacker at LocalService can read too, so no
// host-published secret tells the two apart. The ONE thing an attacker cannot forge is *being the
// driver bound to our devnode*: only that process answers I/O sent to the device the host itself
// created (and the host looks the device up by the instance id `SwDeviceCreate` handed back, so a
// planted look-alike devnode is not in the running). Asking the devnode "which process are you?"
// therefore yields a pid the host can trust, and the mailbox is demoted to what it always should
// have been: a rendezvous for a handle VALUE that is meaningless anywhere but in that process.
//
// Two transports, because the drivers are two different shapes:
// * `pf_xusb` is a plain UMDF2 driver that owns `GUID_DEVINTERFACE_XUSB` and dispatches its own
// IOCTLs -> [`IOCTL_PF_XUSB_GET_CHANNEL_PROOF`].
// * `pf_gamepad` / `pf_mouse` are HID minidrivers with no control device (hidclass owns the
// stack, and UMDF has no control-device objects), so the reachable read path is a HID string
// -> [`HID_STRING_INDEX_CHANNEL_PROOF`], which needs no report-descriptor change. That
// matters: the pads' descriptors, VID/PID and serials are what Steam and SDL fingerprint,
// and a new feature report there would risk the identity work this driver exists to get right.
/// Proof magic ("PFCP" — punktfunk channel proof), and the `PFCP` prefix of the text form.
pub const PROOF_MAGIC: u32 = 0x5043_4650;
/// Reserved HID string index the `pf_gamepad` / `pf_mouse` minidrivers answer with their
/// [`ChannelProof`], fetched by the host with `HidD_GetIndexedString`.
///
/// ⚠️ MEASURED UNUSABLE on .173 (Win11 26200): hidclass does not carry an arbitrary indexed-string
/// request to a UMDF HID minidriver — `HidD_GetIndexedString` failed for EVERY index, including
/// ones the driver demonstrably serves through the named wrappers. Kept because it costs one
/// failed IOCTL and is the right thing to ask first if a later Windows starts forwarding it; the
/// transports that actually work are [`PF_PAD_CONTROL_INTERFACE_GUID_U128`] (if hidclass lets it
/// through) and, for `pf_mouse`, the serial string ([`proof_is_serial_string`]).
///
/// 16-bit on purpose: both `IOCTL_HID_GET_INDEXED_STRING` and `IOCTL_HID_GET_STRING` pack their
/// argument as `(language_id << 16) | string_index`, so only the low word survives the trip and
/// both drivers mask before comparing. `0x5046` ("PF") is still far outside the 1..=255 range a
/// real USB/HID string-descriptor index can occupy, so it cannot collide with a string the OS, a
/// game, or Steam asks for.
pub const HID_STRING_INDEX_CHANNEL_PROOF: u32 = 0x5046;
// ❌ A private device interface (`WdfDeviceCreateDeviceInterface`) was tried here as a
// hidclass-independent transport for the HID minidrivers and MEASURED DEAD on .173 (Win11
// 26200): it registers and enumerates, but `CreateFile` on it is refused (ERROR_GEN_FAILURE)
// because hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for. Do not re-try it for
// `pf_gamepad`/`pf_mouse`; see `pf_umdf_util::hid` for the full measurement.
/// The proof question itself, on whichever interface carries it.
/// `CTL_CODE(0x8000, 0x0FE0, METHOD_BUFFERED, FILE_ANY_ACCESS)`: a function code no xusb22 IOCTL
/// uses, `METHOD_BUFFERED` so the answer is a plain buffer copy, and `FILE_ANY_ACCESS` so the host
/// can ask over a `CreateFile` handle opened with NO access rights (the same way it must open a
/// HID collection). Answering it leaks nothing — a pid is not a secret, and the proof is only
/// worth anything to a process that can already duplicate handles.
pub const IOCTL_PF_GET_CHANNEL_PROOF: u32 = 0x8000_3F80;
/// Whether a driver serves its channel proof AS its HID serial-number string.
///
/// The one transport measured to work against a UMDF HID minidriver today: on .173,
/// `HidD_GetSerialNumberString` succeeds on a zero-access handle and returns the driver's own
/// text, so a proof placed there reaches the host. Enabled for **`pf_mouse` only** — its serial
/// (`PFMOUSE00`) is inert, whereas the pads' serials are what SDL and Steam dedup controllers on,
/// and Steam is already known to mangle a pad's displayed name over serial FORMAT alone.
///
/// `pf_mouse` is also the one that matters most: its section drives a real absolute pointer, so a
/// hijacked mouse channel is desktop control, where a hijacked pad channel is gamepad input.
pub const fn proof_is_serial_string(pad_kind_is_mouse: bool) -> bool {
pad_kind_is_mouse
}
/// The feature report the **PS pad identities** (DualSense / DualShock 4 / Edge) answer the
/// channel proof on.
///
/// `0x85` is already DECLARED as a Feature report in all three captured descriptors and was
/// previously unserved — the driver failed it with `STATUS_INVALID_PARAMETER`. That is what makes
/// this transport free: **no report-descriptor change**, so the VID/PID, report layout, serial and
/// product strings that Steam and SDL fingerprint are untouched, and `HidD_GetFeature` is allowed
/// through by hidclass because the id is in the descriptor. Nothing can have depended on the old
/// failure: SDL reads `0x05`/`0x09`/`0x20` (DualSense) and `0x02`/`0x12`/`0xA3` (DS4); `0x85` is
/// one of Sony's vendor reports neither it nor Steam asks for.
pub const HID_FEATURE_REPORT_CHANNEL_PROOF: u8 = 0x85;
/// The Steam Deck identity's private proof command.
///
/// The Deck descriptor declares ONE unnumbered feature report and Steam drives it as a
/// command/response protocol (`0x83` GET_ATTRIBUTES, `0xAE` GET_STRING_ATTRIBUTE); the driver
/// echoes commands it doesn't know. So the proof rides that same contract — SET_FEATURE this
/// command, then GET_FEATURE the reply — again with no descriptor change. TWO bytes, not one, so
/// a Steam command byte we haven't catalogued can never be mistaken for it.
pub const DECK_PROOF_CMD: [u8; 2] = [0xF9, 0x50];
/// What a driver answers when the host asks, over the device stack, who it is.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct ChannelProof {
/// [`PROOF_MAGIC`].
pub magic: u32,
/// The driver's [`GAMEPAD_PROTO_VERSION`].
pub proto: u32,
/// The pad index the driver read from its devnode Location — cross-checked against the pad
/// the host is delivering, so a mis-resolved devnode can't cross-wire two pads.
pub pad_index: u32,
/// `GetCurrentProcessId()` of the driver's WUDFHost: the duplication target.
pub wudf_pid: u32,
}
impl ChannelProof {
/// This driver's answer. `pad_index` comes from the devnode Location, `wudf_pid` from
/// `GetCurrentProcessId()`.
pub fn new(pad_index: u32, wudf_pid: u32) -> ChannelProof {
ChannelProof {
magic: PROOF_MAGIC,
proto: GAMEPAD_PROTO_VERSION,
pad_index,
wudf_pid,
}
}
/// Validate an answer against the pad the host is actually delivering, yielding the pid to
/// duplicate into. `Err` carries the operator-facing reason — every rejection is a refusal to
/// deliver, so the host must be able to say precisely which check failed rather than falling
/// back to a pid it cannot trust.
pub fn check(&self, expect_pad_index: u32) -> Result<u32, &'static str> {
if self.magic != PROOF_MAGIC {
return Err(
"the devnode's answer is not a punktfunk channel proof (bad magic) — \
some other driver is bound to this device",
);
}
if self.proto != GAMEPAD_PROTO_VERSION {
return Err(
"the driver bound to this devnode speaks a different gamepad protocol \
— update the host and the drivers together",
);
}
if self.pad_index != expect_pad_index {
return Err(
"the devnode answered for a DIFFERENT pad index — the interface lookup \
resolved the wrong device",
);
}
if self.wudf_pid == 0 {
return Err("the driver reported pid 0");
}
Ok(self.wudf_pid)
}
/// The 16 wire bytes of the `pf_xusb` IOCTL answer. Offered here (rather than leaving each
/// side to reach for `bytemuck`) so the driver crates need no extra dependency and both
/// sides go through one length-checked pair with [`from_bytes`](Self::from_bytes).
pub fn to_bytes(self) -> [u8; 16] {
let mut out = [0u8; 16];
out.copy_from_slice(bytemuck::bytes_of(&self));
out
}
/// Parse [`to_bytes`](Self::to_bytes). `None` if the device returned fewer bytes than a whole
/// proof — a short read must refuse the delivery, never be zero-extended into a pid.
///
/// `pod_read_unaligned`, NOT `from_bytes`: the feature-report form offsets the proof by one
/// byte (the report id sits at 0), so the slice is not 4-aligned and `from_bytes` panics on
/// it. Device I/O buffers carry no alignment guarantee either.
pub fn from_bytes(b: &[u8]) -> Option<ChannelProof> {
(b.len() >= 16).then(|| bytemuck::pod_read_unaligned::<ChannelProof>(&b[..16]))
}
/// The proof as a HID **feature report** of exactly `len` bytes: `[report_id, proof(16), 0…]`.
/// A HID feature reply carries its report id in byte 0 and is sized by the descriptor, so the
/// driver pads to whatever length the caller's buffer declares. `None` if `len` cannot hold
/// the id plus a whole proof.
pub fn to_feature_report(self, report_id: u8, len: usize) -> Option<alloc::vec::Vec<u8>> {
if len < 17 {
return None;
}
let mut out = alloc::vec![0u8; len];
out[0] = report_id;
out[1..17].copy_from_slice(&self.to_bytes());
Some(out)
}
/// Parse [`to_feature_report`](Self::to_feature_report) — skips the leading report id.
pub fn from_feature_report(b: &[u8]) -> Option<ChannelProof> {
Self::from_bytes(b.get(1..)?)
}
/// Render as the ASCII text a HID indexed-string answer carries:
/// `PFCP:<proto>:<pad_index>:<wudf_pid>`. Text rather than the raw struct because
/// `HidD_GetIndexedString` is a string channel, and because a human reading a driver log or
/// poking the device with a HID inspector should be able to see what the pad answered.
pub fn to_hid_string(self) -> String {
alloc::format!("PFCP:{}:{}:{}", self.proto, self.pad_index, self.wudf_pid)
}
/// Parse [`to_hid_string`](Self::to_hid_string). `None` on ANY deviation — a foreign string
/// index answered, a truncated read, a non-decimal field, trailing junk — so a host that
/// cannot read a well-formed proof refuses to deliver instead of guessing at a pid.
pub fn from_hid_string(s: &str) -> Option<ChannelProof> {
let rest = s.strip_prefix("PFCP:")?;
let mut it = rest.split(':');
let proto = it.next()?.parse::<u32>().ok()?;
let pad_index = it.next()?.parse::<u32>().ok()?;
let wudf_pid = it.next()?.parse::<u32>().ok()?;
if it.next().is_some() {
return None; // trailing field: not a shape we minted
}
Some(ChannelProof {
magic: PROOF_MAGIC,
proto,
pad_index,
wudf_pid,
})
}
}
/// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a
/// driver only trusts a fully-initialized mailbox.
@@ -754,16 +982,22 @@ pub mod gamepad {
/// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order);
/// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and —
/// iff `host_proto` matches its own version — publishes `driver_pid`;
/// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA
/// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
/// 3. host asks the DEVNODE who the driver is ([`ChannelProof`]) — **not** the mailbox — verifies
/// that pid is a genuine WUDFHost, duplicates the unnamed DATA section into it, then writes
/// `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
/// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates
/// the mapped section's magic + `pad_index` before use.
///
/// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a
/// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService
/// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an
/// injection (it cannot place a valid section handle in the WUDFHost, and the driver's
/// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section).
/// **Trust boundary (v3).** This mailbox is writable by LocalService — it has to be, since that is
/// what the driver's own WUDFHost runs as — so NOTHING in it may decide where the DATA section
/// goes. Through v2 `driver_pid` did decide that, which was the security-review 2026-07-28 hole
/// (see [`ChannelProof`]); step 3 now sources the pid from the device stack and `driver_pid` is
/// advisory only — a liveness/diagnostic hint. What is left here is a handle VALUE, meaningless
/// outside the one process it was minted for, plus pids and version numbers, none of them secret.
/// A LocalService tamperer can therefore still deny a pad (overwrite the fields, squat the name)
/// but can no longer read or inject: it cannot place a valid section handle in the WUDFHost, the
/// driver's magic + `pad_index` validation rejects any handle that does not resolve to this pad's
/// section, and the delivery target is no longer its to choose.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct PadBootstrap {
@@ -942,6 +1176,12 @@ pub mod gamepad {
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
assert!(size_of::<OutSlot>() == 68);
assert!(size_of::<ChannelProof>() == 16);
assert!(offset_of!(ChannelProof, magic) == 0);
assert!(offset_of!(ChannelProof, proto) == 4);
assert!(offset_of!(ChannelProof, pad_index) == 8);
assert!(offset_of!(ChannelProof, wudf_pid) == 12);
assert!(size_of::<PadBootstrap>() == 32);
assert!(offset_of!(PadBootstrap, magic) == 0);
assert!(offset_of!(PadBootstrap, host_proto) == 4);
@@ -1486,4 +1726,111 @@ mod tests {
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA);
}
/// The channel proof is what the host trusts INSTEAD of the mailbox's `driver_pid`
/// (security-review 2026-07-28), so both wire forms — the `pf_xusb` IOCTL struct and the HID
/// indexed-string text the two minidrivers answer — have to survive a round trip byte-for-byte,
/// and every malformed shape has to be rejected rather than half-parsed into a pid the host
/// would then duplicate a live input section into.
#[test]
fn channel_proof_round_trips_in_both_wire_forms() {
use gamepad::*;
let proof = ChannelProof::new(2, 4242);
assert_eq!(proof.magic, PROOF_MAGIC);
assert_eq!(PROOF_MAGIC, u32::from_le_bytes(*b"PFCP"));
assert_eq!(proof.proto, GAMEPAD_PROTO_VERSION);
// XUSB IOCTL form: the raw 16-byte struct.
let bytes = bytemuck::bytes_of(&proof);
assert_eq!(bytes.len(), 16);
assert_eq!(*bytemuck::from_bytes::<ChannelProof>(bytes), proof);
// HID indexed-string form: the same four fields as text.
let s = proof.to_hid_string();
assert_eq!(s, alloc::format!("PFCP:{GAMEPAD_PROTO_VERSION}:2:4242"));
assert_eq!(ChannelProof::from_hid_string(&s), Some(proof));
// Every malformed shape parses to None — the host then refuses to deliver.
for bad in [
"",
"PFCP",
"PFCP:",
"PFCP:3:0", // truncated read
"PFCP:3:0:4242:9", // trailing field we never mint
"PFCP:3:0:-1", // not a u32
"PFCP:3:0:0x10", // not decimal
"PFCP:3:0: 4242", // whitespace is not trimmed away into a valid pid
"NOPE:3:0:4242", // another driver answered this string index
"pfcp:3:0:4242", // prefix is case-sensitive
] {
assert_eq!(
ChannelProof::from_hid_string(bad),
None,
"malformed proof {bad:?} must not parse"
);
}
}
/// `check` is the gate that decides whether a pid is allowed to receive a pad's whole input and
/// rumble surface, so pin each refusal: a foreign driver, a version skew, and — the one that
/// would silently cross-wire two live pads — an answer from the wrong devnode.
#[test]
fn channel_proof_check_refuses_everything_it_should() {
use gamepad::*;
assert_eq!(ChannelProof::new(0, 1234).check(0), Ok(1234));
assert_eq!(ChannelProof::new(3, 1234).check(3), Ok(1234));
// Right shape, WRONG pad: the interface lookup resolved another pad's devnode.
assert!(ChannelProof::new(1, 1234).check(0).is_err());
// A driver that isn't ours answered the reserved string index / IOCTL.
let mut foreign = ChannelProof::new(0, 1234);
foreign.magic = 0xDEAD_BEEF;
assert!(foreign.check(0).is_err());
// Version skew must fail closed, not "probably compatible".
let mut old = ChannelProof::new(0, 1234);
old.proto = GAMEPAD_PROTO_VERSION - 1;
assert!(old.check(0).is_err());
// pid 0 is never a duplication target.
assert!(ChannelProof::new(0, 0).check(0).is_err());
}
/// A v2 driver answers no proof at all and a v2 host never asks, so the version must have moved
/// — this is the tripwire that stops the two halves shipping out of step.
#[test]
fn gamepad_proto_is_at_the_channel_proof_version() {
assert_eq!(gamepad::GAMEPAD_PROTO_VERSION, 3);
}
/// The pad identities carry the proof in a HID FEATURE report — the transport chosen because
/// `0x85` is already declared in the captured descriptors, so nothing about the device's
/// Steam/SDL-visible identity changes. Pin the framing (report id in byte 0, proof in 1..17,
/// zero padding to the descriptor's length) and the short-read refusal.
#[test]
fn channel_proof_feature_report_round_trips_and_refuses_short_reads() {
use gamepad::*;
let proof = ChannelProof::new(1, 4242);
let rep = proof
.to_feature_report(HID_FEATURE_REPORT_CHANNEL_PROOF, 64)
.expect("64 bytes is plenty");
assert_eq!(rep.len(), 64);
assert_eq!(
rep[0], 0x85,
"byte 0 is the report id, as every HID feature reply is"
);
assert!(rep[17..].iter().all(|&b| b == 0), "tail is zero padding");
assert_eq!(ChannelProof::from_feature_report(&rep), Some(proof));
// Exactly big enough, and one byte too small.
assert!(proof.to_feature_report(0x85, 17).is_some());
assert!(proof.to_feature_report(0x85, 16).is_none());
// A truncated read must NOT be zero-extended into a pid.
assert_eq!(ChannelProof::from_feature_report(&rep[..16]), None);
assert_eq!(ChannelProof::from_feature_report(&[]), None);
// The Deck's private command is two bytes so a stray Steam command can't collide, and is
// distinct from the commands the driver already serves.
assert_eq!(DECK_PROOF_CMD.len(), 2);
assert!(!DECK_PROOF_CMD.starts_with(&[0x83]) && !DECK_PROOF_CMD.starts_with(&[0xAE]));
assert!(!DECK_PROOF_CMD.starts_with(&[0xEB]) && !DECK_PROOF_CMD.starts_with(&[0x8F]));
}
}
+4
View File
@@ -59,6 +59,10 @@ windows = { version = "0.62", features = [
"Win32_Devices_Enumeration_Pnp",
# SwDeviceCreate's SW_DEVICE_CREATE_INFO references DEVPROPKEY (Properties).
"Win32_Devices_Properties",
# The channel proof: HidD_GetIndexedString + GUID_DEVINTERFACE_HID (the pads/mouse leg) and
# CreateFileW to open the device interface the proof is asked over.
"Win32_Devices_HumanInterfaceDevice",
"Win32_Storage_FileSystem",
"Win32_System_Memory",
"Win32_System_IO",
"Win32_System_StationsAndDesktops",
@@ -551,7 +551,7 @@ pub fn deck_unit_id(index: u8) -> u32 {
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-gamepad lib.rs.)
pub fn deck_serial(index: u8) -> String {
format!("FVPF{:08X}", deck_unit_id(index))
}
@@ -0,0 +1,512 @@
//! Ask a devnode which process is serving it — the host half of
//! [`pf_driver_proto::gamepad::ChannelProof`].
//!
//! This is what the pad channel trusts instead of the bootstrap mailbox's `driver_pid`. The mailbox
//! has to be openable by LocalService (that is what the driver's own WUDFHost runs as), and the
//! delivery gate `verify_is_wudfhost` only checks that the named process's IMAGE is the system
//! `WUDFHost.exe` — which is world-executable. So through gamepad proto v2 any LocalService
//! principal, notably the deliberately de-privileged plugin runner, could spawn its own WUDFHost,
//! publish that pid, and be handed the pad's live DATA section: forged HID input into the interactive
//! desktop and a read of the remote user's controller state (security-review 2026-07-28).
//!
//! No host-side check could tell the two apart. Everything the real driver can read at LocalService —
//! the devnode's Location, its `Device Parameters` key, the object namespace — an attacker at
//! LocalService can read too, and both are session-0 LocalService processes running the same image,
//! so token, session and image checks all discriminate nothing. The one thing an attacker cannot
//! forge is **being the driver bound to the devnode we created**: I/O sent to that device reaches
//! only the driver PnP bound to it, and we look the device up by the instance id `SwDeviceCreate`
//! handed back, so a planted look-alike devnode is not in the running either.
//!
//! Three transports, one per driver shape — see [`ProofTransport`] for what each of them cost
//! and why the obvious ones did not survive contact with hidclass.
use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{
ChannelProof, DECK_PROOF_CMD, HID_FEATURE_REPORT_CHANNEL_PROOF, HID_STRING_INDEX_CHANNEL_PROOF,
IOCTL_PF_GET_CHANNEL_PROOF,
};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows::core::{GUID, PCWSTR};
use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_Get_Child, CM_Get_Device_IDW, CM_Get_Device_Interface_ListW,
CM_Get_Device_Interface_List_SizeW, CM_Get_Sibling, CM_Locate_DevNodeW,
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_LOCATE_DEVNODE_NORMAL, CR_SUCCESS,
};
use windows::Win32::Devices::HumanInterfaceDevice::{
HidD_GetFeature, HidD_GetIndexedString, HidD_GetProductString, HidD_GetSerialNumberString,
HidD_SetFeature, GUID_DEVINTERFACE_HID,
};
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
use windows::Win32::System::IO::DeviceIoControl;
/// `GUID_DEVINTERFACE_XUSB` {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — the interface `pf_xusb`
/// registers on its own devnode (and what `xinput1_4` enumerates).
const GUID_DEVINTERFACE_XUSB: GUID = GUID::from_u128(0xEC87F1E3_C13B_4100_B5F7_8B84D54260CB);
/// How a driver answers the proof — one variant per driver shape, because what Windows actually
/// carries to each shape differs (all three measured on .173 / Win11 26200, 2026-07-28).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProofTransport {
/// `pf_xusb`: a private IOCTL on `GUID_DEVINTERFACE_XUSB`, the interface it registers on its own
/// devnode. Works because it is NOT a HID minidriver — nothing sits above it, so it owns both
/// `IRP_MJ_CREATE` and its own IOCTL dispatch.
XusbIoctl,
/// `pf_mouse`: the proof arrives as the HID **serial-number string**. The one transport that
/// survived measurement against a UMDF HID minidriver — `HidD_GetSerialNumberString` on a
/// zero-access handle, verified end to end (`PFCP:3:0:7296`, and 7296 was a real
/// service-spawned `WUDFHost.exe`). Safe here because nothing reads the virtual mouse's serial.
HidSerialString,
/// `pf_gamepad` (DualSense / DualShock 4 / Edge / Deck): a HID **feature report**.
///
/// The pads cannot use the serial string — it is what SDL and Steam dedup controllers on, and
/// Steam is known to mangle a pad's displayed name over serial FORMAT alone. They get a feature
/// report instead, and it costs NO report-descriptor change, because the captured descriptors
/// already declare far more feature ids than the driver ever served: `0x85` is declared as a
/// Feature report on DualSense, DualShock 4 and Edge alike and used to fail with
/// `STATUS_INVALID_PARAMETER`. The Deck declares one UNNUMBERED feature report driven as
/// command→response, so its proof rides that existing contract via a private two-byte command.
HidFeatureReport,
}
/// Ask the devnode `instance_id` (as `SwDeviceCreate` reported it) which process serves it, and
/// return that pid — already checked against `expect_pad_index` and this build's protocol version.
///
/// Every failure is a refusal to deliver, so the errors say exactly which step failed: an operator
/// staring at a dead gamepad needs to know whether the devnode is missing, the interface has not
/// appeared yet, or a driver answered something we did not mint.
pub(super) fn query(
instance_id: &str,
transport: ProofTransport,
expect_pad_index: u32,
) -> Result<u32> {
let paths = match transport {
ProofTransport::XusbIoctl => interface_paths(&GUID_DEVINTERFACE_XUSB, instance_id)
.with_context(|| format!("enumerate the XUSB interface on {instance_id}"))?,
ProofTransport::HidSerialString | ProofTransport::HidFeatureReport => {
// hidclass publishes the collection interface on a CHILD PDO, not on our devnode.
let children = child_device_ids(instance_id)
.with_context(|| format!("enumerate the HID children of {instance_id}"))?;
let mut all = Vec::new();
for child in &children {
all.extend(interface_paths(&GUID_DEVINTERFACE_HID, child).unwrap_or_default());
}
all
}
};
if paths.is_empty() {
bail!(
"no device interface for {instance_id} yet — the driver has not finished starting (or \
is not bound to this devnode at all)"
);
}
// A devnode can publish more than one collection; ask each and take the first well-formed proof.
let mut last_err = None;
for path in &paths {
let r = match transport {
ProofTransport::XusbIoctl => ask_ioctl(path, expect_pad_index),
ProofTransport::HidSerialString => ask_hid_path(path, expect_pad_index),
ProofTransport::HidFeatureReport => ask_feature_path(path, expect_pad_index),
};
match r {
Ok(pid) => return Ok(pid),
Err(e) => last_err = Some(e.context(format!("ask {path}"))),
}
}
Err(last_err.unwrap_or_else(|| anyhow!("no interface answered a channel proof")))
}
/// Open `path` and put the proof IOCTL to it (the private pad-control interface, and `pf_xusb`'s own).
fn ask_ioctl(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_xusb(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// Open a HID collection and read the proof out of a FEATURE report — the pad transport.
fn ask_feature_path(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_feature(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// The PS identities answer on the declared-but-unserved report `0x85`; the Deck answers its
/// unnumbered report after a private SET_FEATURE command. Both are tried — one driver binary serves
/// four identities and the host does not know which one this devnode became until the DATA section
/// is attached, which is precisely what we are trying to earn the right to do.
fn ask_feature(h: HANDLE) -> Result<ChannelProof> {
// Feature buffers are sized by the descriptor; the largest of these reports is 64 bytes, and
// Windows accepts a buffer at least that big.
const BUF: usize = 64;
// PS identities: GET_FEATURE 0x85.
let mut buf = [0u8; BUF];
buf[0] = HID_FEATURE_REPORT_CHANNEL_PROOF;
// SAFETY: `h` is the live HID interface handle; `buf` is a valid BUF-sized in/out buffer.
if unsafe { HidD_GetFeature(h, buf.as_mut_ptr().cast(), BUF as u32) } {
if let Some(p) = ChannelProof::from_feature_report(&buf) {
return Ok(p);
}
}
// Deck: SET the private command, then GET the reply. Byte 0 is the (unnumbered) report id 0.
let mut cmd = [0u8; BUF];
cmd[1..1 + DECK_PROOF_CMD.len()].copy_from_slice(&DECK_PROOF_CMD);
// SAFETY: `h` is live; `cmd` is a valid BUF-sized buffer.
let set_ok = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), BUF as u32) };
if set_ok {
let mut reply = [0u8; BUF];
// SAFETY: as above.
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), BUF as u32) }
&& reply.starts_with(&DECK_PROOF_CMD)
{
if let Some(p) = ChannelProof::from_bytes(&reply[DECK_PROOF_CMD.len()..]) {
return Ok(p);
}
}
}
bail!(
"this HID collection carries no channel proof (feature 0x{:02x}: no; Deck command: {}) — \
the driver predates the proof (reinstall: punktfunk-host.exe driver install --gamepad)",
HID_FEATURE_REPORT_CHANNEL_PROOF,
if set_ok {
"no matching reply"
} else {
"SET_FEATURE failed"
},
)
}
/// Open a HID collection and read the proof out of a string.
fn ask_hid_path(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_hid(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// [`query`] for the `channel-proof-probe` subcommand — same call the delivery path makes, so the
/// probe reports the production answer rather than a re-implementation of it.
pub fn probe_pid(
instance_id: &str,
transport: ProofTransport,
expect_pad_index: u32,
) -> Result<u32> {
query(instance_id, transport, expect_pad_index)
}
/// A human-readable walk of the same lookup [`query`] does, reporting each step and — for the HID
/// leg — which of the two IOCTLs Windows actually forwarded to the minidriver.
///
/// This exists because exactly one thing in this design could not be settled by reading: whether
/// hidclass forwards `IOCTL_HID_GET_INDEXED_STRING` (and/or an arbitrary-index `IOCTL_HID_GET_STRING`)
/// down to a UMDF HID minidriver. Both are wired up so the answer only has to be "at least one", but
/// a maintainer should be able to find out which on a real box in one command rather than by
/// inference — hence `punktfunk-host channel-proof-probe`.
pub fn diagnose(instance_id: &str, transport: ProofTransport, expect_pad_index: u32) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "devnode : {instance_id}");
let _ = writeln!(
out,
"transport: {transport:?}, expected pad index {expect_pad_index}"
);
let paths = match transport {
ProofTransport::XusbIoctl => match interface_paths(&GUID_DEVINTERFACE_XUSB, instance_id) {
Ok(p) => p,
Err(e) => {
let _ = writeln!(out, " XUSB interface lookup FAILED: {e:#}");
return out;
}
},
ProofTransport::HidSerialString | ProofTransport::HidFeatureReport => {
let children = child_device_ids(instance_id).unwrap_or_default();
let _ = writeln!(out, " hidclass children: {}", children.len());
let mut all = Vec::new();
for c in &children {
let p = interface_paths(&GUID_DEVINTERFACE_HID, c).unwrap_or_default();
let _ = writeln!(out, " {c} -> {} HID interface(s)", p.len());
all.extend(p);
}
all
}
};
if paths.is_empty() {
let _ = writeln!(
out,
" NO device interface — the driver has not started, or is not bound to this devnode"
);
return out;
}
for path in &paths {
let _ = writeln!(out, " interface {path}");
let handle = match open_device(path) {
Ok(h) => h,
Err(e) => {
let _ = writeln!(out, " open FAILED: {e:#}");
continue;
}
};
let h = HANDLE(handle.as_raw_handle());
match transport {
ProofTransport::XusbIoctl => match ask_xusb(h) {
Ok(p) => {
let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF -> {p:?}");
let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index));
}
Err(e) => {
let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF FAILED: {e:#}");
}
},
ProofTransport::HidFeatureReport => match ask_feature(h) {
Ok(p) => {
let _ = writeln!(out, " feature proof -> {p:?}");
let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index));
}
Err(e) => {
let _ = writeln!(out, " feature proof FAILED: {e:#}");
}
},
ProofTransport::HidSerialString => {
// Report each path separately — this is the whole point of the probe.
let (indexed, serial, control) = ask_hid_both(h);
let _ = writeln!(out, " HidD_GetSerialNumberString -> {serial}");
let _ = writeln!(out, " HidD_GetIndexedString(proof) -> {indexed}");
let _ = writeln!(out, " HidD_GetProductString -> {control}");
}
}
}
out
}
/// The probe's raw material: the proof index alongside a KNOWN-GOOD control, so a failure can be
/// told apart from "user mode cannot reach this driver at all".
///
/// The control is `HidD_GetProductString`, which on-glass succeeds against a UMDF HID minidriver on
/// the very same 0-access handle where `HidD_GetIndexedString` fails for every index — the
/// measurement that established the indexed-string transport is unusable (see [`ask_hid`]).
fn ask_hid_both(h: HANDLE) -> (String, String, String) {
let text = |ok: bool, buf: &[u16]| -> String {
if !ok {
let e = std::io::Error::last_os_error();
return format!("call failed ({e})");
}
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf16_lossy(&buf[..end])
};
let mut a = [0u16; 128];
let bytes = (a.len() * 2) as u32;
// SAFETY: `h` is the live HID interface handle; `a` is a valid `bytes`-sized out-buffer.
let ok_a = unsafe {
HidD_GetIndexedString(
h,
HID_STRING_INDEX_CHANNEL_PROOF,
a.as_mut_ptr().cast(),
bytes,
)
};
let indexed = match (ok_a, decode_proof(&a)) {
(true, Some(p)) => format!("{p:?}"),
(true, None) => format!("answered, but not a proof: {:?}", text(true, &a)),
(false, _) => text(false, &a),
};
let mut c = [0u16; 128];
// SAFETY: as above — live handle, valid out-buffer.
let ok_c = unsafe { HidD_GetSerialNumberString(h, c.as_mut_ptr().cast(), bytes) };
let serial = match (ok_c, decode_proof(&c)) {
(true, Some(p)) => format!("{p:?}"),
(true, None) => format!("plain serial, no proof: {:?}", text(true, &c)),
(false, _) => text(false, &c),
};
let mut b = [0u16; 128];
// SAFETY: as above — live handle, valid out-buffer.
let ok_b = unsafe { HidD_GetProductString(h, b.as_mut_ptr().cast(), bytes) };
let control = format!(
"{} (control: proves user mode reaches this driver)",
text(ok_b, &b)
);
(indexed, serial, control)
}
/// `CreateFileW` with **no** access rights: enough to send `FILE_ANY_ACCESS` IOCTLs and to run the
/// `HidD_*` helpers, and the only thing that works on a HID mouse/keyboard collection, which Windows
/// refuses to open for read from user mode.
fn open_device(path: &str) -> Result<OwnedHandle> {
let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: `wide` is a valid NUL-terminated UTF-16 path for the duration of the call; the returned
// handle is owned solely by the `OwnedHandle` built from it.
let h = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)
.with_context(|| format!("CreateFileW({path})"))?
};
// SAFETY: `h` is the fresh handle just opened, moved into a single owner that closes it on drop.
Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) })
}
/// `pf_xusb`: a private METHOD_BUFFERED IOCTL returning the 16 proof bytes.
fn ask_xusb(h: HANDLE) -> Result<ChannelProof> {
let mut buf = [0u8; 16];
let mut returned = 0u32;
// SAFETY: `h` is the live interface handle; `buf` is a valid 16-byte out-buffer and `returned`
// a valid out-param. METHOD_BUFFERED, so the I/O manager copies — no pointer escapes.
unsafe {
DeviceIoControl(
h,
IOCTL_PF_GET_CHANNEL_PROOF,
None,
0,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
Some(&mut returned),
None,
)
.context("IOCTL_PF_GET_CHANNEL_PROOF")?;
}
ChannelProof::from_bytes(&buf[..returned as usize]).ok_or_else(|| {
anyhow!("the XUSB interface returned {returned} bytes, too short for a channel proof")
})
}
/// `pf_gamepad` / `pf_mouse`: the reserved HID string index.
///
/// ⚠️ **ON-HARDWARE FINDING, .173 (Win11 26200), 2026-07-28 — this transport DOES NOT WORK.**
/// Probed against a live `pf_mouse` devnode with the installed driver: on the SAME 0-access handle,
/// `HidD_GetManufacturerString` / `GetProductString` / `GetSerialNumberString` all succeeded and
/// returned the driver's OWN strings (so user mode does reach a UMDF HID minidriver, and hidclass
/// does translate the named string IOCTLs down to its `IOCTL_HID_GET_STRING` handler) — while
/// `HidD_GetIndexedString` failed for EVERY index tried: 1, 2, 4, 0x0E and 0x5046. Indices 2 and
/// 0x0E are ones the driver demonstrably serves through the named wrappers, so this is not our
/// reserved index being out of range: hidclass does not carry an arbitrary indexed-string request to
/// a UMDF HID minidriver at all.
///
/// The call is kept because it costs one failed IOCTL and is the correct thing to ask first if a
/// later Windows starts forwarding it. What was REMOVED here is a second "fallback" that issued
/// `IOCTL_HID_GET_STRING` directly: that IOCTL is METHOD_NEITHER and **kernel-facing** — hidclass
/// sends it DOWN to the minidriver, user mode never sends it up — so it could not have worked, and
/// on-glass it returned `ERROR_INVALID_FUNCTION` for a control index the device definitely serves.
/// Do not re-add it.
///
/// The HID pads/mouse therefore still need a working proof transport; see the module docs.
fn ask_hid(h: HANDLE) -> Result<ChannelProof> {
let mut buf = [0u16; 128];
let bytes = (buf.len() * 2) as u32;
// SAFETY: `h` is the live HID interface handle; `buf` is a valid `bytes`-sized out-buffer.
if unsafe { HidD_GetSerialNumberString(h, buf.as_mut_ptr().cast(), bytes) } {
if let Some(p) = decode_proof(&buf) {
return Ok(p);
}
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
bail!(
"this HID collection's serial is {:?}, not a channel proof — an old driver is installed \
(reinstall: punktfunk-host.exe driver install --gamepad)",
String::from_utf16_lossy(&buf[..end])
);
}
bail!("HidD_GetSerialNumberString failed on this HID collection")
}
/// Decode a NUL-terminated UTF-16 HID string answer into a proof.
fn decode_proof(buf: &[u16]) -> Option<ChannelProof> {
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let s = String::from_utf16(&buf[..end]).ok()?;
ChannelProof::from_hid_string(&s)
}
/// Every present device-interface path of `class` on `device_id`.
fn interface_paths(class: &GUID, device_id: &str) -> Result<Vec<String>> {
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
let mut len = 0u32;
// SAFETY: `len` is a valid out-param; `wide` is a NUL-terminated id valid for the call.
let cr = unsafe {
CM_Get_Device_Interface_List_SizeW(
&mut len,
class,
PCWSTR(wide.as_ptr()),
CM_GET_DEVICE_INTERFACE_LIST_PRESENT,
)
};
if cr != CR_SUCCESS || len == 0 {
return Ok(Vec::new());
}
let mut buf = vec![0u16; len as usize];
// SAFETY: `buf` is `len` UTF-16 units as the size call just reported; same id/class as above.
let cr = unsafe {
CM_Get_Device_Interface_ListW(
class,
PCWSTR(wide.as_ptr()),
&mut buf,
CM_GET_DEVICE_INTERFACE_LIST_PRESENT,
)
};
if cr != CR_SUCCESS {
bail!("CM_Get_Device_Interface_ListW failed (CONFIGRET {})", cr.0);
}
// A REG_MULTI_SZ-shaped list: NUL-separated, double-NUL terminated.
Ok(buf
.split(|&c| c == 0)
.filter(|s| !s.is_empty())
.map(String::from_utf16_lossy)
.collect())
}
/// The instance ids of `instance_id`'s immediate children — for a HID minidriver, the collection
/// PDOs hidclass created under our devnode.
fn child_device_ids(instance_id: &str) -> Result<Vec<String>> {
let wide: Vec<u16> = instance_id
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut devinst = 0u32;
// SAFETY: `devinst` is a valid out-param; `wide` is a NUL-terminated id valid for the call.
let cr = unsafe {
CM_Locate_DevNodeW(
&mut devinst,
PCWSTR(wide.as_ptr()),
CM_LOCATE_DEVNODE_NORMAL,
)
};
if cr != CR_SUCCESS {
bail!(
"CM_Locate_DevNodeW({instance_id}) failed (CONFIGRET {})",
cr.0
);
}
let mut out = Vec::new();
let mut child = 0u32;
// SAFETY: `devinst` is the devnode just located; `child` is a valid out-param.
if unsafe { CM_Get_Child(&mut child, devinst, 0) } != CR_SUCCESS {
return Ok(out); // no children yet — hidclass has not enumerated the collections
}
loop {
let mut buf = [0u16; 512];
// SAFETY: `child` is a live devnode handle from CM_Get_Child/CM_Get_Sibling; `buf` is a
// valid out-buffer whose length the binding passes along.
if unsafe { CM_Get_Device_IDW(child, &mut buf, 0) } == CR_SUCCESS {
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
out.push(String::from_utf16_lossy(&buf[..end]));
}
let mut next = 0u32;
// SAFETY: as above — `child` is live, `next` is a valid out-param.
if unsafe { CM_Get_Sibling(&mut next, child, 0) } != CR_SUCCESS {
break;
}
child = next;
}
Ok(out)
}
@@ -1,4 +1,4 @@
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`).
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-gamepad`).
//!
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
@@ -37,7 +37,7 @@ use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
/// asserts pin every field; the `pf_gamepad` driver maps the same struct). Derive the size/offsets/magic
/// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
/// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
@@ -385,7 +385,7 @@ impl WinDsIdentity {
WinDsIdentity {
devtype: 0,
instance_prefix: "pf_pad",
hwid: "pf_dualsense",
hwid: "pf_gamepad",
usb_vid_pid: "VID_054C&PID_0CE6",
description: "punktfunk Virtual DualSense",
}
@@ -444,6 +444,13 @@ impl DsWinPad {
(None, None)
}
};
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
// the LocalService-writable mailbox names (security-review 2026-07-28).
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery so the driver holds the DATA section before hidclass asks it for
// descriptors (the driver reads `device_type` from the section to pick its HID identity).
@@ -453,8 +460,8 @@ impl DsWinPad {
channel,
attach: super::gamepad_raii::DriverAttach::new(
id.hwid,
"pf_dualsense.inf", // one driver package serves every PS identity
"C:\\Users\\Public\\pfds-driver.log",
"pf_gamepad.inf", // one driver package serves every PS identity
"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
boot_name,
instance_id,
),
@@ -472,7 +479,7 @@ impl DsWinPad {
serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the
// XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect
// field to publish last: the `pf_dualsense` driver streams the whole `input` region to game
// field to publish last: the `pf_gamepad` driver streams the whole `input` region to game
// READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report)
// is consumed by the game's HID stack, not the driver — so it cannot serve as a separable
// publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout +
@@ -622,7 +629,7 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("pf_deckspike_{index}");
let (hsw, _) = create_swdevice(&SwDeviceProfile {
let (hsw, spike_instance_id) = create_swdevice(&SwDeviceProfile {
instance: &inst,
container_tag: 0x5046_4453, // "PFDS"
container_index: index,
@@ -633,6 +640,13 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
usb_mi: Some(2),
description: "punktfunk Virtual Steam Deck (spike)",
})?;
// The spike drives a real pad channel, so it takes the same devnode-proved delivery a session
// pad does — no special case, and no reason for a bring-up tool to run on the old trust.
channel.bind_devnode(
index as u32,
spike_instance_id,
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = super::gamepad_raii::SwDevice::new(hsw);
channel.deliver_eager(std::time::Duration::from_millis(1500));
println!(
@@ -77,6 +77,13 @@ impl Ds4WinPad {
// `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to
// self-heal a transient PnP failure never retried. The game saw no controller for the whole
// session unless the client unplugged the pad. Matches the XUSB sibling, which propagates.
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
// the LocalService-writable mailbox names (security-review 2026-07-28).
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for
@@ -87,8 +94,8 @@ impl Ds4WinPad {
channel,
attach: super::gamepad_raii::DriverAttach::new(
"pf_dualshock4",
"pf_dualsense.inf", // one driver package serves both HID identities
"C:\\Users\\Public\\pfds-driver.log",
"pf_gamepad.inf", // one driver package serves both HID identities
"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
boot_name,
instance_id,
),
@@ -1,7 +1,7 @@
//! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 /
//! XUSB backends).
//!
//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb`
//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_gamepad`/`pf_xusb`
//! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox
//! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the
//! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources
@@ -15,11 +15,44 @@
//! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad
//! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the
//! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the
//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and
//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a
//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too
//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right).
//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left.
//!
//! **What the mailbox is worth to an attacker** (corrected, security-review 2026-07-28). It carries
//! pids and a handle VALUE, and a handle value IS meaningless outside its process — but the mailbox
//! also *chooses that process*, and it is writable by LocalService (the SDDL the driver's WUDFHost
//! needs to open it by name). The delivery gate, [`pf_capture::verify_is_wudfhost`], authenticates an
//! IMAGE PATH, and `%SystemRoot%\System32\WUDFHost.exe` is world-executable: a LocalService principal
//! — the de-privileged plugin runner, or any other compromised LocalService service — can spawn its
//! own suspended WUDFHost, publish that pid, and be handed `SECTION_MAP_READ|WRITE` on this pad's DATA
//! section. That is forged HID input into the interactive desktop (the pf-mouse section drives a real
//! absolute pointer) and a read of the remote user's live controller state — so the earlier claim that
//! mailbox tampering "yields at worst a gamepad DoS, never a read or an injection" was wrong. (The
//! frame channel is NOT exposed this way: its WUDFHost pid arrives over the pf-vdisplay control
//! device, whose DACL is SYSTEM + Administrators.)
//!
//! **How v3 closes it.** The mailbox no longer decides anything. [`PadChannel::pump`] asks the
//! DEVNODE which process is serving it — [`channel_proof`], the host half of
//! `pf_driver_proto::gamepad::ChannelProof` — and duplicates into that answer. Only the driver PnP
//! actually bound to the device we `SwDeviceCreate`d can answer that device's I/O, and the lookup is
//! keyed by the instance id PnP handed back, so neither a spawned WUDFHost nor a planted look-alike
//! devnode is in the running. `driver_pid` survives as a liveness hint and nothing more; a tamperer
//! can still deny a pad, but denial was always available (squat the name) and is not what mattered.
//!
//! Two further rules make the state machine hold up around it:
//! * **One live target.** A delivery stands until its target process EXITS, judged on a retained
//! `SYNCHRONIZE` handle so a recycled pid cannot fake it. UMDF's genuine
//! restart-the-host-after-a-driver-crash path still re-attaches; nothing else displaces a live one.
//! * **No unproved delivery.** A pad with no `SwDeviceCreate` devnode (the out-of-band `devgen`
//! bring-up path) has nothing to ask, so it refuses to deliver rather than fall back to the
//! mailbox — unless an operator sets [`TRUST_MAILBOX_ENV`], which says so loudly in the log.
//!
//! What is NOT claimed: this authenticates the *devnode*, not the driver binary. An attacker who can
//! already replace the installed, signed driver package owns the endpoint by definition — that is the
//! same floor the frame channel accepts, and it sits above the SECURITY.md admin/SYSTEM boundary.
use super::channel_proof;
/// Re-exported so a pad backend needs only one `use` to wire up its channel.
pub(super) use super::channel_proof::ProofTransport;
use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
use std::ffi::c_void;
@@ -35,7 +68,7 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::{
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR,
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WIN32_ERROR,
};
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
@@ -46,7 +79,8 @@ use windows::Win32::System::Memory::{
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
use windows::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, SetEvent, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
GetCurrentProcess, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_DUP_HANDLE,
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE,
};
/// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
@@ -223,28 +257,80 @@ impl Drop for Shm {
/// seq twice. Starts at 1.
static BOOT_SEQ: AtomicU32 = AtomicU32::new(1);
/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a
/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment).
/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing —
/// the WUDFHost dies with the devnode).
/// Hard cap on FAILED delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so
/// a tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment).
/// Only failures spend this budget — a SUCCESSFUL delivery stands until its target process exits
/// ([`PadChannel::delivered`]), so what is left here is purely the retry allowance for a driver that
/// published a pid and then died before we could reach it.
const MAX_DELIVERY_ATTEMPTS: u32 = 16;
/// How often the delivery state machine may ask the devnode for its channel proof while unattached.
/// The proof query opens a device interface and does real I/O, and the pad service pump ticks every
/// few milliseconds — without this the poll would be a hot loop on the HID stack. A driver takes tens
/// of milliseconds to publish its interface after `SwDeviceCreate`, so a quarter second costs nothing
/// perceptible at pad open and keeps the steady-state cost at zero (once delivered, nothing polls).
const PROOF_PROBE_INTERVAL: Duration = Duration::from_millis(250);
/// Operator escape hatch for the one case the device stack cannot answer: an **out-of-band devnode**
/// (`devgen`/`devcon`, the driver bring-up path) where `SwDeviceCreate` never ran, so the host has no
/// instance id to look an interface up by. Set it and the channel falls back to the pre-v3 behaviour
/// of trusting the mailbox's `driver_pid`, which is exactly the trust the security review removed —
/// hence opt-in, per-boot, and loud in the log. Never needed for a normal host: every pad and the
/// resident mouse are `SwDeviceCreate`d.
const TRUST_MAILBOX_ENV: &str = "PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX";
/// Consecutive unanswered proof queries before the debug line escalates to one operator-facing warn.
/// At [`PROOF_PROBE_INTERVAL`] this is ~5 s — comfortably past a healthy driver's attach (tens of
/// milliseconds) and past the eager window, so it only fires when the pad really is not coming up.
const PROOF_FAILURES_BEFORE_WARN: u32 = 20;
/// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the
/// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's
/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown —
/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent
/// (out-of-band-devnode) driver detects the host is gone).
/// WUDFHost a duplicated DATA handle. Owns both sections (RAII teardown — dropping the channel closes
/// the mailbox, whose *name* then disappears, which is how a persistent (out-of-band-devnode) driver
/// detects the host is gone).
pub(super) struct PadChannel {
data: Shm,
boot: Shm,
boot_name: String,
/// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed
/// verify can't be spun into a hot loop by a static mailbox.
/// The devnode to ask for a channel proof, and how ([`Self::bind_devnode`]). `None` until the
/// caller has a `SwDeviceCreate` instance id — and permanently `None` on the out-of-band devnode
/// path, which is what [`TRUST_MAILBOX_ENV`] exists for.
devnode: Option<(String, ProofTransport)>,
/// The pad index the proof must agree with, so a mis-resolved interface can't cross-wire two pads.
pad_index: u32,
/// When the devnode was last asked (throttle — see [`PROOF_PROBE_INTERVAL`]).
last_probe: Option<Instant>,
/// Last pid acted on (delivered or rejected) — never retry the same value, so a failed verify
/// can't be spun into a hot loop.
last_seen_pid: u32,
attempts: u32,
delivered: bool,
/// The WUDFHost the DATA section was duplicated into. `Some` ⇒ this channel is spoken for and no
/// other process is served while that one lives (see [`Self::pump`]).
delivered: Option<Delivered>,
warned_proto: bool,
warned_cap: bool,
warned_takeover: bool,
warned_unproven: bool,
proof_failures: u32,
}
/// A completed delivery: the WUDFHost we handed the DATA section to, pinned by a live handle.
/// Liveness is judged on the HANDLE, never the pid — the handle pins the process object, so a
/// recycled pid can never read as "the process we served has exited".
struct Delivered {
pid: u32,
/// `SYNCHRONIZE` handle to the target; signaled ⇔ it exited. Same liveness idiom the frame
/// channel's `ChannelBroker::driver_alive` uses.
process: OwnedHandle,
}
impl Delivered {
fn exited(&self) -> bool {
// SAFETY: `process` is the live `OwnedHandle` this channel owns (borrowed for this
// synchronous call); a 0 ms wait only reads the handle's signaled state.
unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) == WAIT_OBJECT_0 }
}
}
impl PadChannel {
@@ -273,11 +359,17 @@ impl PadChannel {
data,
boot,
boot_name,
devnode: None,
pad_index: 0,
last_probe: None,
last_seen_pid: 0,
attempts: 0,
delivered: false,
delivered: None,
warned_proto: false,
warned_cap: false,
warned_takeover: false,
warned_unproven: false,
proof_failures: 0,
})
}
@@ -299,11 +391,28 @@ impl PadChannel {
unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) }
}
/// Bind this channel to the devnode the caller just `SwDeviceCreate`d, so [`Self::pump`] can ask
/// it for a channel proof. Call between `create_swdevice` and [`Self::deliver_eager`].
///
/// `instance_id` is `None` when `SwDeviceCreate` failed and the caller fell back to an
/// out-of-band (`devgen`) devnode: there is then no device to ask, and the channel will refuse to
/// deliver unless [`TRUST_MAILBOX_ENV`] is set.
pub(super) fn bind_devnode(
&mut self,
pad_index: u32,
instance_id: Option<String>,
transport: ProofTransport,
) {
self.pad_index = pad_index;
self.devnode = instance_id.map(|id| (id, transport));
}
/// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms
/// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads.
/// cadence) and from [`Self::deliver_eager`]. Cheap when idle: an atomic load, and once the
/// channel is delivered, one 0 ms wait.
pub(super) fn pump(&mut self) {
// Version diagnostics: the driver writes its own proto version even when it refuses to
// publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches.
// Version diagnostics: the driver writes its own proto version even when it refuses the
// handshake (host/driver mismatch), so the operator sees WHY the pad never attaches.
let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto));
if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto {
self.warned_proto = true;
@@ -315,27 +424,56 @@ impl PadChannel {
drivers: punktfunk-host.exe driver install --gamepad"
);
}
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
if pid == 0 || pid == self.last_seen_pid {
return;
// A delivery stands until its target process dies. Re-delivering to a *different* live
// process was the pre-v3 takeover (see the module docs); UMDF restarting a host whose driver
// crashed is the one legitimate case, and that one is visible here as an exited handle.
if let Some(d) = self.delivered.as_ref() {
if !d.exited() {
return;
}
tracing::info!(
mailbox = %self.boot_name,
exited_pid = d.pid,
"the WUDFHost this channel was delivered to has exited (driver crash / host \
restart) re-attaching to the restarted driver"
);
self.delivered = None;
self.attempts = 0; // a genuine restart earns a fresh budget
self.last_seen_pid = 0;
self.last_probe = None;
}
self.last_seen_pid = pid;
if self.attempts >= MAX_DELIVERY_ATTEMPTS {
if !self.warned_cap {
self.warned_cap = true;
tracing::warn!(
mailbox = %self.boot_name,
attempts = self.attempts,
"gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \
its driver pid (tampering?); no further handles will be duplicated"
"gamepad channel delivery cap reached — no further handles will be duplicated"
);
}
return;
}
// Throttle: asking costs a device open + an IOCTL, and this runs on the pad service pump.
if self
.last_probe
.is_some_and(|t| t.elapsed() < PROOF_PROBE_INTERVAL)
{
return;
}
self.last_probe = Some(Instant::now());
let Some(pid) = self.resolve_driver_pid() else {
return;
};
if pid == self.last_seen_pid {
return; // already tried this one and it failed — don't spin on it
}
self.last_seen_pid = pid;
self.attempts += 1;
match self.deliver_to(pid) {
Ok(seq) => {
self.delivered = true;
Ok((seq, process)) => {
self.delivered = Some(Delivered { pid, process });
tracing::info!(
mailbox = %self.boot_name,
wudf_pid = pid,
@@ -349,24 +487,111 @@ impl PadChannel {
mailbox = %self.boot_name,
pid,
error = %format!("{e:#}"),
"sealed gamepad channel delivery failed — will retry when the mailbox reports \
a different driver pid"
"sealed gamepad channel delivery failed"
);
}
}
}
/// Which process to hand this pad's DATA section to.
///
/// The answer comes from the DEVNODE ([`channel_proof`]), never from the bootstrap mailbox. That
/// is the whole security property: the mailbox is writable by LocalService, so anything in it —
/// including `driver_pid` — is attacker-choosable, whereas only the driver PnP actually bound to
/// the device we created can answer that device's I/O. See the module docs for the attack this
/// closes.
fn resolve_driver_pid(&mut self) -> Option<u32> {
let Some((instance_id, transport)) = self.devnode.clone() else {
return self.unproven_mailbox_pid("this pad has no SwDeviceCreate devnode to ask");
};
match channel_proof::query(&instance_id, transport, self.pad_index) {
Ok(pid) => {
self.proof_failures = 0;
Some(pid)
}
Err(e) => {
self.proof_failures += 1;
// Chatty at debug (the driver simply may not have started yet); one warn once it is
// clearly not coming, so a dead pad names its own cause instead of just going quiet.
// `DriverAttach` prints the operator-facing remedy separately.
if self.proof_failures == PROOF_FAILURES_BEFORE_WARN {
tracing::warn!(
mailbox = %self.boot_name,
devnode = %instance_id,
error = %format!("{e:#}"),
"the pad's devnode has not answered a channel proof — the host will NOT \
hand the DATA section to a pid it cannot verify, so this pad stays \
unattached (an old driver? reinstall: punktfunk-host.exe driver install \
--gamepad)"
);
} else {
tracing::debug!(
mailbox = %self.boot_name,
attempt = self.proof_failures,
error = %format!("{e:#}"),
"no channel proof yet"
);
}
None
}
}
}
/// The pre-v3 behaviour — trust the mailbox's `driver_pid` — for the one case that has no
/// devnode to ask: an out-of-band (`devgen`) devnode created outside `SwDeviceCreate`. Refused
/// unless [`TRUST_MAILBOX_ENV`] is set, because this is exactly the trust the security review
/// removed: any LocalService principal can write that field and be handed the pad's live input
/// surface.
fn unproven_mailbox_pid(&mut self, why: &str) -> Option<u32> {
if std::env::var_os(TRUST_MAILBOX_ENV).is_none() {
if !self.warned_unproven {
self.warned_unproven = true;
tracing::warn!(
mailbox = %self.boot_name,
reason = why,
"cannot ask this pad's driver for a channel proof — REFUSING to deliver the \
DATA section (the mailbox pid is not trustworthy: any local service can write \
it). Set {TRUST_MAILBOX_ENV}=1 to accept the old, unverified handshake on a \
driver bring-up box."
);
}
return None;
}
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
if pid == 0 {
return None;
}
if !self.warned_unproven {
self.warned_unproven = true;
tracing::warn!(
mailbox = %self.boot_name,
reason = why,
"delivering this pad channel on the UNVERIFIED mailbox pid — a local service that \
wins the startup race can redirect this pad's input section (forged gamepad input \
+ a read of pad state). Documented residual; the virtual MOUSE and the XUSB pad are \
both proved."
);
}
Some(pid)
}
/// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine
/// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver
/// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target
/// process (nothing to reap — there is no fallible step after the duplication).
fn deliver_to(&self, pid: u32) -> Result<u32> {
///
/// Returns `(handle_seq, process)` — the process handle is RETAINED by the caller so the
/// one-delivery rule in [`Self::pump`] can tell "our driver crashed and UMDF restarted it" from
/// "someone else is claiming this channel" on the handle rather than on a reusable pid.
fn deliver_to(&self, pid: u32) -> Result<(u32, OwnedHandle)> {
// SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the
// `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the
// synchronous check and forms no lasting alias.
// synchronous check and forms no lasting alias. `SYNCHRONIZE` is requested so the retained
// handle doubles as the incumbent-liveness probe ([`Delivered::exited`]) — the same thing the
// frame channel's `ChannelBroker` asks for.
let process = unsafe {
let h = OpenProcess(
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
false,
pid,
)
@@ -413,7 +638,7 @@ impl PadChannel {
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32))
.store(seq, Ordering::Release);
}
Ok(seq)
Ok((seq, process))
}
/// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or
@@ -425,7 +650,7 @@ impl PadChannel {
loop {
self.pump();
if self.last_seen_pid != 0 || Instant::now() >= deadline {
if !self.delivered {
if self.delivered.is_none() {
tracing::debug!(
mailbox = %self.boot_name,
"eager gamepad-channel delivery window passed without an attach — the \
@@ -620,7 +845,7 @@ impl DriverAttach {
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
PFDS_DEBUG_LOG system env var set + the device restarted)"
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
);
}
}
@@ -712,3 +937,49 @@ fn cm_problem_hint(problem: u32) -> &'static str {
_ => "see Device Manager for this code",
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The one-delivery rule ([`PadChannel::pump`]) rests entirely on [`Delivered::exited`] reading
/// the retained HANDLE rather than the pid, and both of its answers are load-bearing: "alive"
/// is what refuses a LocalService takeover of a live pad channel, and "exited" is what still
/// lets UMDF's restart-the-host-after-a-driver-crash path re-deliver. Neither half is
/// observable from the pad path without a real WUDFHost, so pin the primitive itself.
#[test]
fn delivered_exited_tracks_the_process_object_not_the_pid() {
// A child that parks long enough to be observed alive (no console needed, unlike `pause`).
let mut child = std::process::Command::new("cmd.exe")
.args(["/c", "ping", "-n", "30", "127.0.0.1"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn a parked child");
let pid = child.id();
// SAFETY: plain FFI on our own child's pid; the returned handle is owned solely by the
// `OwnedHandle` built from it (single owner, closes on drop).
let process = unsafe {
let h = OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
false,
pid,
)
.expect("OpenProcess(SYNCHRONIZE) on our own child");
OwnedHandle::from_raw_handle(h.0 as _)
};
let delivered = Delivered { pid, process };
assert!(
!delivered.exited(),
"a running target must read as ALIVE — this is what refuses a channel takeover"
);
child.kill().expect("kill the child");
// `wait` reaps, and only returns once the process has really exited — so the handle is
// signaled by the time we look.
child.wait().expect("reap the child");
assert!(
delivered.exited(),
"an exited target must read as GONE — this is what lets a crashed driver re-attach"
);
}
}
@@ -173,6 +173,11 @@ impl XusbWinPad {
// created". Returning Err routes it through PadSlots' ERROR + capped-backoff retry — parity
// with the Linux uinput path, which self-heals for exactly this reason.
let (hsw, instance_id) = create_swdevice(index)?;
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::XusbIoctl,
);
let _sw = Some(super::gamepad_raii::SwDevice::new(hsw));
// Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it
// the DATA handle before we return means the pad is live for the game's first XInput poll.
@@ -184,7 +189,7 @@ impl XusbWinPad {
attach: super::gamepad_raii::DriverAttach::new(
"pf_xusb",
"pf_xusb.inf",
"C:\\Users\\Public\\pfxusb-driver.log",
"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pfxusb-driver.log",
boot_name,
instance_id,
),
@@ -17,7 +17,7 @@
//! disappears with the host service, which is exactly when nobody is streaming.
use super::dualsense_windows::{create_swdevice, SwDeviceProfile};
use super::gamepad_raii::{DriverAttach, PadChannel};
use super::gamepad_raii::{DriverAttach, PadChannel, ProofTransport};
use anyhow::Result;
use pf_driver_proto::mouse::{input_report, mouse_boot_name, MouseShm, MOUSE_MAGIC};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
@@ -74,6 +74,9 @@ impl VirtualMouse {
(None, None)
}
};
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
// the LocalService-writable mailbox names (security-review 2026-07-28).
channel.bind_devnode(0, instance_id.clone(), ProofTransport::HidSerialString);
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
channel.deliver_eager(Duration::from_millis(1500));
Ok(VirtualMouse {
@@ -82,7 +85,7 @@ impl VirtualMouse {
attach: DriverAttach::new(
"pf_mouse",
"pf_mouse.inf",
"C:\\Users\\Public\\pfmouse-driver.log",
"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pfmouse-driver.log",
boot_name,
instance_id,
),
@@ -353,3 +356,76 @@ pub fn spike_hold(secs: u64) -> Result<()> {
);
Ok(())
}
/// **Channel-proof probe** — settles, on a real box, the one thing the design could not settle by
/// reading: which HID IOCTL hidclass actually forwards to a UMDF HID minidriver.
///
/// The pad channel hands a pad's whole input surface to whichever process the DEVNODE names
/// (`pf_driver_proto::gamepad::ChannelProof`), because the bootstrap mailbox is writable by
/// LocalService and therefore cannot be trusted to name it (security-review 2026-07-28). For the two
/// HID minidrivers that answer travels as a HID string, and both `HidD_GetIndexedString` and a
/// direct arbitrary-index `IOCTL_HID_GET_STRING` are wired up so only one of them has to work. This
/// prints which one did.
///
/// Self-contained: it spins up its OWN throwaway `pf_mouse_probe` devnode at pad index 9, so it can
/// run alongside a live host without touching the resident mouse (index 0) or its mailbox, and the
/// devnode disappears when the command exits. Needs the pf_mouse driver installed
/// (`punktfunk-host.exe driver install --gamepad`).
pub fn channel_proof_probe() -> Result<()> {
use crate::channel_proof::{self, ProofTransport};
/// A pad index no real pad uses, so the proof's index check is actually exercised and the
/// probe can never be confused with the resident mouse at 0.
const PROBE_INDEX: u8 = 9;
println!("creating a throwaway pf_mouse devnode (pad index {PROBE_INDEX})…");
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
instance: "pf_mouse_probe",
container_tag: 0x5046_4D4F, // "PFMO"
container_index: PROBE_INDEX,
hwid: "pf_mouse",
usb_vid_pid: "VID_5046&PID_4D4F",
usb_mi: None,
description: "punktfunk Virtual Mouse (channel-proof probe)",
})?;
let _sw = super::gamepad_raii::SwDevice::new(hsw);
let Some(instance_id) = instance_id else {
anyhow::bail!("SwDeviceCreate reported no instance id — cannot look the devnode up");
};
// PnP has to start the driver and hidclass has to publish the collection interface; both happen
// in tens of milliseconds, but poll rather than sleep a fixed amount so a slow box still reports.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let report = loop {
let r = channel_proof::diagnose(
&instance_id,
ProofTransport::HidSerialString,
PROBE_INDEX as u32,
);
if r.contains("ChannelProof") || std::time::Instant::now() >= deadline {
break r;
}
std::thread::sleep(Duration::from_millis(250));
};
println!("\n{report}");
match channel_proof::probe_pid(
&instance_id,
ProofTransport::HidSerialString,
PROBE_INDEX as u32,
) {
Ok(pid) => println!(
"RESULT: the devnode proved its driver is pid {pid} — the HID channel proof WORKS on \
this build of Windows, so the pad channel never has to trust the mailbox."
),
Err(e) => println!(
"RESULT: no usable channel proof ({e:#}).\n\
If BOTH HID lines above say \"call failed\", hidclass on this build forwards neither \
IOCTL to a UMDF minidriver and the HID pads/mouse need a different transport (the \
xusb leg is unaffected it owns its own device interface). If one says \"answered, \
but not a proof\", an OLD pf_mouse driver is installed: reinstall with\n\
\x20 punktfunk-host.exe driver install --gamepad"
),
}
Ok(())
}
@@ -79,6 +79,13 @@ impl DeckWinPad {
description: "punktfunk Virtual Steam Deck",
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
let (hsw, instance_id) = (Some(hsw), instance_id);
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
// the LocalService-writable mailbox names (security-review 2026-07-28).
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
// it for descriptors, or the pad would enumerate with the default DualSense identity.
@@ -88,8 +95,8 @@ impl DeckWinPad {
channel,
attach: super::gamepad_raii::DriverAttach::new(
"pf_steamdeck",
"pf_dualsense.inf", // one driver package serves every identity
"C:\\Users\\Public\\pfds-driver.log",
"pf_gamepad.inf", // one driver package serves every identity
"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
boot_name,
instance_id,
),
+6
View File
@@ -327,6 +327,12 @@ fn libei_ei_source() -> libei::EiSource {
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
// `#[path]` keeps every `crate::*` module name flat.
/// Windows: asks a devnode which process is serving it (`pf_driver_proto::gamepad::ChannelProof`) —
/// the unforgeable answer the sealed pad channel duplicates its DATA section into, replacing the
/// LocalService-writable bootstrap mailbox as the source of that decision.
#[cfg(target_os = "windows")]
#[path = "inject/windows/channel_proof.rs"]
pub mod channel_proof;
#[cfg(target_os = "linux")]
#[path = "inject/linux/dualsense.rs"]
pub mod dualsense;
+10
View File
@@ -327,6 +327,16 @@ pub fn vmouse_spike(args: &[String]) -> Result<()> {
crate::inject::mouse_windows::spike_hold(secs)
}
/// Windows CHANNEL-PROOF PROBE: settle, on a real box, which HID IOCTL hidclass forwards to a UMDF
/// HID minidriver — the one assumption in the pad channel's v3 delivery gate that could not be
/// settled by reading. Spins up a throwaway `pf_mouse_probe` devnode at pad index 9 (so it is safe
/// to run beside a live host), asks it for its channel proof over both HID paths, and prints which
/// answered. Needs the CURRENT drivers installed: `punktfunk-host.exe driver install --gamepad`.
#[cfg(target_os = "windows")]
pub fn channel_proof_probe(_args: &[String]) -> Result<()> {
crate::inject::mouse_windows::channel_proof_probe()
}
/// Windows: create a virtual DualSense via the UMDF driver (a SwDeviceCreate per-session
/// devnode plus the shared-memory channel) and hold it, pushing one fixed frame (Cross +
/// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle
+4
View File
@@ -559,6 +559,10 @@ fn real_main() -> Result<()> {
// (validates the resident-mouse cursor-presence fix on-glass). `--seconds N`.
#[cfg(target_os = "windows")]
Some("vmouse-spike") => devtest::vmouse_spike(&args),
// Windows: ask a throwaway pf_mouse devnode for its channel proof and report which HID
// transport hidclass forwarded — the pad channel's v3 delivery gate, verified on glass.
#[cfg(target_os = "windows")]
Some("channel-proof-probe") => devtest::channel_proof_probe(&args),
// Windows: create a virtual DualSense (or --ds4/--edge/--deck/--xbox) via the UMDF driver and
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
#[cfg(target_os = "windows")]
+17 -2
View File
@@ -153,6 +153,15 @@ fn install_gamepad(dir: &Path) -> Result<()> {
bail!("no driver .inf in {}", dir.display());
}
trust_cert(dir);
// Retire the PRE-RENAME package first. `pf-dualsense` became `pf-gamepad` (one driver always
// served four identities, so the old name read as if the other three lived elsewhere) and the
// HARDWARE IDS deliberately did not move — they are the binding contract with every devnode the
// host creates and with every installed system. That means an upgraded box would otherwise hold
// TWO store packages claiming `pf_dualsense`/`pf_dualshock4`/…, and PnP would be free to bind
// the stale one. Matched on `pf_dualsense.dll`, a string only the OLD package's INF contains —
// the new INF still mentions the hardware ids, so matching on those would delete what we are
// about to install.
delete_store_drivers(&["pf_dualsense.dll"]);
// Add each package to the store - no /install, no device node: the host SwDeviceCreate's the
// per-session devnode when a client forwards a pad, so PnP binds the store driver on demand.
for inf in &infs {
@@ -187,7 +196,7 @@ fn remove_pad_devnodes() {
// ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
// package, or (--gamepad) the pf-dualsense/pf-xusb driver packages (their devnodes are per-session
// package, or (--gamepad) the pf-gamepad/pf-xusb driver packages (their devnodes are per-session
// SwDeviceCreate'd and are already gone once the service stopped). Locale-safe by construction: we
// never parse pnputil's localized LABELS — devices are matched on the un-localized VALUE side
// (instance IDs / device IDs), and driver packages are found by scanning %WINDIR%\INF\oem*.inf
@@ -226,7 +235,13 @@ fn uninstall_gamepad() -> Result<()> {
// Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall
// fixed), then the store packages.
remove_pad_devnodes();
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb", "pf_mouse"]);
delete_store_drivers(&[
"pf_gamepad",
"pf_dualsense",
"pf_dualshock4",
"pf_xusb",
"pf_mouse",
]);
Ok(())
}
+3 -3
View File
@@ -80,7 +80,7 @@ parse breakage that silently failed installs on non-English boxes.
- **Uninstall** (Add/Remove Programs): runs `service uninstall` (stop + delete service + remove
firewall rules), removes the `PunktfunkWeb` task + its firewall rule, then `driver uninstall` (+
`--gamepad`) removes the punktfunk virtual-device drivers — the pf-vdisplay device node(s) and the
pf-vdisplay / pf-dualsense / pf-xusb driver-store packages (the field report was that they survived
pf-vdisplay / pf-gamepad / pf-xusb driver-store packages (the field report was that they survived
uninstall). **VB-CABLE is intentionally NOT removed** (a third-party shared component the user may
use elsewhere — its own uninstaller is `VBCABLE_Setup_x64.exe -u -h`); the `%ProgramData%\punktfunk`
config (incl. `web-password`) is also left in place.
@@ -123,7 +123,7 @@ fresh install uses the generated random console password — read it from
| `branding/` | Wizard branding: `gen-branding.ps1` renders the brand mark into the committed `wizard-image-*.bmp` / `wizard-small-*.bmp` (100200% DPI) + `punktfunk.ico`. Re-run only on a brand change. |
| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + VB-CABLE + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. |
| `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. |
| `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-dualsense` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. |
| `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-gamepad` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. |
| `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). |
| `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). |
| `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. |
@@ -133,7 +133,7 @@ fresh install uses the generated random console password — read it from
| `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. |
| `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. |
> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-dualsense /
> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-gamepad /
> pf-xusb) are members of the all-Rust `drivers/` workspace (windows-drivers-rs / IddCx) and are
> **rebuilt + signed every release** by `build-pf-vdisplay.ps1` + `build-gamepad-drivers.ps1` - the
> checked-in prebuilt binaries were deleted (a stale `.cat` once stopped covering its `.inf`
+3 -3
View File
@@ -1,6 +1,6 @@
<#
.SYNOPSIS
Build + sign the punktfunk virtual-gamepad UMDF drivers (pf-dualsense = DualSense/DualShock 4, pf-xusb =
Build + sign the punktfunk virtual-gamepad UMDF drivers (pf-gamepad = DualSense/DualShock 4/Edge/Deck, pf-xusb =
Xbox 360 / XInput) FROM SOURCE, in CI, and stage them for the host installer. The gamepad analogue of
build-pf-vdisplay.ps1 - replaces the checked-in prebuilt binaries (packaging/windows/gamepad-drivers/)
so the .dll/.inf/.cat stay in lockstep with the source and never go stale.
@@ -13,7 +13,7 @@
supplied DRIVER_CERT secret) + ONE exported .cer - the layout `punktfunk-host.exe driver install
--gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer).
Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} +
Output (-Out): pf_gamepad.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} +
punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad - it shares
this pipeline + the --gamepad install path.)
@@ -37,7 +37,7 @@ $DriversDir = (Resolve-Path $DriversDir).Path
$clear = Join-Path $PSScriptRoot 'clear-force-integrity.ps1'
$drivers = @(
@{ crate = 'pf-dualsense'; dll = 'pf_dualsense.dll'; inx = 'pf-dualsense\pf_dualsense.inx'; inf = 'pf_dualsense.inf'; cat = 'pf_dualsense.cat' }
@{ crate = 'pf-gamepad'; dll = 'pf_gamepad.dll'; inx = 'pf-gamepad\pf_gamepad.inx'; inf = 'pf_gamepad.inf'; cat = 'pf_gamepad.cat' }
@{ crate = 'pf-xusb'; dll = 'pf_xusb.dll'; inx = 'pf-xusb\pf_xusb.inx'; inf = 'pf_xusb.inf'; cat = 'pf_xusb.cat' }
# Not a gamepad, but it rides the identical UMDF HID pipeline + the same install path
# (`driver install --gamepad` adds every staged .inf): the resident virtual HID mouse that
+1 -1
View File
@@ -7,7 +7,7 @@
# crates/pf-driver-proto from the main tree.
[workspace]
resolver = "2"
members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb", "pf-mouse"]
members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-gamepad", "pf-xusb", "pf-mouse"]
[workspace.package]
edition = "2024"
@@ -1,8 +1,10 @@
# pf-dualsense - punktfunk virtual DualSense (DS5) / DualShock 4 (DS4) UMDF2 HID minidriver.
# pf-gamepad - punktfunk virtual gamepads (DualSense / DualShock 4 / DualSense Edge / Steam Deck),
# ONE UMDF2 HID minidriver serving all four identities. Named for the role, not for one of them —
# it was `pf-dualsense`, which read as if the other three were somewhere else.
# A member of the in-tree drivers workspace (shares the vendored wdk-sys/wdk-build with the bindgen pin
# + the crt-static .cargo/config), built from source per release like pf-vdisplay.
[package]
name = "pf-dualsense"
name = "pf-gamepad"
edition.workspace = true
version.workspace = true
license.workspace = true
@@ -1,4 +1,11 @@
# pf-dualsense — virtual DualSense UMDF2 HID minidriver
# 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 —
@@ -42,24 +49,24 @@ plus the sign steps below, staged for the installer. The original manual dev-box
lore (paths reflect that era's cargo-make layout):
```powershell
cargo make # -> target\debug\pf_dualsense_package\ (.inf/.cat/.dll)
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_dualsense_package\pf_dualsense.dll'
$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_dualsense_package\pf_dualsense.cat
Inf2Cat /driver:target\debug\pf_dualsense_package /os:10_x64
signtool sign /fd SHA256 /sha1 <cert-thumbprint> target\debug\pf_dualsense_package\pf_dualsense.cat
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_dualsense_package\pf_dualsense.inf /install
pnputil /add-driver target\debug\pf_gamepad_package\pf_gamepad.inf /install
devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node
```
@@ -1,6 +1,12 @@
;/*++
; punktfunk virtual PlayStation/Valve pads — UMDF2 HID minidriver INF (M0 spike).
; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck.
; punktfunk virtual gamepads — UMDF2 HID minidriver INF.
; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck — which is why
; the package is called pf_gamepad and not pf_dualsense (it never was one identity).
;
; ⚠️ The HARDWARE IDS below deliberately keep their old names (`pf_dualsense`, `pf_dualshock4`,
; `pf_dualsenseedge`, `pf_steamdeck`). They are the binding contract with every devnode the host
; SwDeviceCreate's and with every already-installed system; renaming them would orphan existing
; installs and buy nothing. Only the PACKAGE identity (INF/CAT/DLL/service) moved to pf_gamepad.
; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx).
; Depends on MsHidUmdf.inf (build >= 22000).
; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install)
@@ -10,7 +16,7 @@ Signature="$WINDOWS NT$"
Class=HIDClass
ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da}
Provider=%ProviderString%
CatalogFile=pf_dualsense.cat
CatalogFile=pf_gamepad.cat
PnpLockdown=1
[DestinationDirs]
@@ -20,7 +26,7 @@ DefaultDestDir = 13
1=%Disk_Description%,,,
[SourceDisksFiles]
pf_dualsense.dll=1
pf_gamepad.dll=1
[Manufacturer]
%ManufacturerString%=pf, NT$ARCH$.10.0...22000
@@ -30,44 +36,44 @@ pf_dualsense.dll=1
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck`
; for the host's other virtual pads — ONE driver binds all of them (every model line below installs
; the same `pfDualSense` section) and serves the matching HID identity per the device_type byte the
; the same `pfGamepad` section) and serves the matching HID identity per the device_type byte the
; host stamps into shared memory.
;
; Each id carries its OWN description: Device Manager reads this string, and a single shared
; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been
; ignored. The HID layer (VID/PID, report descriptor, product string) was always per-type; this
; makes the human-readable name agree with it.
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense
%DeviceDescDS4%=pfDualSense, pf_dualshock4
%DeviceDescEdge%=pfDualSense, pf_dualsenseedge
%DeviceDescDeck%=pfDualSense, pf_steamdeck
%DeviceDesc%=pfGamepad, root\pf_dualsense, pf_dualsense
%DeviceDescDS4%=pfGamepad, pf_dualshock4
%DeviceDescEdge%=pfGamepad, pf_dualsenseedge
%DeviceDescDeck%=pfGamepad, pf_steamdeck
[pfDualSense.NT]
[pfGamepad.NT]
CopyFiles=UMDriverCopy
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT
[pfDualSense.NT.hw]
[pfGamepad.NT.hw]
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT.hw
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.hw
[pfDualSense.NT.Services]
[pfGamepad.NT.Services]
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT.Services
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.Services
[pfDualSense.NT.Filters]
[pfGamepad.NT.Filters]
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.Filters
[pfDualSense.NT.Wdf]
UmdfService="pf_dualsense", pf_dualsense_Install
UmdfServiceOrder=pf_dualsense
[pfGamepad.NT.Wdf]
UmdfService="pf_gamepad", pf_gamepad_Install
UmdfServiceOrder=pf_gamepad
UmdfKernelModeClientPolicy=AllowKernelModeClients
UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects
UmdfMethodNeitherAction=Copy
@@ -76,18 +82,18 @@ UmdfFsContextUsePolicy=CanUseFsContext2
; across multiple simultaneous controllers (multi-pad).
UmdfHostProcessSharing=ProcessSharingDisabled
[pf_dualsense_Install]
[pf_gamepad_Install]
UmdfLibraryVersion=$UMDFVERSION$
ServiceBinary="%13%\pf_dualsense.dll"
ServiceBinary="%13%\pf_gamepad.dll"
[UMDriverCopy]
pf_dualsense.dll
pf_gamepad.dll
[Strings]
ProviderString ="punktfunk"
ManufacturerString ="punktfunk"
ClassName ="HID device"
Disk_Description ="punktfunk DualSense Installation Disk"
Disk_Description ="punktfunk Gamepad Installation Disk"
; One per hardware id — these are what Device Manager shows. Keep them aligned with the product
; strings the driver serves per device_type (src/lib.rs `on_get_string`).
DeviceDesc ="punktfunk Virtual DualSense"
@@ -358,7 +358,7 @@ static DEVTYPE_WAITED: AtomicBool = AtomicBool::new(false);
/// This pad's channel config (magic/size/pad_index offset + our logger).
fn channel_cfg() -> ChannelConfig {
ChannelConfig {
tag: "pf-ds",
tag: "pf-gamepad",
boot_name_prefix: "Global\\pfds-boot-",
data_magic: SHM_MAGIC,
data_size: SHM_SIZE,
@@ -385,14 +385,14 @@ fn pad_index() -> u8 {
}
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
/// or the `PFDS_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
/// or the `PFGAMEPAD_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
/// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report
/// OUTPUT hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see the
/// UMDF host across session 0, so the file stays the bring-up diagnostic when enabled.
fn file_log_enabled() -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFDS_DEBUG_LOG").is_some())
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFGAMEPAD_DEBUG_LOG").is_some())
}
/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex`
@@ -411,7 +411,7 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(std::env::temp_dir().join("pfds-driver.log"))
.open(std::env::temp_dir().join("pf_gamepad-driver.log"))
.ok()
.map(std::sync::Mutex::new)
})
@@ -437,7 +437,7 @@ pub unsafe extern "system" fn driver_entry(
driver: PDRIVER_OBJECT,
registry_path: PCUNICODE_STRING,
) -> NTSTATUS {
log("[pf-ds] DriverEntry");
log("[pf-gamepad] DriverEntry");
// SAFETY: zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback.
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
@@ -457,7 +457,7 @@ pub unsafe extern "system" fn driver_entry(
}
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
log("[pf-ds] EvtDeviceAdd");
log("[pf-gamepad] EvtDeviceAdd");
// Mark as a filter (HID minidriver sits below mshidumdf.sys).
// SAFETY: device_init is provided by the framework and non-null.
@@ -474,14 +474,14 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
)
};
if !nt_success(st) {
dbglog!("[pf-ds] WdfDeviceCreate failed 0x{:08x}", st as u32);
dbglog!("[pf-gamepad] WdfDeviceCreate failed 0x{:08x}", st as u32);
return st;
}
// SAFETY: `device` is the live device just created — the exact contract this fn requires.
let shm_idx = unsafe { wdf::query_location_index(device) };
CHANNEL.set_index(shm_idx);
dbglog!("[pf-ds] shm index = {shm_idx}");
dbglog!("[pf-gamepad] shm index = {shm_idx}");
// Default parallel queue handling all IOCTLs.
// SAFETY: zeroed config then fields set; Size matches the struct.
@@ -507,7 +507,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
};
if !nt_success(st) {
dbglog!(
"[pf-ds] default WdfIoQueueCreate failed 0x{:08x}",
"[pf-gamepad] default WdfIoQueueCreate failed 0x{:08x}",
st as u32
);
return st;
@@ -531,7 +531,10 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
)
};
if !nt_success(st) {
dbglog!("[pf-ds] manual WdfIoQueueCreate failed 0x{:08x}", st as u32);
dbglog!(
"[pf-gamepad] manual WdfIoQueueCreate failed 0x{:08x}",
st as u32
);
return st;
}
MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst);
@@ -558,13 +561,13 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer)
};
if !nt_success(st) {
dbglog!("[pf-ds] WdfTimerCreate failed 0x{:08x}", st as u32);
dbglog!("[pf-gamepad] WdfTimerCreate failed 0x{:08x}", st as u32);
return st;
}
// SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative).
let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) };
log("[pf-ds] device ready (DualSense 054C:0CE6)");
log("[pf-gamepad] device ready (DualSense 054C:0CE6)");
STATUS_SUCCESS
}
@@ -582,7 +585,7 @@ extern "C" fn evt_io_device_control(
// Skip the 8ms READ_REPORT cadence so the log stays readable during a game test;
// the 0x02 OUTPUT report (the gate) and the descriptor handshake still log.
if ioctl != IOCTL_HID_READ_REPORT {
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
dbglog!("[pf-gamepad] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
}
// READ_REPORT forwards to the manual queue (the timer completes it) — this CONSUMES the request
@@ -618,10 +621,16 @@ extern "C" fn evt_io_device_control(
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => request.copy_to_output(&neutral_report(device_type())),
IOCTL_HID_GET_STRING => on_get_string(&request),
// The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process
// serves it, and duplicates the DATA section into the answer — so it never has to trust the
// LocalService-writable bootstrap mailbox to name its target.
_ => STATUS_NOT_IMPLEMENTED,
};
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x}", status as u32);
dbglog!(
"[pf-gamepad] ioctl 0x{ioctl:08x} -> 0x{:08x}",
status as u32
);
request.complete(status);
}
@@ -644,7 +653,7 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
} else {
"SET_OUTPUT_REPORT"
};
dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}");
dbglog!("[pf-gamepad] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}");
// Publish the game's 0x02 output report to the sealed DATA section for the host (rumble /
// lightbar / player-LEDs / adaptive triggers): legacy slot + seq, plus the v2.1 ring.
@@ -696,7 +705,7 @@ fn on_set_feature(request: &Request) -> NTSTATUS {
publish_output(view, &out);
}
}
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
dbglog!("[pf-gamepad] SET_FEATURE (acked, latched for GET)");
STATUS_SUCCESS
}
@@ -718,6 +727,16 @@ fn deck_feature_reply() -> [u8; 64] {
let unit_serial = format!("FVPF{unit_id:08X}");
let unit_serial = unit_serial.as_bytes();
let mut r = [0u8; 64];
// The CHANNEL PROOF, Deck flavour: the Deck's ONE feature report is unnumbered and Steam drives
// it as command→response, so the proof rides that same contract instead of a new report id (no
// descriptor change). Two command bytes, so a Steam command we haven't catalogued cannot collide.
if last.starts_with(&pf_driver_proto::gamepad::DECK_PROOF_CMD) {
let proof =
pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id());
r[..2].copy_from_slice(&pf_driver_proto::gamepad::DECK_PROOF_CMD);
r[2..18].copy_from_slice(&proof.to_bytes());
return r;
}
match last[0] {
0x83 => {
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
@@ -779,6 +798,24 @@ fn on_get_feature(request: &Request) -> NTSTATUS {
let Some(&report_id) = bytes.first() else {
return STATUS_INVALID_PARAMETER;
};
// The CHANNEL PROOF (security-review 2026-07-28): tell the host which process serves this
// devnode, so it never has to trust the LocalService-writable bootstrap mailbox to name its
// duplication target. `0x85` is already declared as a Feature report in all three captured
// descriptors and was previously answered with STATUS_INVALID_PARAMETER, so this costs NO
// report-descriptor change — the identity Steam and SDL fingerprint is untouched. Derived only
// from our own devnode Location + pid: nothing a caller supplies feeds into it.
if report_id == pf_driver_proto::gamepad::HID_FEATURE_REPORT_CHANNEL_PROOF {
let len = request.output_buffer_len();
return match pf_driver_proto::gamepad::ChannelProof::new(
CHANNEL.index(),
std::process::id(),
)
.to_feature_report(report_id, len)
{
Some(rep) => request.copy_to_output(&rep),
None => STATUS_INVALID_PARAMETER, // caller's buffer can't hold id + proof
};
}
// DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble
// for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses
// 0x02/0x12/0xa3.
@@ -801,7 +838,7 @@ fn on_get_feature(request: &Request) -> NTSTATUS {
(1, 0x12) => &ds4_pairing,
(1, 0xA3) => &DS4_FEATURE_FIRMWARE,
(_, other) => {
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}");
dbglog!("[pf-gamepad] GET_FEATURE unknown report id 0x{other:02x}");
return STATUS_INVALID_PARAMETER;
}
};
@@ -825,7 +862,7 @@ fn on_get_string(request: &Request) -> NTSTATUS {
};
let string_id = id_val & 0xFFFF;
let devtype = device_type();
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
dbglog!("[pf-gamepad] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
let s: String = match string_id {
0 | 0x000e => match devtype {
1 => "Sony Computer Entertainment".into(),
@@ -882,7 +919,7 @@ fn device_type() -> u8 {
std::thread::sleep(std::time::Duration::from_millis(10));
}
dbglog!(
"[pf-ds] device_type: sealed channel not attached within 1s — defaulting to the last observed identity"
"[pf-gamepad] device_type: sealed channel not attached within 1s — defaulting to the last observed identity"
);
}
LAST_DEVTYPE.load(Ordering::Relaxed) as u8
@@ -1,6 +1,6 @@
;/*++
; punktfunk virtual HID mouse (absolute pointer) — UMDF2 HID minidriver INF.
; Same skeleton as pf_dualsense.inx (the WDK vhidmini2 UMDF2 shape).
; Same skeleton as pf_gamepad.inx (the WDK vhidmini2 UMDF2 shape).
; Depends on MsHidUmdf.inf (build >= 22000).
; Install: devgen /add /hardwareid "root\pf_mouse" (after pnputil /add-driver /install)
;--*/
+26 -12
View File
@@ -9,7 +9,7 @@
// the report path below is exercised by `punktfunk-host vmouse-spike` (validation) and is the
// future higher-fidelity injection route.
//
// Structure is pf-dualsense minus the identity zoo: one fixed HID identity (PF:MO, an obviously
// Structure is pf-gamepad minus the identity zoo: one fixed HID identity (PF:MO, an obviously
// virtual VID/PID no software matches on), one 8-byte input report (5 buttons + absolute 15-bit
// X/Y + wheel + AC-pan), no feature/output reports. The host channel is the **sealed pad channel**
// (design/gamepad-channel-sealing.md) verbatim — mailbox `Global\pfmouse-boot-<i>`, unnamed
@@ -28,6 +28,7 @@
use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
use pf_driver_proto::gamepad::ChannelProof;
use pf_driver_proto::mouse::{
MOUSE_PID, MOUSE_REPORT_ID, MOUSE_REPORT_LEN, MOUSE_VER, MOUSE_VID, MouseShm,
};
@@ -173,10 +174,10 @@ fn channel_cfg() -> ChannelConfig {
}
}
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
/// or the `PFMOUSE_DEBUG_LOG` (system-wide) env var — the same policy as the pad drivers (audit
/// §4.4): a RELEASE driver never writes the Public file. DebugView can't see the UMDF host across
/// session 0, so the file stays the bring-up diagnostic when enabled.
/// Whether the bring-up file log is enabled (resolved once). OPT-IN — debug builds, or the
/// `PFMOUSE_DEBUG_LOG` (system-wide) env var — the same policy as the pad drivers (audit §4.4):
/// a RELEASE driver never writes it. DebugView can't see the UMDF host across session 0, so the
/// file stays the bring-up diagnostic when enabled.
fn file_log_enabled() -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
@@ -193,10 +194,15 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
if !file_log_enabled() {
return None;
}
// Write to the WUDFHost's own (LocalService) temp dir — NOT world-writable/readable
// `C:\Users\Public`, where any local reader gets the diagnostics and a non-admin can
// pre-create the path as a hard link to redirect this LocalService appender's writes
// (security-review 2026-07-17; pf-xusb/pf-gamepad/pf-vdisplay were moved then, this
// driver was missed — security-review 2026-07-28). Opt-in/debug only.
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\Users\\Public\\pfmouse-driver.log")
.open(std::env::temp_dir().join("pfmouse-driver.log"))
.ok()
.map(std::sync::Mutex::new)
})
@@ -325,7 +331,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst);
// Periodic timer (parent = manual queue): sealed-channel pump + health marks + event-driven
// READ_REPORT completion. 8 ms — the proven pf-dualsense cadence; the mouse is presence-first
// READ_REPORT completion. 8 ms — the proven pf-gamepad cadence; the mouse is presence-first
// (SendInput injects), so a 125 Hz ceiling on the validation/report path is fine.
// SAFETY: zeroed config then fields set.
let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() };
@@ -397,6 +403,9 @@ extern "C" fn evt_io_device_control(
// No output reports are declared; ack a stray write instead of failing the sender.
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => STATUS_SUCCESS,
IOCTL_HID_GET_STRING => on_get_string(&request),
// The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process
// serves it, and duplicates the DATA section into the answer — so it never has to trust the
// LocalService-writable bootstrap mailbox to name its target.
_ => STATUS_NOT_IMPLEMENTED,
};
@@ -406,7 +415,7 @@ extern "C" fn evt_io_device_control(
// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word
// is the language id. Windows polls ids 0x0E/0x0F/0x10 (manufacturer/product/serial) as well as
// the 0/1/2 HID_STRING_ID_* constants — serve both (the pf-dualsense finding).
// the 0/1/2 HID_STRING_ID_* constants — serve both (the pf-gamepad finding).
fn on_get_string(request: &Request) -> NTSTATUS {
let (bytes, _) = match request.input_bytes(4) {
Ok(v) => v,
@@ -418,10 +427,15 @@ fn on_get_string(request: &Request) -> NTSTATUS {
0
};
let string_id = id_val & 0xFFFF;
let s: &str = match string_id {
0 | 0x000E => "punktfunk",
2 | 0x0010 => "PFMOUSE00",
_ => "punktfunk Virtual Mouse",
let s: String = match string_id {
0 | 0x000E => "punktfunk".into(),
// (2) The SERIAL carries the channel proof — the one transport measured to reach a UMDF HID
// minidriver from user mode (`HidD_GetSerialNumberString`, zero-access handle, verified on
// .173). Safe HERE and only here: nothing reads the virtual mouse's serial, whereas the pads'
// serials are what SDL and Steam dedup controllers on. The old value was the inert
// "PFMOUSE00"; the proof text is just as inert and does the security work.
2 | 0x0010 => ChannelProof::new(CHANNEL.index(), std::process::id()).to_hid_string(),
_ => "punktfunk Virtual Mouse".into(),
};
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
for u in s.encode_utf16() {
@@ -1,7 +1,7 @@
//! The sealed pad channel, driver side (`design/gamepad-channel-sealing.md`, gamepad proto v2):
//! poll the named bootstrap mailbox by index, publish our pid (iff the host's proto version
//! matches), adopt the host-delivered DATA-section handle, and validate the mapped section's magic
//! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-dualsense` (they used
//! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-gamepad` (they used
//! to hand-duplicate it), parameterized by [`ChannelConfig`].
//!
//! This module **forbids `unsafe`**: the entire state machine is safe Rust over
@@ -0,0 +1,32 @@
//! What the HID minidrivers could and could not use to answer the **channel proof**
//! (`pf_driver_proto::gamepad::ChannelProof`) — the measured record, so nobody re-derives it.
//!
//! The proof tells the host which process to duplicate a pad's DATA section into. It cannot come
//! from the bootstrap mailbox: that must be openable by LocalService (the driver's own WUDFHost runs
//! as LocalService), so any LocalService principal could name a process of its own and be handed the
//! pad's live input surface (security-review 2026-07-28). Only the driver actually bound to the
//! devnode the host created can answer that devnode's I/O — hence "ask the device stack".
//!
//! ## Measured on .173 (Win11 26200), 2026-07-28 — three attempts, one survivor
//!
//! * ❌ **`IOCTL_HID_GET_INDEXED_STRING`** (`HidD_GetIndexedString`). Fails for EVERY index — 1, 2,
//! 4, 0x0E, 0x5046 — on the same zero-access handle where the NAMED string calls succeed and
//! return the driver's own text. hidclass does not carry an arbitrary indexed-string request to a
//! UMDF HID minidriver at all.
//! * ❌ **A private device interface** (`WdfDeviceCreateDeviceInterface` + a private IOCTL, the shape
//! `pf_xusb` uses). The interface REGISTERS and enumerates fine —
//! `\\?\SWD#punktfunk#pf_mouse_0#{32244793-…}` was present — but `CreateFile` on it is refused:
//! `ERROR_GEN_FAILURE` (31) for access 0 and GENERIC_READ, `ERROR_FILE_NOT_FOUND` (2) for
//! GENERIC_READ|WRITE. hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for, so a second
//! interface on that devnode cannot be opened. This is why `pf_xusb` is fine and these two are not:
//! it is NOT a HID minidriver, so nothing sits above it.
//! * ✅ **The serial-number string.** `HidD_GetSerialNumberString` succeeds on a zero-access handle
//! and returns driver-authored text. Verified end to end: `pf_mouse` answered `PFCP:3:0:7296`, and
//! pid 7296 was a genuine service-spawned `WUDFHost.exe`.
//!
//! So `pf_mouse` serves its proof AS its serial (its `PFMOUSE00` was inert). The pads deliberately do
//! NOT: their serials are what SDL and Steam dedup controllers on, and Steam is already known to
//! mangle a pad's displayed name over serial FORMAT alone. `pf_gamepad` therefore has no working
//! device-stack transport on this Windows build — see `ProofTransport::MailboxUnproven` on the host
//! side for the residual that leaves, and option B (a vendor feature report, which costs a
//! report-descriptor change) if it ever needs closing.
@@ -1,5 +1,5 @@
//! The audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers (`pf-xusb`,
//! `pf-dualsense`).
//! `pf-gamepad`).
//!
//! A UMDF driver cannot be literally free of `unsafe` — WDF dispatch, Win32 section mapping and
//! cross-process shared memory are FFI by nature. What Rust *can* buy is confining every raw
@@ -15,6 +15,9 @@
//! * [`wdf`] — [`wdf::Request`] + queue/device-property helpers: each framework callback converts
//! its raw `WDFREQUEST` into a token exactly once (`unsafe`, with the framework's validity as the
//! contract); everything after that is safe.
//! * [`hid`] — the HID minidrivers' **channel proof** answer (also `#[forbid(unsafe_code)]`): how
//! `pf_gamepad`/`pf_mouse` tell the host, over the device stack, which process is serving this
//! devnode. That is what the host trusts instead of the LocalService-writable bootstrap mailbox.
//!
//! Lint gates (mirrored in every driver crate, enforced by the drivers CI clippy step):
//! `unsafe_op_in_unsafe_fn` + `clippy::undocumented_unsafe_blocks` — every remaining `unsafe {}`
@@ -24,6 +27,7 @@
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod channel;
pub mod hid;
pub mod section;
pub mod wdf;
@@ -58,6 +58,14 @@ const IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER: u32 = 0x8000_6380;
const IOCTL_XUSB_WAIT_FOR_INPUT: u32 = 0x8000_E3AC;
const IOCTL_XUSB_GET_INFORMATION_EX: u32 = 0x8000_E3FC;
/// Our own private IOCTL (NOT part of xusb22): answer the host's **channel proof** — which process
/// is serving this devnode — so the host learns its duplication target from the device stack instead
/// of from the LocalService-writable bootstrap mailbox (see `pf_driver_proto::gamepad::ChannelProof`
/// for why that distinction is the whole security property). Any local caller may ask; the answer is
/// a pid and two version numbers, none of them secret, and it is only worth anything to a process
/// that can already duplicate handles into us.
const IOCTL_PF_GET_CHANNEL_PROOF: u32 = pf_driver_proto::gamepad::IOCTL_PF_GET_CHANNEL_PROOF;
// Xbox 360 wired identity (what GET_INFORMATION reports). 0x0103 unblocks SET_STATE (vibration).
const XUSB_VID: u16 = 0x045E;
const XUSB_PID: u16 = 0x028E;
@@ -389,6 +397,14 @@ extern "C" fn evt_io_device_control(
}
}
IOCTL_XUSB_GET_STATE => request.copy_to_output(&build_get_state(data)),
// The channel proof — deliberately answered from THIS process's own identity, never from
// anything a caller supplied, so the only way to make this devnode name a process is to BE
// the driver bound to it.
IOCTL_PF_GET_CHANNEL_PROOF => {
let proof =
pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id());
request.copy_to_output(&proof.to_bytes())
}
IOCTL_XUSB_GET_LED_STATE => request.copy_to_output(&[0x00, 0x00, 0x06]),
IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]),
IOCTL_XUSB_SET_STATE => on_set_state(&request, data),
+1 -1
View File
@@ -183,7 +183,7 @@ if (-not $NoDriver) {
else { Write-Host "-NoDriver: building installer WITHOUT the bundled pf-vdisplay driver" }
# --- build (from source) + stage the punktfunk virtual-gamepad UMDF drivers --------------------
# pf-dualsense (DualSense / DualShock 4) + pf-xusb (Xbox 360 / XInput) are members of the same drivers
# pf-gamepad (DualSense / DS4 / Edge / Deck) + pf-xusb (Xbox 360 / XInput) are members of the same drivers
# workspace as pf-vdisplay, built from source per release (build-gamepad-drivers.ps1) - same anti-stale
# reasoning as pf-vdisplay; the prior checked-in binaries under gamepad-drivers/ are retired. The
# installer adds each to the store via `punktfunk-host.exe driver install --gamepad` (the host
+2 -2
View File
@@ -29,9 +29,9 @@ fi
# 2. UMDF driver workspace — mirrors windows-drivers.yml's gate (the shipped drivers + the
# audited safe layer; pf-vdisplay is deliberately NOT gated there, so not here either).
if ! (cd "$root/packaging/windows/drivers" \
&& cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check >/dev/null 2>&1); then
&& cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check >/dev/null 2>&1); then
fail "driver workspace is not rustfmt-clean (windows-drivers.yml would fail)" \
"(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense)"
"(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse)"
fi
exit 0