← ALL FILES REPAIR · Analyses
Dossier The Gentlemen

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.

SHA-2563ab9575225e00a83a4ac2b534da5a710bdcf6eb72884944c437b5fbe5c5c9235

Observé 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é

OutilRôlePourquoi celui-là
REA inspect-artifact, inspect-pe-resourcesInventaire, sections, ressourcesLecture sans exécution. Résultat : un seul fichier, aucune ressource PE.
REA inspect-go-binaryVersion Go, modulesTest rapide. Il renvoie unknown : premier signe d'obscurcissement.
Ghidra 12.1.4 (via REA)Désassemblage et décompilationHopper 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 chiffrementSecond moteur indépendant. Il confirme les faits clés du rapport Ghidra. Voir Hopper et REA.
REA analyze, search, decompileAperçu, recherche, pseudo-code2 490 procédures et 4 415 chaînes reconnues, mais aucun nom Go.
Serveur MCP REA (open_binary, set_addresses_names, batch_decompile)Session persistanteChaque appel CLI réimporte le binaire (~95 s à froid). Une session unique a servi ~930 décompilations.
rabin2, stringsImports, sections, chaînes brutesLes 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 GoGhidra ne retrouvait pas les noms. Le magic de la table était modifié.
YARARègle de détectionValide que les marqueurs retenus identifient bien l'échantillon.
gentlemen_memkey.py (Python, cryptography, numpy)Recherche de clés dans un dump mémoireVoir Outil de balayage.
llvm-objdump + scripts Python de graphe d'appelsAtteignabilité depuis main.*, appelants des API Win32Seul 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

  1. Extraction isolée

    Archive ouverte avec le mot de passe infected dans un dossier neuf. Un seul PE en sortie. Observé

  2. Premier tri

    Section .symtab minuscule, imports limités à kernel32, fonctions main.* aux noms aléatoires : un binaire Go obscurci avec garble. Inféré

  3. 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é

  4. Obstacle : pas de noms de fonctions

    Ghidra n'expose que entry. La pclntab n'est pas à son magic habituel (d10889d7 au lieu de fffffff1). Un parseur de 40 lignes retrouve les 2 675 fonctions.

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

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

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

  8. Livrables

    Rapport Markdown, règle YARA, note de rançon extraite, carte des noms Go, pseudo-code des fonctions main.*.

  9. Recoupement avec Hopper

    Même binaire ouvert dans Hopper via REA : vue d'ensemble, décompilation de main.main et de la propagation latérale, puis des deux variantes de la routine de chiffrement. Détails.

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

AttributValeur
Fichier3ab9575225e0…c5c9235.exe, 2 962 944 octets (zip : 1 488 214)
MD54200b46a93c6ab059e2b34ce200c4a5b
SHA-142bcc743c71a9ea083c1c750a398110582796762
Sections.text 1 Mo · .rdata 1,4 Mo · .data 0,76 Mo · .pdata · .xdata · .idata · .reloc · .symtab
Horodatage PE0 (1970-01-01), normalisé
Entropie.text 6,26 · .rdata 5,64 · .data 7,54 (JPEG embarqué). Pas de packer.
Imports46 fonctions de kernel32 ; le reste est résolu à l'exécution
ObscurcissementNoms 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"]
Flot de contrôle de main.main. Dérivé

Options de la ligne de commande

OptionEffet
--passwordObligatoire, doit valoir la constante codée en dur
--path D1,D2Dossiers ou disques ciblés
--T MINDélai avant l'action, en minutes
--silentNe renomme pas les fichiers ; pas de fond d'écran
--systemDisques locaux, via une tâche planifiée SYSTEM
--sharesLecteurs réseau mappés et partages UNC
--fullDeux phases : --system puis --shares
--fast, --superfast, --ultrafastChiffrement 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"]
Schéma par fichier. Clé et nonce : Dérivé de la disposition des tampons (Ghidra, structure confirmée par Hopper). Le pied ne contient que la clé publique : le point de base lu à 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

