Reversing random stealer

Intro

It had been a while since I last took the time to reverse a real malware sample from scratch.
To avoid getting rusty (and because it’s genuinely fun), I gave myself a simple challenge: grab a completely random malware sample and see how far I could go analyzing it.
Off to MalShare, random click, download a binary without knowing what I was getting into.
Spoiler: it wasn’t a crappy crack — it was a fairly ambitious stealer, with password theft, Discord tokens, encryption, and the usual toolkit.

Fingerprints

First data points:

1
2
3
4
5
File:  malware
MD5: f0373657a39b505f247d834f9f82390f
SHA256: d787276563a424bdee4035d346263524a959bce936bb6c0f5424ebd214d9cabe
Size: 1,570,304 bytes (1.5 MB)
Type: PE32 executable (console) Intel 80386, for MS Windows

At the time this article is written, these identifiers are already known (VT, etc).
However, the IoCs (especially contacted IPs) do not fully match what I found 👀

First contact

Let’s open the sample with the best tool (the one and only :binja_love: ). And then:

.text → 200 KB (Executable code)
.data → 50 KB (data, ok)
.rsrc → 539 KB WTF?!

539 KB of resources… In a 1.5 MB console executable… yeah, right.

For reference, .rsrc is supposed to contain legit stuff: icons, Windows dialogs, UI strings, etc. Normally just a few KB, rarely over 100 KB.

Here, we have half a megabyte of data. And looking more closely: no structured icons, no dialogs, no multilingual strings… Just a big binary blob with very high entropy (7.8/8.0).

Almost certainly encrypted.

Strings & Ciphers

First step: look at the strings — we quickly stumble onto:

Jackpot !

1
2
3
4
5
6
"XOR cipher reversed"
"Substitution cipher reversed"
"Transposition cipher reversed"
"Payload decrypted"
"Payload executed successfully"
"Header parsed - headerSize: %d, blockSize: %d, keySize: %d, encryptedSize: %d"

Not subtle at all…

We also notice many anti-debug related strings, plus classic malware stuff (AMSI patch, sandbox detection, etc). The logging is very verbose — they probably forgot to remove debug messages…

With the xrefs, we can identify the method that “decrypts” something:

This function is fairly large, with many memcpy/malloc, lots of loops, strongly suggesting a “decryption” routine.

Looking at where it’s called, we see another heavy function that — surprise — fetches data from .rsrc before calling decryption…

Cleaning up all the AMSI, ETW, etc. patching calls, it looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
for (lpName = 0x3e8; lpName != 0x2329; lpName++)
{
HRSRC hResInfo = FindResourceA(nullptr, lpName, 0xa);

if (hResInfo != 0)
{
HGLOBAL hResData = LoadResource(nullptr, hResInfo);
int32_t payload_ptr = LockResource(hResData);
uint32_t payload_size = SizeofResource(nullptr, hResInfo);

memmove(buffer, payload_ptr, payload_size);
decrypt_payload(&output_buffer, &input_buffer);

// ...execute payload ...
}
}

Clearly, it scans resources for a specific one, checks sizes, decrypts it, and executes the output — classic.

Decrypt

Now we need to extract and decrypt the payload to understand the malware behavior.
According to the embedded debug strings, the algorithm is in 3 stages:

  • Transposition cipher reversed
  • Substitution cipher reversed
  • XOR cipher reversed

Payload extraction

First, we extract the payload.
The dropper pulls resources of type 0xa.

Using debug strings and size checks, we can infer the header structure:

1
"Header parsed - headerSize: %d, blockSize: %d, keySize: %d, encryptedSize: %d"

There’s also a check:

1
2
3
4
004062fd                            if (_Size u<= 0x31)
004068f1 GetTempPathA(nBufferLength: 0x104, lpBuffer)
004068f6 enum PAGE_PROTECTION_FLAGS* eax_118 = &var_ae4
00406910 uint32_t j_2

From this, we deduce a header like:

  • headerSize : between 8 and 15 bytes
  • blockSize : probably 16
  • keySize : probably 32

The rest looks like key + encrypted data.

Looking inside .rsrc, we quickly find a blob matching the format (right after standard resource structs):

We also see buffers similar to:

