KONNI : deux canaux C2, deux clés faciles à retrouver

Catégorie : RAT

Analyste : Gordon PEIRS

Famille : KONNI, outil d'accès distant utilisé par des acteurs nord-coréens liés à l'ombrelle Kimsuky, documenté depuis 2014.

Méthode : reverse statique (Ghidra headless via PyGhidra), complété par de l'émulation CPU (Unicorn Engine) là où l'obfuscation empêche la décompilation. Les échantillons ne sont jamais exécutés et il n'y a aucun accès réseau, à aucun moment.


Résumé

Le composant réseau de KONNI parle au C2 par deux canaux HTTP. Chacun a sa propre faille de dérivation de clé AES-256, et elles sont de nature différente.

Le canal montant (exfiltration de fichiers) chiffre chaque envoi en AES-256-CTR. La clé vaut SHA-256(préfixe_statique + horodatage_local), où l'horodatage vient de GetLocalTime(), à la seconde près. Un défenseur qui connaît le moment de l'upload, ou qui peut le borner, recalcule la clé exacte en testant au plus quelques millions de SHA-256 au lieu des 2²⁵⁶ possibilités théoriques. Une incertitude de 24 heures entières se résout en moins d'une seconde en Python pur.

Le canal descendant (configuration et commandes reçues du C2, stockées en local dans un fichier wiasvc.ini) utilise la même primitive AES-256-CTR, mais sa clé est une constante statique : SHA-256(UTF16LE("wiasvc")), le nom du service sous lequel le malware s'installe. Cette constante est cachée derrière une obfuscation maison (près de 57 000 instructions de bourrage entre l'appel à GetModuleFileNameW et le lancement du thread de travail) qui rend la décompilation illisible. Une émulation CPU (Unicorn Engine, sans exécution réelle) suffit pourtant à la faire apparaître en clair dans un registre au démarrage du thread. J'ai validé cette clé en déchiffrant un vrai fichier de configuration récupéré sur MalwareBazaar : on obtient une liste lisible de 21 domaines C2.

Un rapport public de Malwarebytes (janvier 2022, sur une variante plus furtive de KONNI) confirme de son côté une partie du premier mécanisme : l'IV des fichiers envoyés au C2 est bien dérivé d'un appel à QueryPerformanceCounter. Ce rapport ne détaille ni la construction de la clé du canal montant, ni le second défaut (canal descendant, clé constante cachée par obfuscation). Ces deux points sont ce que cette analyse apporte.

Tout est reproductible depuis runbook.md. Les scripts sont dans tools/.

1. Échantillons

SHA256 Rôle Détails
b60dc12833110098f5eec9a51749d227db7a12d4e91a100a4fd8815695f1093f Composant réseau (DLL de service, wapsvc.dll) PE32+ x86-64, non protégé, exporte ServiceMain, imports WININET.dll complets
174f1e0c65001b227383b46568b084cdf4fc450485c7363a7ff77bf1ee218652 Dropper/déployeur PE32 x86-64, 82 432 octets, dépose et lance le composant réseau via cmd.exe /c wapsvc.bat
aa51573f9abcd4a1ec4a61ee7e5811c0279e015ea22bdb787780d67ce7153a57 Build le plus récent connu (2026-03-17) Protégé par VMProtect (.vmp0/.vmp1, RWX, entropie 7,88), écarté de cette analyse, voir §6

Les trois échantillons viennent d'un même lot MalwareBazaar tagué Konni (2024-06-05 pour les deux premiers). Il existe un build plus récent (mars 2026), mais il est protégé par VMProtect. J'ai choisi de travailler sur les builds non protégés pour aller au bout du mécanisme de chiffrement, au lieu de rester bloqué derrière une couche de virtualisation.

2. Le composant réseau : dropper, service, canaux montant et descendant

Le dropper (174f1e0c...) détecte l'architecture (GetNativeSystemInfo), renomme la bonne variante 32/64-bit du composant réseau en wapsvc.dll, puis lance cmd.exe /c wapsvc.bat pour l'enregistrer et le démarrer comme service Windows (la DLL exporte bien ServiceMain).

Le composant réseau (b60dc128...) implémente deux canaux HTTP vers le C2, tous deux lisibles en clair dans le code décompilé :

  • Descendant (FUN_180004640) : GET <host>/dn.php?name=<...>&prefix=<...>, la réponse brute est écrite sur disque. Il sert à récupérer la configuration et les commandes, et éventuellement des payloads supplémentaires.
  • Montant (FUN_1800049b0) : POST <host>/up.php?name=<...>, upload multipart d'un fichier préalablement chiffré. Il sert à exfiltrer des documents (avec une compression en plus pour les archives .7z/.zip/.rar/.cab/.docx/.xlsx), des captures d'écran, ou la sortie de commandes shell (systeminfo, tasklist) lancées par des chaînes de commande visibles en clair (PTR_u_cmd__c_systeminfo_>..., PTR_u_cmd__c_tasklist_>...).

