diff --git a/PLAN.md b/PLAN.md index 8cd5b59..19a843f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -259,7 +259,7 @@ Explicitly **not** commands: distro install/uninstall/run, networking/memory tun - **WSL access**: `wsl.exe` for everything the registry cannot answer — launch, terminate, mount, unmount, manage — isolated behind `IWslHost` so it can be swapped for the COM `ILxssUserSession` surface from the open-sourced [microsoft/WSL](https://github.com/microsoft/WSL) later. Guest commands run as `wsl.exe -d -u root --exec /absolute/path`: `--exec` does not search PATH, and `-u root` is what gets uid 0 regardless of `DefaultUid`. **Not `wslapi.dll`.** Measured: every entry point returns `E_ACCESSDENIED` from an unpackaged process, including for a distribution name that does not exist, so the refusal is about the caller rather than the argument. These APIs exist for MSIX distribution launchers, which is the one shape goal 6 rules out. See [docs/RESEARCH.md](docs/RESEARCH.md). - **File system**: `CopyFileEx`, `MoveFileEx`, `GetCompressedFileSizeW`, `FSCTL_QUERY_ALLOCATED_RANGES`, `FSCTL_SET_SPARSE`, `GetDiskFreeSpaceEx`, `GetVolumeInformation` (fs type). -- **Elevation**: `ShellExecuteEx` with `runas`, named pipe for IPC; `CheckTokenMembership` to detect admin. +- **Elevation**: `ShellExecuteEx` with `runas`, `CheckTokenMembership` to detect admin, and a **one-way** named pipe (`PIPE_ACCESS_INBOUND`, worker → parent) for progress plus a separate manual-reset event for cancellation. Not a duplex pipe: I/O on a synchronous file object is serialized, so a pending `ReadFile` blocks the concurrent `WriteFile` the other direction needs and both halves hang — measured, see [docs/RESEARCH.md](docs/RESEARCH.md). The pipe name is 128 random bits, created with `FILE_FLAG_FIRST_PIPE_INSTANCE` and a DACL naming only the launching user; the worker verifies the server's pid, image and SID before it streams, and declining the prompt is `ERROR_CANCELLED` → exit 4 (D11). - **Task Scheduler**: `ITaskService` COM for `schedule`. ### 5.4 Architecture @@ -317,6 +317,7 @@ Full design in [docs/CI.md](docs/CI.md): `ci.yml` (lint, MSVC+clang-cl × x64+ar | D8 | WSL2 only; WSL1 is detect-and-refuse | No VHDX to manage; legacy and shrinking user base; avoids a second code path and test-matrix leg for zero functional gain | | D9 | `compact` never stops another distribution on its own: it refuses, names the ones holding the VM open, and requires `--shutdown` | Terminating the target is not enough — the utility VM holds every attached disk for as long as any distribution runs (measured: still locked after 300 s). Shutting everything down silently would kill unrelated work, including Docker Desktop containers, so the user opts in | | D10 | Unattached `CompactVirtualDisk` via `OPEN_VIRTUAL_DISK_VERSION_2` + `VIRTUAL_DISK_ACCESS_NONE` is the default path; attach-read-only is an opt-in | Measured: after `fstrim` the unattached path reclaimed 100% of the freed space in 0.2 s with no administrator rights. V1 + `METAOPS` compacts too, so this is a choice rather than the only option: V2 accepts exactly one mask and rejects the rest at open, whereas V1 accepts masks that open and then fail at the compaction. Corrected 2026-08-30 — the original rationale rested on a spike that measured `ATTACH_RW` while calling it `METAOPS` | +| D11 | The elevated half is one verb, not a re-parsed CLI: it takes a path, re-validates it against the caller's own `Lxss` hive, and reads nothing from the pipe — the pipe is output only | Its command line is readable by any same-user process and UAC is not a boundary against the same user, so the defence cannot be secrecy. Keeping the privileged surface to attach-read-only → compact → detach means a tampered argument can at worst name a different VHDX, which the re-validation refuses. Pipe name is 128 random bits with `FILE_FLAG_FIRST_PIPE_INSTANCE`, and the worker checks the server's pid, image and SID — measured against a squatter in docs/RESEARCH.md | ## 8. Open questions diff --git a/ROADMAP.md b/ROADMAP.md index ad56e6c..6cd95db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ Goal: de-risk the unknowns, have a compiling skeleton and CI. - [x] Guest commands as uid 0; `wslapi.dll` found unusable unpackaged ([#3](https://github.com/wslkit/wsldisk/issues/3)) - [x] Registry layout across WSL inbox 1.x / Store 2.x ([#4](https://github.com/wslkit/wsldisk/issues/4)) - [x] Docker Desktop VHDX lock behaviour when Docker is "stopped" vs quit ([#5](https://github.com/wslkit/wsldisk/issues/5)) -- [~] Elevation relaunch + named-pipe result streaming — [moved to M2](https://github.com/wslkit/wsldisk/issues/6). Compaction turned out to need no elevation at all (D10), so this belongs with the attach-read-only and resize work that does +- [~] Elevation relaunch + named-pipe result streaming — [moved to M2](https://github.com/wslkit/wsldisk/issues/6) and answered there. Compaction turned out to need no elevation at all (D10), so this belonged with the attach-read-only and resize work that does **Exit criteria: met.** CI is green across 16 required checks with the 100% coverage gate passing and no exclusions; every spike is answered in [docs/RESEARCH.md](docs/RESEARCH.md); PLAN.md §8 now separates what was measured from what is still open. @@ -130,6 +130,7 @@ owner, not something CI should do on its own. - [x] `MoveOperation`: preflight (fs type, free space, running), sparse-preserving copy with progress, registry repoint, start test, rollback, source cleanup; same-volume fast path ([#106](https://github.com/wslkit/wsldisk/issues/106)). `--verify` full-hash comparison is still to come - [x] `wsldisk relink ` — the operation existed behind `orphans --relink`; promoted to a command of its own, and taught to honour `--json` ([#63](https://github.com/wslkit/wsldisk/issues/63)) +- [x] Elevation spike: `runas` relaunch, one-way pipe + cancel event, squatting defences ([#6](https://github.com/wslkit/wsldisk/issues/6)) — `spikes/elevation`, results in [docs/RESEARCH.md](docs/RESEARCH.md). A duplex pipe deadlocks, so the plan's IPC shape changed (D11); the `--elevate` code path itself is still to build - [ ] `GrowOperation`: `ResizeVirtualDisk` + `resize2fs`; detect partitioned disks and refuse - [ ] Helper-distro mechanism (tiny Alpine rootfs, on-demand import/remove) or `--via ` - [ ] `ShrinkOperation`: fit check with margin, `e2fsck -f`, `resize2fs `, `ResizeVirtualDisk` (safe flag only), compact, `e2fsck -n` verify diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md index 88055bb..01e8992 100644 --- a/docs/RESEARCH.md +++ b/docs/RESEARCH.md @@ -434,6 +434,149 @@ Found by dogfooding: the reported failure was `compact Ubuntu` sitting through ten "waiting for the disk to be released" lines and then refusing, on a machine where `wsl --list --running` said nothing was running at all. +### Elevation relaunch and result streaming (issue #6) — answered, and it changes the IPC shape + +**The split works, and the control channel cannot share the pipe.** An +unelevated parent can relaunch itself with `runas`, stream progress back from the +elevated half over a named pipe the launching user alone can open, cancel it from +the unelevated console, and exit with the elevated half's own exit code. What the +plan got wrong is the channel: §5.3 says "named pipe for IPC", and a *duplex* +pipe carrying progress one way and cancellation the other deadlocks both +processes. + +Measured on Windows 10 Pro 22H2 (build 19045) — a different host from the M0 +spikes above, which ran on Windows 11 26200 — with an unsigned x64 binary built +by MSVC from `spikes/elevation/elevate.cpp`, driven by `spikes/elevation/run.ps1`. +The account is a **split-token administrator**: `BUILTIN\Administrators` is +present in the filtered token as "Group used for deny only". UAC policy was +`EnableLUA=1`, `ConsentPromptBehaviorAdmin=5` (consent prompt for non-Windows +binaries), `PromptOnSecureDesktop=1`. SIDs are redacted as +`S-1-5-21--1001`. Nothing was compacted: the elevated worker sleeps and +reports progress. + +#### The two halves, measured + +| | parent | elevated worker | +|---|---|---| +| `CheckTokenMembership` (Administrators) | no | yes | +| Integrity level | `0x2000` medium | `0x3000` high | +| Token user SID | `S-1-5-21--1001` | `S-1-5-21--1001` — same | +| `TokenIsElevated` seen through the pipe | — | yes | + +The child is the same user one integrity level up, which is why a pipe whose +DACL is `D:P(A;;GA;;;)` — that user and nobody else, not even SYSTEM, +with inheritance blocked — is openable by the elevated child with no weakening +at all. Mandatory integrity control does not get in the way either: the +restriction is no-write-**up**, and here the high-IL client is writing to a +medium-IL object. + +> **Not measured, and it matters.** This holds because a split-token admin's +> filtered and elevated tokens carry the *same* user SID. Over-the-shoulder +> elevation — a standard user typing a different account's administrator +> credentials — gives the worker a different SID, and this DACL would then deny +> it. There is no second account on the test machine, so that path is untested. +> The implementation must either grant the elevated identity explicitly or fail +> with a clear message instead of an unexplained access denial. + +#### Declining the prompt (issue question 2) + +`ShellExecuteEx` returns `FALSE` with `GetLastError() == ERROR_CANCELLED` (1223). +No crash, no hang, no orphaned child. Mapping that one error to +`ErrorCode::NeedsElevation` gives the exit code 4 the issue asked for, and it is +the only error worth special-casing at that call site. + +#### Cancellation (issue question 4) + +A real `CTRL_C_EVENT` — delivered by a second process that does +`AttachConsole(parent_pid)` + `GenerateConsoleCtrlEvent`, not simulated — reaches +the parent's handler, which returns `TRUE` so the default handler does not kill +the process before the worker's exit code can be collected. The worker stops +within one 200 ms poll, writes its result record and exits 5; the parent +propagates 5. + +**The Ctrl+C does not reach the elevated child.** The worker installs its own +console control handler and reports over the pipe if it ever fires. It never +did — the child is launched through the AppInfo service and does not join the +parent's console process group. So cancellation *must* be explicit; there is no +inherited signal to rely on. That is a safety property, not a limitation: an +elevated worker holding an attached disk should unwind deliberately, never die +where the console happened to be. + +#### The deadlock that changes the design + +The first shape tried was the obvious one: a duplex message pipe, the worker +writing progress from its main thread while a second thread sat in `ReadFile` +waiting for a cancel record. Both processes hung after the *first* progress +record, indefinitely, and only unwedged when the parent was killed — which +released the worker's pending read and let its write complete. + +The cause is not the pipe but the handle. I/O on a synchronous file object is +serialized: a pending `ReadFile` blocks any concurrent `WriteFile` on the same +handle, whichever thread issues it. The parent had the same bug in mirror image +— its Ctrl+C handler tried to write the cancel record while the main thread was +parked in `ReadFile` on that handle. + +Three ways out; the third is what the spike settled on: + +| Option | Cost | +|---|---| +| `FILE_FLAG_OVERLAPPED` on both ends | Correct, but overlapped I/O in both halves for one bit of state | +| A second pipe instance for control | Another name, another ACL, another connect to verify | +| **A named event for cancellation** | One manual-reset event, same user-only DACL, `Local\` namespace; the worker polls it each tick | + +Cancellation is one bit and never needs a reason, so the event wins. The pipe +becomes one-way (`PIPE_ACCESS_INBOUND`, worker → parent), which also removes any +question of the elevated half *reading* instructions from a channel — see below. +`Local\` is correct because elevation keeps the child in the same session. + +#### Name squatting and tampered arguments (issue question 3) + +The pipe namespace is machine-wide: any process on the box can create +`\\.\pipe\` first, and a medium-IL process can ordinarily do so. Two +defences, both measured: + +| Defence | Result | +|---|---| +| Server creates with `FILE_FLAG_FIRST_PIPE_INSTANCE` | A squatter holding the name makes our own `CreateNamedPipe` fail with `ERROR_PIPE_BUSY` (231), so the parent aborts instead of proceeding | +| Worker verifies the server before trusting it | Refused: server PID did not match the launcher PID it was given. It also compares the server's image path and token user SID to its own | + +The name is 128 bits from `BCryptGenRandom`, so winning the race means guessing +the name, not merely being early. + +The deeper answer to "arguments an unprivileged process could tamper with" is to +make the elevated half not worth tampering with. Its command line is visible to +any same-user process, and UAC is not a security boundary against the same user +anyway — so the rule for the implementation is: + +- The elevated worker implements exactly **one verb** (attach read-only, compact, + detach), never a re-parsed copy of the full CLI. +- It takes the target path as an argument and **re-validates it itself**: + canonicalize, confirm it is the `VhdFileName` of a registered distribution in + the caller's own `Lxss` registry hive, refuse anything else. +- It reads no instructions from the pipe. The pipe is output only. + +#### What the shape looks like + +```text +parent (medium IL) worker (high IL, via runas) +------------------ --------------------------- +CheckTokenMembership -> not admin +128-bit random pipe name +CreateNamedPipe INBOUND + FIRST_PIPE_INSTANCE, DACL = user only +CreateEvent Local\...-cancel, same DACL +ShellExecuteEx "runas" -------------> verify server pid/image/sid, else exit +ConnectNamedPipe <--- I|sid|elevated|integrity +ImpersonateNamedPipeClient <--- P|pct|text + (fails with 1368 until a + message has been read) +Ctrl+C -> SetEvent ----------------> polled each tick, unwinds +exit with the worker's code <--- R|code|text +``` + +`ImpersonateNamedPipeClient` is worth calling out: it fails with +`ERROR_CANNOT_IMPERSONATE` (1368) until data has been read from the pipe, so the +client check belongs after the first record, not at connect time. + ### Incidental `wsl.exe` prints `Failed to translate ''` to stderr for every Windows PATH @@ -443,5 +586,6 @@ consider passing `WSLENV`/a clean environment. ### Still open -- Elevation relaunch and named-pipe IPC (#6) — now lower priority, since the - common compaction path needs no elevation at all. +- Over-the-shoulder elevation (#6): whether the worker can be reached at all + when a standard user elevates with *another* account's credentials, and what + the pipe DACL has to say in that case. Needs a second account to measure. diff --git a/spikes/elevation/elevate.cpp b/spikes/elevation/elevate.cpp new file mode 100644 index 0000000..1bf1b9a --- /dev/null +++ b/spikes/elevation/elevate.cpp @@ -0,0 +1,589 @@ +// Spike for issue #6 -- elevation relaunch and named-pipe result streaming. +// +// Throwaway code. It is not built by CMake, not linted and not covered; it +// exists to answer the three security questions in the issue and to pin down +// the IPC shape before any of it is written into src/. Build it with run.ps1. +// +// The shape under test: +// +// parent (medium IL) worker (high IL, launched by runas) +// ------------------ ---------------------------------- +// CheckTokenMembership -> not admin +// random pipe name, FIRST_PIPE_INSTANCE +// DACL: only this user's SID +// ShellExecuteEx "runas" -------------> starts, verifies the *server* before +// it trusts anything it was told +// ConnectNamedPipe <--- I|sid|elevated|integrity +// verify client SID + elevation <--- P|pct|text progress records +// print records as one stream +// Ctrl+C -> SetEvent(cancel) -------> polled each tick, unwinds +// exit with the worker's code <--- R|code|text +// +// The pipe is one-way (worker -> parent) and cancellation rides a separate +// event, not a second direction on the same handle: a synchronous file object +// serializes I/O, so a pending ReadFile blocks a concurrent WriteFile on that +// handle and both halves hang. Measured; see docs/RESEARCH.md. +// +// Roles: (default) parent, --worker, --squat, --send-ctrl-c, --whoami. + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kExitOk = 0; +constexpr int kExitGeneric = 1; +constexpr int kExitNeedsElevation = 4; // ErrorCode::NeedsElevation +constexpr int kExitPartial = 5; // ErrorCode::Partial -- used for cancellation + +std::atomic g_cancelled{false}; +HANDLE g_cancel_event = nullptr; +HANDLE g_worker_pipe = INVALID_HANDLE_VALUE; + +void say(const char* role, const char* fmt, ...) { + printf("[%s] ", role); + va_list args; + va_start(args, fmt); + vprintf(fmt, args); + va_end(args); + printf("\n"); + fflush(stdout); +} + +std::wstring last_error_text(DWORD err) { + LPWSTR buffer = nullptr; + const DWORD len = FormatMessageW( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, err, 0, reinterpret_cast(&buffer), 0, nullptr); + std::wstring text = len ? std::wstring(buffer, len) : std::wstring(); + if (buffer) LocalFree(buffer); + while (!text.empty() && (text.back() == L'\n' || text.back() == L'\r')) text.pop_back(); + return text; +} + +void report_error(const char* role, const char* what, DWORD err) { + say(role, "%s failed: %lu (%ls)", what, err, last_error_text(err).c_str()); +} + +// --- token questions --------------------------------------------------------- + +// "Am I already elevated" -- the issue's first mechanism. CheckTokenMembership +// with a NULL token tests the *effective* token of the calling thread. +bool is_elevated() { + SID_IDENTIFIER_AUTHORITY nt_authority = SECURITY_NT_AUTHORITY; + PSID admins = nullptr; + if (!AllocateAndInitializeSid(&nt_authority, 2, SECURITY_BUILTIN_DOMAIN_RID, + DOMAIN_ALIAS_RID_ADMINS, 0, 0, 0, 0, 0, 0, &admins)) { + return false; + } + BOOL is_member = FALSE; + const BOOL ok = CheckTokenMembership(nullptr, admins, &is_member); + FreeSid(admins); + return ok && is_member; +} + +std::wstring token_user_sid(HANDLE token) { + DWORD needed = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &needed); + std::vector buffer(needed); + if (!GetTokenInformation(token, TokenUser, buffer.data(), needed, &needed)) return L""; + LPWSTR sid_text = nullptr; + if (!ConvertSidToStringSidW(reinterpret_cast(buffer.data())->User.Sid, &sid_text)) + return L""; + std::wstring result = sid_text; + LocalFree(sid_text); + return result; +} + +std::wstring current_user_sid() { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return L""; + std::wstring sid = token_user_sid(token); + CloseHandle(token); + return sid; +} + +// Integrity level as a printable RID: 0x2000 medium, 0x3000 high. +DWORD integrity_level(HANDLE process) { + HANDLE token = nullptr; + if (!OpenProcessToken(process, TOKEN_QUERY, &token)) return 0; + DWORD needed = 0; + GetTokenInformation(token, TokenIntegrityLevel, nullptr, 0, &needed); + std::vector buffer(needed); + DWORD rid = 0; + if (GetTokenInformation(token, TokenIntegrityLevel, buffer.data(), needed, &needed)) { + auto* label = reinterpret_cast(buffer.data()); + const UCHAR count = *GetSidSubAuthorityCount(label->Label.Sid); + rid = *GetSidSubAuthority(label->Label.Sid, count - 1); + } + CloseHandle(token); + return rid; +} + +std::wstring own_image_path() { + std::wstring path(MAX_PATH, L'\0'); + for (;;) { + const DWORD len = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (len == 0) return L""; + if (len < path.size()) { + path.resize(len); + return path; + } + path.resize(path.size() * 2); + } +} + +// --- pipe -------------------------------------------------------------------- + +std::wstring random_pipe_name() { + unsigned char bytes[16] = {}; + if (BCryptGenRandom(nullptr, bytes, sizeof bytes, BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + return L""; + } + std::wstring name = L"\\\\.\\pipe\\wsldisk-"; + for (unsigned char b : bytes) { + wchar_t hex[3] = {}; + swprintf(hex, 3, L"%02x", b); + name += hex; + } + return name; +} + +// The DACL the issue asks about: only the launching user, nothing inherited. +// "GA" to the user's own SID and no other ACE at all -- not even SYSTEM. +PSECURITY_DESCRIPTOR user_only_descriptor(const std::wstring& user_sid) { + const std::wstring sddl = L"D:P(A;;GA;;;" + user_sid + L")"; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, + &descriptor, nullptr)) { + return nullptr; + } + return descriptor; +} + +// One way only: the worker writes, the parent reads. Cancellation travels on a +// separate event, because a second direction on this handle deadlocks -- a +// synchronous file object serializes all I/O, so a pending ReadFile blocks the +// WriteFile the other thread is trying to make. Measured; see docs/RESEARCH.md. +HANDLE create_server_pipe(const std::wstring& name, const std::wstring& user_sid, DWORD access_mode, + bool first_only) { + PSECURITY_DESCRIPTOR descriptor = user_only_descriptor(user_sid); + if (!descriptor) return INVALID_HANDLE_VALUE; + SECURITY_ATTRIBUTES attributes = {}; + attributes.nLength = sizeof attributes; + attributes.lpSecurityDescriptor = descriptor; + attributes.bInheritHandle = FALSE; + + DWORD open_mode = access_mode; + if (first_only) open_mode |= FILE_FLAG_FIRST_PIPE_INSTANCE; + + const HANDLE pipe = CreateNamedPipeW( + name.c_str(), open_mode, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, 1, + 64 * 1024, 64 * 1024, 0, &attributes); + const DWORD err = GetLastError(); + LocalFree(descriptor); + SetLastError(err); + return pipe; +} + +// The control channel: one bit, manual-reset, same user-only DACL, session-local +// namespace ("Local\") because the elevated child stays in the same session. +HANDLE create_cancel_event(const std::wstring& name, const std::wstring& user_sid) { + PSECURITY_DESCRIPTOR descriptor = user_only_descriptor(user_sid); + if (!descriptor) return nullptr; + SECURITY_ATTRIBUTES attributes = {}; + attributes.nLength = sizeof attributes; + attributes.lpSecurityDescriptor = descriptor; + attributes.bInheritHandle = FALSE; + const HANDLE event = CreateEventW(&attributes, TRUE, FALSE, name.c_str()); + const DWORD err = GetLastError(); + LocalFree(descriptor); + SetLastError(err); + return event; +} + +bool write_record(HANDLE pipe, const std::string& record) { + DWORD written = 0; + return WriteFile(pipe, record.data(), static_cast(record.size()), &written, nullptr) != 0; +} + +bool read_record(HANDLE pipe, std::string& out) { + char buffer[4096]; + DWORD read = 0; + if (!ReadFile(pipe, buffer, sizeof buffer, &read, nullptr)) return false; + out.assign(buffer, read); + return true; +} + +// --- worker (elevated half) -------------------------------------------------- + +// The third question in the issue: an unprivileged process could create a pipe +// with our name first and feed the elevated worker instructions. Two defences, +// both measured here: the server creates with FILE_FLAG_FIRST_PIPE_INSTANCE, and +// the worker refuses to talk to a server that is not the process it expects. +bool verify_server(HANDLE pipe, DWORD expected_pid, const char* role) { + ULONG server_pid = 0; + if (!GetNamedPipeServerProcessId(pipe, &server_pid)) { + report_error(role, "GetNamedPipeServerProcessId", GetLastError()); + return false; + } + say(role, "server pid reported as %lu, expected %lu", server_pid, expected_pid); + if (expected_pid != 0 && server_pid != expected_pid) { + say(role, "REFUSING: pipe is served by a different process than the one that launched us"); + return false; + } + const HANDLE server = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, server_pid); + if (!server) { + report_error(role, "OpenProcess(server)", GetLastError()); + return false; + } + wchar_t image[MAX_PATH * 4] = {}; + DWORD size = static_cast(std::size(image)); + const bool got_image = QueryFullProcessImageNameW(server, 0, image, &size) != 0; + HANDLE server_token = nullptr; + std::wstring server_sid; + if (OpenProcessToken(server, TOKEN_QUERY, &server_token)) { + server_sid = token_user_sid(server_token); + CloseHandle(server_token); + } + CloseHandle(server); + if (!got_image) return false; + say(role, "server image %ls", image); + say(role, "server sid %ls", server_sid.c_str()); + if (_wcsicmp(image, own_image_path().c_str()) != 0) { + say(role, "REFUSING: server image is not our own binary"); + return false; + } + if (server_sid != current_user_sid()) { + say(role, "REFUSING: server runs as a different user"); + return false; + } + return true; +} + +BOOL WINAPI worker_ctrl_handler(DWORD type) { + // Measured, not assumed: does a Ctrl+C in the *parent's* console reach the + // elevated child at all? The worker's own console is hidden, so the answer + // is only observable if it travels back over the pipe. + printf("[worker] console control event %lu delivered to the elevated child\n", type); + fflush(stdout); + if (g_worker_pipe != INVALID_HANDLE_VALUE) { + char record[128]; + snprintf(record, sizeof record, "L|console control event %lu reached the elevated child", + type); + write_record(g_worker_pipe, record); + } + return TRUE; +} + +int run_worker(const std::wstring& pipe_name, const std::wstring& event_name, DWORD server_pid, + int seconds) { + const char* role = "worker"; + SetConsoleCtrlHandler(worker_ctrl_handler, TRUE); + say(role, "elevated=%s integrity=0x%04lx sid=%ls", is_elevated() ? "yes" : "no", + integrity_level(GetCurrentProcess()), current_user_sid().c_str()); + + const HANDLE pipe = CreateFileW(pipe_name.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, + nullptr); + if (pipe == INVALID_HANDLE_VALUE) { + report_error(role, "CreateFile(pipe)", GetLastError()); + return kExitGeneric; + } + g_worker_pipe = pipe; + DWORD mode = PIPE_READMODE_MESSAGE; + SetNamedPipeHandleState(pipe, &mode, nullptr, nullptr); + + if (!verify_server(pipe, server_pid, role)) { + CloseHandle(pipe); + return kExitGeneric; + } + say(role, "server verified; streaming"); + + // Opened for SYNCHRONIZE only: the worker waits on cancellation, it never + // signals it. Absent event is not fatal -- it only means no cancel channel. + const HANDLE cancel = event_name.empty() + ? nullptr + : OpenEventW(SYNCHRONIZE, FALSE, event_name.c_str()); + if (!cancel && !event_name.empty()) report_error(role, "OpenEvent(cancel)", GetLastError()); + + // First record: which identity the elevated half actually runs as. The + // worker's own console is hidden, so anything not sent here is never seen. + char identity[512]; + snprintf(identity, sizeof identity, "I|%ls|%s|0x%04lx", current_user_sid().c_str(), + is_elevated() ? "elevated" : "not-elevated", integrity_level(GetCurrentProcess())); + write_record(pipe, identity); + + const int ticks = seconds * 5; + for (int i = 0; i <= ticks; ++i) { + if (cancel && WaitForSingleObject(cancel, 0) == WAIT_OBJECT_0) g_cancelled = true; + if (g_cancelled) { + write_record(pipe, "R|5|cancelled by the unelevated parent, nothing was changed"); + FlushFileBuffers(pipe); + if (cancel) CloseHandle(cancel); + CloseHandle(pipe); + return kExitPartial; + } + const int percent = ticks ? (i * 100 / ticks) : 100; + char record[128]; + snprintf(record, sizeof record, "P|%d|compacting (simulated) as an elevated worker", percent); + if (!write_record(pipe, record)) { + report_error(role, "WriteFile(progress)", GetLastError()); + if (cancel) CloseHandle(cancel); + CloseHandle(pipe); + return kExitGeneric; + } + Sleep(200); + } + write_record(pipe, "R|0|done: 1234567890 bytes reclaimed (simulated)"); + FlushFileBuffers(pipe); + if (cancel) CloseHandle(cancel); + CloseHandle(pipe); + return kExitOk; +} + +// --- parent (unelevated half) ------------------------------------------------ + +BOOL WINAPI parent_ctrl_handler(DWORD type) { + if (type != CTRL_C_EVENT && type != CTRL_BREAK_EVENT) return FALSE; + printf("\n[parent] Ctrl+C -- asking the elevated worker to stop\n"); + fflush(stdout); + g_cancelled = true; + // SetEvent, not a pipe write: the main thread is parked in ReadFile on the + // pipe, and a synchronous handle serializes I/O, so a write from here would + // block behind that read instead of reaching the worker. + if (g_cancel_event) SetEvent(g_cancel_event); + return TRUE; // do not let the default handler kill us; we want the worker's exit code +} + +// Proves the client on the other end is our own elevated child and not a +// squatter that connected first. +void verify_client(HANDLE pipe, DWORD expected_pid) { + const char* role = "parent"; + // Captured before impersonating: while the thread carries the client's + // token, OpenProcessToken on ourselves is checked against *that* token and + // comes back empty, which reads as a mismatch that is not one. + const std::wstring own_sid = current_user_sid(); + ULONG client_pid = 0; + if (GetNamedPipeClientProcessId(pipe, &client_pid)) { + say(role, "client pid %lu (child pid %lu) %s", client_pid, expected_pid, + client_pid == expected_pid ? "MATCH" : "MISMATCH"); + } + if (!ImpersonateNamedPipeClient(pipe)) { + report_error(role, "ImpersonateNamedPipeClient", GetLastError()); + return; + } + HANDLE thread_token = nullptr; + if (OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &thread_token)) { + const std::wstring sid = token_user_sid(thread_token); + TOKEN_ELEVATION elevation = {}; + DWORD needed = 0; + GetTokenInformation(thread_token, TokenElevation, &elevation, sizeof elevation, &needed); + say(role, "client sid %ls %s", sid.c_str(), sid == own_sid ? "MATCH" : "MISMATCH"); + say(role, "client token elevated: %s", elevation.TokenIsElevated ? "yes" : "no"); + CloseHandle(thread_token); + } + RevertToSelf(); +} + +int run_parent(int seconds, int cancel_after_ms) { + const char* role = "parent"; + say(role, "elevated=%s integrity=0x%04lx sid=%ls", is_elevated() ? "yes" : "no", + integrity_level(GetCurrentProcess()), current_user_sid().c_str()); + + const std::wstring user_sid = current_user_sid(); + const std::wstring pipe_name = random_pipe_name(); + const std::wstring event_name = + L"Local\\wsldisk-cancel-" + pipe_name.substr(pipe_name.rfind(L'-') + 1); + say(role, "pipe %ls", pipe_name.c_str()); + say(role, "cancel event %ls", event_name.c_str()); + say(role, "dacl D:P(A;;GA;;;%ls)", user_sid.c_str()); + + const HANDLE pipe = + create_server_pipe(pipe_name, user_sid, PIPE_ACCESS_INBOUND, /*first_only=*/true); + if (pipe == INVALID_HANDLE_VALUE) { + report_error(role, "CreateNamedPipe", GetLastError()); + return kExitGeneric; + } + g_cancel_event = create_cancel_event(event_name, user_sid); + if (!g_cancel_event) report_error(role, "CreateEvent(cancel)", GetLastError()); + SetConsoleCtrlHandler(parent_ctrl_handler, TRUE); + + wchar_t parameters[512]; + swprintf(parameters, std::size(parameters), + L"--worker --pipe %ls --cancel-event %ls --server-pid %lu --seconds %d", + pipe_name.c_str(), event_name.c_str(), GetCurrentProcessId(), seconds); + + const std::wstring image = own_image_path(); + SHELLEXECUTEINFOW info = {}; + info.cbSize = sizeof info; + info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC; + info.lpVerb = L"runas"; + info.lpFile = image.c_str(); + info.lpParameters = parameters; + info.nShow = SW_HIDE; + + say(role, "ShellExecuteEx runas ..."); + if (!ShellExecuteExW(&info)) { + const DWORD err = GetLastError(); + if (err == ERROR_CANCELLED) { + say(role, "UAC declined (ERROR_CANCELLED 1223) -- clean exit %d", kExitNeedsElevation); + CloseHandle(pipe); + return kExitNeedsElevation; + } + report_error(role, "ShellExecuteEx", err); + CloseHandle(pipe); + return kExitGeneric; + } + const DWORD child_pid = GetProcessId(info.hProcess); + say(role, "elevated child pid %lu, integrity 0x%04lx", child_pid, + integrity_level(info.hProcess)); + + if (!ConnectNamedPipe(pipe, nullptr) && GetLastError() != ERROR_PIPE_CONNECTED) { + report_error(role, "ConnectNamedPipe", GetLastError()); + CloseHandle(pipe); + return kExitGeneric; + } + say(role, "connected"); + + if (cancel_after_ms > 0) { + // Same code path the real Ctrl+C handler takes, for an unattended run. + std::thread([cancel_after_ms] { + Sleep(static_cast(cancel_after_ms)); + printf("\n[parent] simulated Ctrl+C after %d ms\n", cancel_after_ms); + fflush(stdout); + g_cancelled = true; + if (g_cancel_event) SetEvent(g_cancel_event); + }).detach(); + } + + std::string record; + int reported = -1; + bool verified = false; + while (read_record(pipe, record)) { + if (!verified) { + // ImpersonateNamedPipeClient fails with 1368 until a message has + // been read from the pipe, so this cannot happen at connect time. + verify_client(pipe, child_pid); + verified = true; + } + if (record.rfind("I|", 0) == 0) { + say(role, "worker identity: %s", record.substr(2).c_str()); + } else if (record.rfind("L|", 0) == 0) { + printf("\n"); + say(role, "worker says: %s", record.substr(2).c_str()); + } else if (record.rfind("P|", 0) == 0) { + const int percent = atoi(record.c_str() + 2); + if (percent != reported) { + reported = percent; + printf("[parent] progress %3d%%\r", percent); + fflush(stdout); + } + } else if (record.rfind("R|", 0) == 0) { + const size_t bar = record.find('|', 2); + const int code = atoi(record.c_str() + 2); + printf("\n"); + say(role, "result: %s (code %d)", record.substr(bar + 1).c_str(), code); + break; + } + } + + WaitForSingleObject(info.hProcess, 30000); + DWORD child_exit = 0; + GetExitCodeProcess(info.hProcess, &child_exit); + say(role, "worker exit code %lu", child_exit); + CloseHandle(info.hProcess); + if (g_cancel_event) CloseHandle(g_cancel_event); + CloseHandle(pipe); + return static_cast(child_exit); +} + +// --- squatter (the attacker in the third question) --------------------------- + +int run_squatter(const std::wstring& pipe_name, int seconds) { + const char* role = "squat"; + say(role, "creating %ls as an unprivileged process", pipe_name.c_str()); + const HANDLE pipe = + create_server_pipe(pipe_name, current_user_sid(), PIPE_ACCESS_DUPLEX, /*first_only=*/true); + if (pipe == INVALID_HANDLE_VALUE) { + report_error(role, "CreateNamedPipe", GetLastError()); + return kExitGeneric; + } + say(role, "holding the name; waiting %d s for a victim to connect", seconds); + if (ConnectNamedPipe(pipe, nullptr) || GetLastError() == ERROR_PIPE_CONNECTED) { + say(role, "a client connected -- feeding it instructions"); + write_record(pipe, "J|attach-rw|C:\\Windows\\System32\\config\\SAM"); + std::string record; + if (read_record(pipe, record)) say(role, "victim said: %s", record.c_str()); + } + Sleep(static_cast(seconds) * 1000); + CloseHandle(pipe); + return kExitOk; +} + +// Delivers a real CTRL_C_EVENT to another console, so the Ctrl+C path is +// measured rather than simulated. +int send_ctrl_c(DWORD pid) { + FreeConsole(); + if (!AttachConsole(pid)) { + report_error("ctrlc", "AttachConsole", GetLastError()); + return kExitGeneric; + } + SetConsoleCtrlHandler(nullptr, TRUE); // do not kill ourselves + const BOOL ok = GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0); + const DWORD err = GetLastError(); + FreeConsole(); + return ok ? kExitOk : static_cast(err); +} + +std::wstring arg_value(int argc, wchar_t** argv, const wchar_t* name, const wchar_t* fallback) { + for (int i = 1; i + 1 < argc; ++i) { + if (_wcsicmp(argv[i], name) == 0) return argv[i + 1]; + } + return fallback; +} + +bool has_flag(int argc, wchar_t** argv, const wchar_t* name) { + for (int i = 1; i < argc; ++i) { + if (_wcsicmp(argv[i], name) == 0) return true; + } + return false; +} + +} // namespace + +int wmain(int argc, wchar_t** argv) { + const int seconds = _wtoi(arg_value(argc, argv, L"--seconds", L"3").c_str()); + + if (has_flag(argc, argv, L"--whoami")) { + say("whoami", "elevated=%s integrity=0x%04lx sid=%ls", is_elevated() ? "yes" : "no", + integrity_level(GetCurrentProcess()), current_user_sid().c_str()); + return kExitOk; + } + if (has_flag(argc, argv, L"--send-ctrl-c")) { + return send_ctrl_c( + static_cast(_wtoi(arg_value(argc, argv, L"--send-ctrl-c", L"0").c_str()))); + } + if (has_flag(argc, argv, L"--squat")) { + return run_squatter(arg_value(argc, argv, L"--pipe", L""), seconds); + } + if (has_flag(argc, argv, L"--worker")) { + return run_worker( + arg_value(argc, argv, L"--pipe", L""), arg_value(argc, argv, L"--cancel-event", L""), + static_cast(_wtoi(arg_value(argc, argv, L"--server-pid", L"0").c_str())), + seconds); + } + return run_parent(seconds, _wtoi(arg_value(argc, argv, L"--cancel-after", L"0").c_str())); +} diff --git a/spikes/elevation/run.ps1 b/spikes/elevation/run.ps1 new file mode 100644 index 0000000..67cf99c --- /dev/null +++ b/spikes/elevation/run.ps1 @@ -0,0 +1,160 @@ +<# +.SYNOPSIS + Builds and drives the elevation/IPC spike for issue #6. + +.DESCRIPTION + Full-mode compaction (attach read-only) and direct `AttachVirtualDisk` need + an elevated token; the common compaction path does not (D10). So the + question is not whether to elevate, but how to elevate *half* the process + and still give the user one output stream and one exit code. + + Five experiments, each answering something the issue asks: + + whoami which token the two halves run with + accept the happy path: runas, connect, stream progress, exit code + decline what declining the UAC prompt does to the parent + cancel a real Ctrl+C in the unelevated console, delivered through + the parent's console with GenerateConsoleCtrlEvent + squat an unprivileged process holding the pipe name first, and the + worker's refusal to talk to a server that is not its launcher + + `accept`, `decline` and `cancel` put a UAC prompt on screen and wait for a + human to answer it. That is the point -- the prompt is what is being + measured. Nothing is compacted: the worker sleeps and reports progress. + +.PARAMETER Experiment + Which experiment to run. `all` runs them in the order above. + +.PARAMETER Seconds + How long the simulated elevated operation runs. The cancel experiment needs + this long enough to interrupt. + +.PARAMETER CancelAfterSeconds + How long to wait before sending the Ctrl+C, in the cancel experiment. It has + to cover a human answering the UAC prompt. + +.PARAMETER OutDir + Where to build. Defaults under %TEMP% so the repository stays clean. + +.PARAMETER SkipBuild + Reuse an existing elevate.exe. + +.EXAMPLE + .\run.ps1 -Experiment squat + +.EXAMPLE + .\run.ps1 -Experiment all -Seconds 6 +#> +[CmdletBinding()] +param( + [ValidateSet('all', 'whoami', 'accept', 'decline', 'cancel', 'squat')] + [string]$Experiment = 'all', + [int]$Seconds = 4, + [int]$CancelAfterSeconds = 8, + [string]$OutDir = (Join-Path $env:TEMP 'wsldisk-spike-elevation'), + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +$source = Join-Path $PSScriptRoot 'elevate.cpp' +$exe = Join-Path $OutDir 'elevate.exe' + +function Write-Banner([string]$Text) { + Write-Host '' + Write-Host "=== $Text " -ForegroundColor Cyan -NoNewline + Write-Host ('=' * [Math]::Max(0, 64 - $Text.Length)) -ForegroundColor Cyan +} + +function Build-Spike { + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path $vswhere)) { throw "vswhere.exe not found; is Visual Studio installed?" } + $install = & $vswhere -latest -prerelease -products * -property installationPath | Select-Object -First 1 + if (-not $install) { throw 'No Visual Studio installation with C++ tools found.' } + $vcvars = Join-Path $install 'VC\Auxiliary\Build\vcvars64.bat' + if (-not (Test-Path $vcvars)) { throw "vcvars64.bat not found under $install" } + + New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + Write-Host "Building $source" -ForegroundColor DarkGray + # No vcpkg, no WIL, no CMake: a spike should build from one command. + $compile = '"{0}" >nul 2>&1 && cl /nologo /std:c++20 /EHsc /W4 /permissive- /utf-8 /Fe:"{1}" /Fo:"{2}\\" "{3}" advapi32.lib shell32.lib bcrypt.lib' -f $vcvars, $exe, $OutDir, $source + cmd /c $compile + if ($LASTEXITCODE -ne 0) { throw "Build failed with exit code $LASTEXITCODE" } +} + +function Invoke-Whoami { + Write-Banner 'whoami -- which token each half runs with' + & $exe --whoami + Write-Host "(the worker's own line appears in the accept experiment, over the pipe)" -ForegroundColor DarkGray +} + +function Invoke-Accept { + Write-Banner 'accept -- approve the UAC prompt when it appears' + & $exe --seconds $Seconds + Write-Host "parent exit code: $LASTEXITCODE" -ForegroundColor Yellow +} + +function Invoke-Decline { + Write-Banner 'decline -- DENY the UAC prompt when it appears' + & $exe --seconds $Seconds + Write-Host "parent exit code: $LASTEXITCODE (expect 4 = NeedsElevation)" -ForegroundColor Yellow +} + +function Invoke-Cancel { + Write-Banner 'cancel -- real Ctrl+C in the unelevated console (approve the prompt)' + $log = Join-Path $OutDir 'cancel.log' + $err = Join-Path $OutDir 'cancel.err' + # The worker has to still be running when the Ctrl+C lands, and the prompt + # is answered by a human, so give the operation room past the send delay. + $workSeconds = [Math]::Max($Seconds, $CancelAfterSeconds + 8) + # Its own console, so GenerateConsoleCtrlEvent has something to attach to. + $parent = Start-Process -FilePath $exe -ArgumentList '--seconds', $workSeconds -PassThru ` + -RedirectStandardOutput $log -RedirectStandardError $err + Write-Host "parent pid $($parent.Id); approve the prompt, then Ctrl+C is sent" -ForegroundColor DarkGray + Start-Sleep -Seconds $CancelAfterSeconds + & $exe --send-ctrl-c $parent.Id | Out-Null + if (-not $parent.WaitForExit(30000)) { + Write-Host 'parent did not exit within 30 s' -ForegroundColor Red + $parent.Kill() + } + Get-Content $log -ErrorAction SilentlyContinue + Get-Content $err -ErrorAction SilentlyContinue + Write-Host "parent exit code: $($parent.ExitCode) (expect 5 = Partial/cancelled)" -ForegroundColor Yellow +} + +function Invoke-Squat { + Write-Banner 'squat -- an unprivileged process holds the pipe name first' + $name = '\\.\pipe\wsldisk-squat-test' + $squatter = Start-Process -FilePath $exe -ArgumentList '--squat', '--pipe', $name, '--seconds', 20 ` + -PassThru -WindowStyle Hidden + Start-Sleep -Milliseconds 700 + try { + Write-Host '-- 1. can a second server create the same name? (FILE_FLAG_FIRST_PIPE_INSTANCE)' -ForegroundColor DarkGray + & $exe --squat --pipe $name --seconds 1 + Write-Host " exit code $LASTEXITCODE" -ForegroundColor Yellow + + Write-Host '-- 2. does a worker talk to a server that did not launch it?' -ForegroundColor DarkGray + # Unelevated on purpose: the identity check is what is under test, not the token. + & $exe --worker --pipe $name --server-pid $PID --seconds 1 + Write-Host " exit code $LASTEXITCODE (expect 1 = refused)" -ForegroundColor Yellow + } finally { + if (-not $squatter.HasExited) { $squatter.Kill() } + } +} + +if (-not $SkipBuild) { Build-Spike } +if (-not (Test-Path $exe)) { throw "$exe not found; run without -SkipBuild" } + +switch ($Experiment) { + 'whoami' { Invoke-Whoami } + 'accept' { Invoke-Accept } + 'decline' { Invoke-Decline } + 'cancel' { Invoke-Cancel } + 'squat' { Invoke-Squat } + 'all' { + Invoke-Whoami + Invoke-Accept + Invoke-Decline + Invoke-Cancel + Invoke-Squat + } +}