Cockatrice/tools/installer-holder-probe/restart_manager_holders.cpp
sentry[bot] bf3562a85a
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
[Installer] Offer to end running Cockatrice immediately when auto-close fails (#7675)
* [Installer] Offer to end running Cockatrice immediately when auto-close fails

* [Installer] Keep the way out that costs nothing in the first prompt

The prompt now offers to end Cockatrice, so what the deleted close-again text
said has to survive in it: an instance the probe reports as waiting for an
answer to a question of its own has been brought forward, and answering that
window is the way out that ends nothing. Say so, and say where to go when Yes
leaves it listed - Task Manager - so an end that cannot take effect does not
loop on the same dialog.

* [Installer] Bring the comments back in line with the code

GuardCloseTried no longer counts closes, and the file header, CloseHolders and
GuardAppsNotRunning still described an escalation after a second close, as did
the probe's --end-holders note.

* [Installer] End Oracle and Servatrice too when the guard's end answer is confirmed

The guard's end answer only ever stopped cockatrice.exe, so an Oracle holding
the card database or a Servatrice service kept the payload locked and the
prompt came back with nothing left that could free it.

The probe now ends every program the payload installs, services included:
isPayloadProgram() - the list restart_manager_holders already used for its
parent walk - decides the set, so a scanner holding a DLL or a program the
user keeps in the install directory is still named and left to the user. A
service is terminated rather than sent a stop through the SCM, which notices
the process exit and marks it stopped afterwards.

The guard prompt now says what ending costs: an unsaved deck, an unwritten
setting, players on a running Servatrice, and an Oracle mid-way through the
card database. It also keeps the notification-area hint, which the probe's
"has no window to ask" answer depends on, and drops the claim that Oracle
and Servatrice had been brought forward.

* [Installer] Give every running-program prompt the same end-or-retry answers

Four prompts meet a running program and each offered a different set: the
guard ended it, the launcher wait only kept waiting, and the recovery prompt
after a failed purge could only ask Cockatrice to close. With Oracle or
Servatrice holding the files, that last one had no answer that could free
anything - Yes and No both retried the purge and Cancel aborted an install
whose old version was already gone.

All four now follow the guard's shape: one automatic attempt, then a prompt
whose Yes ends the programs it names and whose No retries, with Cancel to
give up.

- RecoverFromHolders closes once and purges before it asks anything, then
  offers EndHolders - the same end answer as the guard - instead of a close
  the user had to trigger himself.
- WaitForLauncherToExit can end the launcher by pid, the one process it
  holds a pid for, rather than looping 60 s waits with Cancel as the only
  way out.
- END_COST and AFTER_CLOSING_GUIDANCE are !defines, so the cost of ending
  and the guidance for the answers that end nothing read the same in every
  prompt.

* [Installer] End the launcher through the handle it was waited on

The "end it" answer used to look the launcher's pid up again after a dialog that
can stay open as long as the user takes, so a pid that had been reused in the
meantime - or a /PID= that was never the launcher - would be terminated instead.
The handle the wait already holds names that process and cannot be handed to
another, so the terminate answer now acts on it.

PROCESS_TERMINATE is requested along with PROCESS_SYNCHRONIZE; when only the
synchronize right is granted the launcher is still waited on and the answer falls
back to asking it to close, instead of reporting a live launcher as gone and
writing the payload over it.

* [Installer] Say what the end answer ends, and report a failed end

The prompt offered "end every program listed above", but EndHolders only ends the
programs the payload installs: a scanner holding a payload DLL, or a service the
Restart Manager names by its long name, was listed and then silently left alone, so
Yes looked like it did nothing and the same dialog came back unchanged.

The wording now says which programs Yes ends and that anything else has to be closed
by hand, and the round after an end that did not free the files says so, so the same
dialog no longer reappears word for word. The three prompts are one macro so they
cannot drift apart again.

The added sentences eat into the 1024 characters one NSIS string holds, so the
holder line's budget drops from 400 to 320 characters to keep the longest message
(cancel line included) inside it: 977 characters, measured against the worst case
the probe can produce.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-27 19:09:21 +01:00

314 lines
12 KiB
C++

#include "restart_manager_holders.h"
// clang-format off
// restartmanager.h uses the types from windows.h without including it, so the order
// here is a requirement; Regroup would sort them the other way round.
#include <windows.h>
#include <restartmanager.h>
#include <tlhelp32.h>
// clang-format on
#include <algorithm>
#include <iterator>
#include <utility>
namespace
{
/// Stop registering after this many files. The install directory carries around 150
/// files in practice; the cap only exists so a directory someone has filled with files
/// cannot turn the abort path into a long Restart Manager call.
constexpr std::size_t MAX_PROBED_FILES = 2000;
/// How deep the Plugins walk goes. Its real shape is Plugins\<category>\<plugin>.dll.
constexpr int MAX_PLUGIN_DEPTH = 4;
/// How many parents askingProcessIds() walks up before it stops on its own. A real chain
/// is the probe, the installer or uninstaller, and the shell that started it; the bound
/// only keeps a snapshot with a cycle in it from walking forever.
constexpr int MAX_ASKER_LEVELS = 8;
/// The payload files the installer replaces by name; the DLLs are matched by mask below.
constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"};
/// The payload programs, by image name. A process running one of these is what the guard
/// exists to name, so askingProcessIds() stops its walk up the parent chain at one.
constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"};
void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector<std::wstring> &files)
{
if (files.size() >= MAX_PROBED_FILES) {
return;
}
WIN32_FIND_DATAW entry{};
const HANDLE find = FindFirstFileW((directory + L"\\*").c_str(), &entry);
if (find == INVALID_HANDLE_VALUE) {
return;
}
do {
if (files.size() >= MAX_PROBED_FILES) {
break;
}
const std::wstring name = entry.cFileName;
if (name == L"." || name == L".." || (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
continue;
}
const std::wstring path = directory + L"\\" + name;
if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) {
if (depth < maxDepth) {
collectFilesOf(path, depth + 1, maxDepth, files);
}
} else {
files.push_back(path);
}
} while (FindNextFileW(find, &entry) != 0);
FindClose(find);
}
std::wstring imagePathOf(unsigned long processId)
{
// PROCESS_QUERY_LIMITED_INFORMATION is the right to ask, not to debug: it reads the
// path of a process this one does not own, which is what keeps a SYSTEM-owned holder
// from turning the whole list into an error. One that refuses even this is still
// named, by whatever Restart Manager called it.
const HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
if (process == nullptr) {
return {};
}
wchar_t path[MAX_PATH * 2] = {};
DWORD length = static_cast<DWORD>(std::size(path));
std::wstring imagePath;
if (QueryFullProcessImageNameW(process, 0, path, &length) != 0) {
imagePath.assign(path, length);
}
CloseHandle(process);
return imagePath;
}
std::wstring fileNameOf(const std::wstring &path)
{
const std::size_t separator = path.find_last_of(L"\\/");
return separator == std::wstring::npos ? path : path.substr(separator + 1);
}
/// @p directory with exactly one trailing separator, so that a sibling whose name merely
/// begins with the same letters ("Cockatrice-old" next to "Cockatrice") cannot pass for a
/// subdirectory of it.
std::wstring directoryPrefixOf(const std::wstring &directory)
{
std::wstring prefix = directory;
while (!prefix.empty() && (prefix.back() == L'\\' || prefix.back() == L'/')) {
prefix.pop_back();
}
prefix += L'\\';
return prefix;
}
/// Whether @p path lies inside @p prefix, which is a directory with a trailing separator.
/// Ordinal and case-insensitive, as Windows compares paths.
bool isUnderDirectory(const std::wstring &path, const std::wstring &prefix)
{
if (path.size() < prefix.size()) {
return false;
}
const int length = static_cast<int>(prefix.size());
return CompareStringOrdinal(path.c_str(), length, prefix.c_str(), length, TRUE) == CSTR_EQUAL;
}
/// Copies a fixed-size, NUL-terminated WCHAR array the API filled in.
template <std::size_t N> std::wstring fixedString(const wchar_t (&text)[N])
{
return std::wstring(text, std::find(text, text + N, L'\0'));
}
Holder holderOf(const RM_PROCESS_INFO &info)
{
Holder holder;
holder.processId = info.Process.dwProcessId;
holder.isService = info.ApplicationType == RmService;
holder.appName = fixedString(info.strAppName);
if (holder.isService) {
holder.serviceShortName = fixedString(info.strServiceShortName);
}
holder.imageName = fileNameOf(imagePathOf(holder.processId));
return holder;
}
} // namespace
bool isPayloadProgram(const std::wstring &imageName)
{
for (const wchar_t *program : PAYLOAD_PROGRAMS) {
if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) {
return true;
}
}
return false;
}
std::vector<std::wstring> collectPayloadFiles(const std::wstring &installDir)
{
std::vector<std::wstring> files;
for (const wchar_t *name : PAYLOAD_FILES) {
const std::wstring path = installDir + L"\\" + name;
const DWORD attributes = GetFileAttributesW(path.c_str());
if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
files.push_back(path);
}
}
// Every top-level DLL: the set the purge deletes and therefore the set a running
// instance can have mapped. Matched by mask rather than by a list of expected names,
// so a transitive dependency nobody thought of is covered as well.
WIN32_FIND_DATAW entry{};
const HANDLE dlls = FindFirstFileW((installDir + L"\\*.dll").c_str(), &entry);
if (dlls != INVALID_HANDLE_VALUE) {
do {
if (files.size() >= MAX_PROBED_FILES) {
break;
}
if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 ||
(entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
continue;
}
files.push_back(installDir + L"\\" + entry.cFileName);
} while (FindNextFileW(dlls, &entry) != 0);
FindClose(dlls);
}
// Plugins is the other tree the purge removes. Its data directory is not: it holds
// downloaded pictures, which the purge does not touch and which would turn this into
// a walk over thousands of files.
if (files.size() < MAX_PROBED_FILES) {
collectFilesOf(installDir + L"\\Plugins", 0, MAX_PLUGIN_DEPTH, files);
}
return files;
}
unsigned long findProcessesUsingFiles(const std::vector<std::wstring> &files, std::vector<Holder> &holders)
{
holders.clear();
if (files.empty()) {
return ERROR_SUCCESS;
}
DWORD session = 0;
wchar_t sessionKey[CCH_RM_SESSION_KEY + 1] = {};
DWORD error = RmStartSession(&session, 0, sessionKey);
if (error != ERROR_SUCCESS) {
return error;
}
// One call for the whole set, as the API asks: RmRegisterResources does registry
// work per file and is documented as too expensive to call once per file.
std::vector<LPCWSTR> names;
names.reserve(files.size());
for (const std::wstring &file : files) {
names.push_back(file.c_str());
}
error = RmRegisterResources(session, static_cast<UINT>(names.size()), names.data(), 0, nullptr, 0, nullptr);
if (error == ERROR_SUCCESS) {
UINT needed = 0;
UINT count = 0;
DWORD rebootReasons = 0;
// The first call asks how large the answer is; Restart Manager reports
// ERROR_MORE_DATA rather than truncating a list that does not fit.
error = RmGetList(session, &needed, &count, nullptr, &rebootReasons);
// Bounded: each retry more than covers the list growing between the two calls,
// and an abort path cannot chase a process that keeps opening files. Giving up
// reports the code, which is a truthful answer instead of a truncated list.
for (int attempt = 0; attempt < 3 && error == ERROR_MORE_DATA; ++attempt) {
count = needed;
std::vector<RM_PROCESS_INFO> infos(count);
error = RmGetList(session, &needed, &count, infos.data(), &rebootReasons);
if (error == ERROR_SUCCESS) {
holders.reserve(count);
for (UINT index = 0; index < count; ++index) {
holders.push_back(holderOf(infos[index]));
}
}
}
}
RmEndSession(session);
return error;
}
std::vector<unsigned long> askingProcessIds()
{
std::vector<unsigned long> asking;
asking.push_back(GetCurrentProcessId());
// One snapshot for the whole walk: the parent ids come from it, so a level costs no
// second lookup.
std::vector<PROCESSENTRY32W> processes;
const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) {
return asking;
}
PROCESSENTRY32W entry{};
entry.dwSize = sizeof(entry);
if (Process32FirstW(snapshot, &entry) != FALSE) {
do {
processes.push_back(entry);
} while (Process32NextW(snapshot, &entry) != FALSE);
}
CloseHandle(snapshot);
unsigned long current = asking.back();
for (int level = 0; level < MAX_ASKER_LEVELS; ++level) {
const auto named = std::find_if(processes.begin(), processes.end(), [current](const PROCESSENTRY32W &process) {
return process.th32ProcessID == current;
});
if (named == processes.end() || named->th32ParentProcessID == 0) {
break;
}
const unsigned long parent = named->th32ParentProcessID;
if (isPayloadProgram(fileNameOf(imagePathOf(parent)))) {
break;
}
asking.push_back(parent);
current = parent;
}
return asking;
}
std::vector<Holder> findProcessesRunningFrom(const std::wstring &installDir)
{
std::vector<Holder> holders;
if (installDir.empty()) {
return holders;
}
const std::wstring prefix = directoryPrefixOf(installDir);
// Never the probe, and never the installer or uninstaller that started it: either can
// run from the directory being replaced, and naming one asks the user to close the
// program that is asking (see askingProcessIds).
const std::vector<unsigned long> asking = askingProcessIds();
const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) {
return holders;
}
PROCESSENTRY32W entry{};
entry.dwSize = sizeof(entry);
if (Process32FirstW(snapshot, &entry) != FALSE) {
do {
if (std::find(asking.begin(), asking.end(), entry.th32ProcessID) != asking.end()) {
continue;
}
// One process at a time, because the path is per process; a process that
// refuses to be read is not named here and is still named by Restart
// Manager when it holds a handle. The idle process has no image at all.
const std::wstring imagePath = imagePathOf(entry.th32ProcessID);
if (!isUnderDirectory(imagePath, prefix)) {
continue;
}
Holder holder;
holder.processId = entry.th32ProcessID;
holder.imageName = fileNameOf(imagePath);
holders.push_back(std::move(holder));
} while (Process32NextW(snapshot, &entry) != FALSE);
}
CloseHandle(snapshot);
return holders;
}