3. SHA-256 et AES-256-CTR authentiques, confirmés par décompilation

Il y a deux jeux de fonctions cryptographiques, réutilisés à plusieurs endroits :

  • FUN_180003510 (compression SHA-256, avec les constantes standard K[0..63]) et FUN_1800038e0 (finalisation : padding, longueur en bits, sortie big-endian). C'est un SHA-256 conforme, qui sert à dériver des clés et, ailleurs dans le binaire, à hacher un nom de fichier.
  • FUN_180003b90 (expansion de clé Rijndael, table S-box DAT_180013af0 et constantes de tour UNK_180013cf0, boucle jusqu'à l'indice 0x3c = 60 mots) et FUN_180003db0 (14 tours de chiffrement, incrément de compteur big-endian avec propagation de retenue en fin de bloc, XOR du keystream et du clair). C'est un AES-256 en mode CTR conforme : 60 mots de sous-clés et 14 tours sont la signature d'AES-256, contre 44 mots et 10 tours pour AES-128.

FUN_180002e90, le point d'entrée de l'exfiltration appelé depuis FUN_1800049b0, orchestre le tout :

QueryPerformanceCounter(&compteur);              // 8 octets
SHA256(compteur, 8, &iv16);                       // IV/compteur CTR, tronque a 16 octets : SAIN
FUN_180003a70(&nom_fichier, longueur, &cle32);    // SHA-256(nom_fichier) -> cle AES-256 : PROBLEME, voir §4
FUN_180003b90(&contexte, &cle32);                 // expansion de cle Rijndael
FUN_180003db0(&contexte, donnees, taille);        // AES-256-CTR en place
// sortie = iv16 || donnees_chiffrees

4. Le défaut : la clé AES-256 est un horodatage haché, pas un secret

Le « nom de fichier » haché pour faire la clé n'est pas un identifiant quelconque. C'est une chaîne construite juste avant l'appel par vswprintf. J'ai lu le désassemblage brut, et pas seulement le pseudocode où les arguments variadiques sont simplifiés, pour avoir l'ordre exact des paramètres passés sur la pile :

format = L"%s%02d-%02d %02d-%02d-%02d.txt"
args   = (DAT_180013ee8, wMonth, wDay, wHour, wMinute, wSecond)

DAT_180013ee8 est une constante wide-string en .rdata (présente dans le fichier, pas dans une zone mise à zéro au chargement) : "rr ". Les cinq champs numériques viennent d'un SYSTEMTIME rempli juste avant par GetLocalTime(), à la seconde près. La chaîne finale a donc la forme "rr MM-JJ HH-MI-SS.txt", par exemple "rr 03-22 14-05-33.txt", et :

clé_AES256 = SHA-256("rr " + "MM-JJ HH-MI-SS.txt")

Le seul élément imprévisible de cette chaîne est l'heure locale de la machine victime au moment de l'upload, à la seconde près. Ce n'est ni un secret partagé avec le C2 ni une valeur issue d'un aléa cryptographique. C'est l'horloge système, et dans presque tous les cas réels de réponse à incident on peut la borner précisément : journaux de proxy ou de pare-feu horodatés, en-tête HTTP Date de la réponse du C2 (si l'infrastructure de l'attaquant la journalise ou si elle est observée en transit), fuseau horaire et plage d'activité connue de la victime.

5. Preuve de concept : réduction de 2²⁵⁶ à quelques millions de calculs SHA-256

tools/konni_upload_crypto.py réimplémente tout le schéma en Python, validé par auto-test (un chiffrement puis un déchiffrement cohérents avec la même reconstruction de clé). Le script de récupération, tools/konni_upload_key_bruteforce.py, prend un blob capturé (compteur CTR de 16 octets + chiffré) et une fenêtre de temps plausible. Il teste chaque seconde de la fenêtre et reconnaît le bon déchiffrement à la signature du fichier (PK\x03\x04 pour zip/docx/xlsx, Rar!, 7z\xbc\xaf, MSCF pour cab, ou du texte imprimable).

Test de bout en bout : je chiffre un texte de test avec un horodatage que l'outil de récupération ne connaît pas (2024-03-22 14:37:52), puis je le retrouve par brute-force sur une fenêtre d'une heure :

