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