OptionTexte d'aideRatio par bloc (code)Total chiffré (3 blocs)
(aucune)non indiqué0,0927 %
--fast9 %0,039 %
--superfast3 %0,013 %
--ultrafast1 %0,0030,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)
windowssystem volume informationinteladmin$ipc$! Cynet Ransom Protection(DON'T DELETE)sysvolnetlogon$windows.~ws$windows.~btapplication datamozillamsocachemicrosoftWinSxS$Recycle.Bintor browserbootconfig.msigoogleSystem32perflogsappdatawindows.old
Fichiers exclus (19)
desktop.iniautorun.iniautorun.infntldrbootsect.bakthumbs.dbboot.inintuser.datntuser.dat.logntuser.iniiconcache.dbbootfont.binpagefile.syshiberfil.sysbootmgrbootmgr.efibootmgfw.efi#recycleREADME-GENTLEMEN.txt
Extensions exclues (49)
themepacknlsdiapkgmsilnkexescrbatdrvrtpmspprfmscicokeyocxhostsdiagcabdiagcfgpdbwpxhlpicnsromdllmsstylesmodps1icshtabincmdani386lockcuridxsyscomdeskthemepackshsthemempanomediasplcpladviclmsu

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é

AdresseNom pclntabRôle
0x4f82a0main.xSuNBKyhMYFichier entier (≤ 1 Mio), 99 lignes de pseudo-code Ghidra
0x4f8760main.vuNqC5r3qmuATrois blocs (> 1 Mio), 166 lignes

Variante fichier entier (0x4f82a0)

  1. Lecture

    Alloue un tampon de la taille du fichier (makeslice) et le remplit avec ReadAt.

  2. Clé privée éphémère

    32 octets tirés par crypto/rand dans un tampon de pile mis à zéro au départ.

  3. 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é.

  4. Clé et nonce

    Les deux valeurs de 32 octets sont concaténées (growslice puis memmove). 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.

  5. Chiffrement sur place

    NewUnauthenticatedCipher avec une clé de 32 octets et un nonce de 24 octets (donc XChaCha20), puis XORKeyStream sur tout le tampon.

  6. Écriture

    Seek(0, 0), Write du chiffré, Seek(0, 2), puis le pied : --eph-- + base64(clé publique) + saut de ligne, --marker-- + marqueur + saut de ligne, marqueur, et Sync.

Variante trois blocs (0x4f8760)

  1. Même préparation

    Clé privée, deux opérations X25519, tampon de 64 octets, découpage clé / nonce.

  2. Taille d'un bloc

    B = (int)(taille × ratio), avec le ratio lu dans le double global 0x658280. Les offsets sont 0, (taille − 3B) / 2 + B et le double du deuxième. Le troisième bloc finit donc à la fin du fichier.

  3. 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 de 0x10000 octets alimente un flux continu : ReadAt, XORKeyStream, WriteAt au même endroit.

  4. Test d'annulation

    La boucle contient des appels ifaceeq à chaque tour, probablement une vérification d'annulation par contexte. Inféré

  5. Pied et suffixe

    Même pied que ci-dessus, puis un suffixe selon le drapeau de vitesse actif.

Constantes décodées

ValeurLectureOù
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)
0x3fb70a3d70a3d70a0,09Ratio par défaut, écrit dans 0x658280 par main.main
0x3f9eb851eb851eb80,03Ratio de --fast
0x3f847ae147ae147b0,01Ratio de --superfast
0x3f689374bc6a7efa0,003Ratio de --ultrafast
0x1000001 MioSeuil 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.

