mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-09-02 04:01:25 +02:00
## Summary of the Pull Request
The Runner is the **server** for the two-way named pipes it uses to talk
to `PowerToys.Settings.exe` and the Quick Access host, and it dispatched
privileged JSON commands (`killrunner`, `restart_elevation`,
`module_status`, `powertoys`, `language`, ...) **without authenticating
the caller**. When PowerToys runs elevated ("Run as administrator"), the
pipe DACL grants the shared **Logon SID**, so **any same-user Medium-IL
process could connect and inject commands** — a local privilege
escalation (CWE-732 / CWE-862). The pipe DACL cannot distinguish the
legitimate Medium-IL Settings child from a same-user attacker (identical
user SID, integrity level, and logon session), so this PR authenticates
the connecting process's **binary identity** before any dispatch.
## PR Checklist
- [x] **Tests:** Added/updated and all pass (native gate tests + C#
regression)
- [x] **Localization:** No new end-user-facing strings (only a
diagnostic runner log line)
- [x] **New binaries:** None — the new code compiles into the existing
`PowerToys.Interop` and `runner` binaries; tests were added to the
existing `Common.Utils.UnitTests` project
## Detailed Description of the Pull Request / Additional comments
New `src/common/interop/pipe_caller_auth.{h,cpp}` adds
`interop_auth::AuthenticateClient`, invoked from
`TwoWayPipeMessageIPC::handle_pipe_connection` **before** a message is
queued (fail-closed). A connecting client is accepted only if it is:
- under the **Runner-relative install directory**
(`get_module_folderpath()\WinUI3Apps`, so it adapts to installed and
dev-build layouts),
- an **allow-listed basename** (`PowerToys.Settings.exe` /
`PowerToys.QuickAccess.exe`),
- the Runner's **exact file version** (anti-downgrade), and
- **Microsoft Authenticode-signed**.
The signature is anchored to the **LOCAL MACHINE root store**
(`HCCE_LOCAL_MACHINE` +
`CertVerifyCertificateChainPolicy(AUTHENTICODE)`) rather than
`WinVerifyTrust`'s default user+machine union: the Runner runs as the
same user as a potential attacker and would otherwise trust a forged
signer added to `CurrentUser\Root`. Verdicts are cached per `(pid,
process-creation-time, policy)` with a short TTL so the check isn't
re-run on every message (each `send` opens a new connection). Rejections
are logged.
The gate is added via an **additive** `start(HANDLE, CallerPolicy)`
overload; the managed `start(nullptr)` path is unchanged (gate
disabled), so there is **no ABI break** to `PowerToys.Interop`.
`PIPE_REJECT_REMOTE_CLIENTS` is also set. In **Debug** builds only the
signature check is relaxed (directory/basename/version stay enforced) so
local unsigned builds still connect; the relaxation is compiled out of
Release.
**Scope:** this PR covers the two elevated Runner-server pipes (Settings
+ Quick Access), which are the actual EoP surface. The reverse
Runner->Settings response direction, the duplicated Workspaces
transport, and the AdvancedPaste/PowerDisplay module pipes are
intentionally out of scope and can be handled as follow-ups.
## Validation Steps Performed
**Automated**
- **Native unit tests** (`Common.Utils.UnitTests`,
`PipeCallerAuthTests`): legitimate self-caller accepted; wrong
basename/directory rejected with the reject-log callback firing; version
reading. 6/6 pass.
- **C# regression** (`Microsoft.Interop.Tests.TestSend`): managed
gate-disabled round-trip still works.
- **Builds:** runner Debug + Release, `PowerToys.Interop` Debug +
Release, and the test project all build/link clean.
**Official signed build (validates the Release-only signature path that
local Debug builds skip)**
- Queued the internal "PowerToys Signed YAML Release Build" for this
branch — **green** (`result: succeeded`). The produced installers are
Authenticode `Valid`, signer `Microsoft Corporation`.
**Manual testing on the signed installer (elevated Runner) — passed**
- Installed the signed build and ran the Runner **as administrator**.
Settings and Quick Access open and are fully functional; settings apply,
module toggles work, and the hotkey-conflict request/response
round-trips.
- **No** `Rejected unauthenticated ...` lines during legitimate use →
the genuine signed `PowerToys.Settings.exe` is accepted by the
machine-root signature + version + directory checks (verified in
`RunnerLogs\runner-log_*.log`, requests dispatched normally).
- **Security (negative) check:** a non-elevated `powershell.exe`
discovered the runner pipe via the pipe namespace and attempted
`{"killrunner":true}`; the write failed ("Pipe is broken") because the
Runner rejected the caller and disconnected before dispatch.
`PowerToys.exe` stayed running and logged: `Rejected unauthenticated
Settings pipe client: pid=... image='...\powershell.exe'
reason=bad-directory`.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1d8f85ec-aaee-468a-9348-ba79b059cbaf
309 lines
10 KiB
C++
309 lines
10 KiB
C++
#include "pch.h"
|
|
#include "quick_access_host.h"
|
|
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <vector>
|
|
#include <rpc.h>
|
|
#include <new>
|
|
#include <memory>
|
|
|
|
#include <common/logger/logger.h>
|
|
#include <common/utils/process_path.h>
|
|
#include <common/interop/two_way_pipe_message_ipc.h>
|
|
#include <wil/resource.h>
|
|
|
|
extern void receive_json_send_to_main_thread(const std::wstring& msg);
|
|
|
|
namespace
|
|
{
|
|
wil::unique_handle quick_access_process;
|
|
wil::unique_handle quick_access_job;
|
|
wil::unique_handle show_event;
|
|
wil::unique_handle exit_event;
|
|
std::wstring show_event_name;
|
|
std::wstring exit_event_name;
|
|
std::wstring runner_pipe_name;
|
|
std::wstring app_pipe_name;
|
|
std::unique_ptr<TwoWayPipeMessageIPC> quick_access_ipc;
|
|
std::mutex quick_access_mutex;
|
|
|
|
bool is_process_active_locked()
|
|
{
|
|
if (!quick_access_process)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
DWORD exit_code = 0;
|
|
if (!GetExitCodeProcess(quick_access_process.get(), &exit_code))
|
|
{
|
|
Logger::warn(L"QuickAccessHost: failed to read Quick Access process exit code. error={}.", GetLastError());
|
|
return false;
|
|
}
|
|
|
|
return exit_code == STILL_ACTIVE;
|
|
}
|
|
|
|
void reset_state_locked()
|
|
{
|
|
if (quick_access_ipc)
|
|
{
|
|
quick_access_ipc->end();
|
|
quick_access_ipc.reset();
|
|
}
|
|
|
|
quick_access_process.reset();
|
|
quick_access_job.reset();
|
|
show_event.reset();
|
|
exit_event.reset();
|
|
show_event_name.clear();
|
|
exit_event_name.clear();
|
|
runner_pipe_name.clear();
|
|
app_pipe_name.clear();
|
|
}
|
|
|
|
std::wstring build_event_name(const wchar_t* suffix)
|
|
{
|
|
std::wstring name = L"Local\\PowerToysQuickAccess_";
|
|
name += std::to_wstring(GetCurrentProcessId());
|
|
if (suffix)
|
|
{
|
|
name += suffix;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
std::wstring build_command_line(const std::wstring& exe_path)
|
|
{
|
|
std::wstring command_line = L"\"";
|
|
command_line += exe_path;
|
|
command_line += L"\" --show-event=\"";
|
|
command_line += show_event_name;
|
|
command_line += L"\" --exit-event=\"";
|
|
command_line += exit_event_name;
|
|
command_line += L"\"";
|
|
if (!runner_pipe_name.empty())
|
|
{
|
|
command_line.append(L" --runner-pipe=\"");
|
|
command_line += runner_pipe_name;
|
|
command_line += L"\"";
|
|
}
|
|
if (!app_pipe_name.empty())
|
|
{
|
|
command_line.append(L" --app-pipe=\"");
|
|
command_line += app_pipe_name;
|
|
command_line += L"\"";
|
|
}
|
|
return command_line;
|
|
}
|
|
}
|
|
|
|
namespace QuickAccessHost
|
|
{
|
|
bool is_running()
|
|
{
|
|
std::scoped_lock lock(quick_access_mutex);
|
|
return is_process_active_locked();
|
|
}
|
|
|
|
void start()
|
|
{
|
|
Logger::info(L"QuickAccessHost::start() called");
|
|
std::scoped_lock lock(quick_access_mutex);
|
|
if (is_process_active_locked())
|
|
{
|
|
Logger::info(L"QuickAccessHost::start: process already active");
|
|
return;
|
|
}
|
|
|
|
reset_state_locked();
|
|
|
|
show_event_name = build_event_name(L"_Show");
|
|
exit_event_name = build_event_name(L"_Exit");
|
|
|
|
show_event.reset(CreateEventW(nullptr, FALSE, FALSE, show_event_name.c_str()));
|
|
if (!show_event)
|
|
{
|
|
Logger::error(L"QuickAccessHost: failed to create show event. error={}.", GetLastError());
|
|
reset_state_locked();
|
|
return;
|
|
}
|
|
|
|
exit_event.reset(CreateEventW(nullptr, FALSE, FALSE, exit_event_name.c_str()));
|
|
if (!exit_event)
|
|
{
|
|
Logger::error(L"QuickAccessHost: failed to create exit event. error={}.", GetLastError());
|
|
reset_state_locked();
|
|
return;
|
|
}
|
|
|
|
runner_pipe_name = L"\\\\.\\pipe\\powertoys_quick_access_runner_";
|
|
app_pipe_name = L"\\\\.\\pipe\\powertoys_quick_access_ui_";
|
|
UUID temp_uuid;
|
|
wchar_t* uuid_chars = nullptr;
|
|
if (UuidCreate(&temp_uuid) == RPC_S_UUID_NO_ADDRESS)
|
|
{
|
|
Logger::warn(L"QuickAccessHost: failed to create UUID for pipe names. error={}.", GetLastError());
|
|
}
|
|
else if (UuidToString(&temp_uuid, reinterpret_cast<RPC_WSTR*>(&uuid_chars)) != RPC_S_OK)
|
|
{
|
|
Logger::warn(L"QuickAccessHost: failed to convert UUID to string. error={}.", GetLastError());
|
|
}
|
|
|
|
if (uuid_chars != nullptr)
|
|
{
|
|
runner_pipe_name += std::wstring(uuid_chars);
|
|
app_pipe_name += std::wstring(uuid_chars);
|
|
RpcStringFree(reinterpret_cast<RPC_WSTR*>(&uuid_chars));
|
|
uuid_chars = nullptr;
|
|
}
|
|
else
|
|
{
|
|
const std::wstring fallback_suffix = std::to_wstring(GetTickCount64());
|
|
runner_pipe_name += fallback_suffix;
|
|
app_pipe_name += fallback_suffix;
|
|
}
|
|
|
|
HANDLE token_handle = nullptr;
|
|
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token_handle))
|
|
{
|
|
Logger::error(L"QuickAccessHost: failed to open process token. error={}.", GetLastError());
|
|
reset_state_locked();
|
|
return;
|
|
}
|
|
|
|
wil::unique_handle token(token_handle);
|
|
quick_access_ipc.reset(new (std::nothrow) TwoWayPipeMessageIPC(runner_pipe_name, app_pipe_name, receive_json_send_to_main_thread));
|
|
if (!quick_access_ipc)
|
|
{
|
|
Logger::error(L"QuickAccessHost: failed to allocate IPC instance.");
|
|
reset_state_locked();
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
interop_auth::CallerPolicy qa_caller_policy;
|
|
qa_caller_policy.enabled = true;
|
|
qa_caller_policy.expectedDirectory = get_module_folderpath() + L"\\WinUI3Apps";
|
|
qa_caller_policy.allowedBasenames = { L"PowerToys.QuickAccess.exe" };
|
|
qa_caller_policy.expectedVersion = interop_auth::GetOwnModuleVersion();
|
|
qa_caller_policy.requireMicrosoftSignature = true;
|
|
qa_caller_policy.logReject = [](const interop_auth::AuthResult& r) {
|
|
Logger::warn(L"Rejected unauthenticated Quick Access pipe client: pid={} image='{}' reason={}",
|
|
r.pid,
|
|
r.imagePath,
|
|
r.reasonCode);
|
|
};
|
|
quick_access_ipc->start(token.get(), qa_caller_policy);
|
|
}
|
|
catch (...)
|
|
{
|
|
Logger::error(L"QuickAccessHost: failed to start IPC server for Quick Access.");
|
|
reset_state_locked();
|
|
return;
|
|
}
|
|
|
|
const std::wstring exe_path = get_module_folderpath() + L"\\WinUI3Apps\\PowerToys.QuickAccess.exe";
|
|
if (GetFileAttributesW(exe_path.c_str()) == INVALID_FILE_ATTRIBUTES)
|
|
{
|
|
Logger::warn(L"QuickAccessHost: missing Quick Access executable at {}", exe_path);
|
|
reset_state_locked();
|
|
return;
|
|
}
|
|
|
|
const std::wstring command_line = build_command_line(exe_path);
|
|
std::vector<wchar_t> command_line_buffer(command_line.begin(), command_line.end());
|
|
command_line_buffer.push_back(L'\0');
|
|
STARTUPINFOW startup_info{};
|
|
startup_info.cb = sizeof(startup_info);
|
|
PROCESS_INFORMATION process_info{};
|
|
|
|
BOOL created = CreateProcessW(exe_path.c_str(), command_line_buffer.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr, nullptr, &startup_info, &process_info);
|
|
if (!created)
|
|
{
|
|
Logger::error(L"QuickAccessHost: failed to launch Quick Access host. error={}.", GetLastError());
|
|
reset_state_locked();
|
|
return;
|
|
}
|
|
|
|
quick_access_process.reset(process_info.hProcess);
|
|
|
|
// Assign to job object to ensure the process is killed if the runner exits unexpectedly (e.g. debugging stop)
|
|
quick_access_job.reset(CreateJobObjectW(nullptr, nullptr));
|
|
if (quick_access_job)
|
|
{
|
|
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli = { 0 };
|
|
jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
if (!SetInformationJobObject(quick_access_job.get(), JobObjectExtendedLimitInformation, &jeli, sizeof(jeli)))
|
|
{
|
|
Logger::warn(L"QuickAccessHost: failed to set job object information. error={}", GetLastError());
|
|
}
|
|
else
|
|
{
|
|
if (!AssignProcessToJobObject(quick_access_job.get(), quick_access_process.get()))
|
|
{
|
|
Logger::warn(L"QuickAccessHost: failed to assign process to job object. error={}", GetLastError());
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Logger::warn(L"QuickAccessHost: failed to create job object. error={}", GetLastError());
|
|
}
|
|
|
|
ResumeThread(process_info.hThread);
|
|
CloseHandle(process_info.hThread);
|
|
}
|
|
|
|
void show()
|
|
{
|
|
start();
|
|
std::scoped_lock lock(quick_access_mutex);
|
|
|
|
if (show_event)
|
|
{
|
|
if (!SetEvent(show_event.get()))
|
|
{
|
|
Logger::warn(L"QuickAccessHost: failed to signal show event. error={}.", GetLastError());
|
|
}
|
|
}
|
|
}
|
|
|
|
void stop()
|
|
{
|
|
Logger::info(L"QuickAccessHost::stop() called");
|
|
std::unique_lock lock(quick_access_mutex);
|
|
if (exit_event)
|
|
{
|
|
SetEvent(exit_event.get());
|
|
}
|
|
|
|
if (quick_access_process)
|
|
{
|
|
const DWORD wait_result = WaitForSingleObject(quick_access_process.get(), 2000);
|
|
Logger::info(L"QuickAccessHost::stop: WaitForSingleObject result={}", wait_result);
|
|
if (wait_result == WAIT_TIMEOUT)
|
|
{
|
|
Logger::warn(L"QuickAccessHost: Quick Access process did not exit in time, terminating.");
|
|
if (!TerminateProcess(quick_access_process.get(), 0))
|
|
{
|
|
Logger::error(L"QuickAccessHost: failed to terminate Quick Access process. error={}.", GetLastError());
|
|
}
|
|
else
|
|
{
|
|
Logger::info(L"QuickAccessHost: TerminateProcess succeeded.");
|
|
WaitForSingleObject(quick_access_process.get(), 5000);
|
|
}
|
|
}
|
|
else if (wait_result == WAIT_FAILED)
|
|
{
|
|
Logger::error(L"QuickAccessHost: failed while waiting for Quick Access process. error={}.", GetLastError());
|
|
}
|
|
}
|
|
|
|
reset_state_locked();
|
|
}
|
|
}
|