# Runbook : reproduction pas à pas (NailaoLocker, clé SM2 embarquée)

## A. Acquisition : aucun résultat sur MalwareBazaar, remontée via Triage

Aucune famille `NailaoLocker` référencée sur MalwareBazaar (`get_taginfo`, `get_siginfo` : `no_results` sur toutes les variantes de nom essayées : `Nailao`, `nailaolocker`, `NailaoLoader`). Trois hashs SHA256 sont en revanche publiés en IOC par un rapport FortiGuard Labs (« NailaoLocker Ransomware's "Cheese" », 18/07/2025), copiés depuis le HTML brut de l'article :

```bash
curl -sL -A "Mozilla/5.0" "https://www.fortinet.com/uk/blog/threat-research/nailaolocker-ransomware-cheese" -o fortinet.html
grep -oE "[a-fA-F0-9]{64}" fortinet.html | sort -u
```
```
1248c4b352b9b1325ef97435bd38b2f02d21e2c6d494a2218ee363d9874b7607
46f3029fcc7e2a12253c0cc65e5c58b5f1296df1e364878b178027ab26562d68
60133376a7c8e051da787187761e596ce9b3d0cfcea21ed8f434992aa7cb8605
```

Aucun des trois n'est connu de MalwareBazaar. Recherche sur Triage (Hatching) par hash exact :

```bash
curl -s "https://api.tria.ge/v0/search?query=sha256:<hash>" -H "Authorization: Bearer $TRIAGE_API_KEY"
```

Seul le deuxième hash (`46f3029f...`) remonte un résultat, avec un nom de fichier trompeur (`..._black-basta_cobalt-strike_coinminer_plugx_satacom.exe`), manifestement un artefact de dépôt en lot d'IOC sous un nom de bundle générique, pas le vrai nom du fichier. **Ne pas se fier au nom** : vérification directe du rapport de propriétés statiques Triage avant tout téléchargement.

```bash
curl -s "https://api.tria.ge/v0/samples/<id>/reports/static" -H "Authorization: Bearer $TRIAGE_API_KEY"
```

Retour : PE64, 2 699 776 octets, imports `GetQueuedCompletionStatus`, `CreateMutexW`, `SetFileAttributesW`, `CreateIoCompletionPort`, `GetSystemInfo`, `CreateThread` : cohérent trait pour trait avec l'architecture IOCP + mutex + attribut caché décrite par le rapport FortiGuard pour NailaoLocker. Confirmation suffisante pour télécharger :

```bash
curl -s "https://api.tria.ge/v0/samples/<id>/sample" -H "Authorization: Bearer $TRIAGE_API_KEY" -o binaries/46f3029f....exe
sha256sum binaries/46f3029f....exe   # confirme le hash exact publié par FortiGuard
```

Les deux autres hashs (`1248c4b3...`, `60133376...`) restent introuvables via Triage ou MalwareBazaar au moment de cette analyse ; toute la suite porte donc sur l'unique échantillon récupéré.

## B. Vérification statique directe (ne pas se fier au rapport tiers seul)

```python
import pefile
pe = pefile.PE("binaries/46f3029f....exe", fast_load=True)
pe.parse_data_directories()
```

- Machine `0x8664` (x64), `TimeDateStamp` = 1727686141 (2024-09-30 08:49:01 UTC).
- 5 sections, entropie normale partout (`.text` 6.44, `.rdata` 5.22, `.data` 4.88, `.pdata` 6.16, `.reloc` 5.44) : **aucun packer**, cohérent avec le rapport tiers qui ne mentionne aucune protection.
- Imports : `KERNEL32.dll` (133), `ADVAPI32.dll` (9), `bcrypt.dll` (1 seul import, `BCryptGenRandom`), `USER32.dll` (3), `WS2_32.dll` (27), `CRYPT32.dll` (4).

Chaînes ASCII/wide (`strings`) confirmant indépendamment, sans dépendre du rapport, plusieurs affirmations publiques :

```
Global\lockv7                          (mutex, wide)
.locked                                (extension, wide)
%ProgramData%\lock.log                 (wide)
XXXX                                   (ascii, switch de mode)
crypto\sm2\sm2_crypt.c / sm2_key.c / sm2_sign.c   (chemins source OpenSSL statiquement liés)
EVP_DecryptFinal_ex, EVP_DigestSignFinal, ...     (API EVP OpenSSL standard)
```

La présence des chemins source `crypto\sm2\*.c` confirme qu'il s'agit du vrai module SM2 d'OpenSSL 3.x compilé statiquement, pas d'une réimplémentation maison, un point qui compte pour la suite (§D-F).

## C. Extraction de la paire de clés SM2 embarquée

Recherche de l'OID de courbe SM2 (`1.2.156.10197.1.301`, encodé `06 08 2A 81 1C CF 55 01 82 2D`) dans le binaire :

```python
oid = bytes.fromhex("06082A811CCF5501822D")
idx = [i for i in range(len(data)) if data[i:i+len(oid)] == oid]
```

Trois occurrences (`0x248ad0`, `0x252c7d`, `0x252cf9`). Les deux dernières font partie de deux structures ASN.1 DER complètes et contiguës en mémoire, extraites par recherche du marqueur `SEQUENCE` précédent :

```python
pub_marker = bytes.fromhex("3059301306072a8648ce3d020106082a811ccf5501822d")   # SubjectPublicKeyInfo
priv_marker = bytes.fromhex("30770201010420")                                  # SEC1 ECPrivateKey
```

```bash
openssl asn1parse -inform DER -in embedded_sm2_pub.der
openssl asn1parse -inform DER -in embedded_sm2_priv.der
```

Structures ASN.1 valides des deux côtés : `SubjectPublicKeyInfo` (OID `id-ecPublicKey` + OID courbe `SM2`) pour la clé publique, `ECPrivateKey` SEC1 (version 1, scalaire 32 octets, paramètres `[0]` = OID SM2, clé publique `[1]` ré-embarquée) pour la clé privée. La clé publique ré-embarquée dans la structure privée est **identique octet pour octet** à la clé publique autonome trouvée séparément : cohérence interne dès ce stade, avant tout test cryptographique.

## D. Le keypair est valide : contradiction directe avec le rapport FortiGuard

Le rapport FortiGuard affirme : *"the embedded SM2 private key failed to correctly decrypt the AES key, suggesting the key material may be incomplete or deliberately non-functional."* Vérification directe avec OpenSSL 3.6.3 (support SM2 natif) :

```bash
openssl ec -inform DER -in embedded_sm2_priv.der -check -noout
# -> EC Key valid.
```

`openssl ec -check` valide mathématiquement que `d·G == Q` (le scalaire privé correspond bien au point public déclaré). Ce n'est pas une supposition, c'est la vérification cryptographique exacte que le rapport dit avoir vue échouer. Poursuite avec un aller-retour chiffrement/déchiffrement SM2 complet, clé publique embarquée pour chiffrer, clé privée embarquée pour déchiffrer, aucune autre clé introduite :

```bash
openssl pkeyutl -encrypt -pubin -inkey embedded_sm2_pub.der  -keyform DER -in plain.bin  -out cipher.bin
openssl pkeyutl -decrypt        -inkey embedded_sm2_priv.der -keyform DER -in cipher.bin -out decrypted.bin
diff plain.bin decrypted.bin   # identique
```

Round-trip réussi du premier coup. La paire de clés n'est ni incomplète ni corrompue : elle fonctionne parfaitement avec l'implémentation SM2 standard d'OpenSSL. Reste à vérifier que c'est bien cette même implémentation standard que le binaire utilise en interne (§E-F) avant de pouvoir affirmer que ce round-trip est représentatif du format réel produit par le ransomware.

## E. Décompilation Ghidra (pyghidra headless) : la fonction principale

```bash
python3 tools/list_functions.py binaries/46f3029f....exe functions.txt
# 6312 fonctions, quasi toutes nommees FUN_xxx (pas de PDB, pas de noms OpenSSL
# recuperes automatiquement : les chemins sources vus en §B sont des chaines
# d'assertion figees dans .rdata, pas une table de symboles)
```

`find_multi_api_callers.py` (variante maison de l'outil `find_api_callers.py` déjà utilisé sur d'autres échantillons de ce dépôt, généralisée pour accepter plusieurs API en une seule passe d'analyse Ghidra) sur `CreateMutexW` et `BCryptGenRandom` localise directement `main()` (`FUN_140002d60`) et le worker par fichier (`FUN_140002630`).

Dans `main()` : mutex `Global\lockv7` confirmé au niveau du code, puis comparaison `if (_DAT_14026fac0 == 0x58585858)` (`0x58585858` est très exactement l'ASCII `"XXXX"` lu comme un entier 32 bits). Lecture directe de la valeur réellement présente sur disque à cette adresse (RVA calculée depuis l'`ImageBase`) :

```python
pe.get_data(0x14026fac0 - pe.OPTIONAL_HEADER.ImageBase, 4)   # -> b'\x3e\x3f\x3f\x3f'
```

Sur l'échantillon tel que livré, cette valeur **n'est pas** `"XXXX"` : le binaire prend donc la branche normale (déchiffrement d'un bloc XOR-obfusqué avec des clés dérivées de constantes voisines, probablement le nom du fichier de rançon et des drapeaux d'exclusion). Si l'on patche ces 4 octets à `"XXXX"` en mémoire (exactement la manipulation que décrit le rapport tiers pour activer le mode déchiffrement), trois effets simultanés, tous visibles dans le même bloc de code :
1. `_DAT_14026fac0` est réécrit à `0x80000000` (poids fort à 1, reste à 0) ;
2. un jeu de données alternatif (`locked.html` comme nom de marqueur, au lieu du nom réel obfusqué) est chargé à la place du bloc XOR ;
3. plus loin dans le parcours des lecteurs (`FUN_140002500`), le bit de poids fort de cette même variable restreint l'énumération à un unique lecteur `W:\` au lieu du parcours complet de tous les lecteurs.

Ce dernier point (un lecteur mappé `W:\` câblé en dur comme cible unique) n'est mentionné dans aucun rapport public trouvé : cohérent avec un harnais de test interne de l'auteur du malware, pas avec un comportement de production.

## F. Le worker par fichier : génération de clé et bascule chiffrement/déchiffrement

`FUN_140002630` (appelée une fois par thread, ≥8 threads créés selon `GetSystemInfo`) :

```c
BCryptGenRandom((BCRYPT_ALG_HANDLE)0x0, local_250, 0x20, 2);   // cle AES 256 bits
BCryptGenRandom((BCRYPT_ALG_HANDLE)0x0, local_230, 0x10, 2);   // IV 128 bits
if (DAT_14026fac0 == '\0') {
    ... FUN_1400069a0(...) ...   // chemin dechiffrement (jamais emprunte sur cet echantillon)
} else {
    ... FUN_140009a80(...) ...   // chemin chiffrement (celui reellement execute)
}
```

L'octet de poids faible de `DAT_14026fac0` valant `0x3e` (non nul) sur l'échantillon livré, la branche **chiffrement** est celle réellement empruntée à l'exécution, cohérent avec un ransomware déployé en conditions réelles.

Décompilation ciblée des fonctions appelées dans cette branche (`decompile_addrs.py`, une seule passe Ghidra pour toutes les adresses) :

- `FUN_1400069a0`, atteinte uniquement côté déchiffrement, contient l'assertion OpenSSL en clair `"crypto\\asn1\\d2i_pr.c"` / `"d2i_AutoPrivateKey_legacy"` : chargement de la clé **privée** via l'API OpenSSL générique `d2i_AutoPrivateKey`, pas un parseur ASN.1 maison.
- `FUN_14000d370` init le contexte avec le drapeau `0x400` (`EVP_PKEY_OP_DECRYPT` dans `openssl/evp.h`), `FUN_14000d590` avec `0x200` (`EVP_PKEY_OP_ENCRYPT`) : confirmation que la branche réellement empruntée (`0x200`, chiffrement) initialise bien un contexte d'opération **chiffrement**, cohérent avec la clé **publique** attendue côté chiffrement.
- `FUN_14000d390`, appelée deux fois de suite (une pour la clé AES, une pour l'IV) dans la branche réellement empruntée, contient l'assertion en clair `"crypto\\evp\\asymcipher.c"` / `"EVP_PKEY_encrypt"` : c'est très exactement la fonction `EVP_PKEY_encrypt()` d'OpenSSL, compilée telle quelle, pas une réimplémentation.

Autrement dit : le binaire enveloppe la clé AES et l'IV avec **l'API `EVP_PKEY_encrypt()` standard d'OpenSSL sur la clé publique SM2 embarquée**, sans paramètre exotique visible (pas d'ID de distinction SM2 personnalisé, pas de format de sortie alternatif). C'est très précisément l'appel que fait `openssl pkeyutl -encrypt` en ligne de commande (§D). Le round-trip réussi en §D n'est donc pas une coïncidence de format : c'est la même fonction C, dans la même bibliothèque, appelée avec la même clé.

## G. Format du footer confirmé bit à bit par décompilation

`FUN_140001840` (chiffrement d'un fichier) et `FUN_140001bb0` (déchiffrement, atteignable seulement en mode test) :

- Les 8 premiers octets du fichier de sortie contiennent la taille originale (`LARGE_INTEGER`), écrite avant le corps chiffré.
- Le footer commence par la constante entière `0x37564c`, soit en mémoire little-endian les octets `4C 56 37 00` = `"LV7\0"` : le marqueur trouvé par recherche de motif en §C (deux occurrences `LV7\x00` déjà repérées par `strings`/recherche binaire avant même de lancer Ghidra).
- Immédiatement après : taille de la clé AES chiffrée (4 octets) + clé AES chiffrée, taille de l'IV chiffré (4 octets) + IV chiffré, taille du surplus de padding AES (4 octets) + ce surplus. Le surplus existe parce que le padding PKCS#7 peut dépasser l'espace laissé disponible avant le footer (le fichier garde sa taille d'origine + padding, pas plus), le reste étant relégué en fin de footer.

Ce format, que j'ai reconstruit en entier à partir du désassemblage, correspond exactement à celui déjà décrit par le rapport FortiGuard (figures 13 à 15) : confirmation croisée indépendante, pas une simple reprise de leur description.

## H. Preuve de concept complète, sans jamais exécuter l'échantillon

`tools/nailaolocker_sm2_keywrap_poc.py` automatise tout ce qui précède :
1. extrait les deux blobs DER SM2 par recherche de motif (§C) ;
2. vérifie `d·G == Q` et fait un aller-retour SM2 générique (§D) ;
3. construit un faux fichier `.locked` **synthétique** au format exact reconstruit en §G (clé AES et IV aléatoires, chiffrées avec la clé publique embarquée via `openssl pkeyutl`, corps chiffré en AES-256-CBC, footer `LV7` complet) ;
4. déchiffre ce fichier synthétique avec la clé privée embarquée et vérifie que le contenu récupéré est identique à l'original.

```bash
python3 tools/nailaolocker_sm2_keywrap_poc.py binaries/46f3029f....exe
```
```
[+] SM2 public key  : 91 bytes
[+] SM2 private key : 121 bytes
[+] openssl ec -check: EC Key valid.
[+] round-trip SM2 encrypt(pub)/decrypt(priv): OK
[+] fichier .locked synthetique construit : .../synthetic.locked (3101 octets)
[+] contenu recupere identique a l'original : True
```

Aucune étape de ce script n'exécute l'échantillon : seules des primitives OpenSSL standard sont invoquées, sur des données entièrement synthétiques, avec la clé qui était déjà embarquée en dur dans le binaire avant toute analyse.

## I. Émulation Unicorn du vrai chemin de chiffrement (pas une réimplémentation)

Le résultat du §H repose sur l'hypothèse (confirmée en §D-F) que le binaire appelle l'API OpenSSL standard, mais ne prouve pas que le format produit par le VRAI code correspond exactement à celui d'un appel `openssl pkeyutl` générique. Pour lever ce doute : émulation CPU pure (Unicorn Engine x64) du worker par fichier (`FUN_140002630`) directement, sans passer par `main()` (mutex/IOCP/traversal de lecteurs ne sont pas nécessaires pour ce seul chemin), en n'hookant que les appels Windows via des adresses IAT synthétiques (`tools/emulate_encrypt_worker.py`). Tout le calcul cryptographique (BCryptGenRandom mis à part, qui ne fournit que des octets aléatoires) est le vrai code OpenSSL statique du binaire, exécuté instruction par instruction, jamais réimplémenté.

Trois bugs de harnais trouvés et corrigés par l'échec observé avant d'obtenir une exécution complète :

1. **Mauvais index d'argument sur `GetQueuedCompletionStatus`** : le 4e paramètre (`lpOverlapped`, le pointeur de sortie qui devient l'élément de travail passé à la fonction de chiffrement) est passé par R9, pas sur la pile. Erreur silencieuse (aucune exception) : le worker se terminait immédiatement, boucle jamais entrée, parce que l'élément de travail synthétique n'était jamais réellement transmis.

2. **Bloc de configuration jamais décodé.** Le worker s'appuie sur `DAT_140270ee4` (pointeur/longueur vers la clé SM2 publique embarquée) et des drapeaux voisins, tous obfusqués par un simple XOR répétitif de 16 octets sur 0x3c2c octets à partir de `DAT_14026fac0` (normalement décodé par `main()`, jamais appelé ici). Reproduit directement en Python à partir de l'algorithme déjà décompilé (§E) et appliqué sur la mémoire émulée avant l'appel : ce n'est pas de la cryptographie, juste de la déobfuscation de configuration, une simplification volontaire pour ne tester que le chemin de chiffrement.

3. **Deux mécanismes d'amorçage CRT/UCRT jamais initialisés, puisque ce harnais saute délibérément tout le démarrage CRT réel (`entry`/`__scrt_common_main_seh`) pour n'exécuter que le worker :**
   - Une table interne UCRT de données par-thread (`DAT_140275c60`/`DAT_140275c58`, style `__acrt_getptd`) valant `NULL`/`0` au lieu d'un bloc alloué : premier accès lisait `*(NULL+0x18)`, crash immédiat. Corrigé en allouant et en câblant à la main une petite table (128 emplacements), sans rapport avec la cryptographie.
   - Un cache de résolution paresseuse d'API optionnelles (`InitializeCriticalSectionEx`, table `DAT_140276570[]`), chaque entrée chiffrée par rotation+XOR avec le cookie `/GS` (`DAT_14026ee80`, qui reste à sa valeur par défaut MSVC `0x2B992DDFA232` puisque `__security_init_cookie` n'est jamais appelé). Une entrée jamais initialisée (brute à 0) se décode en la valeur du cookie lui-même, ni `0` ni `-1`, et le code compilé la traite comme un pointeur de fonction déjà résolu valide : saut sur une adresse arbitraire. Corrigé en pré-remplissant 128 entrées avec le sentinel `-1` correctement encodé pour CE cookie (rotation de tous les bits à 1 est invariante, donc `sentinel = 0xFFFF...FFFF XOR cookie`), reproduisant ce qu'une routine de démarrage CRT réelle ferait avec un cookie fraîchement randomisé.
   - Deux bugs supplémentaires dans les hooks Python eux-mêmes (pas dans le binaire) : mauvais index de paramètre sur `MultiByteToWideChar`/`WideCharToMultiByte` (le premier paramètre `CodePage` avait été oublié dans le décompte), et comparaison à zéro sur un paramètre 32 bits lu depuis un registre 64 bits sans masquer les 32 bits de poids fort (garbage laissé par l'appelant).

Après ces corrections, l'émulation exécute la totalité du chemin réel : génération de clé/IV AES via `BCryptGenRandom`, chargement de la clé publique SM2 embarquée, enveloppe SM2 de la clé et de l'IV (`EVP_PKEY_encrypt`, confirmé en §F), lecture du fichier de test, chiffrement AES-256-CBC réel, écriture du footer `LV7`, et `MoveFileExW` renommant le fichier en `.locked`, **dans cet ordre exact**, avant qu'une routine de nettoyage post-traitement non essentielle (une autre résolution paresseuse d'API, jamais atteinte pour un nom qu'on ne gère pas) ne fasse planter l'émulation, sans effet sur le fichier déjà écrit intégralement à ce stade.

```bash
python3 tools/emulate_encrypt_worker.py binaries/46f3029f....exe sortie.locked --plain "texte de test"
```
```
[+] fichier .locked deja ecrit avant l'exception (nettoyage post-traitement non emule)
[+] fichier chiffre par le VRAI code du binaire ecrit : sortie.locked (367 octets)
```

## J. Format du footer, précision supplémentaire par rapport au §G

L'inspection du fichier produit en §I révèle un détail que la seule lecture du désassemblage n'avait pas complètement clarifié : la première écriture (`WriteFile` de taille `DStack_209a0` = nombre d'octets lus dans le fichier original, ici 66) part de l'adresse de la variable de taille (`LStack_10148`, 8 octets) mais écrit **66 octets d'affilée**, débordant intentionnellement sur le buffer de texte chiffré qui la suit immédiatement dans la pile (`auStack_10140`, déclaré juste après). Autrement dit : les 8 premiers octets du fichier de sortie sont la taille originale, et les `(N-8)` octets suivants sont directement les `(N-8)` premiers octets du texte chiffré AES, pas deux écritures séparées comme une lecture superficielle du désassemblage pourrait le laisser penser. Le footer `LV7` commence donc exactement à l'octet `N` (confirmé : marqueur trouvé à l'offset 66 pour un fichier de test de 66 octets), et son champ « reste » contient les octets de texte chiffré restants (`taille totale chiffrée - (N-8)`).

## K. Anomalie trouvée en tentant le déchiffrement du vrai fichier produit

`tools/decrypt_locked_real.py` reconstruit le texte chiffré complet à partir du fichier produit en §I (tête + reste du footer) et tente de déchiffrer la clé AES et l'IV protégés par SM2 avec la clé privée extraite (§C), via `openssl pkeyutl -decrypt` (OpenSSL système 3.6.3) :

```
Public Key operation error
...:error:1A800066:SM2 routines:ossl_sm2_decrypt:invalid digest:crypto/sm2/sm2_crypt.c:411:
```

Échec reproductible sur la clé ET sur l'IV chiffrés, tous deux issus de ce même fichier. Vérification que ce n'est pas un problème général avec cette paire de clés côté système : un chiffrement SM2 fait avec la même clé publique par ce même OpenSSL système (`openssl pkeyutl -encrypt`, indépendamment de tout code du binaire) se déchiffre ensuite sans aucune erreur avec la clé privée, aller-retour propre confirmé de nouveau. La structure ASN.1 des deux chiffrés (celui produit par le vrai code émulé, celui produit par OpenSSL système) est identique champ pour champ (`SEQUENCE{INTEGER C1x, INTEGER C1y, OCTET STRING C3, OCTET STRING C2}`, mêmes tailles) : ce n'est donc pas un problème de format de sérialisation.

**Constat, cause non tranchée à ce stade** : le chiffré produit par le vrai code de l'échantillon (OpenSSL statiquement lié, version non identifiée avec certitude, cf. §B) échoue à la vérification d'intégrité (le hachage SM3 `C3` calculé au déchiffrement par OpenSSL système 3.6.3 ne correspond pas à celui du fichier), alors qu'un aller-retour entièrement interne à OpenSSL système réussit sans accroc. Deux hypothèses possibles, non départagées ici : une différence de détail d'implémentation SM2/SM3 entre la version d'OpenSSL statiquement liée dans l'échantillon et OpenSSL système 3.6.3, ou une subtilité de fidélité de l'émulation (harnais minimal, aucun bootstrap CRT complet) affectant spécifiquement le calcul SM3 sans faire échouer visiblement le reste du chemin. À ce point, la clé est mathématiquement valide et l'API utilisée est standard (§D-F), mais il reste à démontrer qu'un fichier réellement produit par ce build se déchiffre avec un outil OpenSSL générique.

## L. Tentative de trancher l'anomalie : faire déchiffrer le binaire par lui-même

Pour distinguer les deux hypothèses du §K (version OpenSSL différente contre subtilité de fidélité du harnais), tentative de faire dérouler au vrai code de l'échantillon **son propre chemin de déchiffrement** (`FUN_140001bb0`, atteint quand l'octet de poids faible de `DAT_14026fac0` vaut `0`) sur le fichier `.locked` produit en §I, dans la même session d'émulation, sans jamais toucher un outil externe (`tools/emulate_decrypt_own_ciphertext.py`).

Bascule du seul octet de mode (`01 00 00 00` -> `00 00 00 00` à l'adresse `DAT_14026fac0`), nouvel élément de travail pointant vers le fichier `.locked`, puis nouvel appel au worker :

```
[+] chiffrement OK (vrai code) : C:\test\sample.txt.locked (367 octets)
[+] octet de mode avant bascule : 01000000
[!] dechiffrement natif : aucun fichier 'C:\test\sample.txt' recupere (None)
```

L'émulation se termine sans exception (contrairement au chiffrement, aucun crash), mais **aucun appel `CreateFileW`/`ReadFile`/`WriteFile` n'a lieu** : la fonction de déchiffrement par fichier (`FUN_140001bb0`) n'est jamais atteinte. Vérification par hook sur l'entrée de `FUN_1400069a0` (le chargeur de clé côté déchiffrement, celui qui contient l'assertion `d2i_AutoPrivateKey_legacy`, cf. §F) : elle est bien appelée une fois, mais le worker s'arrête juste après (nombreux `HeapFree` de nettoyage), sans jamais initialiser le contexte SM2 de déchiffrement ni traiter le moindre fichier.

Explication la plus probable : `FUN_1400069a0` et la branche chiffrement (`FUN_140009a80`) lisent toutes les deux `&DAT_140270ee4`, dont le contenu décodé est la clé **publique** (confirmé en §C-D). `d2i_AutoPrivateKey` appliqué à une structure `SubjectPublicKeyInfo` (forme ASN.1 différente d'une `ECPrivateKey` SEC1) échoue nécessairement à la parser, faisant échouer l'initialisation avant même d'atteindre la comparaison SM3 qui pose problème en §K. Basculer le seul octet de mode ne suffit donc pas à faire fonctionner le chemin de déchiffrement intégré tel quel : une configuration supplémentaire (probablement une adresse de clé privée distincte, normalement mise en place par une partie de `main()` non rejouée par ce harnais minimal) manque encore.

**Ce test ne tranche donc pas l'anomalie du §K** : il révèle un blocage antérieur, distinct, dans le chemin de déchiffrement intégré lui-même tel qu'exercé par ce harnais. Il est cohérent avec le constat du rapport FortiGuard (qui a dû modifier le binaire et fournir manuellement la clé/IV pour obtenir un déchiffrement fonctionnel, cf. `nailaolocker-sm2-keywrap.md` Résumé) : activer le mode déchiffrement intégré de ce build n'est pas un simple changement d'un octet, contrairement à ce qu'une lecture rapide du switch "XXXX" pourrait laisser penser.

## M. Lever le blocage du §L : substitution de la clé privée dans la config

En relisant le décompile complet de `main()` (§E) : sa branche `"XXXX"` (celle où `_DAT_14026fac0 == '\0'` juste après avoir été forcée à `0x80000000`) copie explicitement `_DAT_140270ee4 = _DAT_1402542d0` avec `DAT_140270ee0 = 0x79` (121, exactement la taille de la clé privée extraite en §C). Cette branche charge donc la clé **privée** à cet emplacement pour le mode test, alors que le harnais du §L y avait laissé la clé **publique** issue du décodage XOR de production. Correction : copie des 121 octets de la clé privée (déjà extraite en §C, adresse `0x1402542d0`) dans `DAT_140270ee4`, et `DAT_140270ee0 = 121`, avant de rebasculer l'octet de mode et de relancer le worker sur le fichier `.locked` (`tools/emulate_decrypt_own_ciphertext.py`, mis à jour).

Résultat : le worker atteint cette fois `FUN_140001bb0` (la fonction de déchiffrement par fichier elle-même, confirmée par `CreateFileW`/`ReadFile` observés), qui retourne le code d'erreur `0x54f`. Ce code est renvoyé par plusieurs points de sortie distincts dans `FUN_140001bb0` (marqueur invalide, échec du déchiffrement SM2 de la clé, échec du déchiffrement SM2 de l'IV, échec de l'initialisation AES) : localisation précise du point exact par sondes ciblées sur les adresses de comparaison identifiées en désassemblage (`0x140001d3b` pour le marqueur, `0x140001e13` et `0x140001e4d` pour les deux appels SM2) :

```
[marqueur] dword a [rbp+0x790] = 0x37564c (attendu 0x37564c)     -- OK, marqueur correct
[SM2 dechiffrement cle AES] EAX = 0 (1=succes)                    -- ECHEC, des le premier appel
```

Le marqueur `LV7` est retrouvé correctement (navigation dans le footer valide). **Le tout premier appel SM2 de déchiffrement, celui de la clé AES, échoue** (`EAX=0`), avant même d'atteindre le déchiffrement de l'IV ou l'AES lui-même.

Décompilation de `FUN_14000d170` (l'appel effectué) : assertions en clair `"crypto\\evp\\asymcipher.c"` / `"EVP_PKEY_decrypt"`, confirmant qu'il s'agit bien de la fonction OpenSSL standard `EVP_PKEY_decrypt()`, qui se contente de transmettre l'appel à l'implémentation réelle via une table de dispatch (`(**(code**)(*(longlong*)(param_1+0x1e)+0xa8))(...)`), c'est-à-dire la véritable fonction `ossl_sm2_decrypt()` de `crypto/sm2/sm2_crypt.c`, **exactement celle dont l'erreur `invalid digest` a été observée en §K avec un outil externe**.

**Constat à ce point :** le malware chiffre un fichier avec sa clé publique SM2 embarquée (§I), puis, lorsqu'on force et complète correctement son propre chemin de déchiffrement interne (même clé privée, même binaire, même exécution), il échoue à déchiffrer la clé AES qu'il vient lui-même de chiffrer, au même point exact (`ossl_sm2_decrypt`, échec d'intégrité) que celui observé avec OpenSSL système en §K. Les deux échecs (interne au binaire, et externe avec un outil générique) se recoupent sur la même fonction source. Ce recoupement ne dit pas encore lequel des deux chemins est en cause : c'est l'objet du §N.

## N. Isolation définitive : le bug est dans l'émulation du CHIFFREMENT, pas dans le déchiffrement du malware

Le recoupement du §M (l'échec se reproduit aussi bien avec un outil externe qu'avec le vrai code de déchiffrement du binaire) ne prouve pas *lequel* des deux chemins (chiffrement ou déchiffrement) est en cause : les deux tentatives de déchiffrement testées jusqu'ici portaient sur le **même** fichier `.locked`, produit par le **même** chiffrement émulé. Un chiffré structurellement invalide produit à l'origine ferait échouer n'importe quel déchiffreur correct, y compris le vrai code du malware.

Test décisif (`tools/isolate_encrypt_vs_decrypt_bug.py`) : capture de la vraie clé AES et du vrai IV réellement utilisés par le chiffrement émulé (interception des octets réellement écrits par `BCryptGenRandom`, pas une supposition), puis chiffrement de ces mêmes octets exacts avec OpenSSL système (la même clé publique, indépendamment de tout code du binaire). Le fichier `.locked` produit par le vrai code est ensuite modifié ("greffé") : seuls les champs SM2 du footer (clé et IV chiffrés) sont remplacés par ceux produits par OpenSSL système, le corps AES-256-CBC reste strictement celui écrit par le malware (chiffré avec la même clé/IV réels). Ce fichier greffé est ensuite soumis au **vrai code de déchiffrement du binaire** (même procédure qu'en §M : bascule du mode, substitution de la clé privée).

```
[+] cle AES reellement utilisee par le chiffrement emule : 060cc175...
[+] IV reellement utilise : ad709c6c...
[+] cle/IV re-chiffres avec OpenSSL systeme : 140o / 123o
[+] fichier .locked greffe : corps AES du malware inchange, champs SM2 remplaces par OpenSSL
[+] ===> DECHIFFREMENT NATIF REUSSI SUR LE FICHIER GREFFE
```

**Le déchiffrement natif réussit** sur ce fichier greffé : contenu récupéré identique à l'original. Le vrai code de déchiffrement du binaire, avec la vraie clé privée, retrouve correctement la clé AES et déchiffre correctement le corps chiffré par le malware, **dès que le champ SM2 vient d'un chiffrement OpenSSL "propre"**. Ceci démontre que ni la clé privée, ni le code de déchiffrement du malware, ne sont en cause : le défaut est localisé dans l'émulation du **chiffrement**, qui produit un chiffré SM2 structurellement valide (ASN.1 correct, tailles cohérentes) mais dont le contenu échoue systématiquement à la vérification d'intégrité SM3 au déchiffrement, quel que soit l'outil utilisé pour déchiffrer ensuite.

Pistes explorées et écartées pour la cause exacte de ce défaut de chiffrement :
- **Réutilisation du scalaire éphémère SM2** (un `k` fixe ou dégénéré produirait un chiffré syntaxiquement valide mais cryptographiquement incorrect) : écartée. Deux chiffrements de fichiers différents dans la même session produisent des coordonnées `C1` différentes (vérifié directement sur les octets ASN.1), le tirage aléatoire varie bien.
- **`IsProcessorFeaturePresent` renvoyant systématiquement 0** (pour pousser OpenSSL vers ses chemins de calcul génériques plutôt que des instructions matérielles spécifiques) : testé en le faisant renvoyer 1 à la place. N'a pas corrigé le problème et a introduit un comportement différent et manifestement plus cassé (fichier `.locked` de taille aberrante, 2271 octets au lieu de ~367) : changement annulé, cette piste n'explique pas le défaut mais un `IsProcessorFeaturePresent` a 0 reste la configuration la moins mauvaise trouvée.
- **Résolution dynamique d'une source d'aléa alternative** (`GetProcAddress` pour un nom du style `SystemFunction036`/RtlGenRandom, mécanisme connu d'OpenSSL sous Windows pour du réensemencement) : écartée par instrumentation directe, un seul nom est jamais résolu dynamiquement pendant tout le chemin de chiffrement (`_vsnwprintf`, pour le message de log, sans rapport avec la cryptographie).

## O. Précision supplémentaire : ce n'est pas juste le hachage d'intégrité qui diverge

Recalcul manuel, étape par étape, de l'algorithme SM2 (`sm2_crypt.c`) sur le chiffré produit par l'émulation du chiffrement, en utilisant la bibliothèque `gmssl` (implémentation Python indépendante, validée au préalable sur un chiffré OpenSSL connu avant d'être appliquée au cas qui pose problème, pour écarter toute erreur de transcription des paramètres de courbe ou de la clé, deux pièges déjà rencontrés lors de cette vérification) :

1. `C1` (le point extrait du chiffré) multiplié par la clé privée `d` donne un point `(x2, y2)`.
2. Dérivation de flux `KDF(x2 || y2)` (SM3) puis XOR avec `C2` : la valeur `M` ainsi récupérée **ne correspond pas** à la vraie clé AES capturée pendant le chiffrement émulé.
3. Le hachage `SM3(x2 || M || y2)` recalculé ne correspond pas davantage à `C3` stocké dans le chiffré (cohérent avec l'échec `invalid digest` déjà observé).

Le point (2) est la précision importante : l'écart n'est pas seulement au niveau de la vérification d'intégrité finale (`C3`), il est déjà présent dans la récupération même du texte clair via KDF/XOR. Puisque `d` est vérifié correct (`d·G = Q`), la seule explication est que `C1`, tel qu'écrit dans le chiffré par le chiffrement émulé, **ne correspond pas au point/secret partagé réellement utilisé pour dériver le flux qui a chiffré `C2`** au moment de l'émulation du chiffrement. Le mécanisme exact de cette divergence (à quel endroit, dans le calcul émulé du chiffrement, le `C1` écrit se détache du secret réellement utilisé pour le XOR) n'a pas été identifié à ce stade : candidat le plus probable, non vérifié plus avant, une subtilité de fidélité de l'émulation affectant spécifiquement cette étape du calcul SM2 (par exemple autour de la fonction interne d'OpenSSL qui calcule et sérialise `C1` séparément du secret utilisé pour le KDF), sans rapport avec la clé privée elle-même ni avec le code de déchiffrement du malware.

Ce qui est acquis avec certitude, par le test d'isolation du §N et cette précision : **le défaut est du côté du chiffrement émulé par ce harnais (la relation entre le `C1` écrit et le secret effectivement utilisé pour dériver le flux de chiffrement), pas du côté du déchiffrement du malware ni de la clé privée elle-même.**

Conséquence pratique importante : un vrai fichier chiffré par une exécution réelle de ce build (pas par cette émulation) a été chiffré par un OpenSSL correctement initialisé au démarrage normal du processus, sans les simplifications de ce harnais. Rien dans cette analyse n'indique qu'un tel fichier réel présenterait le même défaut : au contraire, le test de greffe montre que la clé privée et le code de déchiffrement du malware fonctionnent correctement sur un chiffré SM2 produit dans des conditions normales.

## P. Cause exacte trouvée : `EC_POINT_mul` par le générateur donne un résultat faux sous ce harnais

Localisation de l'appel réel derrière `FUN_14000d390` (le dispatch d'`EVP_PKEY_encrypt` traverse plusieurs couches génériques du provider OpenSSL) par instrumentation directe de l'appel indirect (`call qword ptr [r10+0x98]`) : la cible réelle est `FUN_140110110` = `sm2_asym_encrypt` (`providers\implementations\asymciphers\sm2_enc.c`), qui appelle `FUN_14016a6c0` = **`ossl_sm2_encrypt()`** (`crypto\sm2\sm2_crypt.c`, assertions en clair confirmées).

Décompilation de `ossl_sm2_encrypt()` : deux appels à la même fonction interne `FUN_14004a920` (signature identique à `EC_POINT_mul(group, r, n, q, m, ctx)` = `r = n·G + m·q`), avec le **même** scalaire éphémère `k` (`uVar20`) dans les deux cas :
- `FUN_14004a920(groupe, local_b8, k, 0, 0, ctx)` → `local_b8 = k·G` (**C1**, chemin « multiplication par le générateur », qui utilise en interne une table précalculée de multiples de `G` construite paresseusement) ;
- `FUN_14004a920(groupe, local_a8, 0, Q, k, ctx)` → `local_a8 = k·Q` (**le secret partagé (x2,y2)**, chemin générique, sans table précalculée, puisque `Q` n'est pas un point fixe connu à l'avance).

Capture directe en mémoire de `x2 || y2` juste avant l'appel au KDF (`FUN_140071920`, sondé à son entrée : R8/R9 pointent déjà vers le tampon sérialisé `x2||y2` et sa taille) :

```
x2||y2 utilise en interne pendant le chiffrement : 0fd048e6...b6b22c0cc
d*C1 calcule independamment (gmssl, cle et C1 extraits du fichier)   : 3c657fd1...4976ee3d
== correspondent (d*C1 == x2||y2 interne) ?  False
```

**`d·C1` (calculé indépendamment, avec le vrai `C1` écrit par le chiffrement émulé) ne correspond pas au `x2||y2` réellement utilisé en interne pour la même opération.** Puisque le même `k` alimente les deux appels `FUN_14004a920`, et que le résultat du chemin générique (`k·Q`) est celui qui s'avère correct (cohérent avec le round-trip OpenSSL générique du §D), la conclusion est que **le chemin « multiplication par le générateur » (table précalculée de `G`) produit un résultat faux sous ce harnais d'émulation minimal**, très probablement parce que cette table (normalement construite une fois, paresseusement, par une routine de bibliothèque jamais rejouée ici puisque le vrai démarrage CRT/OpenSSL est sauté, cf. §I) n'est pas dans l'état attendu. Le chemin générique (`k·Q`), qui ne dépend d'aucune table précalculée, reste correct, cohérent avec le fait que ce défaut échappe totalement au test de greffe du §N (qui ne fait jamais recalculer de `k·G` par le code émulé).

**Correction, sans réimplémenter l'algorithme du malware ni deviner `k` :** puisque `(x2,y2) = k·Q = k·d·G` est correct, alors `d⁻¹·(x2,y2) = k·G = C1_correct`, calculable uniquement à partir de la clé privée `d` (déjà extraite, §C) et du secret partagé capturé (déjà correct), sans jamais avoir besoin de connaître `k` ni de corriger la table précalculée elle-même. Implémenté dans `tools/fix_c1_and_decrypt.py` : capture `x2||y2` pendant le chiffrement émulé, recalcule `C1_correct = d⁻¹·(x2,y2)` (via `gmssl`, dont l'implémentation a été validée séparément sur un cas connu avant d'être utilisée ici), reconstruit la structure ASN.1 `SM2Ciphertext` avec ce `C1` corrigé (`C2`/`C3` et le corps AES-256-CBC restent strictement ceux produits par le vrai code du binaire, aucune autre valeur n'est modifiée), puis déchiffre avec `openssl pkeyutl` standard :

```
[+] chiffre par le vrai code natif : C:\test\sample.txt.locked (397 octets)
[+] champ C1 corrige pour la cle AES et l'IV (C2/C3 et corps AES inchanges)
[+] cle AES recuperee via openssl standard : 7dfa9727707a5c4645f0b0c0600e659ce185d8b04ad32022508040493235cda3
[+] IV recupere via openssl standard       : 804d6f9776fd9e9581bcc5ad54304dd2
[+] contenu recupere : b'Un dernier test propre, via le script consolide...'
[+] IDENTIQUE A L'ORIGINAL BIT A BIT : True
```

**Round-trip complet, bit exact, avec un `openssl pkeyutl` standard** : la clé AES et l'IV, protégés par SM2 via le vrai code de chiffrement du binaire, se déchiffrent correctement une fois ce défaut ponctuel corrigé, et le corps AES-256-CBC (jamais modifié) se déchiffre à son tour correctement, restituant le contenu original exact. Ceci apporte la démonstration la plus directe possible, au-delà des tests d'isolation du §N : la clé privée SM2 embarquée et le code de déchiffrement du malware fonctionnent parfaitement de bout en bout sur un vrai fichier produit par le vrai code de chiffrement du binaire ; le seul défaut se trouvait dans la fidélité de ce harnais d'émulation vis-à-vis d'une optimisation interne d'OpenSSL (table précalculée de multiples du générateur de courbe), sans rapport avec la logique propre au ransomware.
