# Crytox : une clé AES-256 qui tient dans un GetTickCount

**Catégorie :** Ransomware

**Analyste :** Gordon PEIRS

**Famille :** Crytox, ransomware actif depuis 2020, toujours actif en 2025 (Talos IR Q1 2025, Halcyon mi/fin 2025 sur un nouvel encrypteur maison).

**Méthode :** reverse statique (désassemblage x86-64 direct, sans décompilateur) d'une chaîne de dépaquetage à quatre niveaux, plus l'émulation de deux de ces niveaux dans Unicorn Engine (CPU seul, aucun appel système transmis à la machine d'analyse). L'échantillon n'est jamais exécuté pour de vrai.

---

## Résumé

En 2022, Zscaler ThreatLabz a décrit chez Crytox un générateur pseudo-aléatoire maison, seedé par `GetTickCount()`, qui produit la clé AES-256 de chiffrement des fichiers. Leur conclusion : un entier de 32 bits ne suffit pas comme graine « avec la puissance de calcul actuelle ». Mais ils ne montrent jamais le mécanisme. Je l'ai reconstruit instruction par instruction sur un échantillon de 2021, avec un deuxième de 2020 pour recouper : les 256 bits de la clé sortent tous d'un unique appel à `GetTickCount()`, passé dans un générateur congruentiel linéaire (LCG) aux constantes figées dans le code. J'ai validé l'algorithme bit à bit contre l'exécution du vrai code machine dans Unicorn. Cette validation tranche un point que le désassemblage seul laisse ambigu : la division finale est en 32 bits, pas en 64. La même méthode m'a donné le modulus réel de l'échantillon et sa configuration chiffrée complète (note de rançon, extension de fichier, clé publique RSA-2048 de l'attaquant, contact de rançon). Une preuve de concept Python reproduit l'algorithme à l'identique et retrouve une clé par brute-force en une fraction de seconde sur un cas de test. Enfin, j'ai démonté et validé par émulation toute la chaîne de dépaquetage (dropper → PE embarqué → shellcode injecteur → payload de chiffrement). On y trouve au passage un script de sabotage anti-récupération, lancé avant le chiffrement, qui purge les journaux d'événements et supprime les clichés instantanés.

> Tout est reproductible depuis [`runbook.md`](writeups/crytox-tickcount-rng/runbook.md). Les scripts sont dans [`tools/`](tools.html?slug=crytox-tickcount-rng).

## 1. Échantillons

| Champ | Valeur |
|---|---|
| SHA256 (analysé principalement, cité directement par Zscaler) | `32eef267a1192a9a739ccaaae0266bc66707bb64768a764541ecb039a50cba67` |
| Première soumission | 2021-04-08 |
| SHA256 (second échantillon, même imphash) | `d60dc6965f6d68a3e7c82d42e90bfda7ad3c5874d2c59a66df6212aef027b455` |
| Première soumission | 2020-04-18 |
| Taille (les deux, identique) | 1 287 168 octets, PE32+ x64, packé UPX |
| Imphash (les deux, identique) | `365b1d12b684a96b167a74679ec9e4e3` |

Le rapport Zscaler cite 9 hashs et le rapport Halcyon (2025) en cite 1. Un seul était sur MalwareBazaar au moment de l'analyse ; j'ai retrouvé le second en pivotant sur l'imphash. L'échantillon 2025 cité par Halcyon (le nouvel encrypteur maison, hors du champ de cette analyse) n'est pas disponible publiquement.

## 2. Chaîne de dépaquetage : quatre niveaux

