Trigona : le même flux OFB revient tous les 1 Mio
Catégorie : Ransomware
Analyste : Gordon PEIRS
Famille : Trigona, ransomware Delphi actif au moins depuis 2022.
Méthode : reverse statique (Ghidra, décompilation et désassemblage x86/x64) de quatre échantillons datés de 2023 à 2026, plus deux preuves de concept Python : l'une reproduit exactement le générateur de nombres en cause, l'autre utilise une vraie implémentation AES-OFB pour démontrer la récupération de texte clair. Les échantillons ne sont jamais exécutés.
Résumé
Trigona chiffre les fichiers en AES-OFB, par blocs de 1 Mio. Sur les quatre échantillons examinés (2023, 2024, 2025 en i386, 2026 en x64), la fonction qui traite un bloc de 1 Mio réinitialise à chaque appel le registre de rétroaction OFB à partir du même IV. Ni cet état ni l'IV n'avancent d'un bloc au suivant. La conséquence, vérifiée par le calcul : dans tout fichier de plus de 1 Mio, chaque bloc de 1 Mio est chiffré avec exactement le même flux de clé que les autres. Un XOR entre deux blocs du chiffré donne directement le XOR des deux blocs de clair correspondants, sans la clé AES et sans l'IV. Il n'y a pas besoin d'une collision chanceuse : ça marche sur 100 % des fichiers assez gros. Il suffit alors de connaître ou de deviner un fragment de clair dans un seul bloc (un en-tête de format, un morceau retrouvé ailleurs) pour récupérer la même portion dans tous les autres blocs de 1 Mio du fichier.
Deuxième défaut, indépendant du premier : l'IV de 16 octets de chaque fichier vient de System.Random, le générateur congruentiel linéaire (LCG) 32 bits du runtime Delphi/Borland, tiré d'un flux global qui n'est jamais réamorcé entre deux fichiers d'un même thread de chiffrement. Ce LCG est linéaire, son état interne ne fait que 32 bits et ses constantes sont publiques. Un seul IV (stocké en clair dans le footer de chaque fichier, comme d'habitude) suffit donc à reconstruire l'état interne exact et à prédire l'IV de n'importe quel autre fichier chiffré par le même thread. On retrouve ce mécanisme à l'identique sur les quatre builds, y compris celui de 2026, où la génération de la clé maîtresse a pourtant été refaite et renforcée (pool d'entropie réalimenté en continu par du bruit temporel, rotation périodique de la clé). La clé a été durcie, l'IV a été oublié.
Tout est reproductible depuis
runbook.md. Les scripts sont danstools/.
1. Échantillons
| SHA256 | Première soumission | Taille | Format |
|---|---|---|---|
b49bf3a4baf637e067a8db7360051eba39713b7958519b49f8e236b6014c8477 |
2023-06-30 | 1 142 272 octets | PE32 i386 |
728a92e80adaf17a7248eb9934e2ffbfb6fbdfee192d86b67d9d765986d402d5 |
2024-01-23 | 163 840 octets (packé UPX, 350 720 déballé) | PE32 i386 |
ddc3e57e4a913dd5bc8070e7a7760b804aa15f0387bafa7de7d4ac6a4f797300 |
2025-02-11 | 350 720 octets | PE32 i386 |
48877a3a4c72c1daf3a80e3c034b56a04cec7ce3856887fed73e645e53c76b96 |
2026-04-21 | 535 040 octets | PE32+ x64 |
La note de rançon, confirmée par une chaîne embarquée : how_to_decrypt.hta.
2. Le défaut central : le registre OFB repart de zéro à chaque bloc de 1 Mio
AES-OFB chiffre en faisant tourner un registre : registre[0] = IV, registre[i] = AES_chiffre(cle, registre[i-1]), et chaque bloc de clair est XORé avec le registre[i] correspondant. Toute la sécurité du mode repose sur le fait que ce registre avance en continu et ne repasse jamais par la même valeur avec la même clé.
Sur le build 2023 (i386), la boucle qui traite un fichier de plus de 1 Mio est FUN_004e3a08 :
DVar1 = 0x100000; // 1 Mio par appel
do {
FUN_004e37bc(param_1, DVar1, param_2, param_4, param_5, local_8, local_14, local_10);
// param_2 = IV/contexte, IDENTIQUE a chaque iteration
local_14 = local_14 + 0x100000; // avance la position dans le fichier
} while (...)
FUN_004e37bc passe cet IV tel quel à FUN_004db93c, qui s'en sert pour (ré)initialiser le registre OFB au tout début de chaque appel :
FUN_00407104((longlong*)*param_3, (longlong*)local_c, (longlong*)0x10); // registre = IV, a CHAQUE appel
for (iVar3 = 0; iVar3 + 0x10 <= param_4; iVar3 += 0x10) {
Encrypt(local_c, local_10); // registre suivant
ciphertext = plaintext ^ local_10;
local_10 -> local_c; // enchainement CORRECT a l'interieur d'un seul appel
}
À l'intérieur d'un bloc de 1 Mio, le chaînage est correct. Le problème est entre deux appels : rien ne fait avancer *param_3 (l'IV) de l'un à l'autre, et FUN_004db93c repart toujours de *param_3 en tête de fonction. Le deuxième bloc de 1 Mio d'un fichier démarre donc, dès son premier octet, avec exactement le même registre que le premier.
Le build 2026 (x64) a un code complètement différent et la même faille de structure. La boucle des gros fichiers (FUN_00441870/FUN_00441960) appelle FUN_00441360 une fois par bloc de 1 Mio, avec le même pointeur d'IV à chaque itération. FUN_00441360 appelle FUN_00437f60, qui réinitialise explicitement le registre OFB à partir de cet IV à chaque appel : FUN_00437da0 copie l'IV dans le contexte AES, le registre est utilisé puis effacé en fin de fonction, et il n'est jamais réécrit chez l'appelant :
FUN_00437da0(*param_6, *param_2, 0x100, *param_3); // Init(cle, IV) -- IV inchange a chaque appel
FUN_00405ab0(*param_3, local_38, 0x10); // registre OFB = IV
for (...) { FUN_00437540(*param_6, local_38, local_38); ... } // chaine du registre A L'INTERIEUR d'un bloc
...
FUN_00406a30(local_38, 0x10, 0); // efface le registre, ne le renvoie jamais
J'ai vérifié sur les deux binaires que le pointeur d'IV passé à la boucle de traitement par blocs n'est modifié nulle part entre deux appels.
3. Preuve de concept : récupération de texte clair sans clé ni IV
tools/trigona_chunk_reuse_poc.py reproduit ce comportement avec une vraie implémentation AES-OFB (pycryptodome), sur un fichier synthétique de deux blocs de 1 Mio chiffrés avec une clé et un IV que l'attaquant simulé ne connaît pas :
- Le XOR des deux blocs de chiffré est exactement égal au XOR des deux blocs de clair, vérifié sur les 1 048 576 octets, sans qu'aucune clé n'intervienne.
- Avec seulement 64 octets de clair connus ou devinés dans le premier bloc (un en-tête de format structuré suffit souvent), on récupère à l'identique les 64 octets correspondants du deuxième. Si on peut reconstituer tout le clair du premier bloc (format connu, copie partielle retrouvée ailleurs), c'est tout le bloc suivant qu'on récupère, et pareil pour chaque bloc de 1 Mio supplémentaire du fichier.
Cette attaque ne demande ni la clé AES, ni l'IV, ni la clé RSA de l'attaquant. Il faut seulement le fichier chiffré, dès qu'il dépasse 1 Mio.
4. Un second défaut, indépendant : l'IV par fichier vient d'un LCG public jamais réamorcé
Sur les quatre builds, la fonction qui fait avancer le générateur System.Random du RTL Delphi/Borland est la même, seule son adresse change :
RandSeed = RandSeed * 0x8088405 + 1 (arithmétique 32 bits, modulo 2^32)
Random(N) = (RandSeed * N) >> 32
RandSeed est correctement initialisé au démarrage, avec de la vraie entropie, sur les quatre builds : du bruit temporel mesuré sur des paires QueryPerformanceCounter consécutives. Je l'ai vérifié au désassemblage brut sur le build 2023, où l'écriture vise bien l'adresse de RandSeed (la constante 0x004ed008 est codée en dur à l'emplacement du pointeur utilisé). Le problème n'est donc pas la graine, c'est ce qu'on en fait. Un seul thread de chiffrement peut parcourir tous les lecteurs locaux (A: à Z:) avec la même clé maîtresse, et pour chaque fichier l'IV de 16 octets est tiré comme ceci :
pour i de 0 a 15:
iv[i] = Random(256) // tirage suivant du MEME flux LCG global, jamais reamorce
J'ai vérifié que cette construction (16 tirages Random(256) à la suite, sans réamorçage entre deux fichiers) est identique sur les builds 2023 et 2025 (i386). Elle est aussi dans le build 2026 (x64, fonction FUN_00441130), qui tire toujours ses IV avec FUN_00405ec0, le wrapper Random(N) brut. C'est le même LCG faible que sur les builds précédents, sans aucun durcissement.
Ce LCG est linéaire, son état fait 32 bits et ses constantes sont publiques. Chaque octet de l'IV expose directement l'octet de poids fort d'un état interne successif. Avec 16 octets consécutifs connus (l'IV, stocké en clair dans le footer de chaque fichier chiffré), il suffit de deviner les 24 bits bas du premier état pour reconstruire l'état complet : une recherche sur 2^24 candidats où les mauvais sont rejetés presque tout de suite, soit quelques secondes en Python pur. Une fois l'état retrouvé à partir d'un seul IV, on calcule exactement l'IV de tout fichier chiffré avant ou après par le même thread. tools/trigona_iv_poc.py le démontre de bout en bout sur une graine plantée, inconnue de l'attaquant simulé.
Cette prédictibilité de l'IV ne dépend pas du défaut de la section 2. Même un fichier qui tient dans un seul bloc de 1 Mio, donc épargné par la réutilisation de flux, partage sa clé avec beaucoup d'autres fichiers. Avec un IV prévisible, on finit par tomber sur une collision exacte (même clé et même IV pour deux fichiers différents) dès qu'un thread traite assez de fichiers. La borne d'anniversaire sur un état de 32 bits place ce seuil vers 54 000 à 77 000 fichiers, un volume réaliste pour un partage de fichiers d'entreprise ou plusieurs lecteurs locaux.
5. La clé maîtresse : saine sur les quatre builds, et renforcée sur le build 2026
Sur les builds 2023 à 2025, la clé maîtresse (32 octets) est générée une fois par thread de chiffrement à partir d'un collecteur de bruit temporel de bonne qualité (paires QueryPerformanceCounter consécutives mesurées bit à bit, 1000 itérations par bit). Elle sert ensuite pour tous les lecteurs traités par ce thread.
Le build 2026 refait cette partie en profondeur. Un pool de 64 octets est initialisé (avec un remplissage provisoire sans importance, destiné à être écrasé), puis mélangé par 256 tours (FUN_00436510, appelée avec 16 puis 240 itérations ; le binaire contient le message de progression « Generate randomizer noice. Please await 15 seconds... »). Chaque tour injecte un peu de bruit temporel : le nombre d'itérations d'une boucle d'attente active entre deux changements de valeur de GetTickCount, combiné par XOR via un hachage MD5 (constantes d'initialisation 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476 retrouvées dans le binaire). Une clé de 32 octets est ensuite extraite de ce pool (FUN_004403e0) à chaque fois qu'il en faut une, avec une rotation basée sur le temps écoulé (contrôlé par GetTickCount) au lieu d'une clé unique pour tout un lecteur. Une même clé est donc exposée moins longtemps que sur les builds précédents. Mais ce renforcement ne corrige pas les défauts des sections 2 et 4, qui touchent le mode de chiffrement et l'IV, pas la qualité de la clé.
6. Portée : quels builds sont concernés
| Défaut | 2023 | 2024 | 2025 | 2026 |
|---|---|---|---|---|
| Réutilisation de flux OFB intra-fichier (section 2) | vérifié par désassemblage | cohérent avec le code identique 2024/2025, non revérifié indépendamment | cohérent avec le code identique 2024/2025, non revérifié indépendamment | vérifié par désassemblage |
| IV par fichier depuis un LCG faible jamais réamorcé (section 4) | vérifié | vérifié (adresses identiques à 2025) | vérifié | vérifié |
| Qualité de la clé maîtresse | bonne (bruit temporel réel) | bonne (idem 2023) | bonne (idem 2023) | renforcée (pool à 256 tours + rotation) |
7. Ce qui n'a pas été résolu
- L'enveloppement de la clé maîtresse (RSA ou autre) : je n'ai trouvé ni chaîne
RSA1niBEGIN PUBLIC KEYdans le binaire 2023, et je n'ai pas retracé le mécanisme au-delà de la construction du footer par fichier. - Le thread de chiffrement des partages réseau (un second point d'entrée de thread visible dans
entrysur 2023) n'a pas été retracé séparément de celui des lecteurs locaux. - La réutilisation de flux à l'intérieur d'un fichier sur 2024 et 2025 n'a pas été revérifiée instruction par instruction (voir le tableau de la section 6). Elle est très probable, puisque le reste du code est identique entre ces builds et 2023/2026, mais je ne l'ai pas confirmée directement.
- Le seuil exact de rotation de la clé sur le build 2026 (nombre de millisecondes ou de fichiers entre deux régénérations) reste à déterminer. Seule la logique de rotation est confirmée.
8. Indicateurs de compromission
| Type | Valeur |
|---|---|
| SHA256 | b49bf3a4baf637e067a8db7360051eba39713b7958519b49f8e236b6014c8477 |
| SHA256 | 728a92e80adaf17a7248eb9934e2ffbfb6fbdfee192d86b67d9d765986d402d5 |
| SHA256 | ddc3e57e4a913dd5bc8070e7a7760b804aa15f0387bafa7de7d4ac6a4f797300 |
| SHA256 | 48877a3a4c72c1daf3a80e3c034b56a04cec7ce3856887fed73e645e53c76b96 |
| Fichier de note de rançon | how_to_decrypt.hta |
| Taille de bloc de chiffrement (constante, sur les quatre builds) | 0x100000 (1 Mio) |
| Construction LCG (constantes publiques du RTL, pas un secret Trigona) | multiplicateur 0x8088405, incrément 1, module 2^32 |
9. Divulgation et usage
Les échantillons étudiés sont publics (MalwareBazaar) et le ransomware est actif depuis plusieurs années. La faille principale (section 2) est une erreur d'implémentation du mode de chiffrement, pas une vulnérabilité dans un composant cryptographique tiers. Le second défaut (section 4) exploite une faiblesse bien connue du générateur public du RTL Delphi/Borland, utilisé ici pour un usage auquel il n'est pas destiné. Les deux PoC (tools/trigona_chunk_reuse_poc.py, tools/trigona_iv_poc.py) démontrent la récupération de clair et la prédiction d'IV sur des données synthétiques uniquement : ils ne déchiffrent aucun contenu réel et n'ont besoin d'aucune clé secrète. Le but est de donner aux équipes de réponse à incident face à un cas Trigona une base directement applicable, dès qu'un fichier chiffré dépasse 1 Mio, pour tenter une récupération partielle à partir des fichiers chiffrés plutôt que de payer.