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)#

  1. entry (004eb98c) appelle FUN_004e6cac() (seed réel), puis FUN_004073f4(10) (délai de démarrage), puis lance deux threads (FUN_00409df4/CreateThread) avec points d'entrée LAB_004e5984 et DAT_004e6a54.
  2. Le thread 004e5984 (force la création de fonction Ghidra via CreateFunctionCmd, non détectée par l'auto-analyse) : - appelle FUN_004e3228 une 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 appelle FUN_004e5220 pour 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.
  3. FUN_004e5220 -> FUN_004e45b4 -> FUN_004e34e4 construit l'IV par fichier : do { uVar1 = FUN_004073f4(0x100); // Random(256) *(char*)(*param_1 + iVar2) = (char)uVar1; iVar2++; } while (iVar2 != 0x10); // 16 octets Aucun re-seed entre les appels : chaque octet est le tirage suivant du même flux LCG global (DAT_004ed008).
  4. FUN_004e2bdc construit le footer écrit en fin de fichier chiffré. Le paramètre param_3 de 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 octets 0x53 ('S') répétés à intervalles réguliers (marqueur de format).
  5. Pour les fichiers de plus de 1 Mio, FUN_004e3a08 boucle par blocs de 0x100000 (1 Mio) et appelle FUN_004e37bc -> FUN_004db93c à chaque tour (voir section dédiée ci-dessous sur le défaut de réutilisation de flux). FUN_004e3aac/FUN_004e37bc avec un 0x80000 (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 :

  1. génération de l'IV du "fichier 1"
  2. 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)
  3. 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 KEY trouvé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.