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 :
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 :
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.
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 :
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)#
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 (
.text6.44,.rdata5.22,.data4.88,.pdata6.16,.reloc5.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 :
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 :
pub_marker = bytes.fromhex("3059301306072a8648ce3d020106082a811ccf5501822d") # SubjectPublicKeyInfo
priv_marker = bytes.fromhex("30770201010420") # SEC1 ECPrivateKey
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) :
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 :
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#
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) :
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) :
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ériqued2i_AutoPrivateKey, pas un parseur ASN.1 maison.FUN_14000d370init le contexte avec le drapeau0x400(EVP_PKEY_OP_DECRYPTdansopenssl/evp.h),FUN_14000d590avec0x200(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 fonctionEVP_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 octets4C 56 37 00="LV7\0": le marqueur trouvé par recherche de motif en §C (deux occurrencesLV7\x00déjà repérées parstrings/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.
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 :
-
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. -
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 deDAT_14026fac0(normalement décodé parmain(), 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. -
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) valantNULL/0au 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, tableDAT_140276570[]), chaque entrée chiffrée par rotation+XOR avec le cookie/GS(DAT_14026ee80, qui reste à sa valeur par défaut MSVC0x2B992DDFA232puisque__security_init_cookien'est jamais appelé). Une entrée jamais initialisée (brute à 0) se décode en la valeur du cookie lui-même, ni0ni-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-1correctement encodé pour CE cookie (rotation de tous les bits à 1 est invariante, doncsentinel = 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 surMultiByteToWideChar/WideCharToMultiByte(le premier paramètreCodePageavait é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.
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) :
C1(le point extrait du chiffré) multiplié par la clé privéeddonne un point(x2, y2).- Dérivation de flux
KDF(x2 || y2)(SM3) puis XOR avecC2: la valeurMainsi récupérée ne correspond pas à la vraie clé AES capturée pendant le chiffrement émulé. - Le hachage
SM3(x2 || M || y2)recalculé ne correspond pas davantage àC3stocké dans le chiffré (cohérent avec l'échecinvalid digestdé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.