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
@@ -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(())
}