Un ransomware sophistiqué a frappé Novatech Industries, chiffrant les données critiques du projet Pulsar. L'analyse forensique du malware devient urgente pour récupérer les informations stratégiques.
L'équipe de sécurité a réussi à isoler le binaire du ransomware ainsi que plusieurs fichiers chiffrés contenant des données sensibles du projet. Les traces réseau révèlent des communications suspectes avec un serveur de commande et contrôle.
L'équipe de réponse à incident de Novatech Industries a isolé un ransomware qui a ciblé les données sensibles du projet Pulsar. L'attaque, qui a eu lieu le 12 septembre 2025, a permis le chiffrement et l'exfiltration partielle de fichiers techniques stratégiques.
L'équipe a récupéré le binaire du malware, plusieurs fichiers chiffrés du projet ainsi qu'une capture réseau des communications établies avec le serveur de commande et contrôle. Cette capture a été automatiquement générée par l'IDS (*Intrusion Detection System*) de l'entreprise au début de l'attaque, mais certaines communications ont été bloquées par les règles de détection tandis que d'autres sont passées entre les mailles du filet et ont été capturées.
Votre mission est d'analyser le ransomware pour comprendre son mécanisme de chiffrement, récupérer les clés cryptographiques, déchiffrer les fichiers compromis et extraire les informations critiques qu'ils contiennent pour évaluer l'ampleur des données exposées.
Dans cet exercice, nous devons analyser un ransomware et déchiffrer les fichiers qu'il a chiffrés. Nous disposons de :
- Le binaire du ransomware (badcatlocker.exe)
- Un PCAP contenant le trafic réseau
- Trois fichiers chiffrés avec l'extension .encrypted
├── badcatlocker.exe # Binaire du ransomware
├── IDS_detection.pcap # Capture réseau
├── encrypted_files/
│ ├── pulsar_config.zip.encrypted
│ ├── PULSAR_Engine_Blueprint_2025-09-12_rev2.png.encrypted
│ └── PULSAR_Thruster_assembly_phase_Specs_v3.7-FINAL.pdf.encrypted
Ouvrir le PCAP dans Wireshark révèle des échanges TCP entre un client et un serveur C&C. Cependant, les payloads semblent obfusqués.
On remarque cependant que les 4 premiers octets de chaque payload sont constants : 1b 69 24 df.
Importer le binaire dans IDA et l'analyser.
On voit dans les strings ce qui confirme ce que l’on pense : il s’agit bien d’un ransomware.

On peut remonter les fonctions jusqu’à la principale en choisissant une string qui a l'air utilisée par le ransomware et qui ne vient pas d'une librairie standard, puis en utilisant la fonction List all cross references pour voir les appels et ainsi de suite. (Il y a d’autres méthodes pour arriver à retrouver la fonction main, sûrement des meilleurs et plus directes).
On arrive sur cette fonction :

On double-clique sur la première fonction appelée : sub_401F90.

Le code récupère la date système et la compare à un entier 21450901.
10000 * Année place l'année au début (ex: 20250000). Le calcul produit une date au format YYYYMMDD.RegOpenKeyExW) et une comparaison de GUID (sub_406AD0(Data, a00000000123412)). Cela signifie que le malware cible une machine spécifique.On peut renommer la fonction CheckTrigger. Il s'agit d'une vérification pour que le binaire ne s'exécute pas en dehors du contexte de l'exercice.
On revient au main et on entre dans la deuxième fonction : sub_402090.

Cette fonction orchestre toute la logique du ransomware.
La première ligne crée un mutex nommé Global\BadCatLockerMutex. Si le mutex existe déjà (code d'erreur 183), cela signifie qu'une instance du ransomware est déjà en cours d'exécution, et la fonction retourne 0 pour éviter une double infection.
On peut renommer la fonction InitializeRansomware.
La fonction appelle ensuite sub_401000, qui semble vérifier l'environnement d'exécution :
int AntiDebug()
{
DWORD TickCount; // [esp+10h] [ebp-4A0h]
unsigned int i; // [esp+20h] [ebp-490h]
HANDLE hSnapshot; // [esp+24h] [ebp-48Ch]
PROCESSENTRY32W pe; // [esp+28h] [ebp-488h] BYREF
_DWORD v5[5]; // [esp+254h] [ebp-25Ch] BYREF
__int16 v6; // [esp+268h] [ebp-248h]
_BYTE v7[42]; // [esp+26Ah] [ebp-246h] BYREF
int v8; // [esp+294h] [ebp-21Ch]
int v9; // [esp+298h] [ebp-218h]
int v10; // [esp+29Ch] [ebp-214h]
int v11; // [esp+2A0h] [ebp-210h]
int v12; // [esp+2A4h] [ebp-20Ch]
__int16 v13; // [esp+2A8h] [ebp-208h]
_BYTE v14[42]; // [esp+2AAh] [ebp-206h] BYREF
int v15; // [esp+2D4h] [ebp-1DCh]
int v16; // [esp+2D8h] [ebp-1D8h]
int v17; // [esp+2DCh] [ebp-1D4h]
int v18; // [esp+2E0h] [ebp-1D0h]
int v19; // [esp+2E4h] [ebp-1CCh]
__int16 v20; // [esp+2E8h] [ebp-1C8h]
_BYTE v21[42]; // [esp+2EAh] [ebp-1C6h] BYREF
int v22; // [esp+314h] [ebp-19Ch]
int v23; // [esp+318h] [ebp-198h]
int v24; // [esp+31Ch] [ebp-194h]
int v25; // [esp+320h] [ebp-190h]
_BYTE v26[48]; // [esp+324h] [ebp-18Ch] BYREF
int v27; // [esp+354h] [ebp-15Ch]
int v28; // [esp+358h] [ebp-158h]
int v29; // [esp+35Ch] [ebp-154h]
int v30; // [esp+360h] [ebp-150h]
int v31; // [esp+364h] [ebp-14Ch]
_BYTE v32[44]; // [esp+368h] [ebp-148h] BYREF
int v33; // [esp+394h] [ebp-11Ch]
int v34; // [esp+398h] [ebp-118h]
int v35; // [esp+39Ch] [ebp-114h]
int v36; // [esp+3A0h] [ebp-110h]
int v37; // [esp+3A4h] [ebp-10Ch]
int v38; // [esp+3A8h] [ebp-108h]
int v39; // [esp+3ACh] [ebp-104h]
int v40; // [esp+3B0h] [ebp-100h]
int v41; // [esp+3B4h] [ebp-FCh]
int v42; // [esp+3B8h] [ebp-F8h]
int v43; // [esp+3BCh] [ebp-F4h]
int v44; // [esp+3C0h] [ebp-F0h]
int v45; // [esp+3C4h] [ebp-ECh]
int v46; // [esp+3C8h] [ebp-E8h]
int v47; // [esp+3CCh] [ebp-E4h]
int v48; // [esp+3D0h] [ebp-E0h]
_BYTE v49[42]; // [esp+3D4h] [ebp-DCh] BYREF
int v50; // [esp+3FEh] [ebp-B2h]
int v51; // [esp+402h] [ebp-AEh]
int v52; // [esp+406h] [ebp-AAh]
int v53; // [esp+40Ah] [ebp-A6h]
int v54; // [esp+40Eh] [ebp-A2h]
__int16 v55; // [esp+412h] [ebp-9Eh]
_BYTE v56[32]; // [esp+414h] [ebp-9Ch] BYREF
int v57; // [esp+434h] [ebp-7Ch]
int v58; // [esp+438h] [ebp-78h]
int v59; // [esp+43Ch] [ebp-74h]
int v60; // [esp+440h] [ebp-70h]
int v61; // [esp+444h] [ebp-6Ch]
int v62; // [esp+448h] [ebp-68h]
int v63; // [esp+44Ch] [ebp-64h]
int v64; // [esp+450h] [ebp-60h]
int v65; // [esp+454h] [ebp-5Ch]
int v66; // [esp+458h] [ebp-58h]
int v67; // [esp+45Ch] [ebp-54h]
int v68; // [esp+460h] [ebp-50h]
int v69; // [esp+464h] [ebp-4Ch]
__int16 v70; // [esp+468h] [ebp-48h]
_BYTE v71[42]; // [esp+46Ah] [ebp-46h] BYREF
CPPEH_RECORD ms_exc; // [esp+498h] [ebp-18h]
if ( IsDebuggerPresent() )
{
dword_424C0C = 1;
return 0;
}
else
{
ms_exc.registration.TryLevel = 0;
if ( NtCurrentPeb()->BeingDebugged )
{
dword_424C0C = 1;
ms_exc.registration.TryLevel = -2;
return 0;
}
else
{
ms_exc.registration.TryLevel = -2;
TickCount = GetTickCount();
Sleep(0x3E8u);
if ( GetTickCount() - TickCount >= 0x384 )
{
v5[0] = dword_423000;
v5[1] = dword_423004;
v5[2] = dword_423008;
v5[3] = dword_42300C;
v5[4] = dword_423010;
v6 = word_423014;
sub_4044A0(v7, 0, 42);
v8 = dword_423018;
v9 = dword_42301C;
v10 = dword_423020;
v11 = dword_423024;
v12 = dword_423028;
v13 = word_42302C;
sub_4044A0(v14, 0, 42);
v15 = dword_423030;
v16 = dword_423034;
v17 = dword_423038;
v18 = dword_42303C;
v19 = dword_423040;
v20 = word_423044;
sub_4044A0(v21, 0, 42);
v22 = dword_423048;
v23 = dword_42304C;
v24 = dword_423050;
v25 = dword_423054;
sub_4044A0(v26, 0, 48);
v27 = dword_423058;
v28 = dword_42305C;
v29 = dword_423060;
v30 = dword_423064;
v31 = dword_423068;
sub_4044A0(v32, 0, 44);
v33 = dword_42306C;
v34 = dword_423070;
v35 = dword_423074;
v36 = dword_423078;
v37 = dword_42307C;
v38 = dword_423080;
v39 = 0;
v40 = 0;
v41 = 0;
v42 = 0;
v43 = 0;
v44 = 0;
v45 = 0;
v46 = 0;
v47 = 0;
v48 = 0;
qmemcpy(v49, aImmunitydebugg, sizeof(v49));
v50 = 0;
v51 = 0;
v52 = 0;
v53 = 0;
v54 = 0;
v55 = 0;
qmemcpy(v56, aCheatengineExe, sizeof(v56));
v57 = 0;
v58 = 0;
v59 = 0;
v60 = 0;
v61 = 0;
v62 = 0;
v63 = 0;
v64 = 0;
v65 = dword_4230D0;
v66 = dword_4230D4;
v67 = dword_4230D8;
v68 = dword_4230DC;
v69 = dword_4230E0;
v70 = word_4230E4;
sub_4044A0(v71, 0, 42);
hSnapshot = CreateToolhelp32Snapshot(2u, 0);
if ( hSnapshot != (HANDLE)-1 )
{
pe.dwSize = 556;
if ( Process32FirstW(hSnapshot, &pe) )
{
do
{
for ( i = 0; i < 9; ++i )
{
if ( !sub_40D000(pe.szExeFile, &v5[16 * i]) )
{
CloseHandle(hSnapshot);
dword_424C0C = 1;
return 0;
}
}
}
while ( Process32NextW(hSnapshot, &pe) );
}
CloseHandle(hSnapshot);
}
return 1;
}
else
{
dword_424C0C = 1;
return 0;
}
}
}
}
On voit en effet plusieurs techniques anti-debugging :
- IsDebuggerPresent et vérification du PEB.
- Timing checks avec GetTickCount et Sleep.
On voit en effet deux appels à GetTickCount, séparés par un Sleep(1000). Si le temps écoulé est inférieur à 900 ms, cela indique probablement la présence d'un debugger qui modifie donc le timing.
CreateToolhelp32Snapshot// Préparation des chaînes (obfusquées ou stack strings)
// v5, v7, v14... contiennent des noms de processus chiffrés/cachés
sub_4044A0(v7, 0, 42); // Probablement une fonction de déchiffrement/nettoyage de string
// ...
qmemcpy(v49, aImmunitydebugg, sizeof(v49)); // "ImmunityDebugger"
qmemcpy(v56, aCheatengineExe, sizeof(v56)); // "CheatEngine.exe"
hSnapshot = CreateToolhelp32Snapshot(2u, 0); // Liste les processus
// ...
Process32FirstW(hSnapshot, &pe);
do {
for ( i = 0; i < 9; ++i ) {
// Comparaison du nom du processus (szExeFile) avec la blacklist
if ( !sub_40D000(pe.szExeFile, &v5[16 * i]) ) {
// Debugger trouvé !
return 0;
}
}
} while ( Process32NextW(...) );
Le malware parcourt la liste de tous les processus en cours d'exécution (CreateToolhelp32Snapshot).
Il compare chaque nom avec une black list.
On voit explicitement des restes de chaînes comme ImmunityDebugger et CheatEngine dans les variables locales ou les données statiques.
Si un de ces outils tourne, le malware s'arrête.
On peut renommer la fonction AntiDebug.
On revient à InitializeRansomware et on entre dans la fonction suivante sub_4014E0 dont le contenu est assez clair :

Le code utilise l'API Windows Cryptography Next Generation (CNG) pour initialiser un fournisseur d'algorithme cryptographique.
On voit dans les données associées à cette fonction :

Le code ouvre donc un fournisseur d'algorithme pour AES, puis configure la propriété de mode de chiffrement en CBC (Cipher Block Chaining).
On peut renommer la fonction InitializeCrypto.
On revient à InitializeRansomware et on entre dans la fonction suivante : sub_4025E0.

Cette fonction initialise la communication réseau avec le serveur C&C. On peut renommer la fonction EstablishC2Connection.
On peut voir l'initialisation de Winsock avec WSAStartup, la création d'un socket TCP, et la tentative de connexion à l'adresse IP et au port spécifiés : pszAddrString et word_424BE0 (95.163.32.45:49213).
On voit aussi des appels à setsockopt pour configurer des options de socket, probablement des timeouts.
Enfin, si la connexion réussit, deux autres fonctions sont appelées : sub_402750 et sub_4027F0.

Cette fonction prépare et envoie un premier paquet au serveur C2. On la renomme SendHandshake.
On ne peut pas s'avancer encore sur son contenu mais on remarque que les 4 premiers octets envoyés sont 1313822273, soit 0x4E4F5641 en hexadécimal, ce qui correspond à la chaîne ASCII NOVA.
On a ensuite 0x1000 (4096 en décimal), 2 bytes nuls, puis on voit que le buffer est complété avec deux blocs de 16 octets générés aléatoirement via sub_4017D0 (que l'on va renommer GenerateRandomBytes car elle appelle simplement BCryptGenRandom).

Il semblerait que ce soit une structure de packets mais on n'en sait pas plus pour l'instant.
La fonction appelle ensuite sub_403250 (que l'on va renommer SendEncryptedPacket, on va voir pourquoi) pour envoyer les données, puis (sub_402EC0 que l'on va renommer WaitForAcknowledge, on va voir pourquoi) pour attendre une réponse.

et

SendEncryptedPacket est en fait un "wrapper" autour de send(). Elle prend un buffer de données en clair (a1) et sa taille (len).
Au lieu d'envoyer les données directement, elle :
sub_406A20, que l'on a renommé MallocWrapper, car elle appelle ensuite sub_40D3C0 que l'on renomme en SafeHeapAlloc).sub_4031D0 pour modifier ce buffer. C'est là qu'on trouve quelque chose d'intéressant !Si on se penche sur la fonction sub_4031D0, on voit qu'elle applique un XOR avec une clé fixe de 16 octets :

On peut renommer cette fonction XORBuffer.
La clé du XOR est stockée à l'adresse 0x41B2A0 :
5A 7F 2B 91 3C 8E 45 D2 69 A7 1F B4 73 C8 54 9D

WaitForAcknowledge attend une réponse du serveur avec recv et vérifie que les 4 premiers octets sont 0x4E4F5641 (NOVA) et que les 4 octets suivants sont 0x2000 (8192 en décimal). Valeur différente du 0x1000 envoyé précédemment. La fonction appelle sub_4032F0 (que l'on va renommer ReceiveEncryptedPacket, on va voir pourquoi) pour recevoir les données.

et sub_403230 (que l'on va renommer XORDecryptBuffer) :

La fonction applique le même XOR que pour l'envoi (l'opération XOR est réversible).
Si on remonte un peu dans l'appel des fonctions, on voit que EstablishC2Connection, après avoir appelé SendHandshake, appelle sub_4027F0 si le handshake réussit.

On voit que cette fonction collecte des informations sur la machine infectée (nom de l'ordinateur, nom de l'utilisateur, un GUID généré par sub_403060), puis construit une chaîne formatée contenant ces informations.
En effet la fonction sub_403060 (que l'on va renommer GetMachineGUID) génère un GUID unique :

Cette fonction tente d'abord de lire le MachineGuid dans le registre Windows (HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography\MachineGuid). C'est un identifiant unique généré par Windows à l'installation du système.
Si la lecture échoue, la fonction génère un GUID de secours (fallback) en combinant :
- Le nom de l'ordinateur
- Le nom de l'utilisateur
- Un timestamp (via sub_4025C0)
Le format du fallback est : "FALLBACK-COMPUTER-USER-TIMESTAMP".
On peut renommer cette fonction GetMachineGUID.
La fonction sub_4025C0 (qui appelle sub_406A5E) récupère le timestamp Unix actuel :


Cette fonction convertit un FILETIME Windows en timestamp Unix : elle soustrait l’offset 116444736000000000 (différence d’époques) puis divise par 10^7 pour obtenir les secondes depuis 1970.
On peut renommer sub_4025C0 en GetUnixTimestamp.
Enfin, la fonction sub_401950 calcule un checksum des données :

C'est un algorithme de hachage simple qui itère sur chaque octet des données et effectue des opérations XOR et de décalage. La valeur initiale -1431677611 (soit 0xAAAA5555 en hexadécimal) sert de "seed".
On peut renommer cette fonction CalculateChecksum.
Après l'envoi des informations, la fonction appelle WaitForAcknowledge que l'on a vu plus haut pour attendre la réponse du serveur.
En résumé, la fonction sub_4027F0 (que l'on renomme RegisterVictim) :
1. Récupère le GUID de la machine
2. Récupère le nom de l'ordinateur et de l'utilisateur
3. Récupère le timestamp actuel
4. Construit une chaîne : "GUID|...|COMPUTER|...|USER|...|TIME|..."
5. Prépare un paquet NOVA avec le Type 0x1006
6. Calcule un checksum de cette chaîne
7. Envoie le header (48 octets) puis la chaîne
8. Attend une réponse du serveur (Type 0x2000)
Cette fonction permet au serveur C2 d'enregistrer et d'identifier chaque victime de manière unique.
On se perd assez facilement dans les fonctions du ransomware mais si on récapitule pour l'instant :
InitializeRansomware :AntiDebug pour vérifier l'absence de débogueurInitializeCrypto pour configurer AES-CBCEstablishC2Connection pour se connecter au serveur C2SendHandshake pour envoyer un paquet de handshake NOVA (Type 0x1000)0x2000)RegisterVictim pour envoyer les infos de la machine (Type 0x1006)Send32BytePayload pour envoyer 32 octets aléatoires (Type 0x1004)0x2000)A ce niveau, on comprend qu'il y a un protocole réseau custom entre le ransomware et son serveur C2, basé sur des paquets qui ont subi un XOR avec une clé fixe.
On a aussi vu les valeurs 0x1000 (4096) et 0x2000 (8192), 0x1006 (4102) qui semblent être des types de messages.
Re-continuons l'analyse de la fonction principale InitializeRansomware.

Après EstablishC2Connection, la fonction InitializeRansomware appelle sub_402BE0 :

Cette fonction suit exactement le même schéma que les précédentes en envoyant un paquet NOVA au serveur C2 :
- Un header de 48 octets (v1)
- Magic bytes 1313822273 ("NOVA")
- Nouveau type de paquet : 4100 (soit 0x1004 en hexadécimal)
- Taille du payload : 32 octets
- Checksum du payload : CalculateChecksum(&pbBuffer, 0x20)
- deux valeurs aléatoires de 16 octets chacune
- Envoi du header puis du payload (&pbBuffer)
- Attente d'accusé de réception (avec WaitForAcknowledge)
En suivant les cross-references de pbBuffer, on voit qu'il est généré dans EstablishC2Connection via GenerateRandomBytes(&pbBuffer, 0x20). Il s'agit donc de 32 octets aléatoires générés au début de la connexion C2.

On peut renommer cette fonction Send32BytePayload.
Pour le moment, on ne sait pas à quoi servent ces 32 octets aléatoires. Les types de paquets que l'on a identifiés sont :
- 0x1000 : Handshake
- 0x1006 : Registration
- 0x1004 : Données de 32 octets (rôle inconnu pour l'instant)
On peut renommer sub_402BE0 en Send32BytePayload.
Après Send32BytePayload, la fonction InitializeRansomware appelle sub_4033E0 :

Cette fonction orchestre l'installation de mécanismes de persistance via 3 sous fonctions :
sub_403430() (Registry Run Key) :

HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Sysupdatesub_403500() (Disable Task Manager) :

HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\DisableTaskMgr = 1sub_403580() (System Directory Copy) :

%SystemDir%\sysupdate.exe (Hidden + ReadOnly)On peut renommer sub_4033E0 en InstallPersistence, sub_403430 en AddRegistryRunKey, sub_403500 en DisableTaskManager, et sub_403580 en CopyToSystemDirectory.
Après l'installation de la persistence, la fonction InitializeRansomware appelle sub_4019A0 :

Cette fonction récupère un chemin via ExpandEnvironmentStringsW (expand les variables d'environnement dans une chaîne, on voit en effet dans la mémoire que les chemins sont par exemple: C:\Users\%USERNAME%\Desktop\), vérifie que le chemin existe, puis appelle sub_401A40 dessus. On peut renommer cette fonction StartFileProcessing.
La fonction sub_401A40 parcourt récursivement les fichiers pour chaque répertoire :

C'est une boucle classique de parcours de répertoire :
- FindFirstFileW / FindNextFileW : Énumération des fichiers
- sub_406AD0 : Compare le nom du fichier avec "." et ".." pour les exclure
- Si c'est un répertoire (dwFileAttributes & 0x10), appel récursif
- Sinon, si sub_401E30 retourne vrai, traitement du fichier via sub_401B70
On peut renommer cette fonction ProcessDirectory.
La fonction sub_406AD0 compare deux chaînes caractère par caractère. C'est une implémentation de wcscmp (comparaison de wide strings). On peut la renommer CompareWideString.
La fonction sub_401E30 détermine si un fichier doit être traité :

Cette fonction vérifie :
1. Le fichier a une extension (sub_404B74 cherche le point)
2. L'extension n'est pas .encrypted (fichier déjà traité)
3. L'extension est dans une liste de 36 extensions cibles (off_4233D8)

off_423468)
On peut renommer cette fonction IsTargetFile.
La fonction sub_401B70 traite un fichier cible :

Cette fonction effectue plusieurs opérations dans l'ordre :
1. Ouvre et lit le contenu du fichier (si < 10 MB)
2. Appelle sub_4029C0 pour traiter le contenu
3. Appelle sub_401880 avec le chemin du fichier, pbBuffer (les 32 octets générés au début), et un buffer pbSecret
4. Appelle sub_401810 pour générer 16 octets dans v8
5. Appelle sub_402CA0 avec le chemin, pbSecret, et v8
6. Appelle sub_401550 pour le traitement final
7. Renomme le fichier en .encrypted
8. Incrémente un compteur et attend 30 secondes
On peut renommer cette fonction ProcessFile.
La fonction sub_4029C0 envoie les métadonnées du fichier au C2 :

Cette fonction envoie :
- Un paquet Type 0x1001 avec les métadonnées formatées : "FILE|path|SIZE|..."
- Puis le contenu du fichier découpé en blocs de 8192 octets (Type 0x1002 (4098))
On peut renommer cette fonction SendFileToC2.
La fonction sub_401880 effectue une opération sur les données :

Cette fonction :
1. Convertit le chemin du fichier en chaîne ASCII
2. Calcule un checksum/hash de ce chemin (4 octets)
3. Pour chaque octet de 0 à 31 :
- Prend l'octet i de a2 (qui est pbBuffer, les 32 octets aléatoires générés au début)
- Le XOR avec l'octet (i % 4) du hash du chemin
- Stocke le résultat dans a3 (qui est pbSecret)
C'est une dérivation de clé : pbSecret[i] = pbBuffer[i] XOR PathHash[i % 4].
On peut renommer cette fonction DeriveFileKey.
La fonction sub_401810 génère des octets aléatoires via srand + rand et utilise sub_4014C0 qui est un alias de GetUnixTimestamp (déjà analysée) :

L'IV est donc pseudo-aléatoire basé sur le timestamp Unix actuel au moment du traitement du fichier.
On peut renommer cette fonction GeneratePseudoRandomBytes (IV).
La fonction sub_402CA0 envoie des informations au C2 :

Cette fonction envoie un paquet Type 0x1005 (4101) avec le contenu suivant :
- La taille du chemin (4 octets)
- Le chemin du fichier (variable)
- La clé dérivée pbSecret (32 octets)
- L'IV généré (16 octets)
On peut renommer cette fonction SendFileKeyToC2.
Enfin, la fonction sub_401550 effectue le chiffrement AES :

Cette fonction :
1. Lit le contenu du fichier
2. Génère une clé AES à partir de pbSecret (32 octets)
3. Applique un padding PKCS7
4. Chiffre avec AES-256-CBC en utilisant l'IV a3 (16 octets)
5. Écrase le fichier avec : [IV 16 octets][Données chiffrées]
On peut renommer cette fonction EncryptFileAES.
Le ransomware va donc parcourir récursivement les répertoires cibles, et pour chaque fichier éligible, il va effectuer les étapes suivantes :
0x1001 + 0x1002)FileKey = pbBuffer XOR Hash(FilePath)0x1005)[IV][Données chiffrées].encryptedLes deux dernières fonctions de InitializeRansomware sont sub_401D20 et sub_402F30.
La fonction sub_401D20 crée des notes de rançon :

Cette fonction écrit le contenu de lpBuffer (la note de rançon située à l'adresse 0x423628) dans 3 fichiers texte :
- Bureau de l'utilisateur
- Dossier Documents
- Racine C:\

Le contenu de la note contient les instructions de paiement (0.5 BTC), un email de contact, et un ID unique.

On peut renommer cette fonction CreateRansomNote.
La fonction sub_402F30 envoie un rapport final au C2 :

Cette fonction construit un message de complétion contenant :
- Le nombre total de fichiers chiffrés (dword_424C10, incrémenté dans ProcessFile)
- Le timestamp Unix de fin
Elle envoie ce rapport au C2 avec le Type 0x1003, puis attend une réponse de 4 octets.
On peut renommer cette fonction SendFinalReport.
On a reverse la fonction principale InitializeRansomware et toutes ses sous-fonctions appelées.
Après InitializeRansomware, la fonction main appelle enfin sub_402140 pour le nettoyage final :

Cette fonction :
1. Ferme le socket C2 et le mutex
2. Crée un fichier batch %TEMP%\cleanup.bat qui s'auto-supprime après 3s
3. Lance le batch et termine
On peut renommer cette fonction Cleanup.
Le ransomware est entièrement reversé !
On a donc maintenant toutes les informations qu'il faut pour comprendre le protocole réseau utilisé par ce ransomware.
Le protocole utilise les types suivants :
- 0x1000 : Handshake initial
- 0x1001 : Métadonnées de fichier (chemin, taille, timestamp)
- 0x1002 : Bloc de données de fichier (8192 octets max)
- 0x1003 : Rapport final (nombre de fichiers, timestamp)
- 0x1004 : Payload de 32 octets (rôle à déterminer)
- 0x1005 : Clé dérivée + IV pour un fichier
- 0x1006 : Enregistrement de la victime (GUID, COMPUTER, USER, TIME)
- 0x2000 : Accusé de réception du serveur
Chaque paquet suit le format général :
- Magic bytes : 1313822273 ("NOVA")
- Type (ex: 0x1001)
- Taille du payload
- Checksum du payload
- Clé de session
- IV
Et est obfusqué via un XOR avec une clé fixe de 16 octets que nous avons extraite.
On a aussi compris que la clé de 32 octets envoyée dans le paquet 0x1004 est générée au début de la connexion C2 et est utilisée pour dériver une clé unique par fichier via un XOR avec un hash du chemin du fichier.
Le ransomware envoie les informations de la victime, puis pour chaque fichier cible, il envoie les métadonnées et le contenu, dérive une clé unique, envoie cette clé au C2, puis chiffre le fichier localement.
La clé de la résolution réside dans la vulnérabilité dans l'implementation cryptographique : la clé de chaque fichier est dérivée de manière prévisible. En effet, l'IV est généré de manière pseudo-aléatoire basée sur le timestamp, et la clé de fichier est dérivée via un simple XOR avec un hash du chemin.
Si on connaît la clé initiale (celle du paquet 0x1004, qu'on peut appeler la "master key"), et qu'on a une plage de temps raisonnable pour deviner l'IV (puisqu'il est basé sur le timestamp), on peut retrouver la clé de chaque fichier et déchiffrer les fichiers. Et c'est le cas, car le fichier statemend.md nous informe que l'attaque a eu lieu le 12 septembre 2025.
Il s'agit en effet de tester linéairement les valeurs possibles de l'IV (basées sur les timestamps probables) et d'essayer de déchiffrer un fichier chiffré jusqu'à obtenir un résultat cohérent (par exemple, en vérifiant le padding PKCS7 ou les magic bytes pour différents types de fichiers).
Commençons par déchiffrer le contenu des paquets réseau capturés pour analyser les données envoyées au serveur C2.
Voici un script python simple qui permet de faire le XOR inversé sur les paquets capturés dans le fichier PCAP vers un fichier PCAP désobfusqué.
#!/usr/bin/env python3
import sys
from scapy.all import rdpcap, wrpcap, TCP, Raw
XOR_KEY = bytes([0x5A,0x7F,0x2B,0x91,0x3C,0x8E,0x45,0xD2,0x69,0xA7,0x1F,0xB4,0x73,0xC8,0x54,0x9D])
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <pcap_file>")
sys.exit(1)
pcap_in = sys.argv[1]
pcap_out = pcap_in.replace('.pcap', '_dexor.pcap')
packets = rdpcap(pcap_in)
for pkt in packets:
if TCP in pkt and pkt[TCP].payload:
data = bytes(pkt[TCP].payload)
dexor_data = bytes(b ^ XOR_KEY[i % 16] for i, b in enumerate(data))
pkt[TCP].payload = Raw(load=dexor_data)
wrpcap(pcap_out, packets)
print(f"Output XORé → {pcap_out}")
Une fois ce fichier obtenu, on peut écrire un script pour extraire la master key (paquet Type 0x1004). Voici un script python simple qui fait cela (même s'il est possible de le faire à la main facilement avec Wireshark) :
#!/usr/bin/env python3
import struct
import sys
from scapy.all import rdpcap, TCP
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <pcap_file>")
sys.exit(1)
pcap_file = sys.argv[1]
def parse_nova_header(data):
"""Parse un header NOVA désobfusqué"""
if len(data) < 48:
return None
magic, packet_type, data_length, checksum = struct.unpack('<IIII', data[:16])
session_key = data[16:32]
iv = data[32:48]
return {
'magic': magic,
'packet_type': packet_type,
'data_length': data_length,
'checksum': checksum,
'session_key': session_key,
'iv': iv
}
def analyze_pcap(pcap_file):
"""Analyse le PCAP et extrait les données désobfusquées"""
print(f"[+] Analyzing PCAP: {pcap_file}")
packets = rdpcap(pcap_file)
master_key = None
for i, packet in enumerate(packets):
if TCP in packet and hasattr(packet[TCP], 'load'):
payload = bytes(packet[TCP].load)
# Parser le header NOVA
header = parse_nova_header(payload)
if header and header['magic'] == 0x4E4F5641: # "NOVA"
print(f"[+] Packet #{i} - Type: 0x{header['packet_type']:04X} (len: {header['data_length']})")
# Chercher le master key (type 0x1004)
if header['packet_type'] == 0x1004 and header['data_length'] == 32:
if len(payload) >= 48 + 32:
master_key = payload[48:48+32]
print(f"[+] Master key found: {master_key.hex()}")
return master_key
if __name__ == "__main__":
master_key = analyze_pcap(pcap_file)
if master_key:
print(f"\n[*] Final Master Key: {master_key.hex()}")
else:
print("\n[-] No master key found in PCAP")
On exécute ce script sur le fichier PCAP désobfusqué pour extraire la master key :

On a maintenant tout ce qu'il nous faut pour déchiffrer les fichiers.
Pour cela, on peut écrire un programme C qui implémente le processus de dérivation de clé et de déchiffrement AES-256-CBC avec vérification de l'IV et suppression du padding PKCS7.
C'est là que la compréhension du reverse-engineering que nous avons fait est importante, car elle nous permet de reproduire exactement le même processus que le ransomware utilise pour chiffrer les fichiers (étant donné que le chiffrement AES-256-CBC est symétrique, le déchiffrement est identique au chiffrement, à part l'inversion de l'opération).
Voici un exemple de code C complet pour déchiffrer les fichiers :
// badcat_decryptor.c - Version bruteforce seconde par seconde
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <windows.h>
#include <bcrypt.h>
#pragma comment(lib, "bcrypt.lib")
#define AES_KEY_SIZE 32
#define AES_IV_SIZE 16
#define START_TIMESTAMP 1757635200 // 12 sept 2025 00:00 UTC
#define END_TIMESTAMP 1757721600 // 13 sept 2025 00:00 UTC (86400 secondes)
static const BYTE master_key[AES_KEY_SIZE] = {
0xDB,0x11,0xBA,0x9C,0xE5,0x41,0x6D,0x8A,0xC4,0x1E,0x72,0xD3,0x5B,0x78,0xF9,0x0C,
0x7E,0x2D,0xB1,0x14,0x43,0x58,0xAF,0xC7,0x20,0xE6,0x4B,0xD8,0x15,0x9A,0x73,0xF4
};
DWORD calc_checksum(const BYTE* data, DWORD size) {
DWORD checksum = 0xAAAA5555;
for (DWORD i = 0; i < size; i++) {
checksum = (checksum << 1) ^ data[i] ^ (i * 0x1234);
}
return checksum;
}
void gen_file_key(const char* virtual_path, BYTE* file_key) {
DWORD path_hash = calc_checksum((BYTE*)virtual_path, (DWORD)strlen(virtual_path));
BYTE hash_bytes[4];
*(DWORD*)hash_bytes = path_hash;
for (int i = 0; i < AES_KEY_SIZE; i++) {
file_key[i] = master_key[i] ^ hash_bytes[i % 4];
}
}
void gen_iv(unsigned int timestamp, BYTE* iv) {
srand(timestamp);
for (int i = 0; i < AES_IV_SIZE; i++) {
iv[i] = (BYTE)(rand() % 256);
}
}
void remove_pkcs7_padding(BYTE* data, DWORD* size) {
if (*size == 0) return;
BYTE pad_value = data[*size - 1];
if (pad_value == 0 || pad_value > 16) return;
for (DWORD i = *size - pad_value; i < *size; i++) {
if (data[i] != pad_value) return;
}
*size -= pad_value;
}
int check_magic(BYTE* data, DWORD size) {
if (size < 4) return 0;
return (data[0] == 0x50 && data[1] == 0x4B) || // ZIP
(data[0] == 0x89 && data[1] == 0x50) || // PNG
(data[0] == 0x25 && data[1] == 0x50); // PDF
}
BYTE* load_encrypted_file(const char* path, long* file_size) {
FILE* f = fopen(path, "rb");
if (!f) return NULL;
fseek(f, 0, SEEK_END);
*file_size = ftell(f);
fseek(f, 0, SEEK_SET);
BYTE* data = malloc(*file_size);
fread(data, 1, *file_size, f);
fclose(f);
return data;
}
int try_decrypt(BYTE* enc_file_data, long file_size, const char* virtual_path,
unsigned int timestamp, BYTE** out_plaintext, DWORD* out_size) {
BYTE stored_iv[16];
memcpy(stored_iv, enc_file_data, 16);
BYTE* enc_data = enc_file_data + 16;
DWORD enc_size = file_size - 16;
// Verify IV first (fast rejection)
BYTE expected_iv[16];
gen_iv(timestamp, expected_iv);
if (memcmp(stored_iv, expected_iv, 16) != 0) {
return 0;
}
// Generate file key
BYTE file_key[32];
gen_file_key(virtual_path, file_key);
// AES-CBC decrypt
BCRYPT_ALG_HANDLE hAlg = NULL;
BCRYPT_KEY_HANDLE hKey = NULL;
BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_AES_ALGORITHM, NULL, 0);
BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE, (BYTE*)BCRYPT_CHAIN_MODE_CBC, sizeof(BCRYPT_CHAIN_MODE_CBC), 0);
BCryptGenerateSymmetricKey(hAlg, &hKey, NULL, 0, file_key, 32, 0);
DWORD pt_size = enc_size;
BYTE* plaintext = calloc(pt_size, 1);
DWORD cb_result = pt_size;
BYTE temp_iv[16];
memcpy(temp_iv, stored_iv, 16);
NTSTATUS status = BCryptDecrypt(hKey, enc_data, enc_size, NULL, temp_iv, 16, plaintext, pt_size, &cb_result, 0);
BCryptDestroyKey(hKey);
BCryptCloseAlgorithmProvider(hAlg, 0);
if (!BCRYPT_SUCCESS(status) || cb_result == 0) {
free(plaintext);
return 0;
}
remove_pkcs7_padding(plaintext, &cb_result);
if (check_magic(plaintext, cb_result)) {
*out_plaintext = plaintext;
*out_size = cb_result;
return 1;
}
free(plaintext);
return 0;
}
int main(int argc, char* argv[]) {
if (argc != 2) {
printf("Usage: %s <input_directory>\n", argv[0]);
printf("Example: %s challenge_files\n", argv[0]);
return 1;
}
printf("BadCatLocker Decryptor\n");
printf("=====================================\n\n");
struct {
const char* encrypted_filename;
const char* virtual_path;
} targets[] = {
{ "pulsar_config.zip.encrypted",
"C:\\srv\\pulsar\\storage\\config\\pulsar_config.zip" },
{ "PULSAR_Engine_Blueprint_2025-09-12_rev2.png.encrypted",
"C:\\srv\\pulsar\\storage\\blueprints\\PULSAR_Engine_Blueprint_2025-09-12_rev2.png" },
{ "PULSAR_Thruster_assembly_phase_Specs_v3.7-FINAL.pdf.encrypted",
"C:\\srv\\pulsar\\storage\\specs\\PULSAR_Thruster_assembly_phase_Specs_v3.7-FINAL.pdf" }
};
int success_count = 0;
for (int t = 0; t < 3; t++) {
char full_path[1024];
snprintf(full_path, sizeof(full_path), "%s\\%s", argv[1], targets[t].encrypted_filename);
printf("[+] Processing: %s\n", targets[t].encrypted_filename);
long file_size;
BYTE* enc_data = load_encrypted_file(full_path, &file_size);
if (!enc_data) {
printf(" ERROR: Cannot read file\n\n");
continue;
}
printf(" Bruteforcing timestamp...\n");
int found = 0;
unsigned int found_ts = 0;
BYTE* plaintext = NULL;
DWORD plaintext_size = 0;
// Bruteforce each second of Sept 12, 2025
for (unsigned int ts = START_TIMESTAMP; ts < END_TIMESTAMP && !found; ts++) {
if ((ts - START_TIMESTAMP) % 10000 == 0) {
printf(" Progress: %d%%\r", (int)(((ts - START_TIMESTAMP) * 100) / 86400));
fflush(stdout);
}
if (try_decrypt(enc_data, file_size, targets[t].virtual_path, ts, &plaintext, &plaintext_size)) {
found = 1;
found_ts = ts;
}
}
if (found) {
// Save decrypted file
char output_path[512];
snprintf(output_path, sizeof(output_path), "DECRYPTED_%s", targets[t].encrypted_filename);
char* dot_pos = strstr(output_path, ".encrypted");
if (dot_pos) *dot_pos = '\0';
FILE* out = fopen(output_path, "wb");
fwrite(plaintext, 1, plaintext_size, out);
fclose(out);
printf(" SUCCESS! Timestamp: %u\n", found_ts);
printf(" Saved: %s (%d bytes)\n", output_path, plaintext_size);
free(plaintext);
success_count++;
} else {
printf(" FAILED: No valid timestamp found\n");
}
free(enc_data);
printf("\n");
}
printf("=====================================\n");
printf("RESULTS: %d/3 files decrypted successfully!\n", success_count);
return 0;
}
On le compile (sur machine Windows), par exemple avec :
cl decrypt_files.c /Fe:badcat_decryptor.exe
Puis on l'exécute :

Il est ensuite possible d'ouvrir les 3 fichiers déchiffrés pour vérifier qu'ils ne sont pas corrompus, et trouver les flags qu'ils contiennent.

Le fichier PULSAR_Thruster_assembly_phase_Specs_v3.7-FINAL.pdf :

Le fichier PULSAR_Engine_Blueprint_2025-09-12_rev2.png :

Le fichier pulsar_config.zip :

Et voilà !