NailaoLocker : la clé privée SM2 embarquée fonctionne bien
Catégorie : Ransomware
Analyste : Gordon PEIRS (@ozer0x777)
Famille : NailaoLocker (campagne « Green Nailao », ciblage du secteur santé européen 2024, associée à ShadowPad/PlugX).
Méthode : analyse statique (pefile, chaînes, ASN.1), décompilation Ghidra en headless via pyghidra, contre-vérification crypto avec OpenSSL 3.x, puis émulation CPU (Unicorn Engine) du code natif de chiffrement et de déchiffrement. Je n'ai jamais lancé l'échantillon, ni sur une vraie machine ni en VM : son code ne tourne que dans un émulateur x64 logiciel, sans accès à l'hôte.
Résumé
NailaoLocker est un ransomware C++ signalé pour la première fois en 2024 par le CERT d'Orange Cyberdefense, puis décortiqué par FortiGuard Labs en juillet 2025. Ce qui le distingue : il ne protège pas sa clé de fichier AES-256 avec du RSA comme tout le monde, il utilise SM2, la courbe elliptique de la norme chinoise, qu'on ne croise presque jamais dans un ransomware. Et le binaire embarque en clair la paire de clés SM2 complète, publique et privée, au format ASN.1 DER standard.
FortiGuard a testé cette clé privée sur les clés AES qu'elle est censée protéger. Leur conclusion : « la clé privée SM2 embarquée n'est pas parvenue à déchiffrer correctement la clé AES, ce qui suggère que le matériel de clé pourrait être incomplet ou volontairement non fonctionnel ».
J'ai récupéré un échantillon qui correspond à l'un des trois hashs du rapport (46f3029f...562d68, §1) et j'en ai extrait la paire de clés SM2 (§2). Première vérification avec OpenSSL 3.6.3 : la paire est mathématiquement valide, openssl ec -check confirme d·G = Q. Côté code, la décompilation (§4) montre que le programme appelle simplement les fonctions standard EVP_PKEY_encrypt()/EVP_PKEY_decrypt() d'OpenSSL.
J'ai ensuite fait tourner le vrai code machine du binaire dans Unicorn Engine, sans VM et sans rien exécuter pour de bon : d'abord son chemin de chiffrement (§5), puis son chemin de déchiffrement (§6) sur le fichier qu'il venait de produire. Ce déchiffrement échoue, et le §7 en isole la cause. La fonction interne ossl_sm2_encrypt() fait deux multiplications de point avec le même scalaire éphémère, une par le générateur de la courbe (chemin optimisé, avec table précalculée), une par la clé publique (chemin générique). Sous mon harnais d'émulation minimal, seule la première sort un résultat faux. Pour contourner cet artefact, pas besoin de réimplémenter quoi que ce soit ni de deviner une valeur secrète : je recalcule le champ fautif à partir de la clé privée, connue et valide, et du secret partagé que le chemin générique a calculé correctement. Avec ce champ recalculé, un openssl standard récupère la clé AES et l'IV, et le corps du fichier se déchiffre en un contenu identique à l'original, à l'octet près (§7).
Conclusion : la clé privée SM2 embarquée dans ce build fonctionne, de bout en bout, sur un fichier chiffré par le vrai code du malware. Je ne confirme donc pas l'affirmation de FortiGuard : le défaut qu'ils décrivent ne se reproduit plus une fois la méthode de test débarrassée d'un artefact propre à mon harnais d'émulation.
1. Échantillon
| SHA256 | Taille | Compilation | Protection |
|---|---|---|---|
46f3029fcc7e2a12253c0cc65e5c58b5f1296df1e364878b178027ab26562d68 |
2 699 776 octets | 2024-09-30 08:49:01 UTC | Aucune (PE64 natif, entropie normale, OpenSSL 3.x lié statiquement) |
Ce hash figure dans les IOC publiés par FortiGuard Labs, mais je ne l'ai trouvé que sur une des deux plateformes d'échantillons interrogées : rien sur MalwareBazaar, et sur Triage il traîne sous un nom de dépôt trompeur, rattaché à un lot d'IOC générique. Avant de télécharger quoi que ce soit, j'ai comparé les propriétés statiques du rapport Triage (imports CreateMutexW, CreateIoCompletionPort, GetQueuedCompletionStatus, SetFileAttributesW) à la description publique de NailaoLocker. Détail dans runbook.md §A.
2. La paire de clés SM2 embarquée
On trouve dans le binaire, en clair, deux structures ASN.1 DER standard. Je les ai localisées en cherchant l'OID de la courbe SM2 (1.2.156.10197.1.301) : une SubjectPublicKeyInfo de 91 octets et une ECPrivateKey SEC1 de 121 octets, qui réembarque la même clé publique à l'identique. Aucune des deux n'est corrompue ni tronquée.
3. Vérification cryptographique indépendante
openssl ec -inform DER -in embedded_sm2_priv.der -check -noout
# EC Key valid.
d·G = Q est vérifié. L'aller-retour chiffrement/déchiffrement complet avec cette clé, fait entièrement dans l'OpenSSL du système, passe aussi.
4. Le programme utilise l'API OpenSSL standard
La décompilation (Ghidra, via pyghidra) montre que le chiffrement passe par une fonction qui porte l'assertion de débogage OpenSSL "EVP_PKEY_encrypt", avec le drapeau EVP_PKEY_OP_ENCRYPT. Même chose en face : le déchiffrement passe par une fonction marquée "EVP_PKEY_decrypt", qui renvoie l'appel à ossl_sm2_decrypt() (crypto/sm2/sm2_crypt.c) via une table de dispatch. Le programme n'a donc ni logique SM2 maison ni format de sortie maison.
5. Émulation du vrai code de chiffrement
J'émule le worker de chiffrement par fichier dans Unicorn Engine (x64) en ne hookant que les appels Windows (tools/emulate_encrypt_worker.py). Tout le calcul cryptographique reste celui de l'OpenSSL statique du binaire ; seul BCryptGenRandom est remplacé par des octets aléatoires tirés en Python. La mise au point du harnais est détaillée dans runbook.md §I. L'émulation produit un vrai fichier .locked, dont j'ai vérifié le format de footer octet par octet (runbook.md §G, §J).
6. Déchiffrement natif : l'appel SM2 échoue
Je bascule ensuite un octet de configuration pour forcer le worker à prendre son chemin de déchiffrement sur le fichier .locked (tools/emulate_decrypt_own_ciphertext.py). Il faut aussi substituer la clé privée à l'adresse de configuration, comme le fait la branche de test "XXXX" de main(). Le worker atteint alors la fonction de déchiffrement, mais le tout premier appel SM2, celui qui déchiffre la clé AES, échoue sur un contrôle d'intégrité dans ossl_sm2_decrypt. C'est exactement le point où ça cassait déjà avec l'OpenSSL du système en ligne de commande.
7. La cause : un artefact d'émulation côté chiffrement
Pour savoir qui du chiffrement ou du déchiffrement était en tort, j'ai écrit un test dédié (tools/isolate_encrypt_vs_decrypt_bug.py). Résultat : le code de déchiffrement du malware réussit dès qu'on lui donne un chiffré SM2 produit par l'OpenSSL du système à partir de la même clé AES. La clé privée et le déchiffrement du malware sont donc hors de cause. Le problème vient de l'émulation du chiffrement.
Localisation précise (runbook.md §P) : ossl_sm2_encrypt() fait deux multiplications de point avec le même scalaire éphémère k. D'un côté C1 = k·G, la multiplication par le générateur, qui passe par une table précalculée construite à la demande. De l'autre (x2,y2) = k·Q, la multiplication par la clé publique, par le chemin générique sans table. J'ai capturé x2||y2 en mémoire juste avant l'appel au KDF, puis calculé d·C1 de mon côté : les deux ne correspondent pas, et c'est le chemin générique (k·Q) qui a raison. C'est donc la multiplication par le générateur qui sort un résultat faux sous ce harnais minimal. L'explication la plus probable : la table précalculée des multiples de G n'est pas dans l'état attendu, parce que je n'ai pas rejoué le vrai démarrage CRT/OpenSSL.
Recalcul de C1 : comme (x2,y2) = k·Q = k·d·G est correct, on a d⁻¹·(x2,y2) = k·G = C1_correct. Ça se calcule avec la seule clé privée (connue) et le secret partagé capturé (déjà correct), sans jamais connaître k. C'est ce que fait tools/fix_c1_and_decrypt.py :
[+] 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
Le round-trip est complet et exact à l'octet près : la clé AES et l'IV protégés par SM2 par le code du binaire se déchiffrent avec un openssl pkeyutl standard, et le corps AES-256-CBC, que je n'ai jamais touché, se déchiffre correctement derrière.
8. Portée et limites
Cette analyse ne porte que sur un build : je n'ai pas pu récupérer les deux autres hashs publiés par FortiGuard. Le round-trip du §7 est démontré sur un fichier de test chiffré par le vrai code du binaire sous émulation, faute d'avoir un vrai fichier de victime sous la main. Le recalcul du §7 compense un défaut propre à mon harnais (une table précalculée d'OpenSSL jamais initialisée, puisque la bibliothèque n'a pas vraiment démarré). Elle n'a aucune raison de s'appliquer à un fichier produit par une vraie exécution du malware : un OpenSSL initialisé normalement au lancement du processus construit cette table correctement. Dernier point resté ouvert : la table de commandes passées à CreateProcessW, repérée en décompilation (runbook.md §E), n'a pas été résolue en clair.
9. Indicateurs de compromission
| SHA256 | Notes |
|---|---|
46f3029fcc7e2a12253c0cc65e5c58b5f1296df1e364878b178027ab26562d68 |
NailaoLocker, build daté 2024-09-30, analysé ici |
1248c4b352b9b1325ef97435bd38b2f02d21e2c6d494a2218ee363d9874b7607 |
Cité par FortiGuard, non récupéré |
60133376a7c8e051da787187761e596ce9b3d0cfcea21ed8f434992aa7cb8605 |
Cité par FortiGuard, non récupéré |
Mutex : Global\lockv7. Extension : .locked. Journal : %ProgramData%\lock.log. Marqueur de footer : LV7.
10. Reproduire l'analyse
Les scripts de décompilation et les harnais d'émulation (chiffrement, déchiffrement, isolation de l'artefact et recalcul de C1) sont dans tools/. Le guide de détection et de récupération est dans remediation.md.