Cockatrice/tools/installer-holder-probe/close_holders.cpp
sentry[bot] 0369aaffa9
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Desktop / Prune caches (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / Ubuntu 26.04 cover (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
[Installer] Name only the payload programs installed in the directory (#7684)
The running-instance lookup named every process whose image path began with the
install directory, subfolders included. Installing into a folder that holds
another application in a subfolder therefore reported that application as
holding the previous version, and nothing the guard offers could clear it: the
close asks cockatrice.exe only, and the end answer refuses anything that is not
a payload program. Both answers were no-ops, so the update could only abort and
tell the user to close a program that was never touching the files.

Name only the programs the payload installs, and only when the image lies in the
install directory itself. A program below the directory that really does hold a
payload file is still named by the Restart Manager lookup, which is the lookup
for that question.

The Windows probe check now covers a payload-named program in a subfolder and a
program the payload does not install; both have to come back as nothing.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-30 13:16:50 +01:00

388 lines
16 KiB
C++

#include "close_holders.h"
#include "payload_paths.h"
#include "restart_manager_holders.h"
// clang-format off
// windows.h has to come before anything that pulls in a Windows type, as everywhere in
// this tool.
#include <windows.h>
// clang-format on
#include <cstddef>
#include <string>
#include <utility>
#include <vector>
namespace
{
/// How long one poll of the asked processes takes. One WaitForSingleObject per process
/// and no enumeration, so polling costs nothing next to the seconds this is called on.
constexpr unsigned long WAIT_POLL_MILLISECONDS = 200;
/// How long the report line may get before it is cut. NSIS holds one string in 1024
/// characters, and this one is written there verbatim; every holder is named, so normally
/// the line stays far below it.
constexpr std::size_t REPORT_CHAR_MAX = 900;
/// The holder as the log has to name it to be actionable. Deliberately not
/// formatHolder(): that one is worded for the dialog, where ", running" says something
/// the line here continues after.
std::wstring processLabel(const Holder &holder)
{
std::wstring label = holder.imageName.empty() ? holder.appName : holder.imageName;
if (label.empty()) {
label = L"an unnamed process";
}
label += L" (pid ";
label += std::to_wstring(holder.processId);
label += L")";
return label;
}
/// Joins the per-holder lines into the one line the installer logs and cuts it to what one
/// NSIS string holds.
std::wstring joinReport(const std::vector<std::wstring> &lines)
{
std::wstring report;
for (const std::wstring &line : lines) {
if (!report.empty()) {
report += L"; ";
}
report += line;
}
if (report.size() > REPORT_CHAR_MAX) {
report.erase(REPORT_CHAR_MAX);
report += L"...";
}
return report;
}
struct WindowSearch
{
DWORD processId = 0;
std::vector<HWND> windows; ///< every top-level window of the process, in Z-order
};
BOOL CALLBACK collectProcessWindow(HWND window, LPARAM parameter)
{
auto &search = *reinterpret_cast<WindowSearch *>(parameter);
DWORD processId = 0;
GetWindowThreadProcessId(window, &processId);
if (processId == search.processId) {
search.windows.push_back(window);
}
return TRUE;
}
/// Whether a window is one the user could be asked to answer.
///
/// Only a framed, non-empty window qualifies. Qt keeps hidden top-level windows of its own
/// next to the application's: the tray icon creates one to receive the TaskbarCreated
/// message, styled WS_OVERLAPPED - which is no frame at all - and titled
/// QTrayIconMessageWindow. Showing one of those puts an empty white rectangle on the
/// taskbar that nothing ever closes again: it has no window procedure of its own to hide it,
/// and the process is busy in the loop this is meant to break, so it stays until the process
/// dies. Posting a close to one is worse than useless too: Qt's tray window answers WM_CLOSE
/// by asking the application to terminate, without going through the confirmation the close
/// is supposed to reach.
bool isAnswerableWindow(HWND window)
{
if ((GetWindowLongPtr(window, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0) {
return false;
}
if ((GetWindowLongPtr(window, GWL_STYLE) & WS_CAPTION) == 0) {
return false;
}
RECT bounds{};
if (GetWindowRect(window, &bounds) == FALSE) {
return false;
}
return bounds.right > bounds.left && bounds.bottom > bounds.top;
}
/// Picks the window to post the close to and the one the user should end up looking at.
struct CloseTarget
{
HWND main = nullptr; ///< first answerable window without an owner: the application's own
HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up
bool hidden = false; ///< the window being closed is not on screen (notification area)
};
/// Only the windows the user could be asked to answer are candidates - see isAnswerableWindow.
///
/// A hidden window is a candidate like a visible one: WM_CLOSE does not need a window on
/// screen, so an instance that is only in the notification area answers it the way it answers
/// its own close button, and the confirmation it raises then is a window Qt shows itself -
/// painted, unlike one restored from outside. Nothing is shown from here. Qt's own bookkeeping
/// windows stay excluded, because a close posted to the notification-area one terminates the
/// instance without the confirmation and that is worse than no close at all.
///
/// @param windows every top-level window the process owns, in Z-order
/// @param askHidden when false, only a window that is on screen is a target: the launcher
/// mode reads "no window" as "past its own prompts and stuck", which stops being true
/// the moment a hidden window is asked to close
CloseTarget chooseCloseTarget(const std::vector<HWND> &windows, bool askHidden)
{
CloseTarget target;
for (const HWND window : windows) {
if (!isAnswerableWindow(window)) {
continue;
}
const bool visible = IsWindowVisible(window) != FALSE;
if (visible && target.toRaise == nullptr) {
target.toRaise = window;
}
if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr && (visible || askHidden)) {
target.main = window;
target.hidden = !visible;
}
}
return target;
}
/// What is left of the window the close was posted to, so a log line tells an instance that
/// answered by hiding from one that never answered at all. A process that hid its window and
/// stayed alive is the one the guard's end answer is for.
///
/// @param window the window the close was posted to, or null when there was none
/// @param wasHidden whether that window was already off screen before the close
std::wstring describeWindowState(HWND window, bool wasHidden)
{
if (window == nullptr || IsWindow(window) == FALSE) {
return L", its window is gone";
}
// WM_NULL is the cheapest way to ask whether the thread behind the window still dispatches.
// A hung instance does not answer at all, which is a different problem from one that
// answered the close and stayed open, and the two want different words in the prompt.
DWORD_PTR ignored = 0;
const bool responding =
SendMessageTimeoutW(window, WM_NULL, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &ignored) != 0;
std::wstring state;
if (IsWindowVisible(window) != FALSE) {
state = L", window still open";
} else {
state = wasHidden ? L", window still hidden" : L", window hidden again";
}
if (!responding) {
state += L" and not responding";
}
return state;
}
/// Restores a minimized window and takes the foreground, so a confirmation Cockatrice
/// answers the close with is in front of the installer instead of behind it.
///
/// A process in the background may not take the foreground on its own. Attaching to the
/// thread that owns the foreground window lifts that restriction for the duration of the
/// call; detaching afterwards leaves the two input queues as they were.
bool bringForward(HWND window)
{
if (IsIconic(window) != FALSE) {
ShowWindow(window, SW_RESTORE);
} else {
ShowWindow(window, SW_SHOW);
}
const HWND foreground = GetForegroundWindow();
const DWORD foregroundThread = foreground != nullptr ? GetWindowThreadProcessId(foreground, nullptr) : 0;
const DWORD currentThread = GetCurrentThreadId();
const bool attached = foregroundThread != 0 && foregroundThread != currentThread &&
AttachThreadInput(currentThread, foregroundThread, TRUE) != FALSE;
BringWindowToTop(window);
SetForegroundWindow(window);
if (attached) {
AttachThreadInput(currentThread, foregroundThread, FALSE);
}
return GetForegroundWindow() == window;
}
/// Waits for @p processes to signal, up to @p waitMilliseconds. Returns whether they all
/// exited and how long the wait took.
bool waitForExit(const std::vector<HANDLE> &processes, unsigned long waitMilliseconds, unsigned long long &elapsed)
{
const ULONGLONG start = GetTickCount64();
const ULONGLONG deadline = start + waitMilliseconds;
for (;;) {
bool allExited = true;
for (const HANDLE process : processes) {
if (WaitForSingleObject(process, 0) != WAIT_OBJECT_0) {
allExited = false;
break;
}
}
elapsed = GetTickCount64() - start;
if (allExited || GetTickCount64() >= deadline) {
return allExited;
}
Sleep(WAIT_POLL_MILLISECONDS);
}
}
} // namespace
bool isCockatriceImage(const std::wstring &imageName)
{
constexpr wchar_t COCKATRICE_IMAGE[] = L"cockatrice.exe";
return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL;
}
CloseHoldersResult
closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMilliseconds, bool askHiddenWindows)
{
CloseHoldersResult result;
std::vector<std::wstring> lines;
std::vector<HANDLE> asked;
std::vector<std::size_t> askedLines;
std::vector<HWND> askedWindows;
std::vector<bool> askedWasHidden;
for (const Holder &holder : holders) {
if (holder.isService || !isCockatriceImage(holder.imageName)) {
// Oracle and Servatrice are named by the prompt and close by hand: Oracle
// writes the card database in place. A service has no window to ask either.
lines.push_back(processLabel(holder) + L" left to you");
continue;
}
WindowSearch search;
search.processId = holder.processId;
EnumWindows(collectProcessWindow, reinterpret_cast<LPARAM>(&search));
CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows);
if (target.main == nullptr) {
// Nothing of the application's own to post a close to: every window left is one of
// Qt's bookkeeping windows, or the only thing on screen is a question whose owner
// window is gone. Neither is a close the application would answer - a close to the
// notification-area window terminates without the confirmation - so the instance is
// named and the guard's end answer is what can free its files.
++result.withoutWindow;
lines.push_back(processLabel(holder) + L" has no window of its own to close");
continue;
}
std::wstring line = processLabel(holder);
if (target.hidden) {
line += L" is hidden in the notification area";
} else {
if (target.toRaise == nullptr) {
target.toRaise = target.main;
}
line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward";
}
// Posted to the instance's own window even when a question of its own is up. The close
// is what that window's close button does, and the application is the one that answers
// it: a current client refuses the close while its confirmation is pending, leaving the
// question in front, and only a client from before that guard accepts it and hides the
// window with the question. Withholding the request therefore protected nobody - it
// left the installer asking the user to close a program it had never asked - while the
// guard's end answer is what frees the files when the close cannot.
//
// Kept open across the wait so a pid reused after the process exits cannot be
// mistaken for the instance that was asked to close.
const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId);
const DWORD openError = process == nullptr ? GetLastError() : ERROR_SUCCESS;
if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) {
// Read the error before CloseHandle, which is free to reset it.
const DWORD postError = GetLastError();
if (process != nullptr) {
CloseHandle(process);
}
lines.push_back(line + L", and the close request could not be sent (win32 " + std::to_wstring(postError) +
L")");
continue;
}
line += L" and asked to close";
++result.askedToClose;
if (process == nullptr) {
line += L", but its exit could not be watched (win32 " + std::to_wstring(openError) + L")";
} else {
askedLines.push_back(lines.size());
asked.push_back(process);
askedWindows.push_back(target.main);
askedWasHidden.push_back(target.hidden);
}
lines.push_back(std::move(line));
}
if (!asked.empty() && waitMilliseconds > 0) {
unsigned long long elapsed = 0;
const bool allExited = waitForExit(asked, waitMilliseconds, elapsed);
for (std::size_t index = 0; index < asked.size(); ++index) {
const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0;
if (exited) {
lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms";
} else {
lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" +
describeWindowState(askedWindows[index], askedWasHidden[index]);
}
}
}
for (const HANDLE process : asked) {
CloseHandle(process);
}
if (lines.empty()) {
result.report = L"nothing is holding the files";
return result;
}
result.report = joinReport(lines);
return result;
}
EndHoldersResult endHolders(const std::vector<Holder> &holders, unsigned long waitMilliseconds)
{
EndHoldersResult result;
std::vector<std::wstring> lines;
std::vector<HANDLE> ended;
std::vector<std::size_t> endedLines;
for (const Holder &holder : holders) {
if (!isPayloadProgram(holder.imageName)) {
// Not one of the programs the payload installs: a scanner that has a DLL open, or
// a program the user keeps in the install directory. It is named and left to the
// user, because ending it would destroy something this installer does not own.
lines.push_back(processLabel(holder) + L" left to you");
continue;
}
// Services included: the installer asked before getting here, and the Service
// Control Manager reports the terminated process as a stopped service afterwards.
const HANDLE process = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, holder.processId);
if (process == nullptr) {
lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(GetLastError()) +
L")");
continue;
}
if (TerminateProcess(process, 1) == FALSE) {
// Read the error before CloseHandle, which is free to reset it.
const DWORD error = GetLastError();
CloseHandle(process);
lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(error) + L")");
continue;
}
++result.ended;
endedLines.push_back(lines.size());
lines.push_back(processLabel(holder) + L" ended");
ended.push_back(process);
}
if (!ended.empty() && waitMilliseconds > 0) {
unsigned long long elapsed = 0;
const bool allExited = waitForExit(ended, waitMilliseconds, elapsed);
for (std::size_t index = 0; index < ended.size(); ++index) {
const bool exited = allExited || WaitForSingleObject(ended[index], 0) == WAIT_OBJECT_0;
lines[endedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms"
: L", still running after " + std::to_wstring(elapsed) + L" ms";
}
}
for (const HANDLE process : ended) {
CloseHandle(process);
}
if (lines.empty()) {
result.report = L"nothing is holding the files";
return result;
}
result.report = joinReport(lines);
return result;
}