feat(host/windows): seal the host↔driver channels (frame + gamepad, proto v2)

Frame ring (pf-vdisplay) and both gamepad SHM channels move off named Global\
objects (openable by any sibling LocalService) to UNNAMED sections/events whose
handles the host DuplicateHandles into the driver's verified WUDFHost with least
access — frame delivery over the SYSTEM+admins-only IOCTL_SET_FRAME_CHANNEL,
pads over a 32-byte named bootstrap mailbox (pid + handle value only, DoS-bounded;
HID minidrivers have no control device). Driver-validated pad_index kills
cross-pad redirects; v1↔v2 mixes fail closed with diagnosis logs on both sides.
Sibling-LocalService denial proven empirically (design/idd-push-security.md,
design/gamepad-channel-sealing.md).

Driver-side raw ops now live behind pf-umdf-util (checked shm accessors, the
forbid(unsafe_code) ChannelClient state machine, WDF request tokens) — the pad
drivers' logic is 100% safe Rust; whole drivers workspace clippy-gated in CI.

driver install --gamepad now sweeps SWD\punktfunk phantom devnodes: a re-created
SwDevice REVIVES the old devnode with its previously-bound driver (never
re-ranks), so an upgrade otherwise leaves the old driver serving — or, across
the v1→v2 fence, a dead pad (found live on the RTX box).

On-glass validated on the RTX 4090 box: frame path 7007 frames p50 2.06 ms
cross-machine; DualSense + XUSB "sealed pad channel mapped"/proto=2 attach via
both the test harness and a real streaming session; phantom-sweep repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 12:08:56 +00:00
parent a3e1ea2b44
commit 95a08e99c3
37 changed files with 2985 additions and 1174 deletions
+236
View File
@@ -0,0 +1,236 @@
# Handoff — sealing the gamepad SHM channels
Status: **implemented (Option A), 2026-07-03 — Windows CI + on-glass validation pending.** The design
below was implemented as proposed; the "Implementation notes" section records what was actually built
and the deltas. Remaining: build + sign + redeploy both pad drivers, then the hardware validation plan
(§Validation) — it needs a physical controller on the box.
This closes the one open residual left by the IDD-push sealed-channel work
(`design/idd-push-security.md`): frames were sealed; the gamepad input/output channel was not.
## Unsafe hygiene (2026-07-03 follow-up — the drivers' `unsafe` was confined)
After the seal landed, the pad drivers' `unsafe` footprint (raw `OpenFileMapping`/`MapViewOfFile`,
`read_unaligned`, the whole bootstrap state machine as bare-pointer arithmetic) was pulled into a new
audited crate **`pf-umdf-util`** (`packaging/windows/drivers/pf-umdf-util/`), so the drivers benefit
from Rust instead of being C-in-Rust:
- `section::MappedView` — a mapped section wrapped as bounds- + alignment-checked accessors
(`load_u32`/`store_u32`/`read_bytes`/…). Callers never see the base pointer; an out-of-range offset
asserts instead of corrupting. `ViewCell` holds the adopted view as a leaked `&'static` (the
re-delivery-must-not-unmap rule, now type-enforced).
- `channel::ChannelClient` — the ENTIRE sealed-channel driver side (publish pid → adopt handle →
validate magic+`pad_index`), as a **`#![forbid(unsafe_code)]`** module over `MappedView`. One
implementation both pad drivers share (was hand-duplicated).
- `wdf::{Request, query_location_index, retrieve_next_request}` — the WDF request/memory/property FFI
behind safe methods; a callback turns its raw `WDFREQUEST` into a `Request` token once (the only
`unsafe` at the driver boundary), and completion consumes the token.
Result: `pf-xusb`/`pf-dualsense` business logic is **100 % safe Rust**; the only remaining `unsafe` in
them is the unavoidable WDF *setup* FFI in `DriverEntry`/`EvtDeviceAdd`/the timer, each with a
`// SAFETY:` proof. The display driver `pf-vdisplay` is inherently FFI-bound (D3D11 / IddCx DDIs /
cross-process textures) so it can't be unsafe-*free*, but it's now unsafe-*audited*: every `unsafe {}`
carries a proof. Both invariants are lint-gated across the whole drivers workspace
(`#![deny(unsafe_op_in_unsafe_fn)]` + `#![deny(clippy::undocumented_unsafe_blocks)]`) and enforced by
a new `cargo clippy -D warnings` step in `windows-drivers.yml`. Verified on the RTX box (.173): the
whole workspace builds + clippies + fmt-checks clean; both gamepad DLLs still produce.
## Implementation notes (what was built, 2026-07-03)
- **Contract** (`pf_driver_proto::gamepad`, `GAMEPAD_PROTO_VERSION = 2`): `PadBootstrap` (32 B —
`magic "PFBT"`, `host_proto`, `driver_pid`, `driver_proto`, `data_handle: u64`, `handle_pid`,
`handle_seq`) with `Pod` + `offset_of!` asserts; `xusb_boot_name`/`pad_boot_name`
(`Global\pf…-boot-<index>`) REPLACE the old `*_shm_name` fns (the DATA-section name is gone);
`XusbShm`/`PadShm` gained `pad_index` (carved from reserved space) so the DRIVER validates a
delivery resolves to *its own* pad — the authentic-side answer to the "redirect the dup into a
different pad's WUDFHost" hardening note (the section content is host-written and unreachable by a
sibling LS, so the check can't be spoofed). Both pad drivers now path-dep `pf-driver-proto` (as
pf-vdisplay does) instead of hand-synced literals.
- **Host** (`inject/windows/gamepad_raii.rs`): `Shm::create_unnamed` (DATA, `D:P(A;;GA;;;SY)`) +
`Shm::create_named` (mailbox, SY+LS, **squat-checked**`ERROR_ALREADY_EXISTS` on create is
close+retry×5 then a hard error, so the handshake never runs through a pre-created object; this also
turns the previously-silent two-hosts-same-index cross-wire into a loud failure). `PadChannel` owns
both + the delivery state machine: poll `driver_pid``OpenProcess`
`verify_is_wudfhost` (now shared with the frame broker in `capture/windows/idd_push.rs`) →
`DuplicateHandle` → publish `data_handle`/`handle_pid`, bump `handle_seq` last (Release). Pumped
from each backend's existing service tick (≤4 ms) + a bounded **eager delivery** (1.5 s) at pad-open
so the DS4's `device_type` is readable before hidclass asks for descriptors. Delivery attempts are
**capped at 16 per pad** so a tampered flapping mailbox can't mint unbounded remote handles. Same
pid never retried (failed verify can't be spun into a hot loop).
- **Drivers** (`pf-xusb`, `pf-dualsense`): per-tick `pump_bootstrap()` (the DS timer / every XUSB
IOCTL + a bounded EvtDeviceAdd worker thread for XUSB's no-game-running case) opens the mailbox *by
name each time* — the name existing doubles as host-liveness, replacing the old per-access section
open; mailbox gone → detach (DS additionally resets the pended-read report to neutral instead of
the old frozen-last-state behavior). The driver writes `driver_proto` always but publishes its pid
**only when `host_proto` matches** (fail closed both ways: v1 host never creates a mailbox a v2
driver polls; a v1 driver opens a name that no longer exists). A delivery is adopted once
(CAS on `handle_seq`, reset when the mailbox disappears so a new host session's counter can't
collide), mapped, and validated: `magic` AND `pad_index == SHM_INDEX` — else unmapped + ignored
(the handle is deliberately NOT closed on validation failure: a tampered value could name an
unrelated handle in the driver's own table). The adopted view is cached and never unmapped
(re-delivery swaps + leaks the old 64/256 B mapping on purpose — a concurrent reader may hold it).
Driver log line for validation step 3: `sealed pad channel mapped (index …)`.
- **Not built:** Option B (devnode custom properties). The residual named mailbox is documented and
DoS-bounded; migrate later if it's ever deemed worth removing.
## The problem (why this exists)
Each virtual pad's host↔driver channel is a **named** shared-memory section:
- `Global\pfxusb-shm-<index>` (64 B, [`pf_driver_proto::gamepad::XusbShm`]) — virtual Xbox 360 / XInput.
- `Global\pfds-shm-<index>` (256 B, [`pf_driver_proto::gamepad::PadShm`]) — virtual DualSense / DualShock 4.
Both are created by the SYSTEM host with DACL `D:(A;;GA;;;SY)(A;;GA;;;LS)` (`inject/windows/gamepad_raii.rs`
`Shm::create`) so the driver's WUDFHost (LocalService) can open them by name. That means **a sibling
LocalService process can `OpenFileMapping` the section by name** and:
- **read** the victim's live controller input (buttons/sticks/gyro/touchpad — host→driver `input` region), and
- **inject/forge** gamepad input or rumble (write the `input` region → the driver feeds it to whatever game
has focus; write the `output` region + bump `out_seq` → forge rumble/LED back to the client).
This is the *same* name-open vector we closed for frames, one module over. Severity is lower than desktop
capture (it's game-controller I/O, scoped to the focused app, and requires the attacker to already have
LocalService code execution), but it is real and it is inconsistent to leave named next to a sealed frame ring.
**Not a stopgap:** randomizing the section name is inadequate — the object namespace is enumerable with
`NtQueryDirectoryObject`, so a random name is discoverable. (Same reason it was rejected for frames.) The fix
is to remove the name.
## Why it isn't already sealed the frame way
The frame channel seals cleanly because pf-vdisplay has a **control device** (the IddCx device interface):
the host duplicates the unnamed handles into the driver's WUDFHost and delivers the values over
`IOCTL_SET_FRAME_CHANNEL`, and the driver reports its own pid in the `IOCTL_ADD` reply.
The pad drivers (`pf-dualsense`, `pf-xusb`) are **UMDF HID minidrivers with no control device** — hidclass
owns the device stack and blocks a freely-openable control interface. That is *why* they use a named section
in the first place. So there is no IOCTL to (a) hand the driver a duplicated handle or (b) learn the driver's
WUDFHost pid. Compounding it: `pszDeviceLocation` (the existing host→driver property) is fixed at
`SwDeviceCreate` time — **before** the WUDFHost process exists — so the host can't duplicate a handle into a
not-yet-created process and stamp its value there. A bidirectional, late-bound handshake is required.
## Current architecture (what to modify)
Host (`crates/punktfunk-host/src/inject/windows/`):
- `gamepad_raii.rs``Shm::create(name, size)` creates the **named** section (SY+LS SDDL) + maps it;
`SwDevice` wraps the `SwDeviceCreate` devnode.
- `gamepad_windows.rs` (XUSB), `dualsense_windows.rs` (DualSense/DS4), `dualshock4_windows.rs` — each creates
its `Shm`, then `create_swdevice(index)` / `create_swdevice(profile)` which stamps the pad **index** into
`info.pszDeviceLocation` (a UTF-16 decimal string) and creates `pf_xusb_<index>` / `pf_pad_<index>`.
Driver (`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`):
- `query_shm_index(device)``WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` → parses the
decimal → `SHM_INDEX` static.
- On first control activity it builds `format!("Global\\pf…-shm-{}", SHM_INDEX)`, `OpenFileMappingW` +
`MapViewOfFile`. The dualsense driver also runs a ~125 Hz timer (writes `driver_heartbeat`) — an existing
poll loop to piggyback a bootstrap-wait on.
Contract (`crates/pf-driver-proto/src/lib.rs` `mod gamepad`): owns `XusbShm`/`PadShm` layouts, the magics,
`xusb_shm_name`/`pad_shm_name`, `device_type`, `GAMEPAD_PROTO_VERSION`, and the driver_proto/heartbeat fields.
## Proposed design — a late-bound bootstrap handshake
Split each pad's channel into **(1) an unnamed DATA section** (the real `XusbShm`/`PadShm`, host↔driver) and
**(2) a tiny bootstrap mailbox** that carries only a magic + the driver's pid + a handle value. The handshake:
1. **Host**, per pad: create the DATA section **unnamed** (`CreateFileMappingW` with `PCWSTR::null()`, DACL
`D:P(A;;GA;;;SY)` — SYSTEM-only, exactly as the sealed frame ring now uses; the driver reaches it by
duplicated handle, which carries access, so no LS ACE is needed). Then create the devnode via
`SwDeviceCreate`, stamping the pad index into `pszDeviceLocation` **as today** (the index still identifies
*which* pad's bootstrap the driver should use).
2. **Driver** `EvtDeviceAdd`: read the index (unchanged `query_shm_index`). Write `std::process::id()` where
the host can read it, then **poll** (piggyback the existing timer) for a delivered handle value; map the
DATA section from it once non-zero.
3. **Host**: learn the driver's pid, `OpenProcess(PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION)`,
**verify it is the WUDFHost servicing this pad's devnode** (see hardening note), `DuplicateHandle` the
DATA section into the WUDFHost, and deliver the resulting handle value back to the driver.
Two viable transports for steps 23's pid-out / handle-in (pick one):
- **Option A — named bootstrap mailbox** (`Global\pf…-boot-<index>`, ~32 B, SY+LS): host creates it; driver
opens it by name (index from location), writes `driver_pid`, spins on `data_handle` != 0; host polls
`driver_pid`, dups the DATA section in, writes `data_handle` + a ready seq. **Safe to leave named + SY+LS**
because it carries *only* a pid (not sensitive) and a handle value (meaningless outside the target WUDFHost)
— identical to the frame channel's "the bootstrap ACL is not load-bearing" argument. A sibling LS that reads
it learns nothing exploitable; one that tampers it can at worst feed a bogus pid/handle → the driver maps a
value that doesn't resolve in its own table → **DoS, not a breach** (the attacker cannot place a valid
section handle in the WUDFHost, so it cannot make the driver map an attacker-controlled section). *Fastest to
build — reuses the existing named-section + poll machinery.*
- **Option B — devnode custom properties** (no `Global\` object at all): driver writes its pid via
`WdfDeviceAssignProperty(DEVPROPKEY_pf_pad_pid)`; host reads it via `CM_Get_DevNode_PropertyW` /
`SetupDiGetDevicePropertyW`, dups in, writes a `DEVPROPKEY_pf_pad_handle` property; driver re-queries it in
its timer. Tighter (property store isn't world-readable like the Global namespace) but more moving parts and
UMDF-property-write ergonomics to prove out. *Cleaner end-state.*
Recommendation: **build Option A first** (small, mirrors the frame channel, gets the DATA section unnamed —
which is the actual isolation win, proven by #3 below), then optionally migrate the bootstrap to Option B if
the residual named mailbox is deemed worth removing.
## Reuse the frame-channel precedent
- **Ownership/adopt-on-success** discipline from `capture/windows/idd_push.rs` `ChannelBroker` — exactly one
side ever closes a duplicated handle value; reap remote duplicates (`DUPLICATE_CLOSE_SOURCE`) on any failure.
- **`verify_is_wudfhost`** (`idd_push.rs`) — before duplicating into the driver-reported pid, confirm it's
`%SystemRoot%\System32\WUDFHost.exe`. **Strengthen it here**: also confirm the pid is the host *servicing
this pad's devnode* (walk devnode → process, e.g. via the driver writing a per-pad nonce it echoes, or a
devnode/PID association) so a tampered bootstrap can't redirect the dup into a *different* pad's WUDFHost.
- **Contract in `pf_driver_proto::gamepad`** — add the bootstrap layout (`PadBootstrap { magic, driver_pid,
data_handle: u64, seq }`) with `Pod` + `offset_of!` asserts, bump `GAMEPAD_PROTO_VERSION`, and (Option A)
keep `pad_shm_name`/`xusb_shm_name` only for the bootstrap mailbox, dropping the data-section name.
- **SDDL** on the DATA section: `D:P(A;;GA;;;SY)` (SYSTEM-only) — validated safe for a duplicated-handle
consumer on the frame ring (the driver's `OpenSharedResource`/`MapViewOfFile` on a handle does not re-check
the object DACL).
## Security properties after the change
- The **DATA section is unnamed** and only ever handle-duplicated into the pad WUDFHost. Empirically
(`design/idd-push-security.md`, RTX box 2026-07-03) a **LocalService token is DACL-denied `OpenProcess` on a
UMDF WUDFHost for every access right incl. `QUERY_LIMITED`** — so a sibling LS cannot dup the handle out or
read the WUDFHost's memory. Unnamed + unopenable-host ⇒ no sibling-LS path to the input/output data. This is
the same guarantee the frame channel now has, and it rests on the same verified property.
- **Residual (Option A):** the bootstrap mailbox stays named + SY+LS, but carries only a pid + handle value →
worst case a sibling LS causes a **gamepad DoS**, never a read or injection. Option B removes even that.
- **Unchanged inherent limits:** admin/SYSTEM = total; the game reading the pad sees the input by design.
## Validation plan (needs hardware)
The blocker for calling this done is that it **requires a physical controller on the box** — the memory notes
repeatedly flag the gamepad path as "needs a physical pad to live-verify," and neither the probe nor a
synthetic client exercises a real game reading the virtual pad.
1. Build + sign + redeploy `pf-dualsense` and `pf-xusb` (same loop as pf-vdisplay:
`packaging/windows/drivers/deploy-dev.ps1` per driver, or `redeploy-*`; DriverVer must strictly increase).
Bump `GAMEPAD_PROTO_VERSION` — a v_new host against a v_old pad driver (or vice-versa) must fail closed, so
deploy host + both pad drivers together.
2. Connect a real client with a physical controller; confirm in a game that input works and rumble/LED return.
3. Driver log (`C:\Users\Public\pfds-driver.log` / `pfxusb-driver.log` in debug builds): confirm the driver
reports its pid, receives a handle, and maps the DATA section (add a `dbglog!` "sealed pad channel mapped").
4. Re-run the **sibling-LS `OpenFileMapping` test**: from a LocalService scheduled task, attempt to open the
old `Global\pf…-shm-<index>` name — it must now **fail (name gone)**, and attempting to open the bootstrap
(Option A) must yield only pid+handle bytes. (Reuse the scheduled-task P/Invoke harness from the #3 frame
test — see the session that produced `design/idd-push-security.md`.)
5. Multi-pad: two controllers → two devnodes, two unnamed DATA sections, two bootstraps by index; confirm no
cross-talk and clean teardown (`SwDeviceClose` + host handle close; the WUDFHost dies with its devnode).
## Risks / gotchas
- **Regression risk to a working feature.** Gamepad input currently works on glass; this reroutes its
bootstrap. Keep the change behind the `GAMEPAD_PROTO_VERSION` bump and be ready to revert both drivers.
- **Chicken-and-egg timing.** The driver loads and wants the handle before the host has dup'd it — the poll
loop must tolerate a bounded wait (mirror the frame path's `wait_for_attach`, ~4 s) and the driver must not
block `EvtDeviceAdd` on it (spin in the timer, not the add callback).
- **Handle value in shared memory is a `u64`.** A WUDFHost handle value is process-local; writing it to the
bootstrap is safe (meaningless elsewhere), but the driver must treat it as untrusted (validate the mapped
DATA section's magic before use — the existing `XusbShm`/`PadShm` magic already gives this).
- **Two drivers, one contract.** DualSense and DualShock 4 share `pf-dualsense`/`PadShm`; XUSB is separate.
Factor the bootstrap into `pf_driver_proto::gamepad` so both drivers + the host use one definition (as the
frame channel does).
## Effort
Medium — comparable to the frame sealed-channel change but across **two** drivers plus the host inject code,
and gated on **physical-controller validation** that can't be driven over SSH. Files: `pf_driver_proto`
(gamepad module), `inject/windows/{gamepad_raii,gamepad_windows,dualsense_windows,dualshock4_windows}.rs`,
`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`. Reference implementation: the frame sealed channel
(`capture/windows/idd_push.rs` + `packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs`
+ `pf_driver_proto` `control`/`frame`).