1
2
3
4
5
6
struct DataBuffer
{
uint8_t* begin;
uint8_t* end;
uint8_t* capacity;
};

Then the 3 decryption steps start.

Transposition

This one is simple: data is processed in 16-byte blocks and each block is reversed.

1
2
Input:  [00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F] [10 11 12 ...]
Output: [0F 0E 0D 0C 0B 0A 09 08 07 06 05 04 03 02 01 00] [1F 1E 1D ...]

Easy

Substitution

This part is trickier.
It uses a dynamic substitution table built per-byte using the key.

  1. Compute base = (i + key[i % keySize]) % 256
  2. Build table: table[j] = (base + j) % 256
  3. Find inverse: find j such that table[j] == encrypted_byte
  4. Replace byte with j

It’s more obfuscation than encryption.
Thanks to debug strings, it was much easier to understand :D

As simple as a XOR

Final step: classic XOR loop using the 32‑byte key.

Let’s decrypt

Running all 3 steps on the extracted payload gives:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def reverse_transposition(data, blockSize):
result = bytearray()
pos = 0

while pos < len(data):
chunk_size = min(blockSize, len(data) - pos)
chunk = data[pos : pos + chunk_size]

# Reverse le chunk
for i in range(len(chunk) - 1, -1, -1):
result.append(chunk[i])

pos += blockSize
return result

def reverse_substitution(data, key):
result = bytearray(data)
keySize = len(key)

for i in range(len(result)):
key_byte = key[i % keySize]
base = (i + key_byte) % 256

sub_table = [(base + j) % 256 for j in range(256)]

# inverse
encrypted_byte = result[i]
for j in range(256):
if sub_table[j] == encrypted_byte:
result[i] = j
break

return result

def reverse_xor(data, key):
result = bytearray()
keySize = len(key)

for i in range(len(data)):
result.append(data[i] ^ key[i % keySize])
return result

We indeed get another binary!

1
2
ghozt@maze:~/research/malware$ file output.exe 
output.exe: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows, 3 sections

.NET stealer

With ilspy we extract the C# code:

1
ghozt@maze:~/research/malware$ ilspycmd output.exe -o ./decompiled_dotnet/

We quickly notice the strings are obfuscated.
Still, we can analyze the general behavior: credential theft, Discord token extraction, decrypting Chrome’s password store via DPAPI…

As for the strings, everything is AES‑encrypted with a lightly obfuscated key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public static class Strings
{
private static readonly string[] Keys = new string[3]
{
"Announcers",
"FV40ACQFNSomHjU6PAMFBjQ9Hh0MAA1dEDglFTwmcBcNOEFdBj0wAxQoBioWAw8RPBkTXSJfLyAuIUJO",
"JRQsKS8AISskJhhcIwEnBjIMSxgPODw+LzstPCcncVc="
};

static Strings()
{

Keys[1] = StringDecrypt.Read(Keys[1], Keys[0]);
Keys[2] = StringDecrypt.Read(Keys[2], Keys[0]);
}

public static string Get(int id)
{
return Decrypt(Convert.FromBase64String(array[id]));
}

private static string Decrypt(byte[] chiperText)
{
using Aes aes = Aes.Create();
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;

ICryptoTransform transform = aes.CreateDecryptor(
Convert.FromBase64String(Keys[1]), // AES Key
Convert.FromBase64String(Keys[2]) // AES IV
);

using MemoryStream stream = new MemoryStream(chiperText.Reverse().ToArray());
using CryptoStream cs = new CryptoStream(stream, transform, CryptoStreamMode.Read);
using StreamReader sr = new StreamReader(cs);

return sr.ReadToEnd();
}
}

There are 190 encrypted strings.
All use AES‑CBC, but with a twist: ciphertext is reversed before decryption.

AES keys themselves are XOR’d then Base64‑encoded.

Reproducing the algorithm gives:

1
2
AES Key: OFhBENNkSFZaYyg2w6yQi5Mrqn7ypiPXDrim64w/FM0=
AES IV: w0EfpMUF62taB/d5TPeCXQ==

And we can decrypt everything!

Targets and data theft

Browsers :

