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
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:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user