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:
@@ -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 2–3'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`).
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user