Analyse statique · rançongiciel Go / Windows x64
The Gentlemen
Ce que fait l'échantillon, comment on l'a démonté sans l'exécuter, ce que le chiffrement laisse récupérer, et ce qui reste incertain.
3ab9575225e00a83a4ac2b534da5a710bdcf6eb72884944c437b5fbe5c5c9235Observé lu tel quel dans le fichier Dérivé issu de la décompilation Inféré déduction plausible Non vérifié à confirmer
Les sections marquées • dans l'index ont été ajoutées après la première version : session Hopper, revue du chiffrement, outil de récupération, vérification réseau et comparaison avec l'avis Microsoft.
Résumé
L'échantillon est un exécutable Windows 64 bits écrit en Go, obscurci avec garble. C'est un rançongiciel de la famille The Gentlemen : la note de rançon, le fond d'écran et le marqueur GENTLEMEN ajouté à chaque fichier le disent. Observé
- Format
- PE32+ x86-64, console, 2,96 Mo
- Langage
- Go ≥ 1.24, garble
- Chiffrement
- X25519 + XChaCha20, une clé par fichier
- Déchiffrement sans clé
- Non réalisable : aucune faille cryptographique trouvée
- Gros fichiers (> 1 Mio)
- 27 % chiffrés par défaut, 73 % en clair
- Seule piste pour une clé
- Dump mémoire pris pendant ou juste après l'infection
- Propagation
- SMB, WMI, tâches, services
- C2 / exfiltration dans le binaire
- Aucune : ni DNS, ni connexion, ni client HTTP
- Exfiltration (selon Microsoft)
- Outils séparés des opérateurs : WinSCP ou WebDAV
Garde d'entrée. Le binaire ne fait rien sans --password G7Vz9eyG, valeur codée en dur. Sans elle il affiche « bad args » et s'arrête. Un lancement nu en bac à sable ne montrera donc aucun comportement. Dérivé
Ajouts depuis la première version. Les routines de chiffrement ont été décompilées avec Hopper et recoupées avec Ghidra. Les trois points ouverts sur la clé, le nonce et la proportion chiffrée sont tranchés, sauf l'ordre exact de concaténation, confirmé par Ghidra seul. Un outil de balayage mémoire a été écrit et validé sur un cas synthétique. Un graphe d'appels couvrant les 890 fonctions de bibliothèque renommées confirme l'absence de communication sortante dans le binaire, et l'avis Microsoft attribue l'exfiltration à des outils séparés. Une affirmation de la première version était fausse (net/http n'est pas lié) et a été corrigée. Détails dans les sections • de l'index.
Méthode et choix des outils
Consigne : analyse statique complète avec REA. Le binaire n'a jamais été lancé. Le principe de REA, séparer ce qui est observé, dérivé, inféré et inconnu, a servi de fil rouge pour tout le dossier.
Les outils et ce qu'ils ont apporté
| Outil | Rôle | Pourquoi celui-là |
|---|---|---|
REA inspect-artifact, inspect-pe-resources | Inventaire, sections, ressources | Lecture sans exécution. Résultat : un seul fichier, aucune ressource PE. |
REA inspect-go-binary | Version Go, modules | Test rapide. Il renvoie unknown : premier signe d'obscurcissement. |
| Ghidra 12.1.4 (via REA) | Désassemblage et décompilation | Hopper et Ghidra étaient tous deux utilisables ; Ghidra a été retenu car il tourne sans interface et que REA l'importe dans une base éphémère : les octets de l'exécutable ne sont jamais modifiés. |
| Hopper (via REA) | Recoupement, décompilation de la fonction de chiffrement | Second moteur indépendant. Il confirme les faits clés du rapport Ghidra. Voir Hopper et REA. |
REA analyze, search, decompile | Aperçu, recherche, pseudo-code | 2 490 procédures et 4 415 chaînes reconnues, mais aucun nom Go. |
Serveur MCP REA (open_binary, set_addresses_names, batch_decompile) | Session persistante | Chaque appel CLI réimporte le binaire (~95 s à froid). Une session unique a servi ~930 décompilations. |
rabin2, strings | Imports, sections, chaînes brutes | Les littéraux Go ne sont pas chiffrés : strings a livré note, aide, commandes et URL en quelques secondes. |
Parseur pclntab maison (Python, lecture seule) | Noms de fonctions Go | Ghidra ne retrouvait pas les noms. Le magic de la table était modifié. |
| YARA | Règle de détection | Valide que les marqueurs retenus identifient bien l'échantillon. |
gentlemen_memkey.py (Python, cryptography, numpy) | Recherche de clés dans un dump mémoire | Voir Outil de balayage. |
llvm-objdump + scripts Python de graphe d'appels | Atteignabilité depuis main.*, appelants des API Win32 | Seul moyen de trier ~900 fonctions sans les lire. Voir C2 et exfiltration. Scripts dans le dossier temporaire de la session, non conservés dans le dépôt. |
Le fil de l'analyse
- Extraction isolée
Archive ouverte avec le mot de passe
infecteddans un dossier neuf. Un seul PE en sortie. Observé - Premier tri
Section
.symtabminuscule, imports limités àkernel32, fonctionsmain.*aux noms aléatoires : un binaire Go obscurci avec garble. Inféré - Les chaînes parlent
Usage de la ligne de commande, note de rançon, commandes PowerShell,
vssadmin,schtasks, listes de services. Les chaînes ne sont pas chiffrées, ce qui oriente toute la suite. Observé - Obstacle : pas de noms de fonctions
Ghidra n'expose que
entry. Lapclntabn'est pas à son magic habituel (d10889d7au lieu defffffff1). Un parseur de 40 lignes retrouve les 2 675 fonctions. - Obstacle : coût de chaque appel
~95 s par appel CLI, puis un délai client de 60 s qui coupait les gros lots. Correction : client MCP persistant avec délai long, renommage par lots de 200, décompilation par lots de 15.
- Obstacle : chaînes Go fusionnées
Go ne termine pas ses chaînes par un octet nul, donc Ghidra les colle les unes aux autres. Un post-traitement relit chaque paire (pointeur, longueur) dans le fichier pour restituer le texte exact.
- Lecture ciblée
46 fonctions
main.*lues une à une, puis les routines de chiffrement, les tables de données (listes de processus, services, exclusions) et les constantes. Dérivé - Livrables
Rapport Markdown, règle YARA, note de rançon extraite, carte des noms Go, pseudo-code des fonctions
main.*. - Recoupement avec Hopper
Même binaire ouvert dans Hopper via REA : vue d'ensemble, décompilation de
main.mainet de la propagation latérale, puis des deux variantes de la routine de chiffrement. Détails. - Revue du chiffrement
Recherche de faille cryptographique, calcul exact de la part chiffrée des gros fichiers, étude de ce qui reste en mémoire, outil de balayage. Détails.
Chiffres de l'analyse
Fonctions de la pclntab. Les 934 décompilées excluent le runtime Go, syscall, reflect et sync ; 52 sorties sont vides (méthodes triviales).
Le binaire
| Attribut | Valeur |
|---|---|
| Fichier | 3ab9575225e0…c5c9235.exe, 2 962 944 octets (zip : 1 488 214) |
| MD5 | 4200b46a93c6ab059e2b34ce200c4a5b |
| SHA-1 | 42bcc743c71a9ea083c1c750a398110582796762 |
| Sections | .text 1 Mo · .rdata 1,4 Mo · .data 0,76 Mo · .pdata · .xdata · .idata · .reloc · .symtab |
| Horodatage PE | 0 (1970-01-01), normalisé |
| Entropie | .text 6,26 · .rdata 5,64 · .data 7,54 (JPEG embarqué). Pas de packer. |
| Imports | 46 fonctions de kernel32 ; le reste est résolu à l'exécution |
| Obscurcissement | Noms de paquets et d'identifiants remplacés (hnheoYz = flag, E9lxa7ZzQ = os/exec, DZ__GdSrd = crypto/ecdh…), magic pclntab aléatoire Inféré : garble |
Fonctionnement du rançongiciel
L'ordre des étapes ci-dessous suit main.main (adresse 0x4fab60).
flowchart TD
A["Lancement"] --> B{"--password = G7Vz9eyG ?"}
B -- non --> X["bad args, sortie"]
B -- oui --> C{"LOCKER_BACKGROUND = 1 ?"}
C -- non --> D["Relance détachée du binaire, sortie"]
C -- oui --> E["Attente de --T minutes"]
E --> F["Defender, copies shadow, journaux"]
F --> G["Arrêt de processus et de services"]
G --> H["Persistance"]
H --> I["Décodage de la clé publique de l'attaquant"]
I --> J["Découverte des cibles"]
J --> K["Chiffrement parallèle"]
K --> L["Propagation latérale"]
L --> M["Fond d'écran, auto-suppression"]
main.main. DérivéOptions de la ligne de commande
| Option | Effet |
|---|---|
--password | Obligatoire, doit valoir la constante codée en dur |
--path D1,D2 | Dossiers ou disques ciblés |
--T MIN | Délai avant l'action, en minutes |
--silent | Ne renomme pas les fichiers ; pas de fond d'écran |
--system | Disques locaux, via une tâche planifiée SYSTEM |
--shares | Lecteurs réseau mappés et partages UNC |
--full | Deux phases : --system puis --shares |
--fast, --superfast, --ultrafast | Chiffrement partiel des gros fichiers |
Le code refuse les combinaisons --shares + --system, --shares + --path, --full avec l'une des trois, et deux flags de vitesse ensemble. Dérivé
Découverte des cibles
Disques locaux
Une commande PowerShell liste les volumes (Win32_Volume, volumes partagés de cluster). Les lettres A à Z sont aussi sondées.
Lecteurs mappés
net use par lettre ; une sortie contenant \\ désigne un lecteur réseau.
Partages UNC
Énumération par WNetOpenEnum / WNetEnumResource, et NetServerEnum pour les hôtes du domaine.
Chaque dossier visité reçoit README-GENTLEMEN.txt. Le programme prend la propriété du chemin avec takeown, donne Everyone le contrôle total avec icacls, et retire l'attribut lecture seule. Dérivé
Chiffrement des fichiers
Chaque fichier a sa propre clé. Seule la clé publique de l'attaquant est dans le binaire, donc la clé qui déchiffre n'y est pas.
flowchart LR R["crypto/rand : 32 octets"] --> P["clé privée éphémère"] P -- "X25519 avec le point de base 9" --> E["clé publique éphémère"] P -- "X25519 avec la clé publique de l'attaquant" --> S["secret partagé 32 octets"] S --> K["clé XChaCha20"] E -- "24 premiers octets" --> N["nonce"] K --> C["fichier chiffré"] N --> C E --> F["pied de fichier en base64"]
0x658700 vaut bien 9.Petits fichiers
Jusqu'à 1 Mio (0x100000) : contenu chiffré en entier, un seul flux.
Gros fichiers
Au-delà : trois blocs de même taille (début, milieu, fin), lus et réécrits par pas de 64 Kio. Le dernier octet du nonce est combiné à l'indice du bloc.
Pied de fichier
--eph--<clé publique éphémère, base64>, --marker--GENTLEMEN, GENTLEMEN, puis --ultra--, --super-- ou --fast-- si partiel. Les 32 derniers octets servent aussi à reconnaître un fichier déjà traité.
Renommage
nom.axfsmg, sauf avec --silent.
Proportion chiffrée des gros fichiers
| Option | Texte d'aide | Ratio par bloc (code) | Total chiffré (3 blocs) |
|---|---|---|---|
| (aucune) | non indiqué | 0,09 | 27 % |
--fast | 9 % | 0,03 | 9 % |
--superfast | 3 % | 0,01 | 3 % |
--ultrafast | 1 % | 0,003 | 0,9 % |
Incohérence résolue. La première version signalait un écart entre le texte d'aide et les constantes. Le texte d'aide donne la part totale chiffrée, les constantes sont des ratios par bloc, et il y a trois blocs : 3 × 0,03 = 9 %, 3 × 0,01 = 3 %, 3 × 0,003 = 0,9 %. Le défaut chiffre donc 27 %. Dérivé Voir Faille et clé.
Récupération. La clé privée éphémère n'est jamais écrite sur disque. Sans la clé privée de l'attaquant, aucun déchiffrement n'est possible, et aucune faiblesse cryptographique n'a été trouvée. Les gros fichiers gardent entre 73 % et 99,1 % de leur contenu en clair, ce qui peut aider pour certains formats. Dérivé
Ce qui n'est pas chiffré
Dossiers exclus (24)
Fichiers exclus (19)
Extensions exclues (49)
La note de rançon
Elle est embarquée dans le binaire (1 559 octets). Elle porte un identifiant de victime codé en dur, un contact Tox, une adresse e-mail et un blog .onion, et revendique une exfiltration de données. Cette revendication est celle de l'acteur : le binaire ne la prouve pas. L'e-mail et l'identifiant suggèrent un build préparé pour une victime précise. Inféré
La fonction de chiffrement, décompilée avec Hopper
Deux routines écrivent le pied --eph-- / --marker--. Elles ont été décompilées avec Hopper (procedure_pseudo_code) puis comparées au pseudo-code Ghidra. Le choix entre les deux se fait dans main.ji5ly9 (0x4f9020), qui lit la taille du fichier : taille < 0x100001 appelle la première, sinon la seconde. Dérivé
| Adresse | Nom pclntab | Rôle |
|---|---|---|
0x4f82a0 | main.xSuNBKyhMY | Fichier entier (≤ 1 Mio), 99 lignes de pseudo-code Ghidra |
0x4f8760 | main.vuNqC5r3qmuA | Trois blocs (> 1 Mio), 166 lignes |
Variante fichier entier (0x4f82a0)
- Lecture
Alloue un tampon de la taille du fichier (
makeslice) et le remplit avecReadAt. - Clé privée éphémère
32 octets tirés par
crypto/randdans un tampon de pile mis à zéro au départ. - Deux opérations X25519
La première, avec le point de base, donne la clé publique éphémère. La seconde, avec la clé publique de l'attaquant passée en argument, donne le secret partagé.
- Clé et nonce
Les deux valeurs de 32 octets sont concaténées (
growslicepuismemmove). Le tampon est tranché en[0:0x20]pour la clé et[0x20:0x38]pour le nonce de 24 octets. Aucun hachage entre X25519 et le chiffreur : pas de KDF. - Chiffrement sur place
NewUnauthenticatedCipheravec une clé de 32 octets et un nonce de 24 octets (donc XChaCha20), puisXORKeyStreamsur tout le tampon. - Écriture
Seek(0, 0),Writedu chiffré,Seek(0, 2), puis le pied :--eph--+ base64(clé publique) + saut de ligne,--marker--+ marqueur + saut de ligne, marqueur, etSync.
Variante trois blocs (0x4f8760)
- Même préparation
Clé privée, deux opérations X25519, tampon de 64 octets, découpage clé / nonce.
- Taille d'un bloc
B = (int)(taille × ratio), avec le ratio lu dans ledoubleglobal0x658280. Les offsets sont0,(taille − 3B) / 2 + Bet le double du deuxième. Le troisième bloc finit donc à la fin du fichier. - Boucle sur 3 blocs
Pour chaque indice
i: copie du nonce de 24 octets,nonce[23] ^= i, nouveau chiffreur. Dans un bloc, la lecture par tranches de0x10000octets alimente un flux continu :ReadAt,XORKeyStream,WriteAtau même endroit. - Test d'annulation
La boucle contient des appels
ifaceeqà chaque tour, probablement une vérification d'annulation par contexte. Inféré - Pied et suffixe
Même pied que ci-dessus, puis un suffixe selon le drapeau de vitesse actif.
Constantes décodées
| Valeur | Lecture | Où |
|---|---|---|
0x2d6172746c752d2d + 0x0a2d | --ultra--\n (10 octets) | Suffixe du pied, drapeau DAT_006c7ae0 (ultrafast) |
0x2d72657075732d2d + 0x0a2d | --super--\n (10 octets) | Drapeau DAT_006c7ae8 (superfast) |
0x2d2d747361662d2d + 0x0a | --fast--\n (9 octets) | Drapeau DAT_006c7af0 (fast) |
0x3fb70a3d70a3d70a | 0,09 | Ratio par défaut, écrit dans 0x658280 par main.main |
0x3f9eb851eb851eb8 | 0,03 | Ratio de --fast |
0x3f847ae147ae147b | 0,01 | Ratio de --superfast |
0x3f689374bc6a7efa | 0,003 | Ratio de --ultrafast |
0x100000 | 1 Mio | Seuil entre les deux variantes |
Hopper retrouve ces quatre affectations de ratio dans main.main : le drapeau ultrafast est testé en premier, puis superfast, puis fast, sinon le défaut. Dérivé
Appels résolus par la pclntab
Hopper n'a pas de noms Go : ses procédures s'appellent sub_XXXXXX. La table ci-dessous associe chaque appel au nom lu dans la pclntab et à son rôle probable.
| Hopper | Nom pclntab | Rôle |
|---|---|---|
sub_4efd80 | dCMD4fgvjt.F1xKKWaKBhy | crypto/rand.Read (lecteur par défaut, puis (*bbtdkx8h).Read à 0x4efcc0) |
sub_4f7740 | I_tJndf.iO0q1G0D | Enveloppe X25519 : appelle crypto/ecdh NewPrivateKey (0x4f6c00) puis ECDH (0x4f6b20) |
sub_4f76e0 | I_tJndf.init.0 | Initialise le point de base : pointeur 0x658700, longueur 32. Octets lus : 09 suivi de 31 zéros |
sub_4efec0 | yX3pzc.iZfjiur8l | chacha20.NewUnauthenticatedCipher ; avec un nonce de 24 octets, appelle HChaCha20 (0x4f0e00) |
sub_4f0140 | yX3pzc.(*UbA4Ia0klTWg).XORKeyStream | Chiffrement du flux |
sub_4b5060, 4b54a0, 4b53a0, 4b5800, 4b5f20 | f92qU715.(*TRonwfLfU). ReadAt, WriteAt, Write, Seek, Sync | Méthodes d'un fichier os.File |
sub_4d81a0 | moBNqIpams.(*NGfODVZEs).EncodeToString | Base64 de la clé publique pour le pied |
sub_46c720, 46c800, 4732e0 | runtime.makeslice, growslice, memmove | Allocation, concaténation, copie |
Ordre de concaténation. L'ordre « secret partagé puis clé publique éphémère » (donc clé = secret partagé, nonce = 24 premiers octets de la clé publique) vient du pseudo-code Ghidra : le résultat du second appel X25519 est le tampon de base, la sortie du premier y est copiée ensuite. Hopper confirme la structure (deux valeurs de 32 octets, tranches 0x20 et 0x38, nonce de longueur 0x18) mais ne montre pas l'ordre. Rien n'a été exécuté. Dérivé pour la structure, À confirmer pour l'ordre.
Faille cryptographique et clé de déchiffrement
Verdict. Aucune faille exploitable dans le chiffrement, et aucune clé récupérable à partir du binaire ou d'un fichier chiffré. Les gros fichiers ne sont chiffrés qu'en partie, ce qui permet de récupérer une partie des données. Analyse statique uniquement. Dérivé
Ce qui a été vérifié
| Question | Résultat | Preuve |
|---|---|---|
| La clé privée de l'attaquant est-elle dans le binaire ? | Non. Seule la clé publique fcb11717…1922 y est. | Constante base64 décodée au démarrage |
| Cette clé publique est-elle affaiblie ? | Non. Point valide, hors ordre faible. | Calcul : l·P = 0 (sous-groupe d'ordre premier) et 8·P ≠ 0. Aucun raccourci par un point dégénéré. |
| Le pied divulgue-t-il la clé privée éphémère ? | Non, seulement la clé publique. | Le point de base lu à 0x658700 vaut 9 ; le premier appel X25519 l'utilise |
| L'aléa est-il prévisible ? | Rien de prédictible vu. | Lecteur par défaut de crypto/rand ; chaînes ProcessPrng et bcryptprimitives dans le binaire. L'appel n'a pas été tracé jusqu'à ProcessPrng : indices convergents, pas une preuve. Inféré |
| Un nonce est-il réutilisé ? | Non, aucune réutilisation de flux de clé vue. | Nonce dérivé de la clé publique éphémère, unique par fichier ; en trois blocs, nonce[23] ^= i et un nouveau chiffreur par bloc |
| Y a-t-il une dérivation de clé ? | Non, la clé est le secret X25519 brut. | Aucun appel de hachage entre X25519 et le chiffreur. Sans conséquence : ce secret est uniforme et inconnu de la victime. Un rapport externe parle d'une dérivation ; elle n'existe pas dans ce build. |
Le mot de passe G7Vz9eyG sert-il à chiffrer ? | Non. | Simple garde de ligne de commande |
Le vrai défaut : le chiffrement partiel
Au-delà de 1 Mio, trois blocs de B = taille × ratio octets sont chiffrés : un au début, un centré, un à la fin. Le reste est intact. Chaque bloc est positionné à 0, (1 − r) / 2 et 1 − r de la taille. Les barres ci-dessous sont à l'échelle (rouge : chiffré).
Fichier de plus de 1 Mio, hors pied. Les blocs de 0,3 % sont tracés avec une largeur minimale visible.
| Mode | Part chiffrée | Part en clair |
|---|---|---|
| défaut | 27 % | 73 % |
--fast | 9 % | 91 % |
--superfast | 3 % | 97 % |
--ultrafast | 0,9 % | 99,1 % |
Pour l'exploiter, il faut retirer le pied. Sa taille est de 81 octets sans suffixe : --eph-- (7) + base64 (44) + saut de ligne (1) + --marker--GENTLEMEN\n (20) + GENTLEMEN (9). Avec le suffixe, 90 octets (--fast--) ou 91 (--super--, --ultra--). Une analyse externe donne « 81 à 91 octets », ce qui concorde. On extrait ensuite les régions intactes. C'est utile pour les images de machines virtuelles, les bases de données, les médias et les archives, beaucoup moins pour les formats dont l'en-tête est critique. Un fichier de 1 Mio ou moins n'a aucune récupération partielle possible. Dérivé
Recherche d'un déchiffreur public
Une recherche web n'a trouvé ni déchiffreur public, ni clé de l'attaquant publiée, ni faille d'implémentation connue pour cette famille. Cette absence ne prouve rien : le projet No More Ransom et les avis des éditeurs sont à surveiller. La clé publique est codée en dur dans ce build, donc les victimes du même build partagent la même clé d'attaquant. Si une autorité publie la clé privée, elle servirait à toutes. Non vérifié
Sources consultées : ManageEngine, AhnLab ASEC, SOC Prime, Hive Pro.
Retrouver des clés dans la mémoire
Seule piste réaliste pour retrouver une clé par fichier : un processus encore actif, ou une image de la RAM prise pendant ou juste après l'infection. Chaque clé ne déchiffre qu'un seul fichier.
Ce que le code laisse en mémoire, pour chaque fichier
| Secret | Où | Preuve |
|---|---|---|
| Clé privée éphémère (32 octets bruts) | Pile du chiffreur | Remplie par crypto/rand, aucun effacement ensuite |
| Deux copies de cette clé privée | Tas : deux objets ecdh.PrivateKey | sub_4f7740 appelle NewPrivateKey deux fois, une par opération X25519 |
| Secret partagé suivi de la clé publique (64 octets) | Tas (growslice, memmove) | La clé XChaCha20 est buf[0:32] |
| Sous-clé XChaCha20 et nonce | Structure du chiffreur | sub_4efec0 copie la sortie de HChaCha20 aux offsets 0 à 0x1f, le nonce en 0x24 |
Aucun appel à memclr, typedmemclr ou wbZero n'apparaît dans 0x4f82a0, 0x4f8760, 0x4f7740, 0x4efec0, 0x4f6c00 ni 0x4f0e00. Les secrets restent donc en mémoire jusqu'à ce que le ramasse-miettes réutilise leurs emplacements. Il y a au moins quatre copies par fichier. Dérivé
La clé publique du pied sert d'ancre
La clé publique éphémère de chaque fichier est connue : elle est dans son pied. On la cherche dans le dump.
- Secret partagé
Les 32 octets qui précèdent la clé publique dans le tampon de 64 octets sont la clé de chiffrement du fichier, avec le nonce
pub[0:24]. Vérification par déchiffrement du premier bloc et contrôle d'un en-tête connu. - Clé privée
Pour un candidat de 32 octets,
X25519(candidat, 9)doit égaler la clé publique du pied. Test exact. On en déduit le secret partagé avec la clé publique de l'attaquant. - Sous-clé du chiffreur
Structure sous-clé (32) + compteur (4) +
0000 ‖ nonce[16:24]. Elle ne permet de déchiffrer que le bloc dont l'indice correspond au nonce trouvé.
Quand ça peut marcher
| Scénario | Chances |
|---|---|
| Processus encore actif : le suspendre (pas le tuer), puis procdump ou WinPmem | Meilleur cas. Le malware peut tourner longtemps sur de gros volumes, et --T retarde le départ |
Capture RAM complète ou snapshot de VM (.vmem, .vmsn) pris pendant ou juste après l'infection | Bon, y compris pour des pages libérées mais non réécrites |
pagefile.sys / hiberfil.sys | Faible mais non nul. Le malware les exclut du chiffrement, donc ils restent lisibles ; ils n'ont des secrets que si des pages ont été paginées ou si la machine a hiberné |
| Machine éteinte ou redémarrée | Quasi nul |
| Processus terminé (il s'auto-supprime à la fin) | Quasi nul sans capture RAM physique immédiate |
Ne pas redémarrer, ne pas tuer le processus. La mémoire serait perdue.
Inconnu : la fraction de clés qui survit. Le chiffrement alloue un tampon de 64 Kio par gros fichier, donc le ramasse-miettes tourne souvent et réutilise les petits emplacements. Le scavenger de Go rend aussi des pages à l'OS après un délai. Ce sont des inférences à partir du fonctionnement de Go, pas des mesures. Inféré Les mesurer demande d'exécuter l'échantillon dans une VM isolée sur des fichiers factices dont on connaît les clés, avec un snapshot à la fin. Ce n'a pas été fait.
Outil de balayage gentlemen_memkey.py
Script Python écrit pour appliquer la piste précédente. Il est en lecture seule sur les entrées et n'utilise pas le réseau. Il lit le pied de chaque fichier chiffré, cherche la clé publique éphémère dans un dump à n'importe quel alignement, teste les trois sources de clé ci-dessus et écrit les fichiers récupérés dans un dossier de sortie. Dépendances : cryptography et numpy.
Chemin local : samples/thegentlemen_extracted/recovery/gentlemen_memkey.py. Le code complet est en bas de cette section.
Utilisation
# validation de l'outil python3 -I recovery/gentlemen_memkey.py selftest # récupération (dump brut ou minidump non compressé) python3 -I recovery/gentlemen_memkey.py recover \ --dump /chemin/proc.dmp --encrypted /chemin/fichiers_chiffres/ --out ./recuperes # option lente : balayage complet du dump pour les clés privées (sans ancre) # --scan-priv [--priv-shifts 0-7 si le dump n'est pas aligné sur 8 octets]
Sortie : *.recovered (tous les blocs déchiffrés, pied retiré) ou *.partial (certains blocs seulement), plus un rapport JSON avec la source, la confiance (pub-match, magic:pdf, entropie-basse, texte) et l'offset dans le dump.
Résultats de l'auto-test
L'auto-test fabrique des fichiers avec le modèle décrit plus haut, un dump synthétique de 6 Mio et cinq cas. Résultat : réussi. Le vecteur de référence de HChaCha20 (draft-irtf-cfrg-xchacha) est respecté.
| Cas | Situation dans le dump | Résultat |
|---|---|---|
| A · 300 Ko, défaut | Tampon secret partagé + clé publique, à une adresse non alignée | Récupéré en entier (en-tête %PDF) |
B · 3 Mo, --fast | Clé privée seule, loin de toute ancre (balayage complet) | Récupéré en entier, pub-match |
C · 2 Mo, --ultrafast | Structure du chiffreur pour le bloc 1 seulement | Récupéré partiellement (1 bloc sur 3), identique à l'original |
| D · 400 Ko | Aucune clé dans le dump | Correctement signalé comme non récupéré |
E · 1,5 Mo, --superfast | Clé privée proche de la clé publique | Récupéré en entier, pub-match |
Un premier essai avait échoué sur le cas C : le test de plausibilité essayait toujours le bloc 0 alors que la structure ne couvre que le bloc 1. Corrigé pour tester le premier bloc couvert par le candidat.
Limites
Circularité
L'auto-test chiffre avec mon propre modèle (clé = secret partagé, nonce = pub[:24], blocs). Si le vrai malware diffère sur un détail, l'outil ne trouvera rien. Il n'y a ni échantillon chiffré réel ni dump réel.
Plausibilité
Pour le secret partagé et la sous-clé, on ne peut pas prouver la clé, seulement vérifier un en-tête connu, une entropie basse ou du texte. Un contenu compressé sans en-tête reconnu peut être manqué ou, rarement, validé par erreur.
Coût du balayage complet
Environ 35 µs par candidat, avec un préfiltre sur les zéros et la diversité des octets. Estimation : une dizaine de minutes pour 1 Go sur 8 cœurs, non mesurée. --priv-shifts 0-7 multiplie le coût par 8. Non vérifié
Compatibilité
Le dump doit être non compressé. Les .dmp compressés ou chiffrés doivent être convertis avant. La disposition exacte des objets du runtime Go dans un vrai processus n'a pas été vérifiée.
Code source complet
#!/usr/bin/env python3
"""Recherche, dans un dump mémoire, les clés par fichier du rançongiciel "The Gentlemen".
Usage défensif : récupération de données après infection. Lecture seule sur les
entrées ; les fichiers récupérés sont écrits dans --out.
Modèle (SHA-256 3ab95752…c9235, analyse statique, voir RAPPORT_ANALYSE.md) :
priv_eph = 32 octets aléatoires (par fichier)
pub_eph = X25519(priv_eph, 9) -> dans le pied "--eph--<base64>"
partagé = X25519(priv_eph, pub_attaquant)
tampon = partagé || pub_eph (64 o, tas Go, jamais effacé)
clé = tampon[0:32] ; nonce24 = tampon[32:56] = pub_eph[0:24]
XChaCha20 : sous-clé = HChaCha20(clé, nonce24[:16]) ; nonce12 = 0000 || nonce24[16:]
<= 1 MiB : tout le fichier, un seul flux
> 1 MiB : 3 blocs de B = int(taille*ratio) octets aux offsets 0, off1, 2*off1
avec off1 = (taille-3B)/2 + B ; nonce24[23] ^= indice du bloc
Sources de clé cherchées, ancrées sur pub_eph (connue grâce au pied) :
key 32 octets juste avant pub_eph (tampon partagé||pub) et fenêtres voisines
priv fenêtre de 32 octets dont X25519(f, 9) == pub_eph (test exact)
subkey structure du chiffreur : sous-clé(32) compteur(4) 0000||nonce[16:24]
Option --scan-priv : balaye tout le dump à la recherche de clés privées (sans ancre).
"""
import argparse
import base64
import json
import os
import re
import struct
import sys
import tempfile
from concurrent.futures import ProcessPoolExecutor
import numpy as np
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
ATTACKER_PUB = bytes.fromhex("fcb11717cab989424755a957c1d55361b119de4fdcfecdb2f2e56b15ad801922")
FULL_LIMIT = 0x100000
RATIOS = {"": 0.09, "fast": 0.03, "super": 0.01, "ultra": 0.003}
FOOTER_RE = re.compile(
rb"--eph--([A-Za-z0-9+/=_-]{43,44})\n--marker--GENTLEMEN\nGENTLEMEN(?:--(fast|super|ultra)--\n)?\Z")
BASEPOINT = (9).to_bytes(32, "little")
MAGICS = [(b"MZ\x90\x00", "pe"), (b"PK\x03\x04", "zip/office"), (b"%PDF-", "pdf"),
(b"\x89PNG\r\n\x1a\n", "png"), (b"\xff\xd8\xff", "jpeg"), (b"GIF8", "gif"),
(b"7z\xbc\xaf\x27\x1c", "7z"), (b"Rar!", "rar"), (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "ole2"),
(b"SQLite format 3\x00", "sqlite"), (b"RIFF", "riff"), (b"OggS", "ogg"), (b"ID3", "mp3"),
(b"<?xml", "xml"), (b"{\\rtf", "rtf"), (b"\x7fELF", "elf")]
# ---------------------------------------------------------------- primitives
def _rotl(v, n):
return ((v << n) & 0xFFFFFFFF) | (v >> (32 - n))
def hchacha20(key, nonce16):
s = list(struct.unpack("<4I", b"expand 32-byte k")) + list(struct.unpack("<8I", key)) \
+ list(struct.unpack("<4I", nonce16))
def qr(a, b, c, d):
s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] = _rotl(s[d] ^ s[a], 16)
s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] = _rotl(s[b] ^ s[c], 12)
s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] = _rotl(s[d] ^ s[a], 8)
s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] = _rotl(s[b] ^ s[c], 7)
for _ in range(10):
qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15)
qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14)
return struct.pack("<8I", *(s[0:4] + s[12:16]))
def chacha_stream(key, nonce12, counter=0):
enc = Cipher(algorithms.ChaCha20(key, struct.pack("<I", counter) + nonce12), mode=None).encryptor()
return enc.update
def xchacha_stream(key, nonce24):
return chacha_stream(hchacha20(key, nonce24[:16]), b"\0\0\0\0" + nonce24[16:])
def block_nonce(pub, index):
n = bytearray(pub[:24])
n[23] ^= index
return bytes(n)
def x25519_pub(priv):
return X25519PrivateKey.from_private_bytes(priv).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
def x25519_shared(priv, peer):
return X25519PrivateKey.from_private_bytes(priv).exchange(X25519PublicKey.from_public_bytes(peer))
# ---------------------------------------------------------------- fichiers chiffrés
def parse_encrypted(path):
size = os.path.getsize(path)
with open(path, "rb") as f:
f.seek(max(0, size - 256))
tail = f.read()
i = tail.rfind(b"--eph--")
m = FOOTER_RE.match(tail, i) if i >= 0 else None
if not m:
return None
b64 = m.group(1).decode().replace("-", "+").replace("_", "/")
try:
pub = base64.b64decode(b64 + "=" * (-len(b64) % 4))
except Exception:
return None
if len(pub) != 32:
return None
mode = (m.group(2) or b"").decode()
orig = size - (len(tail) - i)
return {"path": path, "pub": pub, "mode": mode, "size": orig, "blocks": blocks(orig, mode)}
def blocks(size, mode):
if size <= FULL_LIMIT:
return [(0, size, 0)]
b = int(size * RATIOS[mode])
off1 = (size - 3 * b) // 2 + b
return [(0, b, 0), (off1, b, 1), (2 * off1, b, 2)]
def collect(paths):
out = []
for p in paths:
files = [os.path.join(r, f) for r, _, fs in os.walk(p) for f in fs] if os.path.isdir(p) else [p]
for f in files:
e = parse_encrypted(f)
if e:
out.append(e)
return out
# ---------------------------------------------------------------- recherche mémoire
def find_prefix8(path, targets):
"""Offsets (tout alignement) de chaque préfixe de 8 octets. targets: {bytes8: tag}."""
keys = np.array(sorted({struct.unpack("<Q", t)[0] for t in targets}), dtype=np.uint64)
arr = np.memmap(path, dtype=np.uint8, mode="r")
chunk = 1 << 26
hits = {}
for base in range(0, len(arr), chunk):
seg = arr[base:base + chunk + 8]
for shift in range(8):
sub = seg[shift:]
n = (len(sub) // 8) * 8
if n < 8:
continue
words = np.ascontiguousarray(sub[:n]).view(np.uint64)
idx = np.searchsorted(keys, words)
idx[idx == len(keys)] = 0
for p in np.nonzero(keys[idx] == words)[0]:
off = base + shift + int(p) * 8
if off < base + chunk:
hits.setdefault(struct.pack("<Q", int(words[p])), []).append(off)
return arr, hits
def good_window(w):
return w.count(0) <= 3 and len(set(w)) >= 16
def _priv_chunk(args):
path, s0, n, pubset = args
arr = np.memmap(path, dtype=np.uint8, mode="r")
region = arr[s0:s0 + 8 * n + 24]
n = min(n, (len(region) - 24) // 8)
if n <= 0:
return []
win = np.lib.stride_tricks.as_strided(region, shape=(n, 32), strides=(8, 1))
keep = (win == 0).sum(1) <= 1
srt = np.sort(win, axis=1)
keep &= (1 + (np.diff(srt, axis=1) != 0).sum(1)) >= 24
found = []
for k in np.nonzero(keep)[0]:
cand = bytes(win[k])
if x25519_pub(cand) in pubset:
found.append((s0 + 8 * int(k), cand))
return found
def scan_priv(path, pubs, shifts, workers):
size = os.path.getsize(path)
per = 1 << 21
jobs = [(path, sh + 8 * k0, per, pubs) for sh in shifts for k0 in range(0, max(0, (size - sh - 32) // 8), per)]
found = []
with ProcessPoolExecutor(workers) as ex:
for i, r in enumerate(ex.map(_priv_chunk, jobs)):
found += r
if i % 50 == 0:
print(f" scan priv {i}/{len(jobs)}", file=sys.stderr)
return found
# ---------------------------------------------------------------- plausibilité
def entropy(b):
if not b:
return 0.0
c = np.bincount(np.frombuffer(b, dtype=np.uint8), minlength=256)
p = c[c > 0] / len(b)
return float(-(p * np.log2(p)).sum())
def plausible(data):
for m, name in MAGICS:
if data.startswith(m):
return "magic:" + name
if len(data) >= 256 and entropy(data[:4096]) < 6.0:
return "entropie-basse"
if len(data) >= 32 and sum(32 <= c < 127 or c in (9, 10, 13) for c in data[:512]) >= 0.95 * min(len(data), 512):
return "texte"
return None
class Cand:
def __init__(self, kind, data, offset, nonce12=None, index=0, exact=False):
self.kind, self.data, self.offset, self.nonce12, self.index, self.exact = kind, data, offset, nonce12, index, exact
def stream(self, pub, index):
if self.kind == "key":
return xchacha_stream(self.data, block_nonce(pub, index))
return chacha_stream(self.data, self.nonce12)
def covers(self, index):
return self.kind == "key" or index == self.index
def trial(entry, cand):
covered = [b for b in entry["blocks"] if cand.covers(b[2]) and b[1] > 0]
if not covered:
return None
off, ln, i = covered[0]
with open(entry["path"], "rb") as f:
f.seek(off)
ct = f.read(min(ln, 4096))
return plausible(cand.stream(entry["pub"], i)(ct))
def write_recovered(entry, cand, out_dir):
done = [b for b in entry["blocks"] if cand.covers(b[2])]
name = os.path.basename(entry["path"]) + (".recovered" if len(done) == len(entry["blocks"]) else ".partial")
dst = os.path.join(out_dir, name)
with open(entry["path"], "rb") as src, open(dst, "wb") as out:
left = entry["size"]
while left:
buf = src.read(min(left, 1 << 20))
out.write(buf)
left -= len(buf)
for off, ln, i in done:
ks = cand.stream(entry["pub"], i)
src.seek(off)
out.seek(off)
while ln > 0:
buf = src.read(min(ln, 1 << 16))
out.write(ks(buf))
ln -= len(buf)
return dst, len(done), len(entry["blocks"])
# ---------------------------------------------------------------- recherche principale
def recover(dump, entries, out_dir, window=128, do_scan_priv=False, shifts=(0,), workers=None, attacker_pub=ATTACKER_PUB):
by_pub = {e["pub"]: e for e in entries}
targets = {}
for e in entries:
targets[e["pub"][:8]] = ("pub", e)
for i in range(3):
targets[block_nonce(e["pub"], i)[16:24]] = ("nonce", e, i)
arr, hits = find_prefix8(dump, {k: 1 for k in targets})
cands = {e["path"]: [] for e in entries}
def priv_cand(e, priv, off):
c = Cand("key", x25519_shared(priv, attacker_pub), off, exact=True)
cands[e["path"]].append(c)
for pre, offs in hits.items():
# deux fichiers pourraient partager un préfixe de 8 octets : on re-vérifie contre chaque entrée
for e in entries:
if pre == e["pub"][:8]:
for o in offs:
if bytes(arr[o:o + 32]) != e["pub"]:
continue
lo = max(0, o - window)
ctx = bytes(arr[lo:o + 32 + window])
base = o - lo
if base >= 32:
cands[e["path"]].append(Cand("key", ctx[base - 32:base], o - 32))
for rel in range(-window, window + 1, 8):
p = base + rel
if p < 0 or p + 32 > len(ctx):
continue
w = ctx[p:p + 32]
if not good_window(w):
continue
if rel != -32:
cands[e["path"]].append(Cand("key", w, o + rel))
if x25519_pub(w) == e["pub"]:
priv_cand(e, w, o + rel)
for i in range(3):
if pre == block_nonce(e["pub"], i)[16:24]:
for o in offs:
if o >= 40 and bytes(arr[o - 4:o]) == b"\0\0\0\0":
cands[e["path"]].append(Cand("subkey", bytes(arr[o - 40:o - 8]), o - 40,
nonce12=bytes(arr[o - 4:o + 8]), index=i))
if do_scan_priv:
for off, priv in scan_priv(dump, set(by_pub), shifts, workers):
priv_cand(by_pub[x25519_pub(priv)], priv, off)
report = []
for e in entries:
seen, best = set(), None
for c in cands[e["path"]]:
sig = (c.kind, c.data, c.index)
if sig in seen:
continue
seen.add(sig)
why = "pub-match" if c.exact else trial(e, c)
if why and (best is None or (c.exact and not best[0].exact)):
best = (c, why)
row = {"file": e["path"], "mode": e["mode"] or "défaut", "size": e["size"], "candidats": len(seen)}
if best:
dst, nb, tot = write_recovered(e, best[0], out_dir)
row.update(recovered=dst, blocks=f"{nb}/{tot}", source=best[0].kind, dump_offset=best[0].offset,
confidence=best[1], key=best[0].data.hex() if best[0].kind == "key" else None)
else:
row["recovered"] = None
report.append(row)
return report
# ---------------------------------------------------------------- auto-test synthétique
def model_encrypt(plain, attacker_pub, mode, eph_priv):
pub = x25519_pub(eph_priv)
key = x25519_shared(eph_priv, attacker_pub)
ct = bytearray(plain)
for off, ln, i in blocks(len(plain), mode):
ct[off:off + ln] = xchacha_stream(key, block_nonce(pub, i))(plain[off:off + ln])
suffix = f"--{mode}--\n".encode() if mode else b""
footer = b"--eph--" + base64.b64encode(pub) + b"\n--marker--GENTLEMEN\nGENTLEMEN" + suffix
return bytes(ct) + footer, pub, key
def selftest():
expected = bytes.fromhex("82413b4227b27bfed30e42508a877d73a0f9e4d58a74a853c12ec41326d3ecdc")
got = hchacha20(bytes(range(32)), bytes.fromhex("000000090000004a0000000031415927"))
assert got == expected, "HChaCha20 : vecteur de test RFC (draft-irtf-cfrg-xchacha) non respecté"
print("HChaCha20 : vecteur de référence OK")
tmp = tempfile.mkdtemp(prefix="gmk_")
att_priv = os.urandom(32)
att_pub = x25519_pub(att_priv)
text = (b"%PDF-1.7\n" + b"Rapport trimestriel confidentiel. " * 100000)
def mk(name, size, mode):
plain = (text * (size // len(text) + 1))[:size]
priv = os.urandom(32)
ct, pub, key = model_encrypt(plain, att_pub, mode, priv)
p = os.path.join(tmp, name)
open(p, "wb").write(ct)
return plain, priv, pub, key, p
cases = {n: mk(n, s, m) for n, s, m in [("A.pdf", 300_000, ""), ("B.pdf", 3_000_000, "fast"),
("C.pdf", 2_000_000, "ultra"), ("D.pdf", 400_000, ""),
("E.pdf", 1_500_000, "super")]}
dump = bytearray(os.urandom(6 << 20))
for k in range(0, len(dump), 4096): # zones « pointeurs » peu aléatoires
dump[k:k + 2048] = (b"\x56\x34\x12\x00\xc0\x00\x00\x00" * 256)
def put(off, data):
dump[off:off + len(data)] = data
plainA, privA, pubA, keyA, _ = cases["A.pdf"]
put(1_000_003, keyA + pubA) # tampon partagé||pub, adresse non alignée
plainB, privB, pubB, keyB, _ = cases["B.pdf"]
put(3_000_000, privB) # clé privée seule, loin de toute ancre
plainC, privC, pubC, keyC, _ = cases["C.pdf"]
sub = hchacha20(keyC, block_nonce(pubC, 1)[:16])
put(2_000_011, sub + struct.pack("<I", 7) + b"\0\0\0\0" + block_nonce(pubC, 1)[16:24])
plainE, privE, pubE, keyE, _ = cases["E.pdf"]
put(4_500_000, pubE + bytes(24) + privE) # clé privée à proximité de pub
dump_path = os.path.join(tmp, "dump.bin")
open(dump_path, "wb").write(bytes(dump))
out = os.path.join(tmp, "out")
os.makedirs(out)
entries = collect([tmp + "/A.pdf", tmp + "/B.pdf", tmp + "/C.pdf", tmp + "/D.pdf", tmp + "/E.pdf"])
rep = recover(dump_path, entries, out, do_scan_priv=True, workers=4, attacker_pub=att_pub)
ok = True
for r in rep:
name = os.path.basename(r["file"])
plain = cases[name][0]
if name == "D.pdf":
good = r["recovered"] is None
elif name == "C.pdf":
blk = blocks(len(plain), "ultra")[1]
data = open(r["recovered"], "rb").read() if r["recovered"] else b""
good = bool(data) and r["blocks"] == "1/3" and data[blk[0]:blk[0] + blk[1]] == plain[blk[0]:blk[0] + blk[1]]
else:
good = bool(r["recovered"]) and open(r["recovered"], "rb").read() == plain
ok &= good
print(f" {name}: {'OK' if good else 'ECHEC'} source={r.get('source')} blocs={r.get('blocks')} {r.get('confidence')}")
print("auto-test :", "RÉUSSI" if ok else "ÉCHEC", f"(fichiers dans {tmp})")
return 0 if ok else 1
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("selftest", help="valide l'outil sur un cas synthétique")
r = sub.add_parser("recover")
r.add_argument("--dump", required=True)
r.add_argument("--encrypted", nargs="+", required=True, help="fichiers ou dossiers chiffrés")
r.add_argument("--out", required=True)
r.add_argument("--window", type=int, default=128)
r.add_argument("--scan-priv", action="store_true", help="balayage complet du dump (lent)")
r.add_argument("--priv-shifts", default="0", help="décalages d'alignement testés, ex. 0 ou 0-7")
r.add_argument("--workers", type=int, default=os.cpu_count())
a = ap.parse_args()
if a.cmd == "selftest":
return selftest()
lo, _, hi = a.priv_shifts.partition("-")
shifts = range(int(lo), int(hi or lo) + 1)
os.makedirs(a.out, exist_ok=True)
entries = collect(a.encrypted)
print(f"{len(entries)} fichier(s) avec pied GENTLEMEN valide", file=sys.stderr)
rep = recover(a.dump, entries, a.out, a.window, a.scan_priv, shifts, a.workers)
json.dump(rep, sys.stdout, indent=1, ensure_ascii=False)
print()
return 0
if __name__ == "__main__":
sys.exit(main())
Actions sur le système et le réseau
Avant le chiffrement
| Fonction | Actions |
|---|---|
zKea_2J | Coupe la protection temps réel de Defender ; ajoute une exclusion pour le processus et pour C:\ |
kKWtr5xzZCw | vssadmin delete shadows /all /quiet ; wmic shadowcopy delete ; wevtutil cl sur System, Application, Security ; supprime Prefetch, journaux Defender, journaux RDP, corbeille, historique PowerShell de chaque profil |
d8KcLKg | taskkill /F sur ~80 processus : bases de données, sauvegarde, messagerie, Office, TeamViewer, Veeam, SAP |
y3AznbY7Suo | Désactive (sc config start= disabled) puis arrête ~75 services : Veeam, Backup Exec, Acronis, MSSQL, Exchange, Sophos, Commvault, QuickBooks |
k0t09p7jp5 | Active la découverte réseau : services fdrespub, fdPHost, SSDPSRV, upnphost et règles de pare-feu |
Persistance
Tâches planifiées
UpdateSystem (au démarrage, SYSTEM) et UpdateUser (au démarrage). Chacune relance le binaire avec ses arguments.
Clés Run
GupdateS dans HKLM et GupdateU dans HKCU.
Mode SYSTEM
Tâche gentlemen_system exécutée une fois, puis supprimée.
Propagation latérale
Fonction main.vjR35OEtJxZ (0x4fee40), exécutée après le chiffrement local. Dérivé Hopper retrouve \share$\, C:\Temp, mpr.dll, /GRANT:Everyone,FULL, Add-MpPreference -ExclusionProcess, netsh et UpdateSvc dans cette fonction.
- Copie du binaire dans
C:\Tempet partage SMBshare$ouvert à Everyone. - Ajout de
share$àNullSessionSharesetEveryoneIncludesAnonymous = 1: le SMB devient plus permissif. - Liste des machines du domaine par
NetServerEnum, hôte local exclu. - Pour chaque hôte, le binaire est lancé de six façons successives :
wmic /node,schtasks /S(tâchesDefU,UpdateGU,DefS),sc \\hôte create(servicesDefSvc,UpdateSvc),Invoke-Commandet la classe WMIWin32_Process. Defender est d'abord coupé à distance.
Aucun vol d'identifiants n'a été vu : la propagation semble reposer sur le compte courant. Inféré Les API LogonUserW et NetUserAdd figurent dans les chaînes parce que la couche syscall de Go les déclare toutes ; aucune fonction du programme ne les appelle. Dérivé Voir C2 et exfiltration.
Fin d'exécution
Fond d'écran
JPEG 1920×1080 embarqué (290 967 octets), écrit dans gentlemen.bmp du dossier temporaire, appliqué par SystemParametersInfoW. Texte : « YOUR NETWORK IS LOCKED BY THE GENTLEMEN ».
Auto-suppression
Un .bat attend trois pings, supprime l'exécutable, puis se supprime.
C2 et exfiltration : ce que le binaire contient
Verdict. Aucune communication de commande et contrôle ni exfiltration dans ce binaire : pas de résolution DNS, pas de connexion sortante, pas de client HTTP ni TLS. Les 890 fonctions de bibliothèque renommées ont été triées par un graphe d'appels, pas relues une à une. L'exfiltration revendiquée dans la note est attribuée par Microsoft à des outils séparés (voir Avis Microsoft). Dérivé
Recherche de chaînes
| Recherche | Résultat |
|---|---|
URL http(s)://, ftp, ws | Quatre seulement : téléchargement de Tor et de Tox (note de rançon), blog .onion de l'acteur, et go.dev/issue/66821 (runtime Go) |
| Adresses IPv4 | Aucune |
| Verbes et en-têtes HTTP | Aucun User-Agent, POST /, GET /, multipart/form-data, Authorization, WebSocket |
| API d'envoi Windows | Aucune API WinHTTP, WinINet ni urlmon |
| Outils de transfert | Aucun Invoke-WebRequest, DownloadFile, curl, bitsadmin, certutil -urlcache, rclone, winscp, webhook, outil d'accès distant |
net/http, crypto/tls, mime/multipart | Présentes comme chaînes, mais ce sont les entrées de la table interne des réglages GODEBUG de Go, pas du code lié |
Correction. La première version de ce dossier disait que net/http était lié au binaire sans être appelé depuis main. C'était faux. Le programme entier compte 2 675 fonctions, ce qui est incompatible avec un client HTTP ou TLS lié. Aucun nom de méthode réseau n'a survécu à l'obscurcissement, sauf runtime.netpollinit, toujours présent dans le runtime Go pour Windows.
Tri des fonctions de bibliothèque par graphe d'appels
Le binaire est désassemblé avec llvm-objdump. Le graphe reprend les appels directs, les sauts entre fonctions, les références RIP-relatives à des débuts de fonction, et les pointeurs de fonction trouvés dans les données (closures, itab). Il compte 2 675 fonctions et 17 723 arêtes. Les racines sont les 46 fonctions main.* et 54 initialiseurs de paquets. Le graphe sur-approxime, ce qui est le bon sens pour chercher une capacité. Dérivé
| Mesure | Valeur |
|---|---|
| Fonctions des paquets renommés ou non standard | 890 |
Atteintes depuis main.* ou un initialiseur | 771 (dont 756 depuis main.*) |
| Non atteintes | 119 : méthodes appelées par interface (String, MarshalBinary de time…), stubs d'assembleur du runtime (_rt0_amd64_windows, sigtramp, debugCall*) |
Fonctions syscall atteintes | 120 sur 127 |
Paquets appelés directement par main.* | os (39 appels), os/exec (33), syscall (17), buffers de chaînes (16), un paquet d'erreurs (12), time (9), flag (6), chacha20 (4), base64 (3), crypto/rand, X25519, io/fs, registre Windows |
Paquets reconnus
Identification faite à partir des noms de méthodes qui survivent à garble (Read, Write, String, MarshalBinary, Sum, BlockSize…). Tous les paquets reconnus sont standard ou de support. Inféré
API Win32 appelées par le programme
La méthode (*LazyProc).Call n'est appelée que par trois fonctions de main.*.
| Fonction | API | Usage |
|---|---|---|
main.dubaKa (user32.dll) | SystemParametersInfo | Fond d'écran |
main.hx5YkrU9k (mpr.dll) | WNetOpenEnum, WNetEnumResource | Énumération des partages réseau |
main.hixEDW9 (netapi32.dll) | NetServerEnum, NetApiBufferFree | Liste des machines du domaine |
La couche syscall de Go
Elle déclare 652 noms d'API Win32 ; 84 ont une enveloppe appelante. La méthode d'attribution a d'abord échoué sur des API sûrement utilisées : l'objet de procédure commence 8 octets avant le pointeur de nom. Corrigée, elle retrouve les appelants de CreateFileW, CloseHandle, CreateProcessW, ReadFile et WriteFile. Les résultats réseau ci-dessous viennent de la version corrigée.
Sans aucun appelant
connect, socket, bind, listen, getsockopt, shutdown, GetAddrInfoW, FreeAddrInfoW, DnsQuery_W, gethostbyname, getservbyname, getprotobyname, WSASendTo, WSARecvFrom, WSACleanup, TransmitFile, AcceptEx. Le programme ne peut ni résoudre un nom ni ouvrir une connexion par cette voie.
Comptes et certificats
LogonUserW, NetUserAdd, NetUserDel, NetUserGetInfo, NetShareAdd, NetShareDel, toutes les Cert* et CryptAcquireContextW : aucun appelant. Le partage share$ est créé par une commande, pas par l'API.
Enveloppes atteintes
WSAStartup, WSASend, WSARecv, closesocket, setsockopt, WSAIoctl, WSASocketW, WSAEnumProtocolsW, WSAGetOverlappedResult. Elles sont atteintes par du code générique d'E/S (internal/poll, lecture de la sortie des processus lancés) et par un test de prise en charge d'AF_UNIX dans un initialiseur, jamais par un appel de création ou de connexion de socket.
Exemple de chemin : main.ztLKc8Nhkb6a → un tampon de chaînes → FD.Read de internal/poll → WSARecv. C'est le même code qui appelle ReadFile pour un fichier ou un tube.
Limites de cette vérification. Le graphe suit les appels directs et les pointeurs de fonction dans les données ; un appel calculé purement à l'exécution peut lui échapper. Les 771 fonctions atteintes n'ont pas été lues ligne à ligne. Les scripts sont dans le dossier temporaire de la session et ne sont pas dans le dépôt. Aucun trafic n'a été capturé : voir Limites.
Indicateurs de compromission
Cryptographie et garde
fcb11717cab989424755a957c1d55361b119de4fdcfecdb2f2e56b15ad801922/LEXF8q5iUJHValXwdVTYbEZ3k/c/s2y8uVrFa2AGSI=G7Vz9eyGLOCKER_BACKGROUND=1Contacts de l'acteur (dans la note)
88984846080D639C9A4EC394E53BA616D550B2B3AD691942EA2CCD33AA5B9340FD1A8FF40E9A[email protected]tezwsse5czllksjb7cwp65rvnk4oobmzti2znn42i43bjdfd2prqqkad.onionead0d7a8ae0a6ffb7f0a5873fec4ff5eSur les machines
| Type | Valeurs |
|---|---|
| Fichiers | README-GENTLEMEN.txt, extension .axfsmg, pied --eph-- … --marker--GENTLEMEN (81 à 91 octets), %TEMP%\gentlemen.bmp, C:\Temp\<exe> |
| Partage | share$ sur C:\Temp |
| Tâches planifiées | UpdateSystem, UpdateUser, gentlemen_system, DefU, UpdateGU, DefS |
| Services | DefSvc, UpdateSvc |
| Registre | Run : GupdateS (HKLM), GupdateU (HKCU) ; NullSessionShares ; EveryoneIncludesAnonymous |
Règle YARA
rule Ransom_TheGentlemen_Go_Windows
{
meta:
sample_sha256 = "3ab9575225e00a83a4ac2b534da5a710bdcf6eb72884944c437b5fbe5c5c9235"
strings:
$note = "README-GENTLEMEN.txt" ascii
$usage = "Silent mode (don't rename files)" ascii
$marker = "--marker--" ascii
$eph = "--eph--" ascii
$env = "LOCKER_BACKGROUND" ascii
$title = "YOUR NETWORK IS LOCKED" ascii nocase
$pk_b64 = "/LEXF8q5iUJHValXwdVTYbEZ3k/c/s2y8uVrFa2AGSI=" ascii
$tox = "88984846080D639C9A4EC394E53BA616D550B2B3AD691942EA2CCD33AA5B9340FD1A8FF40E9A" ascii
condition:
uint16(0) == 0x5A4D and (3 of ($note,$usage,$marker,$eph,$env,$title)) or $pk_b64 or $tox
}
Testée sur l'échantillon : elle correspond. Les marqueurs propres au build (clé, Tox) ne détecteront pas d'autres versions ; les marqueurs de comportement ont plus de chances de survivre.
Pistes de détection
- Création d'exclusions Defender sur
C:\ouC:\Temp, et arrêt de la protection temps réel. vssadmin delete shadowsouwevtutil clsuivis de nombreux arrêts de services de sauvegarde.- Création du partage
share$et modification deNullSessionShares. - Tâches planifiées ou services distants nommés
Def*ouUpdate*créés parschtasks /Sousc \\. - Fichiers dont les derniers octets contiennent
--marker--GENTLEMEN: permet de repérer les fichiers déjà chiffrés et leur mode (--fast--,--super--,--ultra--). - Outils des opérateurs, hors du binaire (d'après Microsoft, non vérifiés par nous) : voir la liste de recherche dans Avis Microsoft.
Correspondance MITRE ATT&CK
| Technique | Où dans le binaire |
|---|---|
| T1486 Data Encrypted for Impact | X25519 + XChaCha20, extension .axfsmg |
| T1490 Inhibit System Recovery | vssadmin, wmic shadowcopy |
| T1489 Service Stop | ~75 services désactivés puis arrêtés |
| T1562.001 Impair Defenses | Defender (local et distant), pare-feu |
| T1070.001 / T1070.004 | Journaux d'événements, Prefetch, historique PowerShell |
| T1053.005 · T1547.001 · T1543.003 | Tâches planifiées, clés Run, services |
| T1021.002 · T1047 | Partages SMB, WMI |
| T1135 · T1083 | Découverte des partages, parcours des fichiers |
| T1222.001 | takeown, icacls, attrib |
| T1491.001 Internal Defacement | Fond d'écran |
| T1027 Obfuscated Files | garble |
Comparaison avec l'avis Microsoft
Microsoft décrit Ransom:Win64/Gentlemen.A (publié le 6 octobre 2025, mis à jour le 7 avril 2026). La page a été lue avec un outil de récupération qui résume le contenu : vérifier les formulations exactes sur la page avant de les citer. Elle ne donne aucun hachage, aucun nom de variante ni alias. Le suffixe « .A » est un nom de détection de la famille, pas une variante du code.
Conclusion. Même famille, mais ce n'est pas une variante du code qui exfiltre. Microsoft place l'exfiltration dans des outils séparés des opérateurs : « Data exfiltration uses WinSCP or WebDAV », avec un regroupement préalable dans C:\ProgramData\data. Aucun de ces outils n'est dans notre binaire. Cela concorde avec l'absence de communication sortante constatée, que le dossier présentait jusque-là comme une inférence. Notre échantillon ne peut pas être relié à la page par un hachage. Dérivé pour le binaire, Inféré pour la famille.
Ce qui correspond
| Point | Article | Notre échantillon |
|---|---|---|
| Chiffrement | X25519 + XChaCha20, clé éphémère de 32 octets par fichier | Identique |
| Garde d'entrée | --password, 8 octets, bloque les bacs à sable | G7Vz9eyG (8 caractères) |
| Options | --path, --fast, --superfast, --ultrafast | Présentes |
| Extension | Aléatoire, six caractères (.7mtzhh, .ojuopo) | .axfsmg, six caractères, codée en dur dans ce build |
| Note de rançon | README-GENTLEMEN.txt dans chaque dossier | Identique |
| Fond d'écran, copies shadow, journaux | Fond d'écran changé, wevtutil, copies shadow et sauvegardes supprimées | Présents |
| Défenses | PowerShell : protection temps réel coupée, exclusions | Set-MpPreference et Add-MpPreference |
| Services arrêtés | Sécurité, sauvegarde (Veeam), bases (MSSQL), Exchange | ~75 services, dont ces familles |
| Persistance | Clé Run dans HKLM | Run GupdateS (HKLM) et GupdateU (HKCU), plus deux tâches planifiées |
| Langage | Charge utile en Go | Go ≥ 1.24, garble |
Ce qui diffère ou manque
| Point | Article | Notre échantillon |
|---|---|---|
| Exfiltration | WinSCP ou WebDAV, regroupement dans C:\ProgramData\data, envoi vers un C2 | Aucune trace de WinSCP, WebDAV ni ProgramData\data |
| Pilote vulnérable | ThrottleStop.sys (ou ThrottleBlood.sys), CVE-2025-7771 ; All.exe et Allpatch2.exe modifient NtAddAtom en mémoire noyau | Aucune trace de ThrottleStop, NtAddAtom, All.exe, Allpatch |
| Accès distant | AnyDesk, RDP activé (fDenyTSConnections = 0) | Aucune trace de AnyDesk ni de fDenyTSConnections |
| Registre Defender | DisableAntiSpyware = 1 | Absent : seul PowerShell est utilisé |
| Déploiement latéral | NetExec, PsExec, GPO déployant le binaire par NETLOGON | Aucune trace de PsExec. netlogon n'apparaît que dans la liste des dossiers exclus du chiffrement. Le binaire se propage lui-même par SMB, WMI, schtasks /S, sc \\hôte, Invoke-Command. |
| Options de ligne de commande | Seulement les quatre ci-dessus | Aussi --T, --silent, --system, --shares, --full. L'article simplifie peut-être ; les builds peuvent aussi différer. |
| Plateformes | Windows, Linux, NAS, VMware ESXi | Windows seulement, aucune chaîne ESXi |
| Accès initial | CVE-2024-55591 (FortiOS / FortiProxy), identifiants VPN | Hors du binaire |
À rechercher sur les machines infectées
Liste construite d'après l'article. Rien de ceci n'a été vérifié par nous, et ces éléments ne sont pas dans notre binaire. Non vérifié
- Le dossier
C:\ProgramData\data, et tout usage de WinSCP ou de WebDAV vers l'extérieur avant la date du chiffrement. ThrottleStop.sysouThrottleBlood.syschargé comme pilote ;All.exe,Allpatch2.exe,PowerRun.exe.- AnyDesk installé ; RDP activé (
fDenyTSConnections = 0) ;DisableAntiSpyware = 1. - Changements de GPO et déploiements par le partage NETLOGON ; traces de PsExec et de NetExec dans les journaux.
- Sur les pare-feu Fortinet : tentatives liées à CVE-2024-55591 et connexions VPN avec des identifiants par défaut ou déjà compromis.
- Une extension de six caractères aléatoires sur les fichiers chiffrés, avec la note
README-GENTLEMEN.txtdans chaque dossier.
Affirmations de la note de rançon
« We have exfiltrated all your confidential and business data »
Concorde avec l'article. Le binaire ne le prouve ni ne le contredit. C'est une affirmation de l'acteur.
« RAM dumps … are useless »
Affirmation de l'acteur. L'analyse statique trouve des secrets non effacés en mémoire, donc une récupération par dump mémoire est possible en théorie (voir Clés en mémoire). Non testé en pratique.
Hopper et REA
Cette section garde la trace de la session qui a ajouté Hopper à l'analyse : état de l'installation, configuration de REA, ouverture du binaire, recoupement avec Ghidra.
Journal de la session
- Vérification de l'installation Hopper
Hopper est présent dans
/opt/hopper. Le diagnosticrea doctor --provider hopperest sain. Observé - Ouverture du binaire dans Hopper
rea analyzeavec--provider hoppersur l'échantillon : 1 min 38 à froid. - Décompilation de deux fonctions
main.main(0x4fab60) et la propagation latérale (0x4fee40), environ 88 s chacune en CLI, puis recoupement avec le rapport Ghidra. - REA annoncé prêt mais non enregistré
Le setup avait été lancé, mais rien n'avait été écrit sur cette machine. Diagnostic puis application du setup pour Claude Code seulement.
- Décompilation de la fonction de chiffrement
Via le serveur MCP
reachargé après redémarrage. Voir La fonction de chiffrement. - Revue de faille, étude de la mémoire, outil
Voir Faille et clé, Clés en mémoire et Outil de balayage.
État de l'installation Hopper
| Contrôle | Résultat |
|---|---|
| Lanceur | /opt/hopper/bin/Hopper, exécutable. hopper_mcp est aussi présent. REA le choisit seul sous Linux, HOPPER_LAUNCHER_PATH n'est pas nécessaire. |
| Bibliothèques | ldd ne signale aucune bibliothèque manquante |
| Environnement de démo | Sonde hopper-demo-runtime au vert, strategy=direct, /tmp/.X11-unix en mode 1777 et inscriptible. Xvfb, xvfb-run et python3 installés. |
| Hôte | Ubuntu 24.04, dans la base supportée |
| Node | 24.13.1 alors que .nvmrc demande 24.18.0. N'a pas gêné le diagnostic. |
| Empreinte du lanceur | launcher-sha256:0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded |
| Ghidra / IDA | Non configurés dans cet environnement (GHIDRA_INSTALL_DIR et REA_IDA_MCP_CONFIG absents). Sans importance si seul Hopper sert. |
Configuration de REA pour Claude Code
Après le premier diagnostic, aucun client n'était enregistré : pas de serveur rea dans ~/.claude.json, pas de skill, pas de sauvegarde ~/.claude.json.rea.backup. Le message « REA is ready » collé par l'utilisateur ne correspondait donc pas à l'état de cette machine. Causes possibles : setup annulé avant l'écriture, ou lancé sur une autre machine ou un autre HOME. Un écrasement par Claude Code est écarté : il réécrit ce fichier, mais il ne supprimerait pas le dossier du skill.
Élément écrit par rea setup --yes --client claude_code | Valeur |
|---|---|
| Serveur MCP | rea dans ~/.claude.json : commande /home/ubuntu/rea/scripts/rea.mjs mcp, environnement HOPPER_LAUNCHER_PATH=/opt/hopper/bin/Hopper. Le reste de la configuration est conservé. |
| Skill | ~/.claude/skills/reverse-engineer-anything, version 40, 185 outils, état aligned |
| Sauvegarde | ~/.claude.json.rea.backup |
| Non configurés | Antigravity, Codex, Copilot CLI, Gemini CLI, Hermes, OpenCode |
Le setup pointe sur le dépôt local /home/ubuntu/rea et non sur le paquet npx. Un redémarrage de Claude Code est nécessaire pour charger le serveur ; après cela, les outils mcp__rea__* étaient disponibles. L'enregistrement était toujours présent 20 s après l'écriture. Observé
Vue d'ensemble donnée par Hopper
| Attribut | Valeur |
|---|---|
| Chargeur | -l WinPE --intel-64, format pe, architecture x86_64 |
| Procédures détectées | 45 173 |
| Chaînes | 29 |
| Segments | 10 : un segment sans nom (0x400000, 392 o), .text 0x401000–0x501ab1, .rdata 0x502000–0x657fe8, .data 0x658000–0x710ec0, .pdata, .xdata, .idata, .reloc, .symtab (4 o), symboles externes |
L'écart entre les 45 173 procédures de Hopper et les 2 490 reconnues par Ghidra (2 675 dans la pclntab) n'a pas été expliqué. Non vérifié
Ce que Hopper confirme du rapport Ghidra
| Point | Constat Hopper |
|---|---|
| Drapeaux et garde | Littéraux password, --system, --shares, --silent, --path, --fast, --superfast, --ultrafast, --T, avec bad args à côté. Le texte d'usage est référencé par main.main. |
| Mot de passe en dur | G7Vz9eyG présent dans le binaire (une occurrence) |
| Tâche planifiée et relance | schtasks, gentlemen_system, LOCKER_BACKGROUND=1, ONSTART, /Create, /Delete |
| Propagation latérale | \share$\, C:\Temp, mpr.dll, /GRANT:Everyone,FULL, Add-MpPreference -ExclusionProcess, netsh, share, UpdateSvc |
| Indicateurs | Clé publique en base64 (une occurrence) et extension .axfsmg présentes dans le fichier |
| Chiffrement | Voir La fonction de chiffrement : sans KDF, blocs de 64 Kio, XOR du dernier octet du nonce, pied et suffixes |
Hopper affiche des pseudo-codes proches de l'assembleur, sans noms Go, et ses chaînes Go (pointeur plus longueur, non terminées par un zéro) n'apparaissent pas telles quelles. Les chaînes ci-dessus ont été résolues en relisant les adresses dans le fichier. Les noms viennent de la pclntab Ghidra. Observé
Temps mesurés
| Opération | Durée |
|---|---|
rea analyze avec Hopper, à froid | 1 min 37 s |
rea decompile d'une fonction, en CLI | environ 88 s (la session Hopper redémarre à chaque commande) |
| Appels MCP avec session ouverte | Nettement plus rapides, non chronométrés |
Limites et points ouverts
Pas d'exécution
Tout vient de la décompilation Ghidra et Hopper et des octets du fichier. Les noms Go viennent de la lecture de la pclntab ; ils n'existent que dans la session éphémère, l'exécutable n'est pas modifié.
Triées, pas relues
Les 890 fonctions de bibliothèque renommées par garble ont été triées par graphe d'appels (771 atteintes, 119 non atteintes), pas lues ligne à ligne. Voir C2 et exfiltration.
À confirmer par exécution
Ordre exact de concaténation clé / nonce (Ghidra seul), offsets des trois blocs (calculés d'après Ghidra), taux de survie des clés en mémoire, absence réelle de trafic sortant.
Résolu depuis la première version
Total chiffré avec --fast et suivants : trois fois le ratio, soit 27 %, 9 %, 3 % et 0,9 %.
Outil de récupération
Validé sur un cas synthétique construit avec le même modèle. Il n'a jamais tourné sur un dump ou un fichier chiffré réels.
Réseau
Aucune connexion sortante, résolution DNS ni client HTTP dans le binaire. net/http n'est pas lié (correction de la première version). Une exécution isolée avec capture réseau n'a pas été faite. Dérivé
Avis Microsoft
Lu via un outil de résumé, sans hachage pour relier notre échantillon à la page. Les outils des opérateurs qu'il cite (WinSCP, pilote vulnérable, AnyDesk…) ne sont pas dans le binaire.