HopperNom pclntabRôle
sub_4efd80dCMD4fgvjt.F1xKKWaKBhycrypto/rand.Read (lecteur par défaut, puis (*bbtdkx8h).Read à 0x4efcc0)
sub_4f7740I_tJndf.iO0q1G0DEnveloppe X25519 : appelle crypto/ecdh NewPrivateKey (0x4f6c00) puis ECDH (0x4f6b20)
sub_4f76e0I_tJndf.init.0Initialise le point de base : pointeur 0x658700, longueur 32. Octets lus : 09 suivi de 31 zéros
sub_4efec0yX3pzc.iZfjiur8lchacha20.NewUnauthenticatedCipher ; avec un nonce de 24 octets, appelle HChaCha20 (0x4f0e00)
sub_4f0140yX3pzc.(*UbA4Ia0klTWg).XORKeyStreamChiffrement du flux
sub_4b5060, 4b54a0, 4b53a0, 4b5800, 4b5f20f92qU715.(*TRonwfLfU). ReadAt, WriteAt, Write, Seek, SyncMéthodes d'un fichier os.File
sub_4d81a0moBNqIpams.(*NGfODVZEs).EncodeToStringBase64 de la clé publique pour le pied
sub_46c720, 46c800, 4732e0runtime.makeslice, growslice, memmoveAllocation, 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é

QuestionRésultatPreuve
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é).

débutmilieufin

Fichier de plus de 1 Mio, hors pied. Les blocs de 0,3 % sont tracés avec une largeur minimale visible.

ModePart chiffréePart en clair
défaut27 %73 %
--fast9 %91 %
--superfast3 %97 %
--ultrafast0,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

SecretOùPreuve
Clé privée éphémère (32 octets bruts)Pile du chiffreurRemplie par crypto/rand, aucun effacement ensuite
Deux copies de cette clé privéeTas : deux objets ecdh.PrivateKeysub_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 nonceStructure du chiffreursub_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.

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

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

  3. 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énarioChances
Processus encore actif : le suspendre (pas le tuer), puis procdump ou WinPmemMeilleur 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'infectionBon, y compris pour des pages libérées mais non réécrites
pagefile.sys / hiberfil.sysFaible 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éeQuasi 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é.

CasSituation dans le dumpRésultat
A · 300 Ko, défautTampon secret partagé + clé publique, à une adresse non alignéeRécupéré en entier (en-tête %PDF)
B · 3 Mo, --fastClé privée seule, loin de toute ancre (balayage complet)Récupéré en entier, pub-match
C · 2 Mo, --ultrafastStructure du chiffreur pour le bloc 1 seulementRécupéré partiellement (1 bloc sur 3), identique à l'original
D · 400 KoAucune clé dans le dumpCorrectement signalé comme non récupéré
E · 1,5 Mo, --superfastClé privée proche de la clé publiqueRé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

FonctionActions
zKea_2JCoupe la protection temps réel de Defender ; ajoute une exclusion pour le processus et pour C:\
kKWtr5xzZCwvssadmin 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
d8KcLKgtaskkill /F sur ~80 processus : bases de données, sauvegarde, messagerie, Office, TeamViewer, Veeam, SAP
y3AznbY7SuoDésactive (sc config start= disabled) puis arrête ~75 services : Veeam, Backup Exec, Acronis, MSSQL, Exchange, Sophos, Commvault, QuickBooks
k0t09p7jp5Active 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.

  1. Copie du binaire dans C:\Temp et partage SMB share$ ouvert à Everyone.
  2. Ajout de share$ à NullSessionShares et EveryoneIncludesAnonymous = 1 : le SMB devient plus permissif.
  3. Liste des machines du domaine par NetServerEnum, hôte local exclu.
  4. Pour chaque hôte, le binaire est lancé de six façons successives : wmic /node, schtasks /S (tâches DefU, UpdateGU, DefS), sc \\hôte create (services DefSvc, UpdateSvc), Invoke-Command et la classe WMI Win32_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

RechercheRésultat
URL http(s)://, ftp, wsQuatre 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 IPv4Aucune
Verbes et en-têtes HTTPAucun User-Agent, POST /, GET /, multipart/form-data, Authorization, WebSocket
API d'envoi WindowsAucune API WinHTTP, WinINet ni urlmon
Outils de transfertAucun Invoke-WebRequest, DownloadFile, curl, bitsadmin, certutil -urlcache, rclone, winscp, webhook, outil d'accès distant
net/http, crypto/tls, mime/multipartPré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é