```
dropper (UPX, entry() @ 0x1402e8000)
  └─ écrit sur disque un PE embarqué (RVA 0x1000, 3 043 328 octets, uTox complet)
  └─ énumère les processus, injecte un shellcode (VirtualAllocEx/WriteProcessMemory/CreateRemoteThread)
       shellcode injecteur (4418 octets, résolution d'API par hash)
         └─ rouvre le FICHIER DROPPER ORIGINAL sur disque, en extrait un blob
            chiffré de 17 408 octets à l'offset fixe 0x135ca4
         └─ zéroise puis SUPPRIME le fichier original (autodestruction anti-forensique)
         └─ déchiffre un petit blob de config embarqué (AES-NI), révélant deux noms
            de fichiers, écrit et EXÉCUTE via ShellExecuteA :
              wevtutil.exe cl "<journal>"      (purge des journaux d'événements)
              vssadmin.exe Delete Shadows /All /Quiet
              diskshadow.exe /s ...
         └─ énumère les processus (EXPLORER.EXE en priorité, SVCHOST.EXE en repli),
            y injecte le blob de 17 408 octets déchiffré (même AES-NI)
              payload final (20 480 octets, résolution d'API par hash)
                └─ CryptoAPI (paire de clés RSA-2048 de session), énumération des
                   lecteurs locaux ET réseau, un thread par lecteur, parcours de
                   fichiers, chiffrement, renommage : LE MODULE DE CHIFFREMENT
```

Le PE embarqué au niveau 2 est un client **uTox** légitime et complet (chaînes « uTox », « utox.io », version « 0.14.0 »). Il ne sert que de processus porteur pour l'injection. C'est cohérent avec le contact par messagerie uTox déjà documenté (Halcyon, K7), et on voit ici que son rôle s'arrête là : ce n'est pas une charge utile active.

La résolution d'API par hash (niveaux 3 et 4) utilise le même algorithme sur toute la chaîne : une somme roulante `ROR32` de 7 bits sur le nom de module (UTF-16, en majuscules, terminateur NUL inclus), plus un `ROR32` de 11 bits sur le nom de fonction (ASCII, terminateur NUL inclus). Je l'ai cassé par essais et vérifié sur plus de 70 constantes ([`tools/crack_api_hash.py`](writeups/crytox-tickcount-rng/tools/crack_api_hash.py)).

**Validation dynamique (Unicorn)** : j'ai exécuté les niveaux 3 et 4 dans l'émulateur, CPU seul, chaque appel Windows étant intercepté et simulé. Les stubs Python qui simulent les API Windows doivent respecter exactement la convention d'appel x64 visible au désassemblage (ordre des paramètres de `memcpy(dest,src,n)`, registre du pointeur de sortie de `CryptAcquireContextA`). Le script anti-forensique et le contenu exact du payload de 20 480 octets ont été capturés en clair pendant cette émulation. Je ne les ai pas devinés.

## 3. Le générateur de clé : reconstruction exacte

Le générateur est au niveau 4 (offset `0x1396` du payload de 20 480 octets). Il est appelé **une seule fois par fichier**, juste après l'ouverture du fichier à chiffrer et le positionnement dedans. J'ai cherché tous les sites d'appel dans le binaire : il n'y en a qu'un. Voici la traduction directe du désassemblage, sans rien supposer :

```c
// [r12+0x10] : accumulateur 64 bits PERSISTANT, partagé entre tous les
//              appels sur tout le run (jamais réinitialisé entre fichiers).
// [r12+0x58] : modulus, valeur issue de la configuration embarquée déchiffrée
//              (pas une constante de code, contrairement au multiplicateur
//              et à l'incrémenteur ci-dessous qui SONT figés dans le code).
void generer_cle_32_octets(uint64_t *sortie) {
    uint64_t rax = GetTickCount();          // SEULE injection d'entropie fraîche
    for (int tour = 0; tour < 4; tour++) {  // 4 tours -> 32 octets
        uint64_t acc = 0;
        for (int i = 0; i < 64; i++) {      // 64 itérations par tour
            rax = rax + accumulateur;
            rax = rax * 0x7D6C3 + 0x159EC7;         // LCG, constantes figées
            accumulateur = rax;
            uint32_t haut = (uint32_t)(rax >> 16);
            uint32_t cube = haut * haut * haut;     // arithmétique 32 bits (voir note)
            uint32_t quotient = cube / modulus;     // DIV 32 bits, pas 64
            uint32_t reste    = cube % modulus;
            rax = quotient;                          // réinjecté à l'itération suivante
            acc = (acc >> 1) | ((reste & 1) << 63);   // 1 bit extrait par itération
        }
        *sortie++ = acc;
        rax = acc;   // réinjecté au tour suivant, pas remis à GetTickCount()
    }
}
```

**Constat** : les 256 bits de la clé AES-256 viennent entièrement d'un seul appel à `GetTickCount()`, c'est-à-dire le nombre de millisecondes depuis le démarrage de Windows (compteur 32 bits, résolution typique 10-16 ms, retour à zéro tous les ~49,7 jours). Tout le reste (multiplicateur et incrément figés, élévation au cube, division modulaire, extraction bit à bit) est une mise en forme déterministe et publique. Cette seule valeur suffit donc à reproduire la clé. C'est, au niveau instruction, la confirmation détaillée de ce que Zscaler affirmait en conclusion sans le montrer.

L'accumulateur n'est jamais réinitialisé entre deux fichiers. Si on retrouve la valeur `GetTickCount()` du **premier** fichier chiffré d'un run, on recalcule donc les clés de **tous les fichiers suivants du même run**. Pour récupérer toutes les données d'une victime, il faut au pire un seul brute-force sur un espace de 2^32 valeurs, pas un par fichier.

**Validation bit à bit contre le vrai code machine** : j'ai poursuivi l'émulation Unicorn en trois passes (bootstrap → thread de chiffrement par lecteur → fonction « renommer + chiffrer un fichier »). Chaque passe réutilise la même instance mémoire, parce qu'Unicorn est mono-thread et ne peut pas exécuter nativement un thread lancé par `CreateThread`. J'ai ainsi atteint l'appel réel au générateur de clé et je l'ai tracé instruction par instruction. La trace lève une ambiguïté du désassemblage : l'instruction est `div DWORD PTR [r12+0x58]`, une **division 32 bits** (`EDX:EAX`, avec `EDX` nul d'après la trace), et pas une division 64 bits sur le cube entier. Avec cette division, le PoC Python reproduit octet pour octet la sortie de la vraie fonction exécutée dans Unicorn, sur deux valeurs `GetTickCount` de test indépendantes (`0x123456` et `0x7777777`).

## 4. Extraction de la configuration embarquée : modulus, note de rançon, clé RSA

Avec la même technique d'émulation, j'ai extrait et déchiffré pour de vrai (en AES-NI, exécuté par le moteur d'émulation) la configuration embarquée de cet échantillon, 12 080 octets. Elle contient :

| Champ | Valeur |
|---|---|
| **Modulus réel** (`[r12+0x58]`) | `0xa77efce7` |
| Note de rançon | HTML/HTA complet ("YOUR FILES ARE ENCRYPTED", délai de 5 jours), lancement de `%windir%\utox.exe` |
| Contact de secours | `Colin_Kaan@aol.com` |
| ID d'opérateur (dans la note) | `204E91D375BADE81DC528EFCC105A5D046DB92FCC4B75F08E151053DCD8D5025DA55D689165D` |
| Extension de fichier chiffré | `.waiting` (confirme le format documenté par Zscaler) |
| Fichier de note déployé | `C:\ReadMe.hta` |
| Persistance | `SOFTWARE\Microsoft\Windows\CurrentVersion\Run\`, commande `mshta.exe "C:\ReadMe.hta"` |
| Association d'extension | `.waiting\shell\open\command\` → réaffiche la note au double-clic |
| Privilèges ajustés | `SeBackupPrivilege`, `SeRestorePrivilege` |
| Clé publique attaquant | RSA-2048, format CryptoAPI `PUBLICKEYBLOB` (magic `RSA1`), exposant 65537, module vérifié valide |

Toutes ces valeurs sont **propres à ce build et à cette victime**, comme la clé publique RSA-4096 personnalisée par build que j'ai documentée sur Akira dans ce dépôt. Un autre échantillon Crytox aura sans doute un modulus, une clé RSA et un contact différents, à extraire de la même façon.

## 5. Preuve de concept

[`tools/crytox_lcg_poc.py`](writeups/crytox-tickcount-rng/tools/crytox_lcg_poc.py) réimplémente en Python l'algorithme ci-dessus (avec le vrai modulus `0xa77efce7` comme paramètre par défaut). Il fournit :

- un oracle à texte clair connu (les magic bytes d'un format de fichier courant) qui cherche la valeur `GetTickCount` reproduisant ce clair. Je l'ai **testé sur un cas synthétique généré avec le vrai modulus** : une clé dérivée pour un tick connu, un chiffrement avec pycryptodome, puis la clé retrouvée par brute-force en 0,9 seconde sur 12 346 candidats ;
- un banc de mesure du débit : **~15 000 clés par seconde en Python pur non optimisé, sur un seul cœur**. Ça fait environ 75 heures pour épuiser tout l'espace 32 bits dans le pire cas, ce qui est déjà jouable pour une réponse à incident qui a le temps. Un portage GPU ferait gagner 2 à 3 ordres de grandeur (méthode déjà démontrée sur Akira dans ce dépôt, ~1,45 million de candidats par seconde). Et si on connaît la fenêtre d'infection à quelques heures près (horodatage des fichiers ou de la note de rançon), l'espace à parcourir tombe bien en dessous de ce pire cas.

## 6. Ce qui n'a pas été résolu

- **Le format exact de l'IV et du footer du fichier chiffré.** J'ai localisé la fonction « renommer + chiffrer un fichier » et exécuté son tout début (le renommage par `MoveFileW` puis la réouverture du fichier renommé sont confirmés en dynamique, avant le chiffrement). Mais je n'ai pas rejoué la boucle lecture/chiffrement/écriture jusqu'à la construction de l'IV, ni jusqu'au footer qui contient la clé enveloppée en RSA. Zscaler mentionne ce footer sans en détailler la structure.
- **Je n'avais aucun fichier réellement chiffré par Crytox** pour valider l'oracle sur du ciphertext authentique plutôt que sur un test synthétique. Je n'ai pas tenté d'exécution réelle du malware en VM isolée jetable (le protocole déjà utilisé sur Akira dans ce dépôt) pour cette analyse.
- **Le nouvel encrypteur maison décrit par Halcyon (2025)** n'est pas disponible publiquement. Je ne peux donc pas dire si le défaut persiste dans les builds récents.

## 7. Indicateurs de compromission

| Type | Valeur |
|---|---|
| SHA256 | `32eef267a1192a9a739ccaaae0266bc66707bb64768a764541ecb039a50cba67` |
| SHA256 | `d60dc6965f6d68a3e7c82d42e90bfda7ad3c5874d2c59a66df6212aef027b455` |
| Imphash (les deux) | `365b1d12b684a96b167a74679ec9e4e3` |
| Script de sabotage anti-récupération (capturé en clair par émulation) | `wevtutil.exe cl` (purge des journaux d'événements) + `vssadmin.exe Delete Shadows /All /Quiet` + `diskshadow.exe` |
| Extension de fichiers chiffrés (confirmée par extraction de config) | `.waiting` |
| Fichier de note de rançon | `C:\ReadMe.hta` |
| Contact de rançon (extrait de cet échantillon) | uTox (ID ci-dessus), email `Colin_Kaan@aol.com` |
| Persistance | Clé de registre `Run`, `mshta.exe "C:\ReadMe.hta"` |

## 8. Divulgation et usage

Les échantillons étudiés sont publics (MalwareBazaar) et le ransomware est actif depuis 2020. Rien ici ne révèle une vulnérabilité inconnue dans un logiciel légitime. Ce travail confirme et détaille, au niveau du code machine et avec une validation bit à bit contre son exécution, une faiblesse de génération de clé signalée publiquement (Zscaler 2022) mais jamais montrée. Il fournit aussi un PoC utilisable pour récupérer des fichiers chiffrés par ce build. Le contact de rançon extrait (`Colin_Kaan@aol.com`) est publié uniquement comme indicateur de compromission, comme pour les autres familles de ce dépôt. Le but : donner aux équipes de réponse à incident qui tombent sur un cas Crytox de cette période une base concrète et testée pour évaluer ce qui est récupérable, plutôt qu'une affirmation générale.
