Files
punktfunk/packaging/windows/drivers/pf-umdf-util/src/channel.rs
T
enricobuehlerandClaude Opus 5 560e663aef
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
fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
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>
2026-07-28 16:54:40 +02:00

202 lines
9.6 KiB
Rust

//! 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-gamepad` (they used
//! to hand-duplicate it), parameterized by [`ChannelConfig`].
//!
//! This module **forbids `unsafe`**: the entire state machine is safe Rust over
//! [`section`](crate::section)'s checked accessors — the memory-safety surface of the sealed
//! channel lives in that module alone.
#![forbid(unsafe_code)]
use crate::section::{MappedView, ViewCell, close_handle_value};
use core::mem::offset_of;
use core::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use pf_driver_proto::gamepad::{BOOT_MAGIC, GAMEPAD_PROTO_VERSION, PadBootstrap};
// PadBootstrap field offsets (the mailbox handshake; pinned by pf_driver_proto's asserts).
const BOOT_OFF_MAGIC: usize = offset_of!(PadBootstrap, magic);
const BOOT_OFF_HOST_PROTO: usize = offset_of!(PadBootstrap, host_proto);
const BOOT_OFF_DRIVER_PID: usize = offset_of!(PadBootstrap, driver_pid);
const BOOT_OFF_DRIVER_PROTO: usize = offset_of!(PadBootstrap, driver_proto);
const BOOT_OFF_DATA_HANDLE: usize = offset_of!(PadBootstrap, data_handle);
const BOOT_OFF_HANDLE_PID: usize = offset_of!(PadBootstrap, handle_pid);
const BOOT_OFF_HANDLE_SEQ: usize = offset_of!(PadBootstrap, handle_seq);
const BOOT_SIZE: usize = core::mem::size_of::<PadBootstrap>();
/// What varies between the two pad drivers.
pub struct ChannelConfig {
/// Log-line prefix (`"pf-xusb"` / `"pf-ds"`).
pub tag: &'static str,
/// Mailbox name prefix, completed with the pad index (`"Global\\pfxusb-boot-"` / `"Global\\pfds-boot-"`).
pub boot_name_prefix: &'static str,
/// The DATA section's magic (`XUSB_MAGIC` / `PAD_MAGIC`).
pub data_magic: u32,
/// The DATA section's size (`size_of::<XusbShm>()` / `size_of::<PadShm>()`).
pub data_size: usize,
/// Fallback map length when the full `data_size` map is refused — the legacy section size of a
/// layout that grew by tail extension (`PAD_SHM_LEGACY_SIZE` for the pad channel). Sections are
/// pagefile-backed and page-granular, so the full-size map is expected to succeed against
/// either host generation; this exists so a refused map can never fail the pad closed. Set
/// equal to `data_size` for layouts that never grew. A caller gates tail-extension features on
/// `MappedView::mapped_len()` (plus the layout's own capability field), never on assumption.
pub min_data_size: usize,
/// `offset_of!(…Shm, pad_index)` in the DATA section.
pub pad_index_off: usize,
/// The driver's logger (each driver tees to its own debug file).
pub log: fn(&str),
}
/// Per-pad channel state (a `static` in each driver — per-pad because
/// `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own WUDFHost).
pub struct ChannelClient {
/// The pad index from the devnode Location (which mailbox to poll + the `pad_index` the
/// delivered DATA section must carry).
index: AtomicU32,
/// The adopted DATA view; leaked-on-publish (see [`ViewCell`]) so a re-delivery can never
/// unmap a view a concurrent callback still reads through.
data: ViewCell,
/// The last `handle_seq` consumed (CAS-guarded so concurrent pumps adopt a delivery exactly
/// once). Reset to 0 when the mailbox disappears, so a NEW host session's delivery is always
/// fresh even if its (per-host-process) seq counter collides with the previous session's.
consumed_seq: AtomicU32,
logged_proto_mismatch: AtomicBool,
logged_pid: AtomicBool,
}
impl Default for ChannelClient {
fn default() -> Self {
Self::new()
}
}
impl ChannelClient {
pub const fn new() -> ChannelClient {
ChannelClient {
index: AtomicU32::new(0),
data: ViewCell::new(),
consumed_seq: AtomicU32::new(0),
logged_proto_mismatch: AtomicBool::new(false),
logged_pid: AtomicBool::new(false),
}
}
/// Set the pad index (from the devnode Location, in `EvtDeviceAdd`).
pub fn set_index(&self, idx: u32) {
self.index.store(idx, Ordering::Relaxed);
}
pub fn index(&self) -> u32 {
self.index.load(Ordering::Relaxed)
}
/// The adopted DATA view regardless of mailbox liveness — for write paths where acting on a
/// stale section is harmless (the pump owns the detach semantics).
pub fn data(&self) -> Option<&'static MappedView> {
self.data.get()
}
/// One tick of the sealed-channel state machine: publish our pid (+ proto version) in the
/// mailbox, adopt a delivered DATA handle, and return the attached DATA view — `None` while
/// unattached, on a host/driver version mismatch (fail closed), or when the mailbox is gone
/// (host gone). The mailbox is re-opened by name on every call: the name existing doubles as
/// host-liveness (the host closes it when the pad is torn down).
pub fn pump(&self, cfg: &ChannelConfig) -> Option<&'static MappedView> {
let name = format!("{}{}", cfg.boot_name_prefix, self.index());
let boot = match MappedView::open_named(&name, BOOT_SIZE) {
Some(b) => b,
None => {
// Mailbox gone → the host (or this pad) is gone. Forget the consumed seq so the
// NEXT host session's first delivery always reads as fresh.
self.consumed_seq.store(0, Ordering::Relaxed);
return None;
}
};
// Acquire pairs with the host's Release magic store, so a valid magic implies `host_proto`
// is visible. A missing/garbled magic reads as "no usable mailbox" (same as absent).
if boot.load_u32(BOOT_OFF_MAGIC, Ordering::Acquire) != BOOT_MAGIC {
self.consumed_seq.store(0, Ordering::Relaxed);
return None;
}
// Publish our proto version first (idempotent) — the host logs a mismatch even when we
// refuse to publish a pid below.
boot.store_u32(
BOOT_OFF_DRIVER_PROTO,
GAMEPAD_PROTO_VERSION,
Ordering::Relaxed,
);
let host_proto = boot.load_u32(BOOT_OFF_HOST_PROTO, Ordering::Relaxed);
if host_proto != GAMEPAD_PROTO_VERSION {
if !self.logged_proto_mismatch.swap(true, Ordering::Relaxed) {
(cfg.log)(&format!(
"[{}] host proto {host_proto} != driver proto {GAMEPAD_PROTO_VERSION}\
refusing the handshake (update host + drivers together)",
cfg.tag
));
}
return None; // version mismatch — fail closed
}
let mypid = std::process::id();
if boot.load_u32(BOOT_OFF_DRIVER_PID, Ordering::Relaxed) != mypid {
boot.store_u32(BOOT_OFF_DRIVER_PID, mypid, Ordering::Release);
if !self.logged_pid.swap(true, Ordering::Relaxed) {
(cfg.log)(&format!("[{}] bootstrap: published pid {mypid}", cfg.tag));
}
}
// A delivery addressed to us we haven't consumed? CAS so concurrent pumps (worker thread /
// timer + IOCTL paths) adopt exactly once.
let seq = boot.load_u32(BOOT_OFF_HANDLE_SEQ, Ordering::Acquire);
let cur = self.consumed_seq.load(Ordering::Relaxed);
if seq != 0
&& seq != cur
&& boot.load_u32(BOOT_OFF_HANDLE_PID, Ordering::Relaxed) == mypid
&& self
.consumed_seq
.compare_exchange(cur, seq, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
self.adopt(cfg, boot.load_u64(BOOT_OFF_DATA_HANDLE, Ordering::Relaxed));
}
self.data()
}
/// Map + validate a delivered DATA-section handle VALUE (untrusted until the mapped section
/// carries our magic AND our pad index). On success we own the handle (adopt-on-success) and
/// close it — the view keeps the section alive. On validation failure the handle is
/// deliberately NOT closed: a tampered value could name an unrelated handle in our own table.
fn adopt(&self, cfg: &ChannelConfig, value: u64) {
let Some(view) = MappedView::from_handle_value(value, cfg.data_size)
.or_else(|| MappedView::from_handle_value(value, cfg.min_data_size))
else {
if value != 0 {
(cfg.log)(&format!(
"[{}] delivered DATA handle 0x{value:x} did not map — ignoring",
cfg.tag
));
}
return;
};
let magic = view.load_u32(0, Ordering::Relaxed);
let idx = view.load_u32(cfg.pad_index_off, Ordering::Relaxed);
let want = self.index();
if magic != cfg.data_magic || idx != want {
(cfg.log)(&format!(
"[{}] delivered DATA section failed validation (magic 0x{magic:08x}, pad_index \
{idx}, want {want}) — ignoring",
cfg.tag
));
// `view` drops here → unmapped; the handle stays open (see above).
return;
}
// The value resolved to OUR pad's section, so it is the handle the host duplicated for us —
// we own it; the (about-to-be-leaked) view keeps the section alive after the close.
close_handle_value(value);
self.data.set(view);
(cfg.log)(&format!(
"[{}] sealed pad channel mapped (index {want})",
cfg.tag
));
}
}