MesureValeur
Fonctions des paquets renommés ou non standard890
Atteintes depuis main.* ou un initialiseur771 (dont 756 depuis main.*)
Non atteintes119 : méthodes appelées par interface (String, MarshalBinary de time…), stubs d'assembleur du runtime (_rt0_amd64_windows, sigtramp, debugCall*)
Fonctions syscall atteintes120 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é

osos/execflagencoding/base64crypto/randcrypto/ecdh (X25519)chacha20 (XChaCha20)timeio/fs et parcours de fichiersstrings.Builder / bytes.Bufferinternal/poll (FD)internal/syscall/windowsregistre Windowshachages SHA, HMAC / HKDFcorps de Curve25519

API Win32 appelées par le programme

La méthode (*LazyProc).Call n'est appelée que par trois fonctions de main.*.

FonctionAPIUsage
main.dubaKa (user32.dll)SystemParametersInfoFond d'écran
main.hx5YkrU9k (mpr.dll)WNetOpenEnum, WNetEnumResourceÉnumération des partages réseau
main.hixEDW9 (netapi32.dll)NetServerEnum, NetApiBufferFreeListe 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

Clé publique X25519fcb11717cab989424755a957c1d55361b119de4fdcfecdb2f2e56b15ad801922
Même clé, base64/LEXF8q5iUJHValXwdVTYbEZ3k/c/s2y8uVrFa2AGSI=
Mot de passeG7Vz9eyG
Variable d'env.LOCKER_BACKGROUND=1

Contacts de l'acteur (dans la note)

Tox88984846080D639C9A4EC394E53BA616D550B2B3AD691942EA2CCD33AA5B9340FD1A8FF40E9A
E-mail[email protected]
Blog .oniontezwsse5czllksjb7cwp65rvnk4oobmzti2znn42i43bjdfd2prqqkad.onion
ID victimeead0d7a8ae0a6ffb7f0a5873fec4ff5e

Sur les machines

TypeValeurs
FichiersREADME-GENTLEMEN.txt, extension .axfsmg, pied --eph-- … --marker--GENTLEMEN (81 à 91 octets), %TEMP%\gentlemen.bmp, C:\Temp\<exe>
Partageshare$ sur C:\Temp
Tâches planifiéesUpdateSystem, UpdateUser, gentlemen_system, DefU, UpdateGU, DefS
ServicesDefSvc, UpdateSvc
RegistreRun : 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:\ ou C:\Temp, et arrêt de la protection temps réel.
  • vssadmin delete shadows ou wevtutil cl suivis de nombreux arrêts de services de sauvegarde.
  • Création du partage share$ et modification de NullSessionShares.
  • Tâches planifiées ou services distants nommés Def* ou Update* créés par schtasks /S ou sc \\.
  • 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

TechniqueOù dans le binaire
T1486 Data Encrypted for ImpactX25519 + XChaCha20, extension .axfsmg
T1490 Inhibit System Recoveryvssadmin, wmic shadowcopy
T1489 Service Stop~75 services désactivés puis arrêtés
T1562.001 Impair DefensesDefender (local et distant), pare-feu
T1070.001 / T1070.004Journaux d'événements, Prefetch, historique PowerShell
T1053.005 · T1547.001 · T1543.003Tâches planifiées, clés Run, services
T1021.002 · T1047Partages SMB, WMI
T1135 · T1083Découverte des partages, parcours des fichiers
T1222.001takeown, icacls, attrib
T1491.001 Internal DefacementFond d'écran
T1027 Obfuscated Filesgarble

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

