95a08e99c3
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>
146 lines
12 KiB
Markdown
146 lines
12 KiB
Markdown
# 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-<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.
|