1
2
3
4
5
6
7
8
Login Data           → Credentials Chrome/Edge/Brave
Web Data → Autofill, credit cards
Cookies → Cookies
moz_cookies → Firefox
logins.json → Credentials Firefox
key3.db / key4.db → Firefox keys
Local Storage → Tokens
IndexedDB → Localdataabse

Messaging :

1
2
3
discord\Local Storage\leveldb\     → Discord Tokens
Telegram Desktop\tdata\ → Telegram Sessions
https://discord.com/api/v9/users/@me → Discord API

Crypto Wallets :

1
2
3
wallet.dat                         → Bitcoin Core, Litecoin
Coinbase Wallet → Coinbase
MetaMask → MetaMask

VPN :

1
2
3
NordVPN\user.config                
ProtonVPN\
OpenVPN Connect\profiles\

Gaming & FTP :

1
2
Steam\loginusers.vdf               
FileZilla\sitemanager.xml → FTP servers

And the big one:

1
net.tcp://185.172.128.70:3808

This is the C2 where all stolen data is sent using WCF (Windows Communication Foundation).

System recon

The malware also collects lots of system info:

1
2
3
4
5
SELECT * FROM Win32_Processor          -- CPU
SELECT * FROM Win32_VideoController -- GPU
SELECT * FROM Win32_DiskDrive -- Disques
SELECT * FROM Win32_OperatingSystem -- OS
SELECT * FROM Win32_Process -- Processus

And detects security products:

1
2
ROOT\SecurityCenter\AntivirusProduct
ROOT\SecurityCenter\FirewallProduct

Overview

With all strings decrypted, we can reconstruct the complete workflow:

Phase 1 : Recon

  • System info (CPU, GPU, RAM, disks, OS)
  • Security product detection
  • VM/sandbox checks (QEMU, RDP)
  • Running processes
  • Installed software
  • GeoIP via https://api.ip.sb/ip

Phase 2 : Data theft

  • Browsers: credentials, cookies, autofill, credit cards
  • Discord: tokens via LevelDB
  • Telegram: tdata folder
  • Wallets: crypto wallet files, extensions
  • VPN: configs for NordVPN/ProtonVPN/OpenVPN
  • FTP: FileZilla
  • Gaming: Steam login data

Phase 3 : Exfiltration

  • Compress stolen data
  • Connect to C2: net.tcp://185.172.128.70:3808
  • Send via WCF binary protocol

Persistance

One encrypted Base64 string decodes to another .NET executable, which is written into the Startup folder:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;

[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: AssemblyTitle("qemu-ga")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("qemu-ga")]
[assembly: AssemblyCopyright("Copyright © 2023")]
[assembly: AssemblyTrademark("")]
[assembly: ComVisible(false)]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: TargetFramework(".NETFramework,Version=v4.0", FrameworkDisplayName = ".NET Framework 4")]
[assembly: AssemblyVersion("1.0.0.0")]
internal class Program
{
private static void Main(string[] args)
{
while (true)
{
Thread.Sleep(100000);
Console.ReadLine();
}
}
}

This is likely a dormant backdoor allowing later reattachment.

Conclusion

This sample appears to be a variant of Reline, a fork of RedLine Stealer that surfaced after the October 2024 takedown.
It features:

  • Custom packer with simple ciphers (transposition + substitution + XOR)
  • WCF exfiltration instead of SOAP/HTTP
  • Debug strings left in
  • Obfuscated .NET payload

IoCs Summary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Dropper:
MD5: f0373657a39b505f247d834f9f82390f
SHA256: d787276563a424bdee4035d346263524a959bce936bb6c0f5424ebd214d9cabe

Payload:
MD5: a5f31a46902955162fc83931a0a549e8
SHA256: a0804279a18850ce7af0683da3539c3b74585815fc4ef548576359efc656cf8f

C2:
IP: 185.172.128.70
Port: 3808
Proto: net.tcp://

Keys:
Dropper: 53d04c3483df08e8b3450f4c6cfa9b3d084f819249b14403e63efc9e1a6deb16
AES Key: OFhBENNkSFZaYyg2w6yQi5Mrqn7ypiPXDrim64w/FM0=
AES IV: w0EfpMUF62taB/d5TPeCXQ==