# 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 `DuplicateHandle`s 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_INFORMATION` → `ERROR_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-`, 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.