diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index 596a0ca3a..2268219b1 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -49,6 +49,7 @@ jobs: signPattern: | wxc-exec.exe wxc-host-prep.exe + mxc-learning-mode-broker.exe winhttp-proxy-shim.exe wxc-windows-sandbox-daemon.exe wxc-windows-sandbox-guest.exe diff --git a/build.bat b/build.bat index cb7b170e3..2b386af13 100644 --- a/build.bat +++ b/build.bat @@ -108,6 +108,10 @@ for %%T in (x86_64-pc-windows-msvc aarch64-pc-windows-msvc) do ( copy /Y "!BIN_DIR!\wxc-host-prep.exe" "sdk\bin\!SDK_ARCH!\" >nul echo Copied !SDK_ARCH!\wxc-host-prep.exe ) + if exist "!BIN_DIR!\mxc-learning-mode-broker.exe" ( + copy /Y "!BIN_DIR!\mxc-learning-mode-broker.exe" "sdk\bin\!SDK_ARCH!\" >nul + echo Copied !SDK_ARCH!\mxc-learning-mode-broker.exe + ) if "%WITH_NANVIX%"=="1" ( for %%B in (nanvixd.exe nanvix_rootfs.img python3.initrd) do ( if exist "!BIN_DIR!\%%B" ( diff --git a/docs/host-prep.md b/docs/host-prep.md index 64e077a0a..d842a594b 100644 --- a/docs/host-prep.md +++ b/docs/host-prep.md @@ -28,6 +28,9 @@ privilege-requiring setup work lives in `wxc-host-prep.exe` instead. | `prepare-null-device` | Apply MXC's managed security descriptor to `\Device\Null`. | | `verify-null-device` | Check `\Device\Null` SD against the target without modifying it. | | `dump-null-device` | Print the current `\Device\Null` SD as SDDL. | +| `install-learning-mode-broker` | Register `mxc-learning-mode-broker.exe` as a Manual-start `LocalService` service so unelevated callers can request scoped ETW sessions for per-PID denial capture. | +| `uninstall-learning-mode-broker` | Stop and deregister the `MxcLearningModeBroker` service. Idempotent. | +| `dump-learning-mode-broker` | Report whether the `MxcLearningModeBroker` service is installed, its current state, and the registered binary path. | All subcommands require elevation. The binary aborts with exit code `65` and a clear message if launched without an elevated token (e.g. @@ -211,6 +214,80 @@ Use for triage after `verify-null-device` reports drift. drift label; `dump-null-device` deliberately only reports the current SD. +### `install-learning-mode-broker` + +``` +wxc-host-prep install-learning-mode-broker [--broker-path ] +``` + +Registers `mxc-learning-mode-broker.exe` as a Windows service named +`MxcLearningModeBroker`: + +- **Account**: `NT AUTHORITY\LocalService` — least-privilege built-in + account. `LocalService` does **not** carry + `SeSystemProfilePrivilege` by default, so this subcommand first + grants the privilege explicitly via the LSA `LsaAddAccountRights` + API. The grant is persistent (survives reboots) and idempotent + (no-op when already granted). The grant is **not** revoked on + `uninstall-learning-mode-broker`: another tool on the box may also have + granted the same privilege, and clobbering it would be + destructive. Operators who want a clean revert can run + `secedit` or `ntrights -u SeSystemProfilePrivilege -m \\. + -u "NT AUTHORITY\LocalService"` manually. +- **Start type**: `Demand` / Manual. SCM idle-shutdown stops it + after a short period of inactivity; the next inbound pipe + request restarts it. +- **Display name**: `MXC Denial Capture Broker`. +- **Install location**: the broker binary is **copied into + `%ProgramFiles%\Mxc\mxc-learning-mode-broker.exe`** and the service is + registered to run from there. `Program Files` is writable only by + administrators / SYSTEM / TrustedInstaller, so unprivileged + (Authenticated Users) callers cannot swap the registered service + binary. The directory is resolved via the Known Folders API + (`FOLDERID_ProgramFiles`), so it is correct on localized / redirected + installs. +- **Source binary** (`--broker-path`): the source `mxc-learning-mode-broker.exe` + to copy from, defaulting to the same directory as `wxc-host-prep.exe`. + The install always copies that source into `%ProgramFiles%\Mxc`; + `--broker-path` only changes where the bits are copied *from*. + +Re-running the install with the same registration refreshes the copied +binary (so an install after a rebuild updates the bits). A service +registered with a *different* binary path is an explicit conflict — run +`uninstall-learning-mode-broker` first. + +The broker itself loans ETW-session-creation privilege to +unelevated callers (e.g. `wxc-exec --capture-denials …`). It +does not consume events; the caller drives `OpenTrace` / +`ProcessTrace` against the session name returned by the broker. +See the per-PID denial-capture prototype plan for design notes. + +### `uninstall-learning-mode-broker` + +``` +wxc-host-prep uninstall-learning-mode-broker +``` + +Stops the `MxcLearningModeBroker` service if running, then +`DeleteService`s it, and best-effort removes the copied +`%ProgramFiles%\Mxc\mxc-learning-mode-broker.exe` binary (and the `Mxc` +directory if now empty). Idempotent — exits 0 with a "no change" +message when the service is already absent. Failures during the +best-effort stop or binary removal are non-fatal; the delete still +proceeds and the OS reaps the service on its next service restart. + +### `dump-learning-mode-broker` + +``` +wxc-host-prep dump-learning-mode-broker [--json] +``` + +Reports whether `MxcLearningModeBroker` is installed, its current state +(`Running` / `Stopped` / etc.), and the registered binary path. +Exit code 0 when installed, 1 when not installed. With `--json` +emits a single-line JSON object for machine consumption; without +`--json` emits a short human-readable block on stdout. + ## Logs `prepare-null-device` writes a JSON-Lines log record to diff --git a/docs/learning-mode/deployment-and-lifecycle.md b/docs/learning-mode/deployment-and-lifecycle.md new file mode 100644 index 000000000..0f8b697a0 --- /dev/null +++ b/docs/learning-mode/deployment-and-lifecycle.md @@ -0,0 +1,711 @@ +# Learning-mode capture: deployment & lifecycle design + +Status: **Draft for review.** Owner: learning-mode capture feature. +Reviewers: Tessera crew (`@microsoft/tessera-code-reviewers`). +Related: [`architecture.md`](./architecture.md) (box layout + runtime data flow), +[`consumer-guide.md`](./consumer-guide.md) (application integration contract and gotchas), +[`../host-prep.md`](../host-prep.md) (broker install/uninstall command reference). + +This document exists because the captureDenials work (PR #558 and the stacked PRs +behind it) introduced a **privileged Windows service** — the +`MxcLearningModeBroker` — and reviewers asked the natural follow-on questions that +the code alone does not answer: + +1. **Developer workflow** — end to end, what does a consumer do to *use* + `captureDenials`? Who installs the broker, when, and with what privileges? +2. **Cleanup logic** — if applications cause the service to be created, what + removes it? What about the `SeSystemProfilePrivilege` grant that uninstall + deliberately leaves behind? +3. **Per-app vs shared** — is there one service per application, or one shared + machine-wide service for all consumers? Is that the right model? +4. **Packaging fit** — MXC ships an npm package (`@microsoft/mxc-sdk`) today and + a Rust crate tomorrow. Neither packaging model can register an elevated, + machine-wide OS service at install time. How does broker provisioning fit? + +Per CONTRIBUTING.md §"To spec or not to spec", this is the short written design +we agree on **before** building any of the workflow/lifecycle automation the +questions imply. + +**Scope:** Windows only. The broker is a Windows construct; the Linux/macOS +learning-mode adapters are stubs today (`Err(NotSupported)`) and their +deployment model is explicitly out of scope and called out as future work in the +last section. + +--- + +## TL;DR of the recommendation + +- **Keep one shared, machine-wide service.** Do not move to per-app services. + Cross-tenant isolation is already enforced *per call* by the broker's + impersonate-then-`OpenProcess` check, not by service instancing. +- **Provisioning is an explicit, elevated, out-of-band step — never an app/runtime + side effect, never an npm/cargo install hook.** The SDK and `wxc-exec` stay + unprivileged and only *consume* a broker that an administrator/provisioning + system installed. +- **The SDK gains an unprivileged preflight** (a read-only SCM status probe — see + Q1) so a consumer gets one clear, actionable error ("broker not installed — run + `wxc-host-prep install-learning-mode-broker` as admin") *before* a wasted run, + instead of silently seeing zero denials. +- **Cleanup stays explicit and idempotent** (`uninstall-learning-mode-broker`). + We do **not** add app-driven ref-counting. We *do* make the privilege grant + self-describing — enumerate before granting, record whether *we* added + `SeSystemProfilePrivilege`, and revoke on uninstall only when we did — so an + uninstall is a safe-by-default revert instead of leaving a permanent residual + grant. +- **Packaging:** binaries (`mxc-learning-mode-broker.exe`, `wxc-host-prep.exe`) + ship inside the npm package payload (and, later, are produced by the crate's + build), but **activation is a documented, separate admin/MDM/CI step**, not an + install hook. + +The rest of this doc justifies each of those choices against the alternatives. + +--- + +## Developer workflow in Windows (high level) + +We introduce a new **shared, machine-wide service** whose role is to let +*unprivileged* callers read the ETW traces written when the learning-mode +capability is injected into a process and an access check is **denied**. + +The service is managed by **`wxc-host-prep.exe`** — it can be +installed/uninstalled/inspected through it. An application that uses MXC sandbox +capabilities and also wants the **Learning Mode retry** capability runs +`wxc-host-prep.exe` to install the service. If the service already exists the +install is a **no-op**. The service does **not** auto-start at login (it is +Manual/Demand-start; the SCM idle-stops it and the next inbound request restarts +it). + +`wxc-host-prep.exe` exists so we can keep **`wxc-exec.exe` unprivileged**: all +elevated machine-state changes live in host-prep. Installation also grants +`SeSystemProfilePrivilege` to `LocalService`. The application is responsible for +running the host-prep **uninstall** as part of its own uninstall flow. + +**Distribution.** The npm package payload carries the native binaries +`mxc-learning-mode-broker.exe` and `wxc-host-prep.exe`. (No install hooks — see Q4.) + +**Provisioning.** Once per machine, by running `wxc-host-prep +install-learning-mode-broker`. It installs the service and grants the privilege. + +**Use.** If the service is missing, an unprivileged SDK preflight **fails fast** +with a message telling the user how to remedy it (run the host-prep install). +Otherwise, when `captureDenials` is `true`, the shared service brokers a scoped, +per-PID ETW session for the workload and denials are collected. + +**Uninstall.** Driven by `wxc-host-prep uninstall-learning-mode-broker`. Revokes +`SeSystemProfilePrivilege` only if our install marker recorded that *we* granted +it (track-and-revoke; see Q2). + +--- + +## Application integration: surfacing denials & the approval hook + +Provisioning gets the *capability* onto the box; this section is what the +*application* codes against once the broker is installed. It is OS-independent — +the wire shape is identical on every platform the feature lands on — and is the +answer to "how does my app learn a path was denied, and how do I ask the user to +allow it?" + +> **The SDK no longer drives this loop.** The native `wxc-exec` binary streams +> denials and the **consumer owns** parsing, consent, and the re-spawn loop. +> The SDK keeps only the generic `createConfigFromPolicy` / `spawnSandboxFromConfig` +> surface plus the `captureDenials` field. A reference implementation of the +> parser, the anonymous-pipe `--denials-fd` transport, the default filters, and policy expansion +> lives in the native E2E harness +> (`src/testing/wxc_e2e_tests/src/denial_consumer.rs`); the descriptions below are +> the contract that harness (and any consumer) implements. + +### The denial record + +Every denied access surfaces as a typed `DeniedResource` on the wire: + +```ts +{ + type: 'denial', + path: string, // e.g. C:\Users\me\secret.txt + resourceType: 'file' | 'network' | 'ui' | 'other', + accessType: 'read' | 'write' | 'execute' | 'unknown', + pid: number, + filetime: bigint, +} +``` + +Records are deduped by `(path, accessType)` upstream, and the consumer's default +filters strip the OS "background hum" (loader DLL probes, etc.) so the +application only sees actionable denials. + +### Two delivery modes + +- **Real-time, per denial** — each denial is emitted the instant it occurs as its + own `0x1E`-framed NDJSON `denial` record. The consumer reads these live (off + `wxc-exec`'s **stderr** in pipe mode, or the anonymous pipe read end paired + with **`--denials-fd `** in PTY mode) and can prompt or log per denial mid-run. +- **Consolidated, per run** — the summary terminator line carries the deduped + `deniedResources` array, giving the consumer a race-free single read after exit. + This is the batch that powers the approve-and-retry UX below. + +### The approval hook + +The hook an application uses to ask the user "allow this?" is **consumer-owned** — +it is no longer an SDK callback. The consumer collects the run's denials (live +and/or from the summary), drives whatever approval UX it wants (dialog, CLI +prompt, policy file, …), and decides which paths to grant. + +### What the consumer does with the decision + +1. If the user **approved** at least one denial, the consumer expands its base + config — adding exactly those approved paths to + `filesystem.readonlyPaths` / `readwritePaths` — refusing OS-security-critical + paths even if approved. +2. It **re-spawns the workload once** with the expanded config (enforcement is + non-blocking, so a grant only takes effect on the next run — it cannot + un-fail the already-denied operation). +3. Any paths still denied (or newly hit) surface on the next run, so the + application can prompt again or surface the final state. + +``` +spawn (captureDenials: true) + │ + ▼ +denials stream ──► consumer collects + prompts user ◄── returns approved paths + │ + ▼ +consumer expands config with approved paths + │ + ▼ +re-spawn with expanded config ──► still-denied? ──► prompt again (next round) +``` + +### Guardrails + +- **The consumer owns the cadence.** A typical loop caps at one prompt-and-retry + round; multi-round approval is the consumer's choice. MXC does a single run per + invocation and never loops on its own. +- **Refuse system-critical paths.** Even if the user approves them, the consumer's + policy-expansion must skip OS-security-critical paths (SYSTEM hives, + `kernel32.dll`, …). The reference `expand_readonly_paths` in `denial_consumer.rs` + does this. +- **PTY mode uses an anonymous inherited-handle transport** (`--denials-fd`) instead of stderr, + because the workload owns the terminal. The `DeniedResource` shape and the + approval logic are identical; only the transport differs. + +### Complete, copy-paste samples + +The end-to-end reference — denial parser (0x1E NDJSON framing), default noise +filters, the `--denials-fd` anonymous-pipe side channel, and additive policy +expansion — lives in **`src/testing/wxc_e2e_tests/src/denial_consumer.rs`**, and +`src/testing/wxc_e2e_tests/tests/e2e_windows_capture_denials.rs` exercises the full +pipe-mode (live stderr), side-channel (anonymous pipe), and multi-round +approve-and-respawn flow against the native `wxc-exec` binary. Consumers +reimplement the same contract in their own language; the TypeScript sketch below +shows the shape for a Node consumer. + +Prerequisites: + +- Node.js 18+. +- The broker installed once on the machine (the provisioning step): + `wxc-host-prep install-learning-mode-broker` from an elevated prompt. +- The SDK added as a dependency (for the generic spawn surface only): + + ```bash + npm install @microsoft/mxc-sdk + ``` + +The sketch below shows the consumer-owned loop: build a default-deny config with +`captureDenials: true`, spawn `wxc-exec` via the generic SDK surface, read denials +off stderr (pipe mode), prompt the user, expand the config with approved paths, +and re-spawn. + +```ts +import { + createConfigFromPolicy, + spawnSandboxFromConfig, + getPlatformSupport, + type SandboxPolicy, +} from '@microsoft/mxc-sdk'; +// Consumer-owned helpers — you implement these (reference port in Rust: +// src/testing/wxc_e2e_tests/src/denial_consumer.rs): +// parseDenialStream — split the 0x1E-framed NDJSON, apply default filters +// defaultDenialFilters — drop the OS loader / registry background hum +// expandPolicyFromDenials — additively grant approved paths, refuse critical ones +import { + parseDenialStream, + defaultDenialFilters, + expandPolicyFromDenials, +} from './your-denial-helpers.js'; + +async function runRound(policy: SandboxPolicy, script: string) { + const config = createConfigFromPolicy(policy, 'process'); + config.captureDenials = true; + config.process!.commandLine = script; + + // usePty:false keeps stdout/stderr separate so the NDJSON denial protocol + // (which rides stderr) can be demultiplexed from the workload's own writes. + const child = spawnSandboxFromConfig(config, { usePty: false }); + return parseDenialStream(child.stderr!, { + filters: defaultDenialFilters, + onDenial: (r) => console.log(`denied: ${r.accessType} ${r.path}`), + }); +} + +async function main(): Promise { + const support = getPlatformSupport(); + if (!support.isSupported) { + console.error(`Sandbox not supported here: ${support.reason ?? 'unknown'}`); + process.exit(1); + } + + let policy: SandboxPolicy = { + version: '0.6.0-alpha', + filesystem: { readwritePaths: [], readonlyPaths: [] }, + }; + const script = 'cmd /c type "C:\\Users\\Alice\\Documents\\report.txt"'; + + const result = await runRound(policy, script); + + // captureDenialsActive === false ⇒ the broker wasn't installed/reachable. + if (result.summary?.captureDenialsActive === false) { + console.error( + 'Denial capture is not active — install the broker (as admin):\n' + + ' wxc-host-prep install-learning-mode-broker', + ); + process.exit(2); + } + + if (result.denials.length > 0) { + const approved = await askUserWhichToAllow(result.denials); // consumer UX + policy = expandPolicyFromDenials(policy, approved); + await runRound(policy, script); // re-spawn once with the expanded policy + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +--- + +## Current behavior (as-is) + +This is what exists today, verified against `docs/host-prep.md` and the +`wxc_host_prep` broker install/uninstall implementation. + +### The components + +| Component | Privilege | Role | +|---|---|---| +| `mxc-learning-mode-broker.exe` (service `MxcLearningModeBroker`) | `NT AUTHORITY\LocalService` + `SeSystemProfilePrivilege` | Loans a scoped, per-PID ETW trace handle to unprivileged callers. **One per machine.** | +| `wxc-host-prep.exe` | Requires elevation (`requireAdministrator` in its manifest) | Installs/uninstalls/inspects the service. | +| `wxc-exec.exe` | Unprivileged | The sandbox launcher. Connects to the broker's named pipe, drives `OpenTrace`/`ProcessTrace`, streams denials. | +| `@microsoft/mxc-sdk` | Unprivileged | Spawns `wxc-exec`, parses the denial stream. | + +### Service shape + +- **One shared, machine-wide service.** Name `MxcLearningModeBroker`, pipe + `\\.\pipe\mxc-learning-mode-broker`. It is **not** per-app and **not** + per-session. +- **Account:** `LocalService` (least-privilege built-in). `LocalService` does + not carry `SeSystemProfilePrivilege` by default, so install grants it via the + LSA `LsaAddAccountRights` API. The grant is **persistent** (survives reboots) + and **idempotent**. +- **Start type:** `Demand`/Manual. SCM idle-shutdown stops the process after a + period of inactivity; the next inbound pipe request restarts it. So "installed" + and "running" are different states — a consumer can rely on the service being + *installed* without it being *running*. + +### Lifecycle commands (all elevated) + +- `install-learning-mode-broker [--broker-path ]` — **copy** the broker binary + into the protected install location (`%ProgramFiles%\Mxc`), register the service + to point there, and grant the privilege. `--broker-path` is the **source** to copy + from (defaults to the binary next to `wxc-host-prep`). Idempotent: re-running with + the same source refreshes the installed copy; registering a service already + pointing elsewhere is an explicit conflict (uninstall first). +- `uninstall-learning-mode-broker` — stop + `DeleteService`, and best-effort delete + the copied binary + its (now empty) install directory. Idempotent. + **Does not revoke** `SeSystemProfilePrivilege` (another tool on the box may + rely on it; clobbering an LSA right is destructive). +- `dump-learning-mode-broker [--json]` — report installed/state/binary path. Exit 0 + installed, 1 not. + +### Who installs it today? + +**Nobody automatically.** Install is a manual, elevated, out-of-band step. The +SDK and `wxc-exec` never install, never elevate, never clean up. When the broker +is absent or unreachable, capture degrades gracefully: the summary line reports +`captureDenialsActive: false` and zero denials stream. The SDK surfaces this flag +specifically so a consumer does not misread "0 denials" as "the workload tripped +no denials" when the truth is "the feature was never active." + +### What's missing (the gap the reviewers found) + +- No defined **developer workflow** tying "I want captureDenials" to "the broker is + installed." +- No defined **cleanup ownership** — installs accrete and are never removed; the + privilege grant never goes away. +- No story for how an **npm/crate** consumer gets the service onto a box. + +--- + +## Q1 — Developer workflow + +**Question:** What does a consumer actually do, end to end, to use +`captureDenials`? + +### Options + +**A. Status quo — purely manual.** The consumer reads the docs, runs +`wxc-host-prep install-learning-mode-broker` from an elevated prompt once, then uses +the SDK. If they forget, they silently get `captureDenialsActive: false`. + +- 👍 Zero new code. Clean privilege separation. +- 👎 Poor ergonomics; the failure mode is silent and easy to misdiagnose. + +**B. Provisioning-system step.** Same install command, but positioned as a +machine-provisioning action: MDM policy, an image build step, a CI runner setup +script, or a one-time elevated setup wrapper that ships with the consuming app. + +- 👍 Matches how privileged machine state is *supposed* to be established + (once, by something that legitimately holds admin). Auditable. +- 👎 Requires the consumer to own a provisioning channel; nothing the SDK can do + for an ad-hoc developer on their own box. + +**C. SDK preflight + actionable error.** Keep install out-of-band (A/B), but add +an **unprivileged** SDK preflight that checks whether the service is installed +*before* spawning the workload and, when it's missing, throws/returns a typed, +actionable error with the exact remedy command — instead of letting the run +proceed to a silent post-run `captureDenialsActive: false`. + +The mechanism must stay unprivileged, so it **cannot** shell out to +`dump-learning-mode-broker`: although that command's underlying SCM query is +read-only, `wxc-host-prep.exe` is `requireAdministrator` and aborts (exit 65) / +prompts UAC when launched unelevated. Instead the preflight queries the SCM +directly read-only — `OpenSCManager(SC_MANAGER_CONNECT)` + +`OpenService(SERVICE_QUERY_STATUS)` — via either a `wxc-exec` probe (extending +the existing read-only `wxc-exec --probe` that `getPlatformSupport()` already +uses) or a direct `sc query MxcLearningModeBroker`. `installed: true` is treated as +OK regardless of run state, since the Manual/Demand service restarts on the next +pipe request. `dump-learning-mode-broker` remains the **elevated operator** +diagnostic. + +- 👍 Small, unprivileged, turns a silent post-run failure into a fail-fast with a + one-line fix. Composable with A or B. +- 👎 Still doesn't *install* anything (by design). + +**D. Auto-install from the runtime.** The SDK/`wxc-exec` self-installs the +service on first use. + +- 👎 **Rejected.** Requires the runtime to elevate — exactly the design `wxc-exec` + deliberately abandoned (host-prep owns elevation; `wxc-exec` never + self-elevates). It would also make every consuming app a machine-state mutator, + which is a security and supportability regression. + +### Recommendation + +**B + C.** Position install as an explicit, elevated provisioning step (B), and +add the SDK preflight (C) so the missing-broker case is a clear, actionable error +rather than a silent zero. Keep D off the table. + +Concretely, the documented developer workflow becomes: + +1. **Provision (once per machine, elevated):** + `wxc-host-prep install-learning-mode-broker` + — run by an admin, MDM, image build, or CI setup step. +2. **Develop (unprivileged):** use the SDK / `wxc-exec` normally with + `captureDenials: true`. The SDK preflight verifies the broker is installed and, + if not, fails fast with the remedy command. +3. **Run:** denials stream over stderr (or the `--denials-fd` anonymous-pipe side channel in PTY + mode); the summary reports `captureDenialsActive: true`. + +--- + +## Q2 — Cleanup logic + +**Question:** If apps cause the service to be created, what removes it? And what +about the privilege grant uninstall leaves behind? + +### The two things to clean up + +1. **The service** (`MxcLearningModeBroker`). +2. **The persistent `SeSystemProfilePrivilege` grant** on `LocalService`. + +### Options for the service + +**A. Leave installed (status quo).** The service is installed once and left; +`uninstall-learning-mode-broker` exists for operators who want it gone. Because the +service is Manual-start with SCM idle-shutdown, an idle installed-but-unused broker +costs essentially nothing at rest. + +- 👍 Matches the "provision once" model. No teardown coordination problem. +- 👎 The service lingers after the last consumer is uninstalled; nothing prompts + its removal. + +**B. App-driven ref-counting.** Each consuming app registers/deregisters on +install/uninstall; the last one out removes the service. + +- 👎 **Rejected.** Requires a shared, privileged, concurrency-safe ref-count store + and makes every app a privileged machine-state mutator. High complexity, brittle + failure modes (crashed app never decrements), and it re-introduces the + elevation-from-app problem from Q1/D. + +**C. Explicit uninstall, owned by whoever provisioned (recommended).** The same +actor that ran install (admin/MDM/image/CI) owns uninstall. Idempotent, safe to +run in teardown scripts. + +- 👍 Symmetric with the provisioning model; no runtime coupling. +- 👎 Requires the provisioner to remember teardown (acceptable — it's the same + actor that did setup). + +### Options for the privilege grant + +The current uninstall **does not** revoke `SeSystemProfilePrivilege` because +another tool might rely on it and blind revocation is destructive. That is the +safe default, but it means an "uninstall" doesn't fully revert machine state — a +legitimate concern for hardened/clean-revert environments. + +- **G1. Never revoke (status quo).** Safe but leaves a residual right. +- **G2. Always revoke on uninstall.** Clean revert, but can break a co-installed + tool that shares the account/right. +- **G3. Opt-in revoke.** Add `uninstall-learning-mode-broker --revoke-privilege` + that performs the LSA `LsaRemoveAccountRights` revocation, documented as "only + safe when no other tool on the box depends on `LocalService` holding + `SeSystemProfilePrivilege`." Default stays non-revoking. Better than G1/G2 but + still puts the "is it safe to revoke?" judgement on the operator. +- **G4. Track-and-revoke (recommended).** Make the grant self-describing so revoke + is safe *by default*: + 1. **At install**, before granting, call `LsaEnumerateAccountRights` on the + `LocalService` SID and scan for `SeSystemProfilePrivilege`. If it's already + present, the grant is a no-op and we record **`we_granted = false`**; if not, + we `LsaAddAccountRights` and record **`we_granted = true`**. + 2. **Persist the marker** across the install→uninstall process boundary — e.g. a + small state file under `%ProgramData%\mxc\` (host-prep already writes logs + there for the null-device op) or a registry value. + 3. **At uninstall**, revoke **only when `we_granted == true`**. This reverts to + the exact pre-install state and never clobbers a grant we did not create. + + The grant code already anticipates this path: `privilege.rs` keeps a + free-helper around commented "for a future audit/revoke path (e.g. via + `LsaEnumerateAccountRights`)." + + **Caveats to document:** + - LSA account rights are a **set, not reference-counted**. If a *different* tool + independently grants the same privilege *after* us, our marker still reads + "we added it" and our revoke removes it out from under them. Enumerate-before- + grant narrows but cannot fully close this cross-tool race — an OS limitation, + not something we can solve. Within our own ecosystem (one broker, one host-prep) + it is airtight. + - The buffer returned by `LsaEnumerateAccountRights` must be freed with + **`LsaFreeMemory`**, not `LocalFree`. + +### Recommendation + +**Service: C (explicit, provisioner-owned uninstall).** Do not build +ref-counting. **Privilege: G4 (track-and-revoke).** Enumerate before granting, +persist whether we added the right, and revoke on uninstall only when we did — +giving a safe-by-default revert instead of today's permanent residual grant. +Optionally still expose `--revoke-privilege` / `--keep-privilege` flags for +operators who want to override the tracked decision. Document the cross-tool LSA +caveat in `host-prep.md`. + +--- + +## Q3 — Per-app vs shared service + +**Question:** One service per app, or one shared service for all? Is the current +shared model right? + +### Options + +**A. One shared machine-wide service (status quo).** All consumers talk to the +same `MxcLearningModeBroker` over one well-known pipe. + +**B. One service per app / per tenant.** Each consumer gets its own service +instance (distinct name + pipe). + +**C. Per-session / per-launch service.** A fresh broker spun up for each sandbox +launch. + +### Why isolation does **not** require per-app instances + +The instinct behind per-app services is isolation: "app X must not see app Y's +denials, and must not be able to attach to app Y's processes." But that boundary +is **already enforced per call**, not per service instance. From the broker +security model (see `architecture.md`): + +- On every `OpenDenialSession(pid)` the broker does + `ImpersonateNamedPipeClient` then `OpenProcess(pid, + PROCESS_QUERY_LIMITED_INFORMATION)` **under the caller's token**. If the caller + cannot open the target via Windows ACLs, the request is rejected + (`unauthorized`). A caller can therefore only start sessions for processes it + could already inspect. +- `ExtendDenialSession` requires the caller SID to match the recorded + session-owner SID, and re-validates every PID through the same impersonation + check. + +So Windows itself — which already models sandbox tokens, RDP sessions, and +multi-user boxes — is the isolation boundary. Splitting into per-app services +would duplicate that boundary at the service layer while adding real cost: + +- **Naming/discovery:** every consumer needs a unique service+pipe name and a way + to discover its own — a coordination problem the single well-known pipe avoids. +- **Privilege multiplication:** N services means N `LocalService` privilege + grants and N installs/uninstalls to track. +- **ETW ceiling pressure:** Windows enforces a system-wide user-mode ETW session + ceiling (~64). The broker is a *broker* of sessions, not a session itself, so one + broker serving many callers is strictly better for that budget than many + brokers. + +Per-session (C) is even worse: it pays the install/elevation cost on the hot +path, which is precisely what the broker model exists to avoid. + +### Recommendation + +**A — keep one shared machine-wide service.** Isolation is a per-call property +enforced by impersonation, not a property of service instancing. Per-app/per-session +instances add naming, privilege, and ETW-budget cost for no security gain. The +existing "not in scope" hardening follow-up (restricting the pipe ACL to a +process-trust SID via code signing) remains the right lever if we ever need to +narrow *who* may connect — that is orthogonal to instance count. + +--- + +## Q4 — Packaging fit (npm today, Rust crate tomorrow) + +**Question:** MXC ships an npm package today and a Rust crate tomorrow. Neither +can register an elevated machine-wide service at install time. How does broker +provisioning fit? + +### The core constraint + +A package install (`npm install`, `cargo add`/`cargo build`) runs with the +**developer's/CI's normal privileges**, in an arbitrary location, possibly many +times across many projects on one machine. Registering a machine-wide OS service +is an **elevated, machine-singleton, root-owned** action. These two are +fundamentally mismatched. The packaging model can *deliver the binaries*; it must +not *activate* them. + +### Options + +**A. npm `postinstall` hook installs the service.** + +- 👎 **Rejected.** `postinstall` isn't elevated (fails on a standard dev box), + runs on every `npm install` in every project (machine-singleton violated), is + widely disabled in CI (`--ignore-scripts`), and turns a library install into a + privileged machine mutation — a supply-chain red flag. Same reasoning rules out + a `build.rs`-driven install for the crate. + +**B. Ship the binaries in the package; activate separately (recommended).** The +npm package payload already carries the native binaries it spawns; include +`mxc-learning-mode-broker.exe` and `wxc-host-prep.exe` in that payload (and, for the +crate, have the build emit them to a known target dir). Activation is the +documented elevated step from Q1: +`wxc-host-prep install-learning-mode-broker --broker-path `. + +- 👍 No elevation in the install path; binaries are present and discoverable; + activation is explicit and auditable. The `--broker-path` flag points install at + the packaged broker binary as the **source** to copy into the protected install + location (`%ProgramFiles%\Mxc`), wherever the package placed it. +- 👎 Consumer must run the separate step (mitigated by Q1's preflight error + + docs, and by helper scripts below). + +**C. Provisioning helpers for the common channels.** On top of B, ship thin, +documented helpers for the realistic activation channels: + +- **Dev box:** a one-line elevated command (or a `setup-learning-mode.ps1` helper + mirroring the existing `scripts/setup-wslc.ps1` pattern) that resolves the + packaged broker path and runs the install. +- **CI:** a documented runner setup step (the runner already has admin). +- **Fleet/MDM:** the install command as an MDM-pushed configuration action. + +### Discoverability detail + +The SDK already auto-discovers the native binaries it spawns (npm-packaged +`bin//` and local-dev `target//{release,debug}/`). The same +resolution should expose the broker/host-prep paths so the helper script and the +preflight error can name the exact `--broker-path` to use, regardless of how the +package was installed. + +### Recommendation + +**B + C.** Bundle the binaries in the package payload; keep activation as an +explicit elevated step; ship per-channel helpers (dev/CI/MDM) and reuse the +binary-discovery logic so the right `--broker-path` is always available. **No +install hooks** in npm or cargo. + +--- + +## Recommended end-to-end design (stitched) + +Putting Q1–Q4 together: + +1. **Distribution:** `mxc-learning-mode-broker.exe` + `wxc-host-prep.exe` ship in the + npm package payload (and are produced by the crate build into the known target + dir). Install hooks are explicitly *not* used. +2. **Provisioning (once per machine, elevated, out-of-band):** + `wxc-host-prep install-learning-mode-broker [--broker-path ]`, + run by admin / MDM / image build / CI setup, optionally via a shipped helper + script. Installs the single shared `MxcLearningModeBroker` service and grants + the privilege. +3. **Use (unprivileged):** the SDK preflight checks the broker's install state via a + read-only SCM query (a `wxc-exec` probe or `sc query`, *not* the elevated + `dump-learning-mode-broker`); if absent, it fails fast with the exact remedy + command. Otherwise the consumer uses `captureDenials: true` normally; one + shared service brokers scoped ETW sessions, isolation enforced per call by + impersonation. +4. **Teardown (elevated, provisioner-owned):** + `wxc-host-prep uninstall-learning-mode-broker` (idempotent), which by default + revokes `SeSystemProfilePrivilege` **only if our install marker recorded that + we granted it** (track-and-revoke). No app-driven ref-counting. + +This keeps the runtime unprivileged, the privileged surface tiny and explicit, +the service a single shared broker, and packaging free of elevation. + +--- + +## Open questions for the Tessera review + +1. **Helper-script surface:** do we want a first-party `setup-learning-mode.ps1` + (mirroring `setup-wslc.ps1`) in-repo, or only documented one-liners? +2. **SDK preflight shape:** since the check must be unprivileged (host-prep is + `requireAdministrator`), confirm the probe mechanism — a `wxc-exec --probe` + extension vs. a direct `sc query` from the SDK — and whether it returns a typed + error/exception vs. a capability object (`{ installed, state, remedy }`). And + should it run automatically when `captureDenials: true`, or be opt-in? +3. **Privilege-grant override:** is track-and-revoke (G4) enough on its own, or do + we still want explicit `--revoke-privilege` / `--keep-privilege` flags to + override the tracked decision? And where should the "we_granted" marker live — + `%ProgramData%\mxc\` state file vs. registry? +4. **CI provisioning ownership:** is broker install part of the MXC test-runner + image, or a per-pipeline step? (Affects where the e2e tests can run.) +5. **Crate activation parity:** for the future Rust-crate consumer, do we expose a + small `mxc` provisioning subcommand, or rely solely on `wxc-host-prep`? +6. **Pipe-ACL hardening interaction:** if we later restrict the pipe ACL to a + process-trust SID (code-signed `wxc-exec`), does that change any of the + per-app-vs-shared reasoning? (Believed orthogonal; confirm.) + +--- + +## Decision log + +To be filled during/after the Tessera review. + +| # | Decision | Outcome | Owner | Date | +|---|----------|---------|-------|------| +| Q1 | Developer workflow (B+C: provision step + SDK preflight) | _pending_ | | | +| Q2 | Cleanup (explicit uninstall; track-and-revoke privilege grant) | _pending_ | | | +| Q3 | One shared machine-wide service | _pending_ | | | +| Q4 | Bundle binaries, activate separately, no install hooks | _pending_ | | | + +--- + +## Future work (out of scope) + +- **Linux/macOS deployment.** The Linux (`fanotify`+audit) and macOS + (EndpointSecurity) adapters are stubs today. Their privilege-brokering and + provisioning stories differ from Windows services and will be designed when + those adapters are implemented. The cross-platform pieces above the adapter + (denial channel wire format, SDK preflight concept, "provision out-of-band" + principle) are intended to carry over. +- **Pipe-ACL narrowing** to a code-signed process-trust SID — tracked as a + hardening follow-up in `architecture.md`. diff --git a/sdk/tests/integration/test-helpers.ts b/sdk/tests/integration/test-helpers.ts index e3e80724c..c81a036b0 100644 --- a/sdk/tests/integration/test-helpers.ts +++ b/sdk/tests/integration/test-helpers.ts @@ -66,7 +66,8 @@ export const EXPECTED_MACOS_BINARIES = [ // Binaries that are optional (feature-gated or only present in certain builds) // but still legitimate if found in the package. const OPTIONAL_BINARIES = [ - 'wslcsdk.dll', // Only built with --with-wslc + 'wslcsdk.dll', // Only built with --with-wslc + 'mxc-learning-mode-broker.exe', // Learning-mode broker service; copied only when built (full build.bat) ]; // Combined list of all known binaries across platforms. The npm package diff --git a/src/Cargo.lock b/src/Cargo.lock index 0c693c46d..5a704ab26 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1161,6 +1161,14 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "learning_mode_broker_protocol" +version = "0.7.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1428,6 +1436,19 @@ dependencies = [ "wxc_common", ] +[[package]] +name = "mxc_learning_mode_broker" +version = "0.7.0" +dependencies = [ + "learning_mode_broker_protocol", + "serde", + "serde_json", + "thiserror", + "uuid", + "windows", + "windows-service", +] + [[package]] name = "mxc_pty" version = "0.7.0" @@ -2658,6 +2679,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags 2.13.0", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -2981,6 +3013,7 @@ dependencies = [ "thiserror", "windows", "windows-core", + "windows-service", "wxc_common", ] diff --git a/src/Cargo.toml b/src/Cargo.toml index 84e7ba361..8328323b2 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -2,6 +2,7 @@ members = [ "core/wxc", "core/wxc_common", + "core/learning_mode_broker_protocol", "core/lxc", "core/mxc_darwin", "core/mxc-sdk", @@ -25,6 +26,7 @@ members = [ "backends/seatbelt/common", "host/wxc_host_prep", "host/wxc_winhttp_proxy_shim", + "host/mxc_learning_mode_broker", "testing/wxc_e2e_tests", "testing/wxc_test_driver", "testing/wxc_test_proxy", @@ -67,6 +69,7 @@ windows = { version = "0.62", features = [ "Win32_Networking_WinSock", "Win32_Security_Credentials", "Win32_Security_Authorization", + "Win32_Security_Authentication_Identity", "Win32_System_Memory", "Win32_System_Diagnostics_Debug", "Win32_Security_Isolation", @@ -91,6 +94,7 @@ anyhow = "1" base64 = "0.22" clap = { version = "4", features = ["derive"] } wxc_common = { path = "core/wxc_common" } +learning_mode_broker_protocol = { path = "core/learning_mode_broker_protocol" } appcontainer_common = { path = "backends/appcontainer/common" } windows_sandbox_common = { path = "backends/windows_sandbox/common" } isolation_session_common = { path = "backends/isolation_session/common" } @@ -98,6 +102,7 @@ hyperlight_common = { path = "backends/hyperlight/common" } nanvix_runner = { path = "backends/nanvix/runner" } tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["v4"] } +windows-service = "0.7" getrandom = "0.2" libc = "0.2" nix = { version = "0.29", features = ["fs", "mount", "sched", "signal", "net", "process", "user", "term"] } diff --git a/src/core/learning_mode_broker_protocol/Cargo.toml b/src/core/learning_mode_broker_protocol/Cargo.toml new file mode 100644 index 000000000..1c196cab0 --- /dev/null +++ b/src/core/learning_mode_broker_protocol/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "learning_mode_broker_protocol" +version.workspace = true +edition.workspace = true + +# Cross-platform crate holding the named-pipe RPC wire format shared +# between the `mxc-learning-mode-broker` service (server) and its clients +# (`wxc-exec`'s learning-mode backend). It carries only the request and +# response data shapes (`BrokerRequest`, `BrokerResponse`, PIPE_NAME, protocol +# version, stable error codes) and depends on nothing OS-specific, so the +# privileged broker can build against it without pulling in the consumer-side +# ETW capture backend (`learning_mode_windows`). + +[dependencies] +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/src/core/learning_mode_broker_protocol/src/lib.rs b/src/core/learning_mode_broker_protocol/src/lib.rs new file mode 100644 index 000000000..46030bfbb --- /dev/null +++ b/src/core/learning_mode_broker_protocol/src/lib.rs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Wire protocol shared between `wxc-exec` (client) and `mxc-learning-mode-broker` +//! (server). +//! +//! The protocol is request/response over a **message-mode** named pipe +//! (`PIPE_TYPE_MESSAGE` | `PIPE_READMODE_MESSAGE`): each side writes exactly +//! one JSON message per pipe operation — there is no newline framing. The +//! broker accepts one request per connection, returns one response, and +//! closes the connection. +//! +//! Two request kinds are defined: +//! +//! - [`BrokerRequest::OpenDenialSession`] — creates a fresh privileged ETW +//! session scoped to a target PID. Used once per `wxc-exec` invocation +//! at workload-spawn time. +//! - [`BrokerRequest::ExtendDenialSession`] — extends the PID filter of an +//! already-open session with the full new list. Used by the runner's +//! IOCP listener every time the workload spawns a descendant. +//! +//! Default pipe name: `\\.\pipe\mxc-learning-mode-broker`. + +use serde::{Deserialize, Serialize}; + +/// The default named-pipe path the broker listens on. +pub const PIPE_NAME: &str = r"\\.\pipe\mxc-learning-mode-broker"; + +/// Current protocol version. Bumped on incompatible changes; the server +/// rejects requests carrying a different version. +/// +/// - **1** → only `OpenDenialSessionRequest` understood; the request was +/// serialised at the top level (no enum wrapper). +/// - **2** → all requests wrapped in a [`BrokerRequest`] enum so the broker +/// can dispatch on the variant. Adds +/// [`BrokerRequest::ExtendDenialSession`]. +pub const PROTOCOL_VERSION: u32 = 2; + +/// Wrapper enum for every request the broker accepts. The discriminator +/// is a `kind` field at the top of the JSON object; serde routes to the +/// matching variant. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum BrokerRequest { + /// Create a fresh denial-capture session. + OpenDenialSession(OpenDenialSessionRequest), + /// Extend the PID filter of an already-open session. + ExtendDenialSession(ExtendDenialSessionRequest), +} + +/// Wrapper enum for every response the broker sends. The discriminator is +/// the original `status` tag for backwards-compat with the +/// `OpenDenialSession` shape, plus a `kind` tag so callers know which +/// request shape the response is paired with. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum BrokerResponse { + OpenDenialSession(OpenDenialSessionResponse), + ExtendDenialSession(ExtendDenialSessionResponse), +} + +/// Client → server: ask the broker to open a privileged ETW session scoped +/// to a target sandboxed PID and (optionally) an AppContainer package SID. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenDenialSessionRequest { + /// Wire-format protocol version. Must equal `PROTOCOL_VERSION`. + pub protocol_version: u32, + /// PID of the sandboxed child process. + pub target_pid: u32, + /// Optional AppContainer LowBox SID in SDDL form. When present, the + /// broker adds an `EVENT_FILTER_TYPE_PACKAGE_ID` filter alongside the + /// PID filter. + pub package_sid: Option, +} + +/// Server → client: result of `OpenDenialSessionRequest`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum OpenDenialSessionResponse { + /// The privileged ETW session was created. The caller can now call + /// `OpenTraceW(session_name)` to start consuming events. The caller + /// is also responsible for stopping the session via `ControlTraceW(STOP)` + /// when its workload exits — the broker grants the caller's user SID + /// session-control rights via `EventAccessControl` at create time. + /// + /// (Earlier iterations of this protocol returned a duplicated + /// `TRACEHANDLE`. ETW handles are not kernel handles and cannot be + /// duplicated across processes; the cross-process contract is the + /// session name + ACL grant, which is what this variant carries.) + #[serde(rename_all = "camelCase")] + Ok { + /// Symbolic session name used by `OpenTraceW`. + session_name: String, + }, + /// The broker refused or failed to open the session. `code` carries a + /// stable string discriminator for SDK display; `message` is human + /// readable. + #[serde(rename_all = "camelCase")] + Error { + /// Stable error code. See `ERROR_*` constants in this module. + code: String, + /// Human-readable message. + message: String, + }, +} + +/// Client → server: replace the PID filter on an already-open denial +/// session with `pids`. Used to add new descendants to the filter as +/// the runner's IOCP listener observes them. +/// +/// The protocol is **idempotent and stateless**: the caller sends the +/// full new PID list every time (root PID + all known descendants), and +/// the broker replaces the filter as-is. The broker does not track which +/// PIDs have been added previously — the kernel's filter is the +/// source of truth. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtendDenialSessionRequest { + /// Wire-format protocol version. Must equal `PROTOCOL_VERSION`. + pub protocol_version: u32, + /// Name returned by the `OpenDenialSessionResponse::Ok` for this + /// session. + pub session_name: String, + /// Complete new PID list. The broker REPLACES the filter (not + /// appends), so the caller must include the root PID and every + /// previously-added descendant. + pub pids: Vec, +} + +/// Server → client: result of `ExtendDenialSessionRequest`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum ExtendDenialSessionResponse { + /// The PID filter was updated. Subsequent ETW events from any of + /// the listed PIDs reach the session. + Ok, + /// The broker could not update the filter. `code` is a stable + /// discriminator (see `error_code`); `message` is human readable. + #[serde(rename_all = "camelCase")] + Error { code: String, message: String }, +} + +/// Stable error codes the broker emits in error responses. +pub mod error_code { + /// Request payload was malformed or unparseable. + pub const BAD_REQUEST: &str = "badRequest"; + /// Wire-format version did not match `PROTOCOL_VERSION`. + pub const VERSION_MISMATCH: &str = "versionMismatch"; + /// Caller is not authorized to open denial sessions (failed pipe ACL + /// check, missing entitlement, etc.). + pub const UNAUTHORIZED: &str = "unauthorized"; + /// Privileged Win32 call failed (e.g. `StartTraceW`, + /// `EnableTraceEx2`, `DuplicateHandle`). + pub const WIN32_FAILURE: &str = "win32Failure"; + /// The broker hasn't implemented this code path yet (used by the + /// skeleton that ships before the full ETW work lands). + pub const NOT_IMPLEMENTED: &str = "notImplemented"; + /// Caller referred to a session name the broker cannot resolve. + pub const UNKNOWN_SESSION: &str = "unknownSession"; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_request_round_trip() { + let req = OpenDenialSessionRequest { + protocol_version: PROTOCOL_VERSION, + target_pid: 12345, + package_sid: Some("S-1-15-2-1".to_string()), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: OpenDenialSessionRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(req.target_pid, parsed.target_pid); + assert_eq!(req.package_sid, parsed.package_sid); + assert_eq!(req.protocol_version, parsed.protocol_version); + } + + #[test] + fn open_response_ok_serializes_with_status_tag() { + let resp = OpenDenialSessionResponse::Ok { + session_name: "mxc-denials-1234".to_string(), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"status\":\"ok\""), "got {json}"); + assert!(json.contains("\"sessionName\":\"mxc-denials-1234\"")); + } + + #[test] + fn open_response_error_round_trip() { + let resp = OpenDenialSessionResponse::Error { + code: error_code::NOT_IMPLEMENTED.to_string(), + message: "ETW path not yet wired".to_string(), + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: OpenDenialSessionResponse = serde_json::from_str(&json).unwrap(); + match parsed { + OpenDenialSessionResponse::Error { code, message } => { + assert_eq!(code, error_code::NOT_IMPLEMENTED); + assert_eq!(message, "ETW path not yet wired"); + } + _ => panic!("expected Error variant"), + } + } + + #[test] + fn broker_request_wrapper_dispatches_on_kind() { + let open = BrokerRequest::OpenDenialSession(OpenDenialSessionRequest { + protocol_version: PROTOCOL_VERSION, + target_pid: 42, + package_sid: None, + }); + let json = serde_json::to_string(&open).unwrap(); + assert!( + json.contains("\"kind\":\"openDenialSession\""), + "got {json}" + ); + let parsed: BrokerRequest = serde_json::from_str(&json).unwrap(); + match parsed { + BrokerRequest::OpenDenialSession(r) => assert_eq!(r.target_pid, 42), + _ => panic!("expected OpenDenialSession variant"), + } + } + + #[test] + fn extend_request_round_trip() { + let req = ExtendDenialSessionRequest { + protocol_version: PROTOCOL_VERSION, + session_name: "mxc-denials-abcd".to_string(), + pids: vec![100, 200, 300], + }; + let wrapped = BrokerRequest::ExtendDenialSession(req.clone()); + let json = serde_json::to_string(&wrapped).unwrap(); + assert!( + json.contains("\"kind\":\"extendDenialSession\""), + "got {json}" + ); + assert!(json.contains("\"pids\":[100,200,300]")); + let parsed: BrokerRequest = serde_json::from_str(&json).unwrap(); + match parsed { + BrokerRequest::ExtendDenialSession(r) => { + assert_eq!(r.session_name, req.session_name); + assert_eq!(r.pids, req.pids); + } + _ => panic!("expected ExtendDenialSession variant"), + } + } + + #[test] + fn extend_response_ok_serializes_compactly() { + let resp = BrokerResponse::ExtendDenialSession(ExtendDenialSessionResponse::Ok); + let json = serde_json::to_string(&resp).unwrap(); + assert!( + json.contains("\"kind\":\"extendDenialSession\""), + "got {json}" + ); + assert!(json.contains("\"status\":\"ok\""), "got {json}"); + } +} diff --git a/src/host/mxc_learning_mode_broker/Cargo.toml b/src/host/mxc_learning_mode_broker/Cargo.toml new file mode 100644 index 000000000..9374c9ff9 --- /dev/null +++ b/src/host/mxc_learning_mode_broker/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mxc_learning_mode_broker" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "mxc-learning-mode-broker" +path = "src/main.rs" + +# Windows-only binary: a privileged service that loans ETW-session +# creation privilege to unelevated callers (e.g. wxc-exec) for a single +# scoped denial-capture session per request. See +# `docs/learning-mode/deployment-and-lifecycle.md` for the design notes. + +[target.'cfg(target_os = "windows")'.dependencies] +learning_mode_broker_protocol = { workspace = true } +windows = { workspace = true } +windows-service = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } diff --git a/src/host/mxc_learning_mode_broker/src/caller_context.rs b/src/host/mxc_learning_mode_broker/src/caller_context.rs new file mode 100644 index 000000000..55f9e1830 --- /dev/null +++ b/src/host/mxc_learning_mode_broker/src/caller_context.rs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Per-connection caller identification + access checks. +//! +//! Each pipe connection carries an identity (the user SID of the +//! process that connected) and a PID (`GetNamedPipeClientProcessId`). +//! The broker enforces two access invariants: +//! +//! 1. **`OpenDenialSession(target_pid)`** is only honoured when the +//! caller (under impersonation) can open `target_pid` for query. +//! This uses Windows' ACL system as the source of truth: if the +//! caller's token does not grant +//! `PROCESS_QUERY_LIMITED_INFORMATION` on the target, the broker +//! refuses to spin up an audit session for it. Sandbox runtimes +//! legitimately re-parent + token-restrict their workloads, so +//! parent-PID or SID-equality checks don't generalise; the +//! impersonate-then-OpenProcess check does. +//! +//! 2. **`ExtendDenialSession(name, pids)`** is only honoured when the +//! caller's SID matches the SID that originally opened the session +//! (tracked out-of-band in `pipe_server.rs`'s ownership map) AND +//! each PID in the extend list passes the same +//! impersonate-then-OpenProcess check as #1. + +use std::ffi::c_void; + +use windows::core::PWSTR; +use windows::Win32::Foundation::{CloseHandle, GetLastError, LocalFree, HANDLE, HLOCAL}; +use windows::Win32::Security::Authorization::ConvertSidToStringSidW; +use windows::Win32::Security::{ + GetTokenInformation, RevertToSelf, TokenUser, TOKEN_QUERY, TOKEN_USER, +}; +use windows::Win32::System::Pipes::{GetNamedPipeClientProcessId, ImpersonateNamedPipeClient}; +use windows::Win32::System::Threading::{ + GetCurrentThread, OpenProcess, OpenThreadToken, PROCESS_QUERY_LIMITED_INFORMATION, +}; + +/// Identifies the process on the other end of an accepted pipe +/// connection. +/// +/// Constructed once per connection by [`from_pipe`]. After +/// construction, the broker has already called `RevertToSelf` — the +/// caller's identity lives in this struct, not on the thread token, +/// so downstream ETW work runs with the broker's `LocalService` + +/// `SeSystemProfilePrivilege` as usual. +#[derive(Debug, Clone)] +pub struct CallerContext { + /// PID of the process that connected to the pipe. Currently used + /// for diagnostic logging; retained on the struct for future + /// per-PID rate limiting / audit checks. + #[allow(dead_code)] + pub pid: u32, + /// String-form SID (`S-1-5-...`) of the user the caller is running + /// as. Used as the ownership key in the extend-session map and as + /// the authority for the same-user PID ownership check. + pub sid: String, +} + +/// Reason a caller context could not be built. +#[derive(Debug, thiserror::Error)] +pub enum CallerContextError { + #[error("GetNamedPipeClientProcessId failed: Win32 error {0:#X}")] + GetClientPid(u32), + #[error("ImpersonateNamedPipeClient failed: Win32 error {0:#X}")] + Impersonate(u32), + #[error("OpenThreadToken failed: Win32 error {0:#X}")] + OpenThreadToken(u32), + #[error("GetTokenInformation(TokenUser) failed: Win32 error {0:#X}")] + GetTokenInformation(u32), + #[error("ConvertSidToStringSidW failed: Win32 error {0:#X}")] + ConvertSid(u32), +} + +/// Builds a [`CallerContext`] for a freshly-connected pipe handle. +/// +/// **Order matters**: impersonates the caller, reads the token, then +/// reverts. All Win32 calls outside the impersonate/revert window run +/// with the broker's own (`LocalService`) identity. +pub fn from_pipe(pipe: HANDLE) -> Result { + let mut pid: u32 = 0; + // SAFETY: `pipe` is a valid server-side pipe handle; `&mut pid` is + // a valid out-parameter. + let res = unsafe { GetNamedPipeClientProcessId(pipe, &mut pid) }; + if res.is_err() { + let last = unsafe { GetLastError() }; + return Err(CallerContextError::GetClientPid(last.0)); + } + + // Impersonate so subsequent OpenThreadToken reads the caller's + // token, not the broker's. + let imp = unsafe { ImpersonateNamedPipeClient(pipe) }; + if imp.is_err() { + let last = unsafe { GetLastError() }; + return Err(CallerContextError::Impersonate(last.0)); + } + + // Use an RAII guard so RevertToSelf runs even on early returns. + let _revert = RevertGuard; + + let mut token = HANDLE::default(); + let r = unsafe { OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, true, &mut token) }; + if r.is_err() { + let last = unsafe { GetLastError() }; + return Err(CallerContextError::OpenThreadToken(last.0)); + } + let _token_handle = TokenHandle(token); + + // First call sizes the buffer; second call fills it. + let mut required: u32 = 0; + let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut required) }; + if required == 0 { + let last = unsafe { GetLastError() }; + return Err(CallerContextError::GetTokenInformation(last.0)); + } + let mut buf = vec![0u8; required as usize]; + let r = unsafe { + GetTokenInformation( + token, + TokenUser, + Some(buf.as_mut_ptr() as *mut c_void), + required, + &mut required, + ) + }; + if r.is_err() { + let last = unsafe { GetLastError() }; + return Err(CallerContextError::GetTokenInformation(last.0)); + } + + // SAFETY: `buf` starts with a `TOKEN_USER` struct followed by the + // SID data the struct points into. + let token_user = unsafe { &*(buf.as_ptr() as *const TOKEN_USER) }; + let sid_ptr = token_user.User.Sid; + + let mut sid_wide = PWSTR::null(); + let r = unsafe { ConvertSidToStringSidW(sid_ptr, &mut sid_wide) }; + if r.is_err() { + let last = unsafe { GetLastError() }; + return Err(CallerContextError::ConvertSid(last.0)); + } + let sid_string = unsafe { wide_to_string(sid_wide) }; + // ConvertSidToStringSidW allocates with LocalAlloc; free with + // LocalFree. + unsafe { + let _ = LocalFree(Some(HLOCAL(sid_wide.0 as *mut c_void))); + } + + Ok(CallerContext { + pid, + sid: sid_string, + }) +} + +/// Validates that the caller (identified by the live pipe handle's +/// impersonation token) has at least `PROCESS_QUERY_LIMITED_INFORMATION` +/// access to `target_pid`. This is the right check because the +/// Windows ACL system already encodes "who can audit whom" — if the +/// caller can open the process at all, they have legitimate access. +/// +/// Re-impersonates on each call (the [`from_pipe`] reverter already +/// dropped the impersonation token). The broker's `LocalService` +/// identity is restored before returning. +pub fn caller_can_query_pid(pipe: HANDLE, target_pid: u32) -> bool { + let imp = unsafe { ImpersonateNamedPipeClient(pipe) }; + if imp.is_err() { + return false; + } + let _revert = RevertGuard; + + // While impersonating, try to open the target. Success means the + // caller's token grants access via normal Windows ACLs. The + // returned handle is closed by `TokenHandle` (mis-named but + // semantically a process handle here). + let result = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, target_pid) }; + match result { + Ok(h) => { + // SAFETY: we own this handle for the duration of this + // function call. + unsafe { + let _ = CloseHandle(h); + } + true + } + Err(_) => false, + } +} + +/// Same as [`caller_can_query_pid`] but checks a batch of PIDs under +/// a single impersonation bracket. Returns `false` if ANY pid is not +/// accessible to the caller's token. +pub fn caller_can_query_all_pids(pipe: HANDLE, target_pids: &[u32]) -> bool { + let imp = unsafe { ImpersonateNamedPipeClient(pipe) }; + if imp.is_err() { + return false; + } + let _revert = RevertGuard; + + for &pid in target_pids { + let result = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }; + match result { + Ok(h) => unsafe { + let _ = CloseHandle(h); + }, + Err(_) => return false, + } + } + true +} + +struct RevertGuard; +impl Drop for RevertGuard { + fn drop(&mut self) { + // RevertToSelf can fail in pathological cases; ignore the + // result — there's nothing we can do, and the thread is about + // to be reused for the next connection (which re-impersonates + // anyway). + unsafe { + let _ = RevertToSelf(); + } + } +} + +struct TokenHandle(HANDLE); +impl Drop for TokenHandle { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.0); + } + } +} + +unsafe fn wide_to_string(pwstr: PWSTR) -> String { + if pwstr.0.is_null() { + return String::new(); + } + let mut len = 0usize; + while *pwstr.0.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(pwstr.0, len); + String::from_utf16_lossy(slice) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caller_can_query_self_pid_via_dummy_pipe() { + // We can't synthesise a pipe handle here easily; this test + // just exercises the `OpenProcess` round-trip in isolation by + // calling it from a non-impersonated thread, which exercises + // the underlying code path even if the impersonation step + // would normally precede it. Real coverage of the + // impersonate-then-open path lives in the VM functional tests. + let h = unsafe { + windows::Win32::System::Threading::OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, + false, + std::process::id(), + ) + } + .expect("self OpenProcess"); + unsafe { + let _ = CloseHandle(h); + } + } +} diff --git a/src/host/mxc_learning_mode_broker/src/etw_session.rs b/src/host/mxc_learning_mode_broker/src/etw_session.rs new file mode 100644 index 000000000..9276b6ff9 --- /dev/null +++ b/src/host/mxc_learning_mode_broker/src/etw_session.rs @@ -0,0 +1,404 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Privileged ETW-session creation owned by the broker. +//! +//! Per request the broker: +//! 1. Generates a unique session name (`mxc-denials-`). +//! 2. Calls `StartTraceW` with `EVENT_TRACE_REAL_TIME_MODE` and a +//! fresh `EVENT_TRACE_PROPERTIES_V2` block. (Real-time mode, not +//! private — private sessions are in-process only and can't be +//! consumed by the unelevated caller.) +//! 3. Calls `EnableTraceEx2` for each provider (Microsoft-Windows-Kernel +//! -General + the MXC OS-side TraceLogging provider) with an +//! `EVENT_FILTER_TYPE_PID` filter scoped to the requested PID. If a +//! package SID was supplied an `EVENT_FILTER_TYPE_PACKAGE_ID` filter +//! is added too. +//! 4. (TODO) Calls `EventAccessControl` to grant the caller's user SID +//! `TRACELOG_ACCESS_REALTIME | TRACELOG_GUID_ENABLE | WMIGUID_QUERY` +//! on the session GUID so the unelevated caller can `OpenTrace` / +//! `ControlTrace(STOP)`. Phase 2.2 returns the session name without +//! this grant — the prototype caller is also elevated, so the default +//! ACL is sufficient. The grant call lands when wxc-exec is wired in +//! Phase 3 and we observe the access-denied error. +//! 5. Returns the session name in the wire response. The caller then +//! owns the lifecycle. +//! +//! On any failure mid-setup the partially-created session is torn down +//! before returning an error to the caller, so we don't leak ETW slots +//! (Windows has a hard system-wide limit of ~64 user-mode sessions). + +use std::mem::size_of; + +use windows::core::{GUID, PCWSTR}; +use windows::Win32::Foundation::{ERROR_ALREADY_EXISTS, WIN32_ERROR}; +use windows::Win32::System::Diagnostics::Etw::{ + ControlTraceW, EnableTraceEx2, StartTraceW, CONTROLTRACE_HANDLE, ENABLE_TRACE_PARAMETERS, + EVENT_CONTROL_CODE_ENABLE_PROVIDER, EVENT_FILTER_DESCRIPTOR, EVENT_TRACE_CONTROL_QUERY, + EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_PROPERTIES, EVENT_TRACE_PROPERTIES_V2, + EVENT_TRACE_REAL_TIME_MODE, TRACE_LEVEL_VERBOSE, WNODE_FLAG_TRACED_GUID, +}; + +/// `Microsoft-Windows-Kernel-General` provider GUID. +/// Source: documented Windows ETW provider GUID. +const KERNEL_GENERAL_PROVIDER: GUID = GUID::from_u128(0xa68ca8b7_004f_d7b6_a698_07e2de0f1f5d); + +/// MXC OS-side TraceLogging provider GUID. +/// Source: `src/tools/mxc_diagnostic_console/src/etw.rs`. +const MXC_OS_PROVIDER: GUID = GUID::from_u128(0xf6ec123e_314e_400b_9e0a_151365e23083); + +/// `EVENT_FILTER_TYPE_PID = 0x00000008`. +const EVENT_FILTER_TYPE_PID: u32 = 0x0000_0008; + +/// Failure that wraps a Win32 error in human-readable form. +#[derive(Debug)] +pub struct EtwError { + pub op: &'static str, + pub code: u32, +} + +impl std::fmt::Display for EtwError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: Win32 error {:#X}", self.op, self.code) + } +} + +impl std::error::Error for EtwError {} + +/// A live ETW session held by the broker while the caller consumes it. +/// +/// Phase 2.2: the broker creates and immediately disengages — the caller +/// owns lifecycle. The struct is returned from the create path so +/// callers in tests can stop the session deterministically; the +/// production hot path `mem::forget`s it. Allow dead-code on the +/// fields/methods used only by tests + future cleanup paths. +pub struct LiveSession { + pub name: String, + #[allow(dead_code)] + pub control_handle: CONTROLTRACE_HANDLE, +} + +impl LiveSession { + /// Stops the session via `ControlTraceW(STOP)`. Idempotent: a + /// session already stopped or never started returns an error which + /// is logged and swallowed. + #[allow(dead_code)] + pub fn stop(self) { + let mut name_wide: Vec = self.name.encode_utf16().chain(std::iter::once(0)).collect(); + let mut props = empty_session_properties(&self.name); + + // SAFETY: `props` is a properly-sized EVENT_TRACE_PROPERTIES_V2 + // initialized with a valid loggername buffer offset; `name_wide` + // is a valid null-terminated UTF-16 string. ControlTraceW with + // STOP frees the session. + let status = unsafe { + ControlTraceW( + self.control_handle, + PCWSTR(name_wide.as_mut_ptr()), + &mut props as *mut _ as *mut EVENT_TRACE_PROPERTIES, + EVENT_TRACE_CONTROL_STOP, + ) + }; + if status != WIN32_ERROR(0) { + eprintln!( + "[mxc-learning-mode-broker] ControlTraceW(STOP) for {} failed: {:#X}", + self.name, status.0 + ); + } + } +} + +/// Creates a denial-capture session scoped to `target_pid`. +/// +/// `_package_sid` is accepted for forward compatibility but the +/// PACKAGE_ID filter wiring is deferred to Phase 3 (we need to wire up +/// the SID parser + filter blob shape; PID alone gets us the prototype +/// data we need). +pub fn create_denial_session( + target_pid: u32, + _package_sid: Option<&str>, +) -> Result { + let session_name = format!("mxc-denials-{}", uuid::Uuid::new_v4().simple()); + + let mut props = empty_session_properties(&session_name); + let mut name_wide: Vec = session_name + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + + let mut control_handle = CONTROLTRACE_HANDLE::default(); + + // SAFETY: `props` is a properly-sized EVENT_TRACE_PROPERTIES_V2 with + // a valid trailing loggername buffer; `name_wide` is null-terminated + // UTF-16 owned for the duration of the call. + let status = unsafe { + StartTraceW( + &mut control_handle, + PCWSTR(name_wide.as_mut_ptr()), + &mut props as *mut _ as *mut EVENT_TRACE_PROPERTIES, + ) + }; + + if status == ERROR_ALREADY_EXISTS { + // A session with the same name exists. UUIDs make this practically + // impossible — surface as a hard error so we notice immediately. + return Err(EtwError { + op: "StartTraceW (already exists)", + code: status.0, + }); + } + if status != WIN32_ERROR(0) { + return Err(EtwError { + op: "StartTraceW", + code: status.0, + }); + } + + // Enable each provider with a PID filter. If any enable call fails we + // tear down the session before returning so we don't leak an ETW + // slot. + let target_pids = [target_pid]; + + if let Err(e) = enable_provider_for_pid(control_handle, &KERNEL_GENERAL_PROVIDER, &target_pids) + { + let _ = stop_partial(&session_name, control_handle); + return Err(e); + } + + if let Err(e) = enable_provider_for_pid(control_handle, &MXC_OS_PROVIDER, &target_pids) { + let _ = stop_partial(&session_name, control_handle); + return Err(e); + } + + Ok(LiveSession { + name: session_name, + control_handle, + }) +} + +fn enable_provider_for_pid( + handle: CONTROLTRACE_HANDLE, + provider: &GUID, + target_pids: &[u32], +) -> Result<(), EtwError> { + let pid_filter = EVENT_FILTER_DESCRIPTOR { + Ptr: target_pids.as_ptr() as u64, + Size: std::mem::size_of_val(target_pids) as u32, + Type: EVENT_FILTER_TYPE_PID, + }; + + let mut filters = [pid_filter]; + + let params = ENABLE_TRACE_PARAMETERS { + Version: 2, + EnableProperty: 0, + ControlFlags: 0, + SourceId: GUID::default(), + EnableFilterDesc: filters.as_mut_ptr(), + FilterDescCount: filters.len() as u32, + }; + + // SAFETY: `handle` is a valid CONTROLTRACE_HANDLE returned by + // StartTraceW; `params` and the embedded filter descriptor reference + // `target_pids` and `filters` which both outlive the call. + let status = unsafe { + EnableTraceEx2( + handle, + provider, + EVENT_CONTROL_CODE_ENABLE_PROVIDER.0, + TRACE_LEVEL_VERBOSE as u8, + 0xFFFF_FFFF_FFFF_FFFF, + 0, + 0, + Some(¶ms), + ) + }; + + if status != WIN32_ERROR(0) { + return Err(EtwError { + op: "EnableTraceEx2", + code: status.0, + }); + } + + Ok(()) +} + +fn stop_partial(name: &str, handle: CONTROLTRACE_HANDLE) -> Result<(), EtwError> { + let mut name_wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + let mut props = empty_session_properties(name); + + let status = unsafe { + ControlTraceW( + handle, + PCWSTR(name_wide.as_mut_ptr()), + &mut props as *mut _ as *mut EVENT_TRACE_PROPERTIES, + EVENT_TRACE_CONTROL_STOP, + ) + }; + + if status != WIN32_ERROR(0) { + return Err(EtwError { + op: "ControlTraceW(STOP) during cleanup", + code: status.0, + }); + } + Ok(()) +} + +/// Extends the PID filter of an already-open denial session. +/// +/// Looks up the session's `CONTROLTRACE_HANDLE` by name via +/// `ControlTraceW(NULL, name, QUERY)` (the broker does not retain +/// per-session state across requests), then re-enables both providers +/// with the new PID list. `EnableTraceEx2` replaces the existing filter +/// for the provider, so the caller must supply the **full** PID list +/// (root + all known descendants). +pub fn extend_denial_session(session_name: &str, pids: &[u32]) -> Result<(), EtwError> { + if pids.is_empty() { + return Err(EtwError { + op: "extend_denial_session: empty PID list", + code: 0, + }); + } + + // 1. Recover the CONTROLTRACE_HANDLE for this session by querying it + // by name. ControlTraceW with EVENT_TRACE_CONTROL_QUERY accepts a + // NULL handle when the session name is provided, and populates + // `props.Wnode.HistoricalContext` (which IS the trace handle). + let mut name_wide: Vec = session_name + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut props = empty_session_properties(session_name); + + // SAFETY: name_wide is a valid null-terminated UTF-16 buffer alive + // for the duration of the call. props is a properly-sized + // EVENT_TRACE_PROPERTIES_V2 block. + let status = unsafe { + ControlTraceW( + CONTROLTRACE_HANDLE::default(), + PCWSTR(name_wide.as_mut_ptr()), + &mut props as *mut _ as *mut EVENT_TRACE_PROPERTIES, + EVENT_TRACE_CONTROL_QUERY, + ) + }; + if status != WIN32_ERROR(0) { + return Err(EtwError { + op: "ControlTraceW(QUERY) for extend", + code: status.0, + }); + } + + // SAFETY: After a successful EVENT_TRACE_CONTROL_QUERY, + // `Wnode.Anonymous1.HistoricalContext` is the active variant of the + // union and contains the trace handle. + let handle = CONTROLTRACE_HANDLE { + Value: unsafe { props.base.Wnode.Anonymous1.HistoricalContext }, + }; + + // 2. Re-enable both providers with the new PID filter. EnableTraceEx2 + // replaces the previous filter. + enable_provider_for_pid(handle, &KERNEL_GENERAL_PROVIDER, pids)?; + enable_provider_for_pid(handle, &MXC_OS_PROVIDER, pids)?; + Ok(()) +} + +/// Builds an `EVENT_TRACE_PROPERTIES_V2` block sized to hold the session +/// name as a trailing UTF-16 buffer. +/// +/// ETW expects the properties struct followed by space for the session +/// name (and optionally a logfile name). We stack-allocate via the V2 +/// struct and rely on its built-in trailing reserve to hold the name — +/// `LoggerNameOffset` points just past the struct. +fn empty_session_properties(_session_name: &str) -> EtwSessionProperties { + // Layout: header followed by 2 wide-string buffers (LoggerName + + // LogFileName). We use a fixed-size buffer big enough for any UUID + // session name plus padding — 256 wide chars per buffer. + const NAME_BUF_WCHARS: usize = 256; + + let mut p: EtwSessionProperties = unsafe { core::mem::zeroed() }; + p.base.Wnode.BufferSize = size_of::() as u32; + p.base.Wnode.Flags = WNODE_FLAG_TRACED_GUID; + p.base.LogFileMode = EVENT_TRACE_REAL_TIME_MODE; + // Buffer tuning for descendant-tracking workloads. A single + // `cmd /c …` workload at the root produces hundreds of kernel-audit + // events during process init (locale setup, registry probes, + // default-user noise that the SDK filters out later). Each + // descendant added to the PID filter (Phases A-E of the + // captureDenials descendant-tracking work) generates its own + // init storm, so a launcher-shaped workload like `cmd /c findstr X` + // or `cmd /c npm run …` scales the event volume linearly with the + // number of descendants. + // + // Two knobs: + // - `BufferSize` (per-buffer size in KB; default 64 KB) + // - `Maximum/MinimumBuffers` (count of buffers) + // + // Total kernel-side capacity = BufferSize × MaximumBuffers. + // Raising the ceiling does not allocate more memory upfront — the + // kernel only allocates buffers on demand up to the cap. Bigger + // per-buffer size means the user-mode consumer gets more headroom + // per drain cycle, which matters when the consumer thread is the + // bottleneck (TDH decode + dedupe done synchronously on the ETW + // callback thread, which is the current architecture). + // + // Empirical: defaults (64 KB × 64) lost ~1300 events on a 2-PID + // test workload. Bumping the count to 256 alone did not help — + // the kernel buffer count wasn't the binding constraint; + // user-mode consumer throughput was. We additionally double the + // per-buffer size to 128 KB, giving the consumer ~2× longer + // between buffer fills. + // + // The real fix is a faster ETW consumer (decode + dedupe off the + // callback thread, into a worker pool). Tracked as a separate + // follow-up. This tuning is a pragmatic capacity bump in the + // meantime; it can be undone freely once the consumer refactor + // lands. + p.base.BufferSize = 128; + p.base.MinimumBuffers = 4; + p.base.MaximumBuffers = 256; + p.base.LoggerNameOffset = size_of::() as u32; + p.base.LogFileNameOffset = + (size_of::() + NAME_BUF_WCHARS * size_of::()) as u32; + + p +} + +/// Stack-friendly wrapper holding `EVENT_TRACE_PROPERTIES_V2` plus +/// space for the session name and (unused) logfile name. +#[repr(C)] +struct EtwSessionProperties { + base: EVENT_TRACE_PROPERTIES_V2, + logger_name: [u16; 256], + log_file_name: [u16; 256], +} + +// SAFETY: `EtwSessionProperties` is `#[repr(C)]` and contains only POD +// fields; we initialize via `core::mem::zeroed()` which is valid for +// all-POD structs. +unsafe impl Send for EtwSessionProperties {} + +#[cfg(test)] +mod tests { + use super::*; + + /// Sanity-only: ensure the layout math doesn't underflow / overflow + /// for the offsets we hand the kernel. + #[test] + fn props_layout_offsets_are_in_range() { + let p = empty_session_properties("any"); + assert!(p.base.LoggerNameOffset > 0); + assert!(p.base.LogFileNameOffset > p.base.LoggerNameOffset); + assert!(p.base.Wnode.BufferSize as usize >= size_of::()); + } + + /// Sanity: provider GUIDs have the expected high-bytes so we don't + /// silently swap the constants between Kernel-General and MXC OS. + #[test] + fn provider_guids_distinct_and_recognizable() { + assert_ne!(KERNEL_GENERAL_PROVIDER, MXC_OS_PROVIDER); + assert_eq!(KERNEL_GENERAL_PROVIDER.data1, 0xa68ca8b7); + assert_eq!(MXC_OS_PROVIDER.data1, 0xf6ec123e); + } +} diff --git a/src/host/mxc_learning_mode_broker/src/main.rs b/src/host/mxc_learning_mode_broker/src/main.rs new file mode 100644 index 000000000..04cfed847 --- /dev/null +++ b/src/host/mxc_learning_mode_broker/src/main.rs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `mxc-learning-mode-broker` — privileged service that loans ETW session-creation +//! privilege to unelevated callers (e.g. `wxc-exec`) for per-PID denial +//! capture. +//! +//! ## Design +//! +//! The broker is a Windows service running as `LocalService`, registered with +//! manual start so SCM idle-shutdown can stop it ~60s after the last +//! request. It listens on a named pipe (see +//! `learning_mode_broker_protocol::PIPE_NAME`) with an ACL that admits only +//! interactive-logon users (and the broker's own `LocalService` account). +//! +//! Per request: +//! 1. Read an `OpenDenialSessionRequest` from the connected pipe. +//! 2. Verify, under the caller's impersonation token, that the caller may +//! query the target PID (delegating "who may audit whom" to Windows' +//! own ACLs). +//! 3. Create a private ETW session +//! (`StartTraceW` + `EVENT_TRACE_PRIVATE_LOGGER_MODE`) filtered to the +//! requested PID and (optionally) AppContainer package SID. +//! 4. Return the ETW session *name*; the caller opens and consumes the +//! trace by name (`OpenTraceW`). Ownership of the session lifecycle is +//! handed to the caller, which stops it at workload exit. +//! +//! ## Operating modes +//! +//! - `mxc-learning-mode-broker` (no flags) — service entry point. Invoked by SCM +//! when the service starts. Begins the named-pipe accept loop. +//! - `mxc-learning-mode-broker --debug` — runs the service main interactively in +//! the current console (no SCM). Useful for testing the pipe + RPC +//! without registering as a service. + +#![cfg(target_os = "windows")] + +mod caller_context; +mod etw_session; +mod pipe_server; +mod service; + +use std::env; + +fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + let debug = args.iter().any(|a| a == "--debug"); + + if debug { + eprintln!("[mxc-learning-mode-broker] running in --debug mode (no SCM)"); + pipe_server::run_until_signal() + } else { + // Hand control to SCM. `service::ffi_service_main` will be invoked + // by the SCM dispatcher when the service is started. + service::start_dispatcher() + } +} diff --git a/src/host/mxc_learning_mode_broker/src/pipe_server.rs b/src/host/mxc_learning_mode_broker/src/pipe_server.rs new file mode 100644 index 000000000..9b5f1a5b2 --- /dev/null +++ b/src/host/mxc_learning_mode_broker/src/pipe_server.rs @@ -0,0 +1,720 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Named-pipe server that hosts the `OpenDenialSession` RPC. +//! +//! Accepts connections, parses a single [`BrokerRequest`] from the pipe, writes a +//! single [`BrokerResponse`], then disconnects. +//! +//! Connections are handled serially in the accept loop thread. +//! +//! Pipe ACL: scoped to interactive-logon users. The broker runs as +//! `LocalService` and would be vulnerable to confused-deputy attacks if +//! the pipe were world-accessible. + +use std::collections::HashMap; +use std::error::Error; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use windows::core::PCWSTR; +use windows::Win32::Foundation::{ + CloseHandle, GetLastError, LocalFree, ERROR_MORE_DATA, ERROR_NO_DATA, ERROR_PIPE_CONNECTED, + HANDLE, HLOCAL, INVALID_HANDLE_VALUE, +}; +use windows::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, +}; +use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; +use windows::Win32::Storage::FileSystem::{ + FlushFileBuffers, ReadFile, WriteFile, FILE_FLAGS_AND_ATTRIBUTES, PIPE_ACCESS_DUPLEX, +}; +use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_MESSAGE, + PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, +}; + +use learning_mode_broker_protocol::{ + error_code, BrokerRequest, BrokerResponse, ExtendDenialSessionRequest, + ExtendDenialSessionResponse, OpenDenialSessionRequest, OpenDenialSessionResponse, PIPE_NAME, + PROTOCOL_VERSION, +}; + +use crate::caller_context::{self, CallerContext}; + +/// Maps session names to the SID that opened them. Populated by +/// `handle_open` and consulted by `handle_extend` to enforce that +/// only the original creator can extend a session's PID filter. +/// Without this, any interactive user could enumerate session names +/// (e.g. via `logman query -ets`) and call `ExtendDenialSession` to +/// add their own PID to someone else's filter. +type OwnershipMap = Arc>>; + +/// Shape of the security descriptor in SDDL form. +/// +/// - `D:` — discretionary ACL +/// - `(A;;GA;;;LS)` — Allow Generic All to the broker's own account, +/// `NT AUTHORITY\LocalService` (well-known SID `LS` = `S-1-5-19`). The +/// broker is the pipe owner and re-creates a fresh instance for every +/// connection, which requires `FILE_CREATE_PIPE_INSTANCE`; the object +/// owner only receives `READ_CONTROL | WRITE_DAC` implicitly, so this +/// explicit ACE is required for the accept loop to create instances +/// beyond the first. +/// - `(A;;0x12019b;;;IU)` — Interactive Users (well-known SID `IU` = +/// `S-1-5-4`) get exactly the rights needed to connect and exchange +/// messages — `FILE_GENERIC_READ | FILE_GENERIC_WRITE` **minus** +/// `FILE_CREATE_PIPE_INSTANCE` (0x4). Withholding create-instance stops +/// an unprivileged interactive user from standing up a competing +/// instance of our well-known pipe (pipe-squatting / confused deputy). +/// NOTE: clients must therefore open the pipe with this explicit access +/// mask (not `GENERIC_WRITE`, whose named-pipe generic mapping *includes* +/// `FILE_CREATE_PIPE_INSTANCE`), or `CreateFile` fails with access denied. +/// - `(A;;GA;;;BA)` — Allow Generic All to Built-in Administrators +/// (allows `wxc-host-prep` / diagnostic tooling running elevated). +const PIPE_SDDL: &str = "D:(A;;GA;;;LS)(A;;0x12019b;;;IU)(A;;GA;;;BA)"; + +const PIPE_BUFFER_SIZE: u32 = 8 * 1024; + +/// Hard cap on a single reassembled request. Message-mode reads can span +/// multiple `ReadFile` calls (`ERROR_MORE_DATA`) when a request exceeds +/// `PIPE_BUFFER_SIZE`; this bounds how much an untrusted caller can send +/// before we reject with `BAD_REQUEST`, preventing unbounded allocation. +const MAX_REQUEST_SIZE: usize = 256 * 1024; + +/// Runs the pipe accept loop until the process is signaled to stop. Used +/// by the `--debug` mode where there's no SCM stop signal — Ctrl-C kills +/// the process. +pub fn run_until_signal() -> Result<(), Box> { + let stop_flag = Arc::new(AtomicBool::new(false)); + run_until_stop_flag(stop_flag) +} + +/// Runs the pipe accept loop until `stop_flag` is set. +/// +/// Connections are handled **serially** in the same thread. The +/// prototype's actual workload is one `OpenDenialSessionRequest` per +/// `wxc-exec --capture-denials` invocation: there is no benefit to +/// concurrent handling, and an earlier per-connection-thread design +/// left the next pipe instance unable to accept new clients (the +/// accept-loop iteration completed before the worker's +/// `DisconnectNamedPipe` ran, and Windows wouldn't match a new client +/// to the listening instance). Synchronous handling sidesteps that +/// entirely. If we ever need to support concurrent requests we should +/// move to overlapped I/O with a proper completion port, not naive +/// thread-per-connection. +pub fn run_until_stop_flag(stop_flag: Arc) -> Result<(), Box> { + let mut first = true; + let ownership: OwnershipMap = Arc::new(Mutex::new(HashMap::new())); + + while !stop_flag.load(Ordering::SeqCst) { + let pipe = create_pipe_instance(first)?; + first = false; + + // ConnectNamedPipe blocks until a client connects. On stop the + // control handler sets `stop_flag` and calls `wake_accept_loop`, + // which makes a throwaway self-connection to unblock this call so + // `Stop`/`Shutdown` returns promptly instead of hanging until the + // next real client. + let connect_result = unsafe { ConnectNamedPipe(pipe, None) }; + + // Successful connection: Ok(()) OR Err(ERROR_PIPE_CONNECTED) + // (client raced us between create and connect — still a valid + // connection). + let connected = match connect_result { + Ok(()) => true, + Err(e) if e.code() == ERROR_PIPE_CONNECTED.to_hresult() => true, + Err(e) => { + eprintln!("[mxc-learning-mode-broker] ConnectNamedPipe failed: {e}"); + unsafe { + let _ = CloseHandle(pipe); + } + if stop_flag.load(Ordering::SeqCst) { + break; + } + thread::sleep(Duration::from_millis(50)); + continue; + } + }; + debug_assert!(connected); + + // If we were unblocked by the shutdown self-connection, don't try + // to service it — disconnect and exit the loop. + if stop_flag.load(Ordering::SeqCst) { + unsafe { + let _ = DisconnectNamedPipe(pipe); + let _ = CloseHandle(pipe); + } + break; + } + + // Handle the request synchronously, then disconnect + close + + // loop back to create a fresh instance. + if let Err(e) = handle_connection(pipe, &ownership) { + eprintln!("[mxc-learning-mode-broker] handler error: {e}"); + } + unsafe { + let _ = DisconnectNamedPipe(pipe); + let _ = CloseHandle(pipe); + } + } + + Ok(()) +} + +/// Wakes a thread blocked in `ConnectNamedPipe` inside the accept loop by +/// making a throwaway client connection to our own pipe. Paired with the +/// stop flag, this lets `Stop`/`Shutdown` return promptly instead of +/// hanging until the next real client connects. +/// +/// Best-effort: any error is ignored. If the accept loop is between +/// iterations (not yet blocked) the connection may not land, but the loop +/// re-checks the stop flag on its next iteration regardless. +pub fn wake_accept_loop() { + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + + // SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION so the server's + // `ImpersonateNamedPipeClient` succeeds cleanly on the wake path. + const SECURITY_SQOS_PRESENT: u32 = 0x0010_0000; + const SECURITY_IMPERSONATION: u32 = 0x0002_0000; + + for _ in 0..5 { + match OpenOptions::new() + .read(true) + .write(true) + .custom_flags(SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION) + .open(PIPE_NAME) + { + Ok(f) => { + drop(f); + return; + } + Err(_) => thread::sleep(Duration::from_millis(10)), + } + } +} + +fn handle_connection(pipe: HANDLE, ownership: &OwnershipMap) -> Result<(), Box> { + // Identify the caller *before* reading the request so a malformed + // or oversized message can't bypass the security check. + let caller = match caller_context::from_pipe(pipe) { + Ok(c) => c, + Err(e) => { + // Without a verified identity we can't safely service this + // connection. Reply with a generic permission error and + // drop the connection. + let resp = BrokerResponse::OpenDenialSession(OpenDenialSessionResponse::Error { + code: error_code::UNAUTHORIZED.to_string(), + message: format!("caller identification failed: {e}"), + }); + let body = serde_json::to_vec(&resp)?; + let mut written = 0u32; + unsafe { + let _ = WriteFile(pipe, Some(&body), Some(&mut written), None); + let _ = FlushFileBuffers(pipe); + } + return Err(format!("caller_context: {e}").into()); + } + }; + + // Message-mode pipe: a single logical request may still span multiple + // `ReadFile` calls if it exceeds `PIPE_BUFFER_SIZE` — each partial read + // returns `ERROR_MORE_DATA` until the final chunk. Reassemble the full + // message, bounded by `MAX_REQUEST_SIZE`, so an oversized request yields + // a structured `BAD_REQUEST` instead of a dropped connection. + let mut buf = vec![0u8; PIPE_BUFFER_SIZE as usize]; + let mut message: Vec = Vec::new(); + + loop { + let mut bytes_read = 0u32; + let read_ok = unsafe { ReadFile(pipe, Some(&mut buf), Some(&mut bytes_read), None) }; + message.extend_from_slice(&buf[..bytes_read as usize]); + + if read_ok.is_ok() { + break; + } + + let last = unsafe { GetLastError() }; + match last { + ERROR_NO_DATA => { + // Client closed without sending — nothing to reply to. + return Ok(()); + } + ERROR_MORE_DATA => { + if message.len() > MAX_REQUEST_SIZE { + // Refuse oversized requests with a structured error + // rather than silently dropping the connection. + let resp = + BrokerResponse::OpenDenialSession(OpenDenialSessionResponse::Error { + code: error_code::BAD_REQUEST.to_string(), + message: format!("request exceeds maximum of {MAX_REQUEST_SIZE} bytes"), + }); + if let Ok(body) = serde_json::to_vec(&resp) { + let mut written = 0u32; + unsafe { + let _ = WriteFile(pipe, Some(&body), Some(&mut written), None); + let _ = FlushFileBuffers(pipe); + } + } + return Ok(()); + } + // More chunks pending — keep reading. + } + _ => return Err(format!("ReadFile failed: {last:?}").into()), + } + } + + let response = handle_request(&message, &caller, pipe, ownership); + let response_bytes = serde_json::to_vec(&response)?; + + let mut written = 0u32; + unsafe { + WriteFile(pipe, Some(&response_bytes), Some(&mut written), None) + .map_err(|e| format!("WriteFile failed: {e}"))?; + let _ = FlushFileBuffers(pipe); + } + + Ok(()) +} + +/// Handles a parsed request: dispatches on the [`BrokerRequest`] variant. +/// For `OpenDenialSession`, creates an ETW session via the privileged +/// `etw_session` module and returns its name. For `ExtendDenialSession`, +/// updates the running session's PID filter. +/// +/// On any failure tears down (or refuses to extend) without leaking ETW +/// slots. +fn handle_request( + bytes: &[u8], + caller: &CallerContext, + pipe: HANDLE, + ownership: &OwnershipMap, +) -> BrokerResponse { + let req: BrokerRequest = match serde_json::from_slice(bytes) { + Ok(r) => r, + Err(e) => { + return BrokerResponse::OpenDenialSession(OpenDenialSessionResponse::Error { + code: error_code::BAD_REQUEST.to_string(), + message: format!("malformed request: {e}"), + }); + } + }; + + match req { + BrokerRequest::OpenDenialSession(open_req) => { + BrokerResponse::OpenDenialSession(handle_open(open_req, caller, pipe, ownership)) + } + BrokerRequest::ExtendDenialSession(ext_req) => { + BrokerResponse::ExtendDenialSession(handle_extend(ext_req, caller, pipe, ownership)) + } + } +} + +fn handle_open( + req: OpenDenialSessionRequest, + caller: &CallerContext, + pipe: HANDLE, + ownership: &OwnershipMap, +) -> OpenDenialSessionResponse { + if req.protocol_version != PROTOCOL_VERSION { + return OpenDenialSessionResponse::Error { + code: error_code::VERSION_MISMATCH.to_string(), + message: format!( + "client protocol version {} does not match server {PROTOCOL_VERSION}", + req.protocol_version + ), + }; + } + + // Security check #1: under the caller's impersonation token, the + // caller must be able to OpenProcess the target. This delegates + // "who can audit whom" to Windows' own ACL system, which already + // models sandboxed-workload tokens correctly. + if !caller_context::caller_can_query_pid(pipe, req.target_pid) { + return OpenDenialSessionResponse::Error { + code: error_code::UNAUTHORIZED.to_string(), + message: format!( + "caller cannot open target PID {} (no PROCESS_QUERY_LIMITED_INFORMATION access)", + req.target_pid + ), + }; + } + + match crate::etw_session::create_denial_session(req.target_pid, req.package_sid.as_deref()) { + Ok(session) => { + let name = session.name.clone(); + // Record session ownership so a later ExtendDenialSession + // can only be honoured for the same caller SID. + if let Ok(mut map) = ownership.lock() { + map.insert(name.clone(), caller.sid.clone()); + } + // Phase 2.2: broker hands ownership of the session lifecycle + // to the caller. By dropping `session` here without calling + // `.stop()` we leave the ETW session active in the kernel — + // intentional. The caller's `wxc-exec` invocation owns + // `ControlTraceW(STOP)` at workload exit. If the caller + // crashes the session leaks until the next reboot; tracked + // as an open issue in the prototype plan. + std::mem::forget(session); + OpenDenialSessionResponse::Ok { session_name: name } + } + Err(e) => OpenDenialSessionResponse::Error { + code: error_code::WIN32_FAILURE.to_string(), + message: format!( + "failed to create denial session for PID {}: {}", + req.target_pid, e + ), + }, + } +} + +fn handle_extend( + req: ExtendDenialSessionRequest, + caller: &CallerContext, + pipe: HANDLE, + ownership: &OwnershipMap, +) -> ExtendDenialSessionResponse { + if req.protocol_version != PROTOCOL_VERSION { + return ExtendDenialSessionResponse::Error { + code: error_code::VERSION_MISMATCH.to_string(), + message: format!( + "client protocol version {} does not match server {PROTOCOL_VERSION}", + req.protocol_version + ), + }; + } + + if req.pids.is_empty() { + return ExtendDenialSessionResponse::Error { + code: error_code::BAD_REQUEST.to_string(), + message: "extendDenialSession requires a non-empty pids list".into(), + }; + } + + // Security check #2: the SID that opened this session must match + // the SID extending it. Without this, an attacker who enumerated + // session names (e.g. via `logman query -ets`) could call + // ExtendDenialSession to add their PID to someone else's filter + // and observe their denials. + let recorded_sid = ownership + .lock() + .ok() + .and_then(|m| m.get(&req.session_name).cloned()); + match recorded_sid { + Some(sid) if sid == caller.sid => {} + Some(_) => { + return ExtendDenialSessionResponse::Error { + code: error_code::UNAUTHORIZED.to_string(), + message: format!("caller is not the owner of session `{}`", req.session_name), + }; + } + None => { + return ExtendDenialSessionResponse::Error { + code: error_code::UNKNOWN_SESSION.to_string(), + message: format!( + "session `{}` is not known to this broker instance", + req.session_name + ), + }; + } + } + + // Security check #3: each PID being added to the filter must be + // queryable by the caller. Same rationale as the open check. + if !caller_context::caller_can_query_all_pids(pipe, &req.pids) { + return ExtendDenialSessionResponse::Error { + code: error_code::UNAUTHORIZED.to_string(), + message: + "one or more PIDs in the extend request are not accessible to the caller's token" + .into(), + }; + } + + match crate::etw_session::extend_denial_session(&req.session_name, &req.pids) { + Ok(()) => ExtendDenialSessionResponse::Ok, + Err(e) => { + // Distinguish "session doesn't exist" (caller passed a bad + // name) from generic Win32 failures so SDK consumers can + // surface a clearer error. + let code = if e.code == windows::Win32::Foundation::ERROR_WMI_INSTANCE_NOT_FOUND.0 { + error_code::UNKNOWN_SESSION + } else { + error_code::WIN32_FAILURE + }; + ExtendDenialSessionResponse::Error { + code: code.to_string(), + message: format!( + "failed to extend denial session `{}` to {} PID(s): {}", + req.session_name, + req.pids.len(), + e + ), + } + } + } +} + +fn create_pipe_instance(first: bool) -> Result> { + let name_wide: Vec = PIPE_NAME.encode_utf16().chain(std::iter::once(0)).collect(); + + // Build a SECURITY_DESCRIPTOR from the SDDL string. The descriptor is + // owned by the OS allocator (LocalAlloc); we hand its pointer to + // SECURITY_ATTRIBUTES for the lifetime of CreateNamedPipeW. The pipe + // handle gets its own copy at creation, so the SDDL allocation can + // be freed after. + let sddl_wide: Vec = PIPE_SDDL.encode_utf16().chain(std::iter::once(0)).collect(); + let mut psd = PSECURITY_DESCRIPTOR::default(); + + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR(sddl_wide.as_ptr()), + SDDL_REVISION_1, + &mut psd, + None, + ) + .map_err(|e| format!("SDDL conversion failed: {e}"))?; + } + + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: psd.0, + bInheritHandle: false.into(), + }; + + // `PIPE_ACCESS_DUPLEX` because we read the request and write the + // response on the same handle. `FILE_FLAG_FIRST_PIPE_INSTANCE` on the + // first instance only — prevents another process from squatting on + // our well-known pipe name. + let mut open_mode = FILE_FLAGS_AND_ATTRIBUTES(PIPE_ACCESS_DUPLEX.0); + if first { + // FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000. + open_mode = FILE_FLAGS_AND_ATTRIBUTES(open_mode.0 | 0x0008_0000); + } + + let handle = unsafe { + CreateNamedPipeW( + PCWSTR(name_wide.as_ptr()), + open_mode, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + PIPE_BUFFER_SIZE, + PIPE_BUFFER_SIZE, + 0, + Some(&sa), + ) + }; + + // Free the SDDL-derived SD now that the pipe has its own copy. + // ConvertStringSecurityDescriptorToSecurityDescriptorW allocates the + // descriptor with LocalAlloc; release it with LocalFree once + // CreateNamedPipeW has captured its own copy into the pipe object. + if !psd.0.is_null() { + unsafe { + let _ = LocalFree(Some(HLOCAL(psd.0))); + } + } + + if handle == INVALID_HANDLE_VALUE { + let last = unsafe { GetLastError() }; + return Err(format!("CreateNamedPipeW failed: {last:?}").into()); + } + + Ok(handle) +} + +#[cfg(test)] +mod tests { + use super::*; + use learning_mode_broker_protocol::{ + BrokerRequest, ExtendDenialSessionRequest, OpenDenialSessionRequest, PROTOCOL_VERSION, + }; + + fn extract_open(resp: BrokerResponse) -> OpenDenialSessionResponse { + match resp { + BrokerResponse::OpenDenialSession(r) => r, + other => panic!("expected OpenDenialSession variant, got {other:?}"), + } + } + + fn extract_extend(resp: BrokerResponse) -> ExtendDenialSessionResponse { + match resp { + BrokerResponse::ExtendDenialSession(r) => r, + other => panic!("expected ExtendDenialSession variant, got {other:?}"), + } + } + + /// Synthetic caller context for unit tests. Real callers come from + /// `caller_context::from_pipe`. + fn test_caller() -> CallerContext { + CallerContext { + pid: std::process::id(), + sid: "S-1-5-21-test-caller".to_string(), + } + } + + /// Dummy pipe handle for unit tests that don't exercise the + /// impersonate-then-OpenProcess check (those are covered on the + /// VM since they need a real impersonation token). + fn dummy_pipe() -> HANDLE { + HANDLE::default() + } + + fn empty_ownership() -> OwnershipMap { + Arc::new(Mutex::new(HashMap::new())) + } + + #[test] + fn handle_request_rejects_bad_json() { + match extract_open(handle_request( + b"not json at all", + &test_caller(), + dummy_pipe(), + &empty_ownership(), + )) { + OpenDenialSessionResponse::Error { code, .. } => { + assert_eq!(code, error_code::BAD_REQUEST); + } + _ => panic!("expected Error variant"), + } + } + + #[test] + fn handle_open_rejects_version_mismatch() { + let req = BrokerRequest::OpenDenialSession(OpenDenialSessionRequest { + protocol_version: PROTOCOL_VERSION + 99, + target_pid: 1, + package_sid: None, + }); + let bytes = serde_json::to_vec(&req).unwrap(); + match extract_open(handle_request( + &bytes, + &test_caller(), + dummy_pipe(), + &empty_ownership(), + )) { + OpenDenialSessionResponse::Error { code, .. } => { + assert_eq!(code, error_code::VERSION_MISMATCH); + } + _ => panic!("expected Error variant"), + } + } + + #[test] + fn handle_open_rejects_inaccessible_target_pid() { + // PID 0 (the idle process) can't be opened by anyone. With a + // dummy pipe handle the impersonation will also fail-closed, + // so we expect a UNAUTHORIZED error. + let req = BrokerRequest::OpenDenialSession(OpenDenialSessionRequest { + protocol_version: PROTOCOL_VERSION, + target_pid: 0, + package_sid: None, + }); + let bytes = serde_json::to_vec(&req).unwrap(); + match extract_open(handle_request( + &bytes, + &test_caller(), + dummy_pipe(), + &empty_ownership(), + )) { + OpenDenialSessionResponse::Error { code, .. } => { + assert_eq!(code, error_code::UNAUTHORIZED); + } + _ => panic!("expected Error variant for inaccessible PID"), + } + } + + #[test] + fn handle_extend_rejects_version_mismatch() { + let req = BrokerRequest::ExtendDenialSession(ExtendDenialSessionRequest { + protocol_version: PROTOCOL_VERSION + 99, + session_name: "mxc-denials-xxx".into(), + pids: vec![1, 2], + }); + let bytes = serde_json::to_vec(&req).unwrap(); + match extract_extend(handle_request( + &bytes, + &test_caller(), + dummy_pipe(), + &empty_ownership(), + )) { + ExtendDenialSessionResponse::Error { code, .. } => { + assert_eq!(code, error_code::VERSION_MISMATCH); + } + _ => panic!("expected Error variant"), + } + } + + #[test] + fn handle_extend_rejects_empty_pid_list() { + let req = BrokerRequest::ExtendDenialSession(ExtendDenialSessionRequest { + protocol_version: PROTOCOL_VERSION, + session_name: "mxc-denials-xxx".into(), + pids: vec![], + }); + let bytes = serde_json::to_vec(&req).unwrap(); + match extract_extend(handle_request( + &bytes, + &test_caller(), + dummy_pipe(), + &empty_ownership(), + )) { + ExtendDenialSessionResponse::Error { code, .. } => { + assert_eq!(code, error_code::BAD_REQUEST); + } + _ => panic!("expected Error variant"), + } + } + + #[test] + fn handle_extend_rejects_unknown_session() { + let req = BrokerRequest::ExtendDenialSession(ExtendDenialSessionRequest { + protocol_version: PROTOCOL_VERSION, + session_name: "mxc-denials-i-was-never-opened".into(), + pids: vec![std::process::id()], + }); + let bytes = serde_json::to_vec(&req).unwrap(); + match extract_extend(handle_request( + &bytes, + &test_caller(), + dummy_pipe(), + &empty_ownership(), + )) { + ExtendDenialSessionResponse::Error { code, .. } => { + assert_eq!(code, error_code::UNKNOWN_SESSION); + } + _ => panic!("expected Error variant for unknown session"), + } + } + + #[test] + fn handle_extend_rejects_different_caller_sid() { + let ownership = empty_ownership(); + ownership.lock().unwrap().insert( + "mxc-denials-shared".to_string(), + "S-1-5-21-other-user".to_string(), + ); + let req = BrokerRequest::ExtendDenialSession(ExtendDenialSessionRequest { + protocol_version: PROTOCOL_VERSION, + session_name: "mxc-denials-shared".into(), + pids: vec![std::process::id()], + }); + let bytes = serde_json::to_vec(&req).unwrap(); + match extract_extend(handle_request( + &bytes, + &test_caller(), + dummy_pipe(), + &ownership, + )) { + ExtendDenialSessionResponse::Error { code, message } => { + assert_eq!(code, error_code::UNAUTHORIZED); + assert!( + message.contains("not the owner"), + "unexpected message: {message}" + ); + } + _ => panic!("expected Error for SID mismatch"), + } + } +} diff --git a/src/host/mxc_learning_mode_broker/src/service.rs b/src/host/mxc_learning_mode_broker/src/service.rs new file mode 100644 index 000000000..b24f01b57 --- /dev/null +++ b/src/host/mxc_learning_mode_broker/src/service.rs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Windows service framework wiring for `mxc-learning-mode-broker`. +//! +//! Uses the `windows-service` crate to register a service control +//! handler with SCM, then runs the named-pipe server until the service +//! receives a `Stop` control. Idle shutdown (the SCM "stop after N +//! seconds idle" behavior) is configured at install time, not here — +//! the service simply runs `pipe_server::run_until_signal()` and exits +//! when signaled. + +use std::error::Error; +use std::ffi::OsString; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use windows_service::define_windows_service; +use windows_service::service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType, +}; +use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; +use windows_service::service_dispatcher; + +use crate::pipe_server; + +define_windows_service!(ffi_service_main, service_main); + +/// Canonical SCM service name. Single-sourced here so the dispatcher +/// registration and the control-handler registration can never desync. +pub const SERVICE_NAME: &str = "MxcLearningModeBroker"; + +/// Hands control to the SCM dispatcher under [`SERVICE_NAME`]. Blocks until +/// the service stops. +pub fn start_dispatcher() -> Result<(), Box> { + service_dispatcher::start(SERVICE_NAME, ffi_service_main)?; + Ok(()) +} + +fn service_main(_args: Vec) { + if let Err(e) = run_service() { + eprintln!("[mxc-learning-mode-broker] service exited with error: {e}"); + } +} + +fn run_service() -> Result<(), Box> { + let stop_flag = Arc::new(AtomicBool::new(false)); + + // Status handle holder so the control handler can update SCM as we + // transition through stop. + let stop_flag_for_handler = stop_flag.clone(); + let event_handler = move |control_event| -> ServiceControlHandlerResult { + match control_event { + ServiceControl::Stop | ServiceControl::Shutdown => { + stop_flag_for_handler.store(true, Ordering::SeqCst); + // Unblock the accept loop's `ConnectNamedPipe` so the + // service stops promptly instead of hanging until the next + // client connects. + pipe_server::wake_accept_loop(); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + } + }; + + let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; + + let set_status = + |state: ServiceState, accept: ServiceControlAccept| -> Result<(), windows_service::Error> { + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: state, + controls_accepted: accept, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::from_secs(0), + process_id: None, + }) + }; + + set_status( + ServiceState::Running, + ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, + )?; + + pipe_server::run_until_stop_flag(stop_flag.clone())?; + + set_status(ServiceState::Stopped, ServiceControlAccept::empty())?; + Ok(()) +} diff --git a/src/host/wxc_host_prep/Cargo.toml b/src/host/wxc_host_prep/Cargo.toml index f7ca71411..5638395b4 100644 --- a/src/host/wxc_host_prep/Cargo.toml +++ b/src/host/wxc_host_prep/Cargo.toml @@ -16,6 +16,7 @@ clap = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true } windows-core = { workspace = true } +windows-service = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/src/host/wxc_host_prep/src/cli.rs b/src/host/wxc_host_prep/src/cli.rs index 612b2df8c..f07275403 100644 --- a/src/host/wxc_host_prep/src/cli.rs +++ b/src/host/wxc_host_prep/src/cli.rs @@ -57,6 +57,20 @@ enum Command { /// Print the current `\Device\Null` security descriptor in SDDL /// form (and optionally as JSON). DumpNullDevice(DumpNullDeviceArgs), + + /// Register `mxc-learning-mode-broker.exe` as a Manual-start Windows service + /// so unelevated callers (e.g. `wxc-exec`) can request scoped ETW + /// sessions for per-PID denial capture. Idempotent when the + /// binary path matches. + InstallLearningModeBroker(InstallLearningModeBrokerArgs), + + /// Stop and deregister the `MxcLearningModeBroker` service. Idempotent + /// when the service is already absent. + UninstallLearningModeBroker, + + /// Report whether `MxcLearningModeBroker` is installed, its current state, + /// and the registered binary path. + DumpLearningModeBroker(DumpLearningModeBrokerArgs), } #[derive(clap::Args)] @@ -104,6 +118,22 @@ struct DumpNullDeviceArgs { json: bool, } +#[derive(clap::Args)] +struct InstallLearningModeBrokerArgs { + /// Override the *source* `mxc-learning-mode-broker.exe` to copy from. + /// Defaults to the same directory as `wxc-host-prep.exe`. The binary + /// is always installed into `%ProgramFiles%\Mxc`. + #[arg(long = "broker-path")] + broker_path: Option, +} + +#[derive(clap::Args)] +struct DumpLearningModeBrokerArgs { + /// Emit machine-readable JSON results on stdout. + #[arg(long)] + json: bool, +} + pub fn run() -> i32 { let cli = Cli::parse(); @@ -124,5 +154,10 @@ pub fn run() -> i32 { } Command::VerifyNullDevice(args) => crate::null_device::run_verify(args.json), Command::DumpNullDevice(args) => crate::null_device::run_dump(args.json), + Command::InstallLearningModeBroker(args) => { + crate::learning_mode_broker::run_install(args.broker_path.as_deref()) + } + Command::UninstallLearningModeBroker => crate::learning_mode_broker::run_uninstall(), + Command::DumpLearningModeBroker(args) => crate::learning_mode_broker::run_dump(args.json), } } diff --git a/src/host/wxc_host_prep/src/learning_mode_broker/mod.rs b/src/host/wxc_host_prep/src/learning_mode_broker/mod.rs new file mode 100644 index 000000000..9adee13b6 --- /dev/null +++ b/src/host/wxc_host_prep/src/learning_mode_broker/mod.rs @@ -0,0 +1,508 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Install / uninstall / inspect the `MxcLearningModeBroker` Windows service. +//! +//! The broker itself lives in `mxc-learning-mode-broker.exe` (built from the +//! `mxc_learning_mode_broker` crate). `wxc-host-prep` is the supported way to +//! register / deregister it with SCM, matching the existing +//! `prepare-system-drive` / `prepare-null-device` admin-time-only +//! pattern. +//! +//! Conventions: +//! - **Service name**: `MxcLearningModeBroker` (matches the constant the broker +//! binary itself uses with `start_dispatcher`). +//! - **Display name**: `"MXC Denial Capture Broker"` (visible in +//! services.msc). +//! - **Account**: `NT AUTHORITY\LocalService` — least-privilege. The +//! account doesn't carry `SeSystemProfilePrivilege` by default, so +//! `install-learning-mode-broker` grants it explicitly via the LSA +//! `LsaAddAccountRights` API before creating the service. See the +//! `privilege` submodule. +//! - **Start type**: `Demand` (manual). SCM idle-shutdown stops it +//! ~60s after the last request; restart is automatic on the next +//! inbound pipe connection (well, on the next `wxc-exec` invocation +//! that opens the pipe — the service is started by either the +//! caller or by an explicit `Start-Service MxcLearningModeBroker`). +//! - **Install location**: the broker binary is **copied into a protected +//! `Program Files` subdirectory** (`%ProgramFiles%\Mxc\mxc-learning-mode-broker.exe`) +//! and the service is registered to run from there. `Program Files` is +//! writable only by administrators / SYSTEM / TrustedInstaller, so +//! unprivileged (Authenticated Users) callers cannot swap the registered +//! service binary. +//! - **Source binary**: `mxc-learning-mode-broker.exe` next to +//! `wxc-host-prep.exe` (the SDK bin dir). Override the *source* with +//! `--broker-path`; install always copies that source into `Program Files`. + +mod privilege; + +use std::ffi::OsString; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +use windows::core::PCWSTR; +use windows::Win32::Storage::FileSystem::CopyFileW; +use windows::Win32::System::Com::CoTaskMemFree; +use windows::Win32::UI::Shell::{FOLDERID_ProgramFiles, SHGetKnownFolderPath, KNOWN_FOLDER_FLAG}; +use windows_service::service::{ + ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceState, ServiceType, +}; +use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + +const SERVICE_NAME: &str = "MxcLearningModeBroker"; +const SERVICE_DISPLAY_NAME: &str = "MXC Denial Capture Broker"; +const SHIM_BINARY_FILENAME: &str = "mxc-learning-mode-broker.exe"; + +/// `Program Files` subdirectory the broker binary is installed into. +const SHIM_INSTALL_SUBDIR: &str = "Mxc"; + +/// Service runs as `NT AUTHORITY\LocalService` (least-privilege). +/// `SeSystemProfilePrivilege` is granted to this account at install +/// time so the broker can call `StartTraceW`. +const SERVICE_ACCOUNT: &str = "NT AUTHORITY\\LocalService"; + +/// Default *source* path: `\mxc-learning-mode-broker.exe`. +fn default_broker_binary_path() -> Result { + let exe = std::env::current_exe() + .map_err(|e| format!("could not determine wxc-host-prep path: {e}"))?; + let dir = exe + .parent() + .ok_or_else(|| "wxc-host-prep has no parent directory".to_string())?; + Ok(dir.join(SHIM_BINARY_FILENAME)) +} + +/// Resolves the *source* binary to copy into the install location. An +/// explicit `--broker-path` overrides the default SDK-bin-dir location; the +/// source must exist on disk. +fn resolve_source_broker_path(override_path: Option<&str>) -> Result { + match override_path { + Some(p) => { + let pb = PathBuf::from(p); + if !pb.exists() { + return Err(format!("broker binary not found at {}", pb.display())); + } + Ok(pb) + } + None => { + let pb = default_broker_binary_path()?; + if !pb.exists() { + return Err(format!( + "broker binary not found at default path {}\n\ + pass --broker-path to specify an explicit source location", + pb.display() + )); + } + Ok(pb) + } + } +} + +/// Returns the machine's `Program Files` directory via the Known Folders +/// API (correct on localized / redirected installs, unlike a hardcoded +/// path). `wxc-host-prep` ships as a native binary, so this resolves the +/// real 64-bit `Program Files` (no WoW64 redirect to `Program Files (x86)`). +fn program_files_directory() -> Result { + unsafe { + let pwstr = SHGetKnownFolderPath(&FOLDERID_ProgramFiles, KNOWN_FOLDER_FLAG(0), None) + .map_err(|e| format!("SHGetKnownFolderPath(ProgramFiles) failed: {e}"))?; + let result = pwstr + .to_string() + .map_err(|e| format!("Program Files path was not valid UTF-16: {e}")); + // SHGetKnownFolderPath allocates with CoTaskMemAlloc — free it + // regardless of whether the conversion above succeeded. + CoTaskMemFree(Some(pwstr.0 as *const core::ffi::c_void)); + Ok(PathBuf::from(result?)) + } +} + +/// `%ProgramFiles%\Mxc` — the install directory for the broker binary. +fn broker_install_dir() -> Result { + Ok(program_files_directory()?.join(SHIM_INSTALL_SUBDIR)) +} + +/// Wide, NUL-terminated form of a path for the `*W` Win32 APIs. +fn to_wide_nul(p: &Path) -> Vec { + p.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +/// Copies the source broker binary to the install target, overwriting any +/// existing copy. The destination inherits the `Program Files` DACL +/// (administrators / SYSTEM / TrustedInstaller write only). +fn copy_broker_to_target(source: &Path, target: &Path) -> Result<(), String> { + let src_w = to_wide_nul(source); + let dst_w = to_wide_nul(target); + // bFailIfExists = false: install overwrites a stale copy in place. + unsafe { CopyFileW(PCWSTR(src_w.as_ptr()), PCWSTR(dst_w.as_ptr()), false) }.map_err(|e| { + format!( + "CopyFile {} -> {} failed: {e}", + source.display(), + target.display() + ) + }) +} + +/// Best-effort hardening audit of the installed binary. Confirms the +/// target lives under the resolved install root (so it inherits the +/// protected `Program Files` DACL) and warns if any broad principal still +/// holds an explicit write ACE on it. Never fails the install — it is a +/// diagnostic, not a gate. +fn verify_target_hardening(target: &Path, install_root: &Path) { + if !target.starts_with(install_root) { + eprintln!( + "warning: broker target {} is not under {}; protected ACL inheritance is not guaranteed", + target.display(), + install_root.display() + ); + return; + } + + // Broad principals that must NOT have write on a privileged service + // binary: Authenticated Users, BUILTIN\Users, Everyone. + const BROAD_PRINCIPALS: &[(&str, &str)] = &[ + ("S-1-5-11", "Authenticated Users"), + ("S-1-5-32-545", "Users"), + ("S-1-1-0", "Everyone"), + ]; + // Write-equivalent rights: FILE_WRITE_DATA | FILE_APPEND_DATA | DELETE + // | WRITE_DAC | WRITE_OWNER | GENERIC_ALL | GENERIC_WRITE. + const BROAD_WRITE_BITS: u32 = 0x0000_0002 + | 0x0000_0004 + | 0x0001_0000 + | 0x0004_0000 + | 0x0008_0000 + | 0x1000_0000 + | 0x4000_0000; + + for (sid, label) in BROAD_PRINCIPALS { + if let Ok(aces) = wxc_common::filesystem_dacl::scan_explicit_aces_for_sid(target, sid) { + for ace in aces { + if ace.ace_type == wxc_common::filesystem_dacl::AceType::Allow + && (ace.access_mask & BROAD_WRITE_BITS) != 0 + { + eprintln!( + "warning: installed broker {} grants write access to {label} \ + (mask {:#010x}); unprivileged callers may be able to replace it", + target.display(), + ace.access_mask + ); + } + } + } + } +} + +/// Implements `wxc-host-prep install-learning-mode-broker`. +/// +/// Copies the source broker binary into `%ProgramFiles%\Mxc` and registers +/// the service to run from there. Idempotent: if the service is already +/// registered with the same (install-target) binary path it refreshes the +/// copy and returns success. A service registered with a *different* +/// binary path is an explicit conflict — caller must +/// `uninstall-learning-mode-broker` first. +/// +/// Always re-applies the `SeSystemProfilePrivilege` grant to +/// `LocalService` (no-op when already granted, per LSA semantics). +pub fn run_install(broker_path_override: Option<&str>) -> i32 { + let source_path = match resolve_source_broker_path(broker_path_override) { + Ok(p) => p, + Err(e) => { + eprintln!("error: {e}"); + return 1; + } + }; + + let install_dir = match broker_install_dir() { + Ok(p) => p, + Err(e) => { + eprintln!("error: {e}"); + return 3; + } + }; + let target_path = install_dir.join(SHIM_BINARY_FILENAME); + + // 1. Grant SeSystemProfilePrivilege to LocalService BEFORE creating + // the service. If the privilege grant fails the service is + // useless anyway, so bail early without touching SCM. + if let Err(e) = privilege::grant_se_system_profile_to_local_service() { + eprintln!("error: could not grant SeSystemProfilePrivilege to LocalService: {e}"); + return 3; + } + println!("granted SeSystemProfilePrivilege to NT AUTHORITY\\LocalService"); + + let manager = match ServiceManager::local_computer( + None::<&str>, + ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE, + ) { + Ok(m) => m, + Err(e) => { + eprintln!("error: OpenSCManager failed: {e}"); + return 3; + } + }; + + // If the service already exists, treat as idempotent when the binary + // path matches the install target; otherwise tell the caller to + // uninstall first. + if let Ok(existing) = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_CONFIG) { + match existing.query_config() { + Ok(cfg) => { + let existing_path = strip_quotes(&cfg.executable_path.to_string_lossy()); + let new_path = target_path.to_string_lossy().to_string(); + if paths_match(&existing_path, &new_path) { + // Same registration — refresh the on-disk copy so an + // install after a binary rebuild updates the bits. + if let Err(e) = copy_broker_to_target(&source_path, &target_path) { + eprintln!("error: {e}"); + return 1; + } + verify_target_hardening(&target_path, &install_dir); + println!( + "service {SERVICE_NAME} already installed with binary {} (refreshed)", + existing_path + ); + return 0; + } + eprintln!( + "error: service {SERVICE_NAME} already installed with a different binary:\n \ + existing: {existing_path}\n requested: {new_path}\n\ + run `wxc-host-prep uninstall-learning-mode-broker` first" + ); + return 1; + } + Err(e) => { + eprintln!("warning: could not read existing service config: {e}"); + // fall through — try to create; CreateService will fail + // with ERROR_SERVICE_EXISTS which we surface verbatim. + } + } + } + + // 2. Stage the binary in the protected install directory before + // registering the service so the SCM record never points at a + // missing file. + if let Err(e) = std::fs::create_dir_all(&install_dir) { + eprintln!( + "error: could not create install directory {}: {e}", + install_dir.display() + ); + return 1; + } + if let Err(e) = copy_broker_to_target(&source_path, &target_path) { + eprintln!("error: {e}"); + return 1; + } + println!( + "staged broker binary {} -> {}", + source_path.display(), + target_path.display() + ); + verify_target_hardening(&target_path, &install_dir); + + let service_info = ServiceInfo { + name: OsString::from(SERVICE_NAME), + display_name: OsString::from(SERVICE_DISPLAY_NAME), + service_type: ServiceType::OWN_PROCESS, + start_type: ServiceStartType::OnDemand, + error_control: ServiceErrorControl::Normal, + executable_path: target_path.clone(), + launch_arguments: vec![], + dependencies: vec![], + // Least-privilege account; privilege grant happened above. + account_name: Some(OsString::from(SERVICE_ACCOUNT)), + account_password: None, + }; + + match manager.create_service(&service_info, ServiceAccess::QUERY_CONFIG) { + Ok(_svc) => { + println!( + "installed service {SERVICE_NAME}\n display: {SERVICE_DISPLAY_NAME}\n \ + binary: {}\n account: {SERVICE_ACCOUNT} (Manual start, with SeSystemProfilePrivilege)", + target_path.display() + ); + 0 + } + Err(e) => { + eprintln!("error: CreateService failed: {e}"); + 1 + } + } +} + +/// Implements `wxc-host-prep uninstall-learning-mode-broker`. +/// +/// Stops the service if it's running, then deletes the SCM record. +/// Idempotent: a service that doesn't exist is a successful exit. +pub fn run_uninstall() -> i32 { + let manager = match ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + { + Ok(m) => m, + Err(e) => { + eprintln!("error: OpenSCManager failed: {e}"); + return 3; + } + }; + + let service = match manager.open_service( + SERVICE_NAME, + ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE, + ) { + Ok(s) => s, + Err(e) => { + // Most common case: service doesn't exist. Treat as success. + let msg = e.to_string(); + if msg.contains("does not exist") || msg.contains("1060") { + println!("service {SERVICE_NAME} is not installed (no change)"); + return 0; + } + eprintln!("error: OpenService failed: {e}"); + return 1; + } + }; + + // Best-effort stop. Failures here are non-fatal — DeleteService can + // still mark the service for removal, and the OS reaps it on next + // service restart. + if let Ok(status) = service.query_status() { + if status.current_state == ServiceState::Running { + if let Err(e) = service.stop() { + eprintln!("warning: failed to stop service before delete: {e}"); + } + } + } + + match service.delete() { + Ok(_) => { + println!("uninstalled service {SERVICE_NAME}"); + // Best-effort cleanup of the staged binary. The SCM record is + // gone, so a leftover binary is harmless, but privileged + // artifacts shouldn't linger. + if let Ok(install_dir) = broker_install_dir() { + let target = install_dir.join(SHIM_BINARY_FILENAME); + match std::fs::remove_file(&target) { + Ok(_) => println!("removed {}", target.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => eprintln!("warning: could not remove {}: {e}", target.display()), + } + // Remove the install directory only if now empty. + let _ = std::fs::remove_dir(&install_dir); + } + 0 + } + Err(e) => { + eprintln!("error: DeleteService failed: {e}"); + 1 + } + } +} + +/// Implements `wxc-host-prep dump-learning-mode-broker`. +/// +/// Reports installed-or-not, current state, and the registered binary +/// path. Exits 0 when installed, 1 when not. +pub fn run_dump(json: bool) -> i32 { + let manager = match ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + { + Ok(m) => m, + Err(e) => { + eprintln!("error: OpenSCManager failed: {e}"); + return 3; + } + }; + + let service = match manager.open_service( + SERVICE_NAME, + ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS, + ) { + Ok(s) => s, + Err(e) => { + let msg = e.to_string(); + if msg.contains("does not exist") || msg.contains("1060") { + if json { + println!("{{\"installed\":false}}"); + } else { + println!("service {SERVICE_NAME}: not installed"); + } + return 1; + } + eprintln!("error: OpenService failed: {e}"); + return 2; + } + }; + + let cfg = match service.query_config() { + Ok(c) => c, + Err(e) => { + eprintln!("error: QueryServiceConfig failed: {e}"); + return 2; + } + }; + let status = match service.query_status() { + Ok(s) => s, + Err(e) => { + eprintln!("error: QueryServiceStatusEx failed: {e}"); + return 2; + } + }; + + let state = format!("{:?}", status.current_state); + let binary = strip_quotes(&cfg.executable_path.to_string_lossy()); + let start_type = format!("{:?}", cfg.start_type); + + if json { + println!( + "{{\"installed\":true,\"name\":\"{SERVICE_NAME}\",\"state\":\"{state}\",\ + \"binary\":\"{}\",\"startType\":\"{start_type}\"}}", + binary.replace('\\', "\\\\") + ); + } else { + println!("service {SERVICE_NAME}:"); + println!(" installed: yes"); + println!(" state: {state}"); + println!(" binary: {binary}"); + println!(" start type: {start_type}"); + } + 0 +} + +/// SCM stores the binary path either bare or wrapped in quotes when it +/// contains spaces. Strip a single pair of surrounding quotes for +/// comparison / display. +fn strip_quotes(s: &str) -> String { + if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +/// Compares two filesystem paths case-insensitively (Windows convention). +fn paths_match(a: &str, b: &str) -> bool { + Path::new(a) + .to_string_lossy() + .eq_ignore_ascii_case(&Path::new(b).to_string_lossy()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_quotes_removes_surrounding_quotes() { + assert_eq!( + strip_quotes("\"C:\\Program Files\\app.exe\""), + "C:\\Program Files\\app.exe" + ); + assert_eq!(strip_quotes("C:\\nopath.exe"), "C:\\nopath.exe"); + assert_eq!(strip_quotes(""), ""); + assert_eq!(strip_quotes("\""), "\""); + } + + #[test] + fn paths_match_case_insensitive() { + assert!(paths_match("c:\\foo\\bar.exe", "C:\\Foo\\Bar.EXE")); + assert!(!paths_match("c:\\foo.exe", "c:\\bar.exe")); + } +} diff --git a/src/host/wxc_host_prep/src/learning_mode_broker/privilege.rs b/src/host/wxc_host_prep/src/learning_mode_broker/privilege.rs new file mode 100644 index 000000000..ab61aa0c2 --- /dev/null +++ b/src/host/wxc_host_prep/src/learning_mode_broker/privilege.rs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! LSA privilege management for the denial broker. +//! +//! `mxc-learning-mode-broker` runs as `LocalService` (least-privilege account) +//! but needs `SeSystemProfilePrivilege` to call `StartTraceW`. The +//! built-in `LocalService` SID does **not** carry this privilege by +//! default — we have to grant it explicitly via the LSA Account Rights +//! API at install time. +//! +//! Grants persist across reboots. We do not revoke on uninstall: a +//! manual cleanup is fine to leave to the operator, and over-eagerly +//! revoking could clobber another tool on the box that also granted +//! the same privilege to `LocalService`. The leftover grant is small +//! (one extra privilege on one well-known account that already runs +//! plenty of MS-shipped services) and harmless without the broker +//! binary to use it. +//! +//! Idempotent: `LsaAddAccountRights` is a no-op when the account +//! already has the requested privilege. + +use std::ptr; + +use windows::core::PWSTR; +use windows::Win32::Foundation::{LocalFree, HLOCAL, NTSTATUS}; +use windows::Win32::Security::Authentication::Identity::{ + LsaAddAccountRights, LsaClose, LsaNtStatusToWinError, LsaOpenPolicy, LSA_HANDLE, + LSA_OBJECT_ATTRIBUTES, LSA_UNICODE_STRING, POLICY_CREATE_ACCOUNT, POLICY_LOOKUP_NAMES, +}; +use windows::Win32::Security::{CreateWellKnownSid, WinLocalServiceSid, PSID, WELL_KNOWN_SID_TYPE}; + +/// Grants `SeSystemProfilePrivilege` to `NT AUTHORITY\LocalService`. +/// +/// Returns `Ok(())` on success or when the account already has the +/// privilege. Returns `Err` with a human-readable message on any LSA +/// failure. +pub fn grant_se_system_profile_to_local_service() -> Result<(), String> { + grant_privilege_to_well_known(WinLocalServiceSid, "SeSystemProfilePrivilege") +} + +fn grant_privilege_to_well_known( + sid_type: WELL_KNOWN_SID_TYPE, + privilege_name: &str, +) -> Result<(), String> { + // 1. Build the SID for the target well-known account. + let mut sid_buf = [0u8; 64]; // SECURITY_MAX_SID_SIZE = 68; 64 fits LocalService comfortably. + let mut sid_size = sid_buf.len() as u32; + + // SAFETY: `sid_buf` is a valid mutable buffer; `sid_size` is its + // capacity. CreateWellKnownSid writes the SID and updates `sid_size` + // to the actual size used. + unsafe { + CreateWellKnownSid( + sid_type, + None, + Some(PSID(sid_buf.as_mut_ptr() as *mut _)), + &mut sid_size, + ) + .map_err(|e| format!("CreateWellKnownSid({sid_type:?}) failed: {e}"))?; + } + + let sid = PSID(sid_buf.as_mut_ptr() as *mut _); + + // 2. Open the local LSA policy with rights to add account rights + // + look up SIDs. + let attrs = LSA_OBJECT_ATTRIBUTES::default(); + let mut policy: LSA_HANDLE = LSA_HANDLE::default(); + + // SAFETY: `attrs` is a valid zero-initialized structure (LSA's + // documentation explicitly accepts this). `policy` is a valid out + // pointer. + let status = unsafe { + LsaOpenPolicy( + None, + &attrs, + (POLICY_CREATE_ACCOUNT | POLICY_LOOKUP_NAMES) as u32, + &mut policy, + ) + }; + nt_check(status, "LsaOpenPolicy")?; + + // 3. Build an LSA_UNICODE_STRING for the privilege name. The buffer + // is borrowed for the duration of the LsaAddAccountRights call + // only, so a Vec on the stack is fine. + let mut wide: Vec = privilege_name.encode_utf16().collect(); + let len_bytes = (wide.len() * 2) as u16; + let rights = [LSA_UNICODE_STRING { + Length: len_bytes, + MaximumLength: len_bytes, + Buffer: PWSTR(wide.as_mut_ptr()), + }]; + + // SAFETY: `policy` is a valid LSA handle from LsaOpenPolicy. `sid` + // points into `sid_buf` which outlives the call. `rights` points to + // valid LSA_UNICODE_STRING entries whose Buffer fields point into + // `wide` (also outlives the call). + let status = unsafe { LsaAddAccountRights(policy, sid, &rights) }; + + // 4. Close the policy handle whether the grant succeeded or not. + // Failure to close is logged but not surfaced (the grant result + // is what matters). + unsafe { + let close_status = LsaClose(policy); + if close_status.0 != 0 { + eprintln!( + "[learning-mode-broker] warning: LsaClose returned NTSTATUS {:#X}", + close_status.0 + ); + } + } + + nt_check(status, "LsaAddAccountRights") +} + +fn nt_check(status: NTSTATUS, op: &str) -> Result<(), String> { + if status.0 == 0 { + return Ok(()); + } + // SAFETY: pure conversion call. + let win_err = unsafe { LsaNtStatusToWinError(status) }; + Err(format!( + "{op} failed: NTSTATUS={:#X} -> Win32 error {win_err}", + status.0 + )) +} + +// LocalFree wrapper kept here to keep usage local should we ever +// allocate an LSA-side buffer (e.g. via LsaEnumerateAccountRights for +// a future audit/revoke path). Currently unused — guarded with +// #[allow(dead_code)] to satisfy `-D warnings` while documenting +// intent. +#[allow(dead_code)] +fn local_free_if_set(p: *mut core::ffi::c_void) { + if !p.is_null() { + // SAFETY: caller asserts `p` was returned by LsaAlloc-family or + // LocalAlloc and has not been freed yet. + unsafe { + let _ = LocalFree(Some(HLOCAL(p))); + } + } +} + +// Silence the unused-import warning for `ptr` when local_free_if_set is +// gated out — keep the wrapper above for future revoke support. +#[allow(dead_code)] +const _: *mut core::ffi::c_void = ptr::null_mut(); diff --git a/src/host/wxc_host_prep/src/main.rs b/src/host/wxc_host_prep/src/main.rs index 0bcdc0e02..d28b9ec6e 100644 --- a/src/host/wxc_host_prep/src/main.rs +++ b/src/host/wxc_host_prep/src/main.rs @@ -25,6 +25,8 @@ mod cli; #[cfg(target_os = "windows")] mod elevation_check; #[cfg(target_os = "windows")] +mod learning_mode_broker; +#[cfg(target_os = "windows")] mod log; #[cfg(target_os = "windows")] mod null_device;