Runbook : Trigona (RNG / IV OFB)
Corpus#
| SHA256 | Date première soumission | Taille | Format |
|---|---|---|---|
b49bf3a4baf637e067a8db7360051eba39713b7958519b49f8e236b6014c8477 |
2023-06-30 | 1 142 272 | PE32 i386, non packé |
728a92e80adaf17a7248eb9934e2ffbfb6fbdfee192d86b67d9d765986d402d5 |
2024-01-23 | 163 840 (packé UPX) / 350 720 (binaries_unpacked/728a92e8_unpacked.exe) |
PE32 i386 |
ddc3e57e4a913dd5bc8070e7a7760b804aa15f0387bafa7de7d4ac6a4f797300 |
2025-02-11 | 350 720 | PE32 i386 |
48877a3a4c72c1daf3a80e3c034b56a04cec7ce3856887fed73e645e53c76b96 |
2026-04-21 | 535 040 | PE32+ x64 |
Outils : PyGhidra (analyse headless), objdump, pefile, scripts maison dans tools/
(list_functions.py, decompile_addr.py, decompile_by_name.py,
find_api_callers.py, find_constant_refs.py, find_string_refs.py,
find_data_refs.py, crack_api_hash.py, trigona_iv_poc.py). Aucune exécution
réelle de l'échantillon.
Deux fonctions touchent au seed, une seule compte#
Sur le build 2023, FUN_004073a4 lit QueryPerformanceCounter/
GetTickCount dans une variable locale, appelle Random(MaxInt), puis appelle
un pointeur de fonction, sans jamais écrire la valeur QPC lue nulle part.
Prise seule, elle laisserait croire que Randomize() est du code mort et que
RandSeed reste à 0. Ce n'est pas le cas.
La recherche des références de données sur l'adresse de RandSeed
(find_data_refs.py, DAT_004ed008) fait sortir une seconde fonction,
FUN_004e6cac, qui elle écrit réellement dans RandSeed :
FUN_004d55c8(&local_8, 4); // 4 octets de bruit temporel genuine
iVar1 = FUN_004d413c(&local_8); // 4 octets -> entier 32 bits
*(int *)PTR_DAT_004f4078 = iVar1; // ecriture dans RandSeed
Vérification par désassemblage brut (pas seulement le C décompilé) :
4e6cd4: mov edx, [0x4f4078]
4e6cda: mov [edx], eax
et lecture des octets bruts de .data à l'adresse 0x4f4078 :
08 d0 4e 00 = 0x004ed008 en little-endian, soit exactement l'adresse de
RandSeed. FUN_004e6cac est appelée de manière inconditionnelle, en tout
premier dans entry (004ebdeb), avant tout autre traitement.
FUN_004d55c8/FUN_004d413c ne sont pas une source faible : elles collectent
le bruit de gigue via des paires QueryPerformanceCounter consécutives
(FUN_004d54a4, 1000 itérations x 8 bits, plus 8 tours de Sleep(1)), la même
technique haute qualité utilisée pour la clé maîtresse AES. RandSeed est donc
correctement initialisé avec de l'entropie réelle dès le démarrage, sur les
quatre builds (vérifié explicitement sur 2023, 2025, 2026 ; structurellement
identique sur 2024 vu l'adresse-à-adresse identique avec 2025). Le code mort
de FUN_004073a4 existe bien, mais c'est un artefact inoffensif à côté du
vrai mécanisme de seed.
Conséquence pour l'analyse : abandon complet de l'hypothèse initiale. La
vraie faiblesse à chercher n'est pas "graine nulle/prévisible" mais la manière
dont ce flux Random(), correctement seedé une fois, est ensuite consommé.
Reconstruction de l'architecture réelle (build 2023, i386)#
entry(004eb98c) appelleFUN_004e6cac()(seed réel), puisFUN_004073f4(10)(délai de démarrage), puis lance deux threads (FUN_00409df4/CreateThread) avec points d'entréeLAB_004e5984etDAT_004e6a54.- Le thread
004e5984(force la création de fonction Ghidra viaCreateFunctionCmd, non détectée par l'auto-analyse) : - appelleFUN_004e3228une seule fois, au tout début, qui génère 32 octets de cle via le même collecteur de gigue haute qualité (FUN_004d54a4) ; - boucle ensuite sur les 26 lettres de lecteur (A:àZ:,iVar5 != 0x1a) et appelleFUN_004e5220pour chaque lecteur prêt, en réutilisant la même cle tout du long ; - effacé la clé en mémoire seulement après la boucle complète. FUN_004e5220->FUN_004e45b4->FUN_004e34e4construit l'IV par fichier :do { uVar1 = FUN_004073f4(0x100); // Random(256) *(char*)(*param_1 + iVar2) = (char)uVar1; iVar2++; } while (iVar2 != 0x10); // 16 octetsAucun re-seed entre les appels : chaque octet est le tirage suivant du même flux LCG global (DAT_004ed008).FUN_004e2bdcconstruit le footer écrit en fin de fichier chiffré. Le paramètreparam_3de cette fonction est exactement l'adresse de l'IV généré à l'étape 3 ;FUN_00407104(memcpy) le copie en clair à l'offset 2 du footer (16 octets), avant taille de fichier, nom original et identifiant de campagne, suivi d'un motif fixe de 4 octets0x53('S') répétés à intervalles réguliers (marqueur de format).- Pour les fichiers de plus de 1 Mio,
FUN_004e3a08boucle par blocs de0x100000(1 Mio) et appelleFUN_004e37bc->FUN_004db93cà chaque tour (voir section dédiée ci-dessous sur le défaut de réutilisation de flux).FUN_004e3aac/FUN_004e37bcavec un0x80000(512 Ko) fixe et un offset fichier codé en dur à 0 est un chemin distinct, réserve aux petits fichiers tenant en un seul appel.
Défaut majeur : le registre OFB repart du même IV à chaque bloc de 1 Mio#
Découverte en poussant la vérification du build 2026 au-delà de la question initiale "clé/IV suffisamment aléatoires", puis retrouvée à l'identique sur le build 2023 par retour en arrière : la boucle de traitement des fichiers de plus de 1 Mio ne fait jamais progresser l'IV (ni l'état OFB) d'un bloc au suivant. Chaque bloc de 1 Mio d'un même fichier est chiffré avec exactement le même flux de clé.
Build 2023 (i386) : FUN_004e3a08 boucle avec DVar1 = 0x100000 et
appelle FUN_004e37bc(param_1, DVar1, param_2, param_4, param_5, local_8,
local_14, local_10) a chaque tour ; seul local_14/local_10 (position
dans le fichier) avance, param_2 (le pointeur d'IV) est identique à tous
les tours. FUN_004e37bc transmet ce pointeur à FUN_004db93c, qui
réinitialisé le registre OFB depuis *param_3 en tête de fonction, à
chaque appel :
FUN_00407104((longlong*)*param_3,(longlong*)local_c,(longlong*)0x10); // registre = IV
for (...) { registre = Encrypt(registre); ciphertext = plaintext ^ registre; }
Le chaînage interne à un seul appel (donc à un seul bloc de 1 Mio) est correct ; c'est l'absence de progression entre deux appels qui casse tout.
Build 2026 (x64) : même défaut, code totalement différent. La boucle
FUN_00441870/FUN_00441960 appelle FUN_00441360 par bloc de 1 Mio en
transmettant le même pointeur d'IV (param_4) à chaque tour. FUN_00441360
appelle FUN_00437f60, qui appelle FUN_00437da0 (Init clé+IV, copie l'IV
dans le contexte AES) puis chaîne correctement le registre OFB à
l'intérieur de l'appel, avant de l'effacer en fin de fonction
(FUN_00406a30(local_38,0x10,0)) sans jamais le renvoyer à l'appelant.
FUN_00437540 est confirmé être une implémentation AES par T-tables
classique (DAT_0046b0f8 etc.), donc FUN_00437f60 est bien un
DCPrijndael-équivalent en mode OFB, pas un chiffrement maison.
Vérifié sur les deux binaires : aucune écriture vers le pointeur d'IV
n'existe entre deux appels consécutifs de la fonction de traitement par
bloc. La taille de bloc (0x100000) est identique sur les deux builds.
Preuve de concept : tools/trigona_chunk_reuse_poc.py, avec une vraie
implémentation AES-OFB (pycryptodome), reproduit ce comportement exact sur
un fichier synthétique de deux blocs de 1 Mio. Résultat vérifié : XOR direct
des deux blocs de texte chiffré = XOR exact des deux blocs de texte clair
sur les 1 048 576 octets ; avec 64 octets de texte clair connus pour le
premier bloc, les 64 octets correspondants du second bloc sont récupérés à
l'identique.
Portée non entièrement revirifiée : confirmé par désassemblage sur 2023 et 2026 uniquement. Très probable sur 2024/2025 vu l'identité totale du reste du code entre ces builds et 2023, mais pas revérifié instruction par instruction (voir section Non résolu).
Vérification LCG : constantes et wrapper (identiques sur les 4 builds)#
| Build | Adresse RandSeed |
Adresse fonction d'avancement | Adresse wrapper Random(N) |
|---|---|---|---|
| 2023-06-30 | DAT_004ed008 |
004073e0 |
004073f4 |
| 2024-01-23 (unpacked) | DAT_00440008 |
004049bc |
004049d0 |
| 2025-02-11 | DAT_00440008 |
004049bc |
004049d0 |
| 2026-04-21 (x64) | DAT_00465010 |
00405ea0 |
00405ec0 |
Fonction d'avancement, identique partout (const_refs sur 0x8088405) :
DAT_xxx = DAT_xxx * 0x8088405 + 1;
c'est exactement System.Random/RandSeed du RTL Borland/Delphi.
Construction de l'IV par fichier, retrouvée identique :
- 2023 : FUN_004e34e4, boucle iVar2 != 0x10 sur FUN_004073f4(0x100)
- 2025 : FUN_0042fa98, boucle iVar2 != 0x10 sur FUN_004049d0(0x100)
(adresses identiques 2024/2025, non re-vérifié octet à octet sur le binaire
2024 UPX-unpacked distinct, mais même layout mémoire confirmé par ailleurs
sur plusieurs autres fonctions)
Build 2026 (x64) : architecture RNG remaniée, tranchée après poursuite de l'analyse#
Poursuite demandée explicitement pour trancher le point laissé ouvert. Deux générateurs distincts coexistent sur ce build, avec des sorts différents.
Clé maîtresse : durcie et rotée dans le temps.
- FUN_00434480 est confirmé être MD5 (constantes d'initialisation
0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, structure
Init/Update/Final classique via FUN_004342e0/FUN_00434320/FUN_004343e0),
pas SHA-256.
- FUN_00405e60, appelée en tout premier dans entry (0045e1fd), lit
QueryPerformanceCounter, la XORe avec un tirage Random(MaxInt), et
réinjecte le résultat dans RandSeed via FUN_00405e50. Contrairement à
FUN_004073a4 sur le build 2023, ici la valeur QPC est réellement utilisée.
- FUN_00435990 remplit un pool de 64 octets (DAT_0047c700) via 64 tirages
Random(256) (le LCG faible) -- un remplissage provisoire, appelé à être
recouvert.
- FUN_00436510(0x10) puis FUN_00436510(0xf0) (16 puis 240 tours,
message de progression retrouvé dans le binaire : "Generate randomizer
noice. Please await 15 seconds...") appellent chacun FUN_00435a50, qui
mesure le nombre d'itérations d'une boucle d'attente active entre deux
changements de valeur de GetTickCount (une vraie source de gigue
système), combine ce compte avec 2 octets extraits du pool courant, puis
FUN_00435bb0 XOR ce résultat (après hachage MD5 de plusieurs vues
permutées) directement dans DAT_0047c700. Le pool est donc réellement
réamorcé par du bruit temporel authentique 256 fois avant que quoi que ce
soit n'en soit extrait pour un usage cryptographique -- le hachage
nécessaire pour ce remaniement a bien été localisé, il ne s'agit pas de
MD5 servant seul de source d'aléa mais de vecteur de mélange.
- L'extraction réelle de la clé passé par FUN_00436c70 -> FUN_004367e0
(le pool durci, pas le LCG brut) : FUN_004403e0 génère 32 octets ainsi,
utilisés comme clé maîtresse par FUN_004404a0 ("Generate master key." /
"Encrypt internal data." / "Init tail." -- messages de debug retrouvés
dans le binaire).
- FUN_004404a0 est appelé depuis l'intérieur des threads de fichiers
(FUN_0044e520, FUN_0044e990, FUN_00450a90, FUN_00450e30,
FUN_0045cb40, FUN_0044ce60, FUN_00449990, FUN_0045c300), pas
seulement une fois au démarrage. Trace dans FUN_00449990 (le
parcours-et-chiffrement par lecteur, appelé une fois par lecteur comme sur
2023) : la régénération de clé est conditionnée par le temps écoulé depuis
la dernière génération (GetTickCount() - *param_10 comparé à un seuil
configure), vérifiée avant CHAQUE fichier. La clé tourne donc
périodiquement dans le temps, pas seulement une fois par lecteur comme sur
2023-2025 -- une fenêtre d'exposition par clé plus petite, sans toutefois
réduire à zéro le risque de collision (le nombre de fichiers traités
pendant une fenêtre de rotation dépend d'un seuil non déterminé sans
exécution réelle).
IV par fichier : toujours le LCG faible, non durci. FUN_00441130
(appelée juste avant la sélection du chemin de chiffrement adapté à la
taille du fichier, dans FUN_00443320) génère les 16 octets de l'IV ainsi :
do {
uVar1 = FUN_00405ec0(0x100); // Random(256), LCG BRUT, pas le pool durci
*(char*)(*param_1 + iVar2) = (char)uVar1;
iVar2++;
} while (iVar2 != 0x10);
Mécanisme identique, instruction pour instruction, à FUN_0042fa98 (2025)
et FUN_004e34e4 (2023). Le durcissement appliqué à la clé n'a pas été
applique à l'IV : deux générateurs distincts dans le même binaire, un
corrige, l'autre non.
Conclusion pour ce build : la clé maîtresse est saine et désormais rotée dans le temps (amélioration réelle par rapport à 2023-2025) ; l'IV par fichier reste vulnérable de façon identique aux builds précédents ; et le défaut indépendant de réutilisation de flux intra-fichier par bloc de 1 Mio (section dédiée plus haut) est confirmé présent sur ce build aussi, ce qui rend la question de la qualité de la clé largement secondaire pour tout fichier de plus de 1 Mio.
Preuve de concept (IV prédictible)#
tools/trigona_chunk_reuse_poc.py : voir la section dédiée au défaut de
réutilisation de flux intra-fichier plus haut, PoC AES-OFB réelle
(pycryptodome) sur ce défaut la, indépendant de la piste LCG ci-dessous.
tools/trigona_iv_poc.py : réimplémentation exacte du LCG (mult=0x8088405,
inc=1, mod 2^32) et de la construction d'IV (16x Random(256)).
Démontré, à partir d'une graine de départ totalement inconnue :
- génération de l'IV du "fichier 1"
- récupération exacte de l'état interne à partir de ce seul IV (recherche sur les 24 bits bas seulement, rejet quasi immédiat des mauvais candidats des le deuxième octet comparé, quelques secondes en Python pur)
- prédiction exacte de l'IV du "fichier 2" (vérifié bit a bit contre la valeur réelle)
Exécution vérifiée : la graine plantée (0xCAFEBABE) est retrouvée à
l'identique, et les deux IV prédits correspondent exactement aux IV réels.
Calcul de la borne d'anniversaire sur l'espace d'état 32 bits : environ 54 500 fichiers chiffrés par le même thread suffisent pour une probabilité de 50% qu'au moins deux fichiers reçoivent un IV strictement identique (même clé, même flux non re-seede), ce qui est une échelle réaliste pour un partage fichier ou un parc de plusieurs lecteurs locaux.
Non résolu#
- Format exact du wrapping de la clé maîtresse (RSA ou autre) : aucune chaîne
RSA1/BEGIN PUBLIC KEYtrouvée dans le binaire 2023 ; mécanisme d'enveloppement de clé non retracé au-delà de la construction du footer. - Deuxième thread (partages réseau, point d'entrée
DAT_004e6a54) non retracé indépendamment ; très probablement mêmes fonctions partagées que le thread lecteurs locaux, non confirmé instruction par instruction. - Défaut de réutilisation de flux intra-fichier (bloc de 1 Mio) : confirmé par désassemblage sur 2023 et 2026 uniquement. Non revérifié indépendamment sur 2024/2025 (très probable vu l'identité du reste du code entre ces builds, mais pas confirmé instruction par instruction).
- Seuil exact de rotation de clé sur le build 2026 (durée ou nombre de fichiers entre deux régénérations) : logique de rotation confirmée, valeur du seuil non déterminée sans exécution réelle.