Files
punktfunk/design/gamepad-channel-sealing.md
T
enricobuehler 95a08e99c3 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>
2026-07-03 12:08:56 +00:00

18 KiB
Raw Permalink Blame History

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-checkedERROR_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_pidOpenProcessverify_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.rsShm::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).