Files
punktfunk/design/idd-push-security.md
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

12 KiB

IDD-push frame channel — security model (the sealed channel)

Status: implemented (host capture/windows/idd_push.rs + driver packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs, contract crates/pf-driver-proto v2). Windows CI-validated; on-glass validation pending.

What is being protected

The IDD-push path moves whole-desktop frames — including the secure desktop (UAC prompts, the lock screen) — from the pf-vdisplay driver (UMDF, running in a WUDFHost.exe under LocalService) into the SYSTEM host for encoding. That data is SYSTEM-tier-sensitive, and because we bypass the OS capture APIs (Desktop Duplication / WGC), we own the isolation those APIs would have provided.

DDA's isolation property is that capturer and consumer are the same process: there is no openable channel at all — to reach the frames you must own the capturing process. The sealed channel reproduces exactly that property for our two-process design.

The design

┌──────────────────────────┐        control device (SY+BA only)       ┌───────────────────────────┐
│  Host (SYSTEM service)   │ ── IOCTL_SET_FRAME_CHANNEL: handle ────▶ │  pf-vdisplay driver        │
│  creates header/event/   │        VALUES only (integers)            │  (WUDFHost, LocalService)  │
│  ring textures UNNAMED,  │                                          │  maps/opens the duplicated │
│  DuplicateHandle()s them │ ◀── frames via keyed-mutex textures ──── │  handles; publishes frames │
│  INTO WUDFHost, encodes  │            (no names anywhere)           │                            │
└──────────────────────────┘                                          └───────────────────────────┘
     trust boundary: only these two processes ever hold a handle to any frame object
  1. Every frame object is unnamed (header section, frame-ready event, all ring textures — CreateFileMappingW/CreateEventW/CreateSharedHandle with a null name). An unnamed object is in no namespace: it cannot be enumerated (NtQueryDirectoryObject can't see it), cannot be opened by name, and cannot be pre-created ("squatted"). It can be shared only by handle duplication.
  2. The host is the broker. SYSTEM opens the driver's WUDFHost with PROCESS_DUP_HANDLE (the pid comes from the IOCTL_ADD reply, per-monitor, so a WUDFHost restart can't leave us duplicating into a dead process) and DuplicateHandles each object in. The reverse direction — LocalService injecting into SYSTEM — is correctly denied by the OS, which is why the broker must be the host.
  3. The bootstrap carries only integers. IOCTL_SET_FRAME_CHANNEL delivers the duplicated handle values. A handle value is only meaningful inside the target process's handle table: a third party that read (or even forged) the message would learn nothing openable and could at most feed values that don't resolve — a DoS of its own session, not a read. The bootstrap's ACL is therefore not load-bearing; we still restrict the control device to D:P(A;;GA;;;SY)(A;;GA;;;BA) (INF Security), because ADD/REMOVE/CLEAR_ALL shouldn't be world-callable either.

Net result: the only way to reach the frames is to already run code as SYSTEM (the host) or inside that specific WUDFHost (the driver) — DDA's property, achieved in user mode.

Why user-mode, not a kernel driver

Ring level does not govern cross-process memory visibility — the handle/VAD access checks do; a user process cannot ReadProcessMemory a LocalService process regardless of rings. What kernel-mode would change is the blast radius of a driver bug: UMDF caps a pf-vdisplay compromise at the LocalService token, a KMDF display driver would make it ring-0 full-system. Least-blast-radius is the reason punktfunk ships zero kernel drivers (the gamepad stack dropped ViGEmBus for UMDF for the same reason). The correct control for "SYSTEM-tier data in the channel" is sealing the channel — done above — not raising the ring.

Handle-lifetime invariants (the auditable list)

  1. Frame objects unnamed; bootstrap carries only handle values. ✔ by construction
  2. bInheritHandle: false on every object — no child inherits a handle. ✔
  3. Zero-init header + atomic magic-last publish (the driver never acts on a half-initialized ring); generation-tagged publish tokens reject stale-ring frames. ✔
  4. Attacker-influenced header fields are bounds-checked before use (generation/seq/slot unpacking; ring_len clamped; the driver validates IOCTL_SET_FRAME_CHANNEL before adopting anything). ✔
  5. Adopt-on-success-only: the driver owns (and eventually closes) the delivered handles iff the IOCTL completed successfully; on ANY error completion it leaves them untouched and the host reaps its remote duplicates (DUPLICATE_CLOSE_SOURCE). Exactly one side closes each value — no double-close of possibly-reused handle values, no leak on a half-delivered channel. ✔
  6. Single ownership inside the driver: each delivery lives in exactly one place (monitor stash → publisher), and whichever owner dies — replaced stash, dropped publisher, removed monitor, reaped watchdog, departed device — closes the handles (FrameChannel/publisher Drop). Host-side objects are RAII (MappedSection, OwnedHandle); nothing survives the capturer. ✔
  7. The object DACL is D:P(A;;GA;;;SY)SYSTEM only, protected. Since the driver reaches the objects via duplicated handles (which carry their own access; OpenSharedResource1 on a handle does not re-check the object DACL), the LocalService ACE was dropped — the minimal DACL. ✔ (on-glass confirmed 2026-07-03: the driver still attaches + delivers frames with SYSTEM-only objects.)
  8. The duplication target is verified. Before duplicating frame handles into AddReply.wudf_pid, the host confirms that pid is %SystemRoot%\System32\WUDFHost.exe (verify_is_wudfhost). A spoofed devnode advertising our interface GUID cannot redirect frames to an arbitrary process. ✔
  9. Handles are duplicated with least privilege, not DUPLICATE_SAME_ACCESS. The driver's copy of the header section is SECTION_MAP_READ|WRITE (matched by the driver mapping FILE_MAP_READ|WRITE, not FILE_MAP_ALL_ACCESS), the frame-ready event is EVENT_MODIFY_STATE (the driver only signals it), and the ring textures keep their already-scoped CreateSharedHandle access (DXGI_SHARED_RESOURCE_READ|WRITE). So a compromised driver's handles can map/signal but cannot WRITE_DAC/WRITE_OWNER/DELETE the objects — the "give unnamed shared objects proper (minimal) security attributes, because DuplicateHandle can still reach them" discipline (Raymond Chen, devblogs 2015-06-04). Marginal here (the driver is already a trusted frame endpoint) but correct hygiene, and it applies identically to the gamepad DATA section. ✔ (on-glass confirmed 2026-07-03: the driver attaches + streams frames=7035 with the least-access header handle.)

Ring recreation (mid-session HDR flip) and host build-retries re-deliver a complete fresh handle set; the driver treats a pending delivery as newest-wins (a retry's ring is a different header mapping, whose generation bump an old publisher can never observe).

Empirical verification (2026-07-03, RTX box)

The headline claim — "reaching a frame requires already being one of the two endpoint processes" — was tested, not just argued. A LocalService-token process (scheduled task, the sibling-service stand-in) attempting OpenProcess on the pf_vdisplay WUDFHost was denied every access right: PROCESS_DUP_HANDLE, PROCESS_VM_READ, PROCESS_QUERY_INFORMATION, and even PROCESS_QUERY_LIMITED_INFORMATIONERROR_ACCESS_DENIED. The QUERY_LIMITED denial is decisive: it is a read-class right MIC permits across integrity levels, so its denial is a DACL exclusion of the LocalService SID, not an integrity ceiling — meaning even a higher-integrity LocalService service is denied (LocalService lacks SeDebugPrivilege, so it cannot bypass the DACL). Combined with the objects being unnamed, a sibling LocalService has no reachable path to a frame: no name to open, no way to dup the handles out of WUDFHost, no way to read WUDFHost's memory. The baseline (an elevated admin, holding SeDebugPrivilege) opened WUDFHost freely — expected, and the reason "admin/SYSTEM = total" stays on the residual list below.

Residual limits — the honest floor

  • The virtual display is a real monitor. Any process in the interactive session can capture it through the ordinary OS APIs (DDA/WGC/BitBlt), exactly as it can capture any physical monitor. That floor is identical for every virtual-display streaming stack (Sunshine + VDD, Apollo/SudoVDA); the sealed channel keeps our transport above that floor rather than below it. This is the single most realistic way for unprivileged session code to see the streamed pixels, and it is outside our channel entirely.
  • The gamepad channels are now sealed too (2026-07-03, design/gamepad-channel-sealing.md, gamepad proto v2 — on-glass validation pending): the pad DATA sections (XusbShm/PadShm) are UNNAMED with D:P(A;;GA;;;SY), handle-duplicated into the pad's WUDFHost by the host broker (inject/windows/gamepad_raii.rs PadChannel, reusing this design's verify_is_wudfhost + adopt-on-success discipline), and the driver validates the mapped section's magic + pad_index before use. The pad drivers have no control device (hidclass), so the handshake runs over a tiny named bootstrap mailbox (Global\pf…-boot-<index>, SY+LS, PadBootstrap) that carries only pids and a handle value — nothing exploitable; the residual is that a sibling LocalService can tamper the mailbox for a gamepad DoS (never a read or an injection; deliveries are capped, and the mailbox is squat-checked at create). The old sibling-LS read/inject vector on Global\pf…-shm-* is gone — the names no longer exist.
  • Admin / SYSTEM = total. The control device is D:P(A;;GA;;;SY)(A;;GA;;;BA), so an admin can drive IOCTL_SET_FRAME_CHANNEL (DoS a live session) and, with SeDebugPrivilege, dup a section into WUDFHost to exfiltrate; and an admin can plant a fake devnode with our interface GUID to impersonate the driver. All admin-gated (no non-privileged escalation), but the control plane is explicitly not a boundary against admin. The host↔driver channel has no mutual authentication beyond the GET_INFO version handshake + the verify_is_wudfhost image check.
  • WDA_EXCLUDEFROMCAPTURE windows are visible. IDD-push taps the present side, not the capture side, so windows that exclude themselves from capture still appear in the stream — true of every virtual-display streaming stack. Untested on our lab box; treat as expected behavior.
  • DRM/HDCP: protected content is blanked by DWM at composition, and HDCP is a monitor↔GPU handshake an indirect display cannot satisfy — neither is bypassed by this path.
  • IDD-push is currently the sole Windows capture path (DDA and the WGC relay were removed). An OS-mediated-capture-only mode would trade away secure-desktop capture and latency; if a deployment requires it, that's a feature request, not a toggle that exists today.