PointArticleNotre échantillon
ChiffrementX25519 + XChaCha20, clé éphémère de 32 octets par fichierIdentique
Garde d'entrée--password, 8 octets, bloque les bacs à sableG7Vz9eyG (8 caractères)
Options--path, --fast, --superfast, --ultrafastPrésentes
ExtensionAléatoire, six caractères (.7mtzhh, .ojuopo).axfsmg, six caractères, codée en dur dans ce build
Note de rançonREADME-GENTLEMEN.txt dans chaque dossierIdentique
Fond d'écran, copies shadow, journauxFond d'écran changé, wevtutil, copies shadow et sauvegardes suppriméesPrésents
DéfensesPowerShell : protection temps réel coupée, exclusionsSet-MpPreference et Add-MpPreference
Services arrêtésSécurité, sauvegarde (Veeam), bases (MSSQL), Exchange~75 services, dont ces familles
PersistanceClé Run dans HKLMRun GupdateS (HKLM) et GupdateU (HKCU), plus deux tâches planifiées
LangageCharge utile en GoGo ≥ 1.24, garble

Ce qui diffère ou manque

PointArticleNotre échantillon
ExfiltrationWinSCP ou WebDAV, regroupement dans C:\ProgramData\data, envoi vers un C2Aucune trace de WinSCP, WebDAV ni ProgramData\data
Pilote vulnérableThrottleStop.sys (ou ThrottleBlood.sys), CVE-2025-7771 ; All.exe et Allpatch2.exe modifient NtAddAtom en mémoire noyauAucune trace de ThrottleStop, NtAddAtom, All.exe, Allpatch
Accès distantAnyDesk, RDP activé (fDenyTSConnections = 0)Aucune trace de AnyDesk ni de fDenyTSConnections
Registre DefenderDisableAntiSpyware = 1Absent : seul PowerShell est utilisé
Déploiement latéralNetExec, PsExec, GPO déployant le binaire par NETLOGONAucune 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 commandeSeulement les quatre ci-dessusAussi --T, --silent, --system, --shares, --full. L'article simplifie peut-être ; les builds peuvent aussi différer.
PlateformesWindows, Linux, NAS, VMware ESXiWindows seulement, aucune chaîne ESXi
Accès initialCVE-2024-55591 (FortiOS / FortiProxy), identifiants VPNHors 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.sys ou ThrottleBlood.sys chargé 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.txt dans 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

  1. Vérification de l'installation Hopper

    Hopper est présent dans /opt/hopper. Le diagnostic rea doctor --provider hopper est sain. Observé

  2. Ouverture du binaire dans Hopper

    rea analyze avec --provider hopper sur l'échantillon : 1 min 38 à froid.

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

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

  5. Décompilation de la fonction de chiffrement

    Via le serveur MCP rea chargé après redémarrage. Voir La fonction de chiffrement.

  6. 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ôleRé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èquesldd ne signale aucune bibliothèque manquante
Environnement de démoSonde hopper-demo-runtime au vert, strategy=direct, /tmp/.X11-unix en mode 1777 et inscriptible. Xvfb, xvfb-run et python3 installés.
HôteUbuntu 24.04, dans la base supportée
Node24.13.1 alors que .nvmrc demande 24.18.0. N'a pas gêné le diagnostic.
Empreinte du lanceurlauncher-sha256:0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded
Ghidra / IDANon 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_codeValeur
Serveur MCPrea 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ésAntigravity, 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

AttributValeur
Chargeur-l WinPE --intel-64, format pe, architecture x86_64
Procédures détectées45 173
Chaînes29
Segments10 : 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

PointConstat Hopper
Drapeaux et gardeLitté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 durG7Vz9eyG présent dans le binaire (une occurrence)
Tâche planifiée et relanceschtasks, 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
IndicateursClé publique en base64 (une occurrence) et extension .axfsmg présentes dans le fichier
ChiffrementVoir 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érationDurée
rea analyze avec Hopper, à froid1 min 37 s
rea decompile d'une fonction, en CLIenviron 88 s (la session Hopper redémarre à chaque commande)
Appels MCP avec session ouverteNettement 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.