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
+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),