$ python3 konni_upload_key_bruteforce.py blob.bin 2024-03-22_14:00:00 2024-03-22_15:00:00
[*] fenetre de recherche : 3601 seconde(s)
[+] cle retrouvee, horodatage local victime : 2024-03-22 14:37:52 (2273 candidat(s) teste(s))
[+] 548 octet(s) recuperes, apercu : ...

Sur une incertitude complète de 24 heures (86 400 secondes), retrouver la clé et déchiffrer le contenu prend moins d'une seconde en Python pur sur mon poste d'analyse.

6. Le canal descendant : une constante cachée sous obfuscation, retrouvée par émulation CPU

Le canal descendant (config et commandes reçues du C2 via dn.php, stockées en local dans un fichier wiasvc.ini) utilise la même primitive AES-256-CTR. Sa clé est dérivée d'un matériau (DAT_18001a4d0) qui n'est pas lisible en clair dans le désassemblage statique : il est copié depuis un paramètre reçu au démarrage du thread de travail (wcscpy_s(&DAT_18001a4d0, 0x104, param_1 + 0x104)).

Le script d'installation, wiasvc.bat, est dans le même lot MalwareBazaar. Il installe le composant réseau comme un service Windows générique (sc create wiasvc binpath= "%windir%\System32\svchost.exe -k wiasvc" ...) et ne passe aucun secret, ni en ligne de commande ni dans le registre. Il référence un fichier wiasvc.ini et le supprime après l'installation, ce qui confirme que c'est bien le fichier de config chiffré local.

Le paramètre passé au thread de travail (param_1) est construit par ServiceMain, dans un code qui enchaîne GetModuleFileNameW puis un appel à une fonction (FUN_18011e3b2) que Ghidra n'arrive pas à désassembler proprement (Control flow encountered bad instruction data, halt_baddata()). Cette fonction patche un octet en mémoire à une adresse calculée, puis continue dans le code qu'elle vient de modifier. C'est de l'anti-désassemblage classique par code auto-modifiant.

Émulation CPU (Unicorn Engine, x86-64, tools/emulate_service_init.py), sans exécution réelle ni accès réseau. Je charge le PE en mémoire émulée avec un TEB/PEB minimal (registre GS_BASE). La résolution dynamique des API est interceptée, y compris GetProcAddress, appelé à l'exécution pour obtenir RegisterServiceCtrlHandlerW/CreateThread qui ne sont pas dans la table d'import statique. Un hook sur les écritures en mémoire exécutable suit le code auto-modifiant (avec ctl_remove_cache d'Unicorn pour invalider le cache de traduction après chaque patch). L'émulation démarre à ServiceMain et j'intercepte le premier CreateThread. Le paramètre passé au thread de travail contient deux chaînes : le chemin du fichier .ini local et, à l'offset +0x104, la chaîne "wiasvc".

Test de contrôle : j'ai remplacé le chemin de module fourni à l'émulation (GetModuleFileNameW) par une valeur arbitraire (ZZZZZZZZ.dll au lieu de wiasvc.dll). La chaîne récupérée au CreateThread ne change pas, c'est toujours "wiasvc". Elle n'est donc pas dérivée du nom du fichier sur disque. C'est une constante figée, indépendante de l'environnement d'exécution, cachée par les ~57 000 instructions de bourrage que l'émulation traverse entre les deux appels.

clé_config = SHA-256(UTF16LE("wiasvc"))

La validation qui tranche : le même lot MalwareBazaar contient un fichier de 611 octets (hash a56a8217bad8523c3f2f163a69a50fba79142ab0a71b4a1443fb4143847e7146, tagué unknown) dont la taille et l'entropie collent avec une instance capturée de wiasvc.ini. Avec cette clé (tools/konni_config_crypto.py), il se déchiffre en une liste lisible de 21 domaines C2 (voir §8). La clé et le schéma de chiffrement du canal descendant sont donc confirmés d'un coup.

"wiasvc" est le nom de service choisi pour cette campagne (il se fait passer pour « Windows Image Acquisition Service », ce que confirme wiasvc.bat). Un autre build ou une autre campagne peut utiliser un autre nom de service, et la clé changerait avec. Ce qui se généralise à ces variantes, c'est la méthode (émuler jusqu'au CreateThread et lire le paramètre), pas le résultat "wiasvc".

7. Ce qui n'a pas été résolu

  • Je n'ai pas examiné le build de mars 2026 (aa51573f9a...), protégé par VMProtect. Sans lever cette protection, impossible de dire si les deux défauts de dérivation de clé existent encore sur les builds récents.
  • Je n'ai utilisé aucune capture de trafic réel, puisque je m'interdis d'exécuter l'échantillon ou de contacter une infrastructure C2. Les PoC sont validés soit par auto-test de la réimplémentation Python (canal montant), soit sur un vrai fichier de configuration récupéré sur MalwareBazaar, jamais sur une capture réseau en direct (canal descendant).
  • Je n'ai pas regardé les autres fichiers du lot MalwareBazaar (une .dll de 4,5 Mio, un .cab, deux .msi).
  • L'obfuscation de FUN_18011e3b2 (code auto-modifiant, ~57 000 instructions de bourrage) n'a été traversée que par émulation, pour cette clé. Je n'ai pas étudié son fonctionnement interne (quel obfuscateur, outil tiers ou développement maison) au-delà de ce qu'il fallait pour extraire la constante.

8. Indicateurs de compromission

Type Valeur
SHA256 (composant réseau, wapsvc.dll/wiasvc.dll) b60dc12833110098f5eec9a51749d227db7a12d4e91a100a4fd8815695f1093f
SHA256 (dropper) 174f1e0c65001b227383b46568b084cdf4fc450485c7363a7ff77bf1ee218652
SHA256 (installeur, wiasvc.bat) b792867d9275c0ed64cbb501431fe0368c08ee3fa284a72590e46881241b2076
SHA256 (config C2 capturée, wiasvc.ini chiffré) a56a8217bad8523c3f2f163a69a50fba79142ab0a71b4a1443fb4143847e7146
SHA256 (build récent, VMProtect, non analysé) aa51573f9abcd4a1ec4a61ee7e5811c0279e015ea22bdb787780d67ce7153a57
Noms de fichiers/service déposés wapsvc.dll/wiasvc.dll (variantes selon campagne), wiasvc.ini, wiasvc.bat, service Windows wiasvc ("Windows Image Acquisition Service")
Chemins C2 /dn.php?name=...&prefix=... (GET, descendant), /up.php?name=... (POST, montant)
Préfixe statique de dérivation de clé (canal montant) "rr "
Format de l'horodatage utilisé comme matériau de clé (canal montant) MM-JJ HH-MI-SS (heure locale victime, GetLocalTime, granularité seconde)
Matériau de clé du canal descendant "wiasvc" (nom de service, propre à cette campagne)
Domaines C2 (extraits du fichier de config déchiffré) victory-2024.mywebcommunity.org, 3cym4ims.medianewsonline.com, j1p75639.medianewsonline.com, 99695njd.myartsonline.com, mhhnv7s9.myartsonline.com, g66nzt8q.mygamesonline.org, p593d8g9.mygamesonline.org, mbfasq54.mypressonline.com, tl2j38w9.mypressonline.com, t8nptw2h.mywebcommunity.org, w9uzs9la.mywebcommunity.org, zcvbm1zv.onlinewebshop.net, zomfaa9a.onlinewebshop.net, 694qf6w8.scienceontheweb.net, 24ev0apa.scienceontheweb.net, c6cdg4su.sportsontheweb.net, 5s6bqbea.sportsontheweb.net, jbkza9h7.atwebpages.com, 88zr7cua.atwebpages.com, p8tebfel.getenjoyment.net, cor8xcib.getenjoyment.net

9. Divulgation et usage

Les échantillons étudiés sont publics (MalwareBazaar) et l'outil est documenté depuis 2014. Un rapport public de Malwarebytes (2022) confirme l'usage de QueryPerformanceCounter pour l'IV des uploads, ce qui recoupe une partie de ce travail, mais il ne détaille ni la construction de la clé AES-256 du canal montant ni la constante cachée qui protège le canal descendant. Ce que j'apporte : la description précise de ces deux dérivations de clé, la démonstration par des réimplémentations qui fonctionnent que la confidentialité des deux canaux C2 de KONNI ne tient pas (l'un repose sur l'horloge locale de la victime, l'autre sur une constante qu'on extrait par émulation malgré l'obfuscation), et une liste vérifiée de domaines C2 liés à cette campagne. Une équipe de réponse à incident qui dispose d'un trafic capturé, d'un fichier de configuration local ou simplement de l'échantillon a ainsi un moyen concret de récupérer du contenu chiffré sans accès à l'infrastructure de l'attaquant.

10. Reproduire l'analyse

Le journal détaillé est dans runbook.md. Le PoC et l'outil de récupération sont dans tools/ (konni_upload_crypto.py, konni_upload_key_bruteforce.py, konni_config_crypto.py, emulate_service_init.py). Le guide de détection et de remédiation : remediation.md.