Runbook : reproduction pas à pas (KONNI, lot MalwareBazaar 2024-06-05)

A. Recherche de l'échantillon#

KEY=$(cat ../.mb_api_key)
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
  -d "query=get_taginfo&tag=KONNI&limit=1000" | python3 -c "
import json,sys
d=json.load(sys.stdin)
for e in sorted(d.get('data', []), key=lambda x: x.get('first_seen',''), reverse=True)[:20]:
    print(e.get('first_seen'), e.get('sha256_hash'), e.get('file_type'), e.get('file_size'))
"

53 échantillons taggés Konni. Le plus récent (aa51573f9a..., 2026-03-17) s'avère protégé par VMProtect (.vmp0/.vmp1 RWX, entropie 7,88, table d'import quasi vide) : écarté après vérification directe avec pefile, piste conservée pour un travail futur mais hors de portée immédiate (même type de blocage que RagnarLocker/VMProtect dans ce dépôt). Recherche de recherche d'un échantillon Kimsuky/AppleSeed comme piste alternative initialement envisagée : AppleSeed déjà bien documenté publiquement (RC4 + clé dérivée MD5, Malwarebytes/Telsy), KONNI retenu car moins documenté au niveau algorithme précis.

Lot retenu, non protégé : 2024-06-05, plusieurs fichiers sous un même horodatage MalwareBazaar (.exe x3, .dll, .cab, .msi x2, .bat, .unknown). Récupération des trois .exe :

h1=174f1e0c65001b227383b46568b084cdf4fc450485c7363a7ff77bf1ee218652  # dropper
h2=b60dc12833110098f5eec9a51749d227db7a12d4e91a100a4fd8815695f1093f  # composant reseau
h3=aa51573f9abcd4a1ec4a61ee7e5811c0279e015ea22bdb787780d67ce7153a57  # build recent, VMProtect
for h in $h1 $h2 $h3; do
  curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
    -d "query=get_file&sha256_hash=$h" -o "binaries/$h.zip"
  cd binaries && 7z x -pinfected -y "$h.zip" && cd ..
done

B. Triage#

import pefile
for h in (h1, h2, h3):
    pe = pefile.PE(f"binaries/{h}.exe", fast_load=True)
    pe.parse_data_directories()
    print(h, pe.get_warnings())
    for s in pe.sections:
        print(" ", s.Name.decode(errors="ignore").strip("\x00"), round(s.get_entropy(), 2))

h1 (82 432 octets) : sections normales, imports CRT + WinExec/ MoveFileW/DeleteFileW (profil dropper). h2 (1 156 608 octets, PE32+ x86-64) : imports WININET.dll complets, section non standard .piuy0 (code+données, entropie ~6,8 uniforme, pas de packing façon VMProtect). h3 : .vmp0/.vmp1 RWX, table d'import quasi vide, écarté (voir §A).

C. Décompilation Ghidra headless (PyGhidra), vue d'ensemble#

source ~/.venvs/ctf-reverse/bin/activate
python3 tools/entry_and_overview.py binaries/$h2.exe tools/overview_b60dc128.txt

Deux points d'entrée : entry (DllMain classique) et ServiceMain (export nommé, confirme un service Windows). 456 fonctions.

D. Localisation de la logique réseau#

python3 tools/find_api_callers.py binaries/$h2.exe InternetOpenW tools/out_internetopen.txt
python3 tools/find_api_callers.py binaries/$h2.exe HttpSendRequestW tools/out_httpsend.txt

Deux fonctions distinctes : FUN_180004640 (GET .../dn.php?name=...&prefix=..., canal descendant, écrit la réponse brute sur disque) et FUN_1800049b0 (POST .../up.php?name=..., canal montant, chiffre le fichier avant upload multipart). C'est dans FUN_1800049b0 que la routine de chiffrement (FUN_180002e90) est appelée.

E. Identification des primitives cryptographiques#

python3 tools/decompile_addr.py binaries/$h2.exe tools/decomp_crypto.txt \
  0x180002e90 0x180003a70 0x180005ef4 0x1800050f0
python3 tools/decompile_addr.py binaries/$h2.exe tools/decomp_crypto2.txt \
  0x180003b90 0x180003db0 0x180003510 0x1800038e0 0x180005f98

Constantes IV SHA-256 (0x6a09e667, 0xbb67ae85, etc.) reconnues dans FUN_180002e90 et FUN_180003a70 : confirme FUN_180003510 (compression) et FUN_1800038e0 (finalisation) comme une implémentation SHA-256 standard. FUN_180003b90 (boucle jusqu'à l'indice 0x3c = 60 mots de sous-clés) et FUN_180003db0 (14 tours, incrément de compteur big-endian en fin de boucle) confirmés comme AES-256 en mode CTR (60 mots/14 tours = signature AES-256, contre 44 mots/10 tours pour AES-128).

F. Reconstruction du schéma de dérivation de clé#

Dans FUN_180002e90 : QueryPerformanceCounter puis SHA256 tronqué à 16 octets pour le compteur CTR initial (IV, sain) ; FUN_180003a70 (SHA-256) appliqué à une chaîne construite juste avant par vswprintf (FUN_180005f98 -> _vswprintf_s_l), pour produire la clé AES-256.

Le pseudocode Ghidra simplifiait les arguments variadiques de l'appel vswprintf ; désassemblage brut nécessaire pour retrouver l'ordre exact des paramètres :

python3 -c "
import pyghidra
pyghidra.start(install_dir='/opt/ghidra')
with pyghidra.open_program('binaries/$h2.exe', analyze=True) as flat_api:
    program = flat_api.getCurrentProgram()
    listing = program.getListing()
    fm = program.getFunctionManager()
    af = program.getAddressFactory()
    f = fm.getFunctionContaining(af.getAddress('180004a8c'))
    it = listing.getInstructions(f.getBody(), True)
    started = False
    for instr in it:
        if str(instr.getAddress()) >= '1800049f0': started = True
        if started: print(instr.getAddress(), instr)
        if str(instr.getAddress()) >= '180004a95': break
"

Résultat : R8=&DAT_180013ea8 (format L"%s%02d-%02d %02d-%02d-%02d.txt"), R9=&DAT_180013ee8 (1er vararg, %s), puis sur la pile dans l'ordre wMonth, wDay, wHour, wMinute, wSecond (chargés depuis un SYSTEMTIME rempli par GetLocalTime() juste avant, offsets +2 cohérents avec la structure SYSTEMTIME standard). Vérification que DAT_180013ee8 est bien une constante présente dans le fichier (.rdata, pas la zone .data mise à zéro au chargement) :

import pefile
pe = pefile.PE(f"binaries/{h2}.exe", fast_load=True)
rva = 0x180013ee8 - pe.OPTIONAL_HEADER.ImageBase
print(pe.get_data(rva, 8).decode("utf-16le"))  # "rr "

G. Recherche d'une source publique corroborant ou contredisant ce constat#

Recherche web : un rapport Malwarebytes (janvier 2022, variante "stealthier" de KONNI) confirme indépendamment que l'IV des fichiers envoyés au C2 est dérivé d'un appel QueryPerformanceCounter, et mentionne une méthode "timestamp-based" pour les URLs d'exfiltration, sans détailler la construction de la clé AES elle-même ni confirmer que l'horodatage sert directement de matériau de clé. Traité comme une corroboration partielle, pas une contradiction : le mécanisme d'IV est cohérent avec cette analyse, le point nouveau (l'horodatage comme clé, pas seulement comme IV ou nom de fichier) n'est pas couvert par la source publique trouvée.

H. Vérification de la source du canal descendant (config/commandes)#

python3 tools/find_addr_writes.py binaries/$h2.exe tools/writes_18001a4d0_all.txt 0x18001a4d0
python3 tools/decompile_addr.py binaries/$h2.exe tools/decomp_keysrc.txt 0x180001440

DAT_18001a4d0 (matériau de clé du canal descendant, FUN_180001890/ FUN_180002ab0) est rempli par wcscpy_s(&DAT_18001a4d0, 0x104, param_1 + 0x104) dans FUN_180001440 (boucle de travail principale), où param_1 est le paramètre du thread de travail, pas une constante du binaire. Confirmé par inspection de section (.data, offset au-delà de SizeOfRawData, donc zone mise à zéro au chargement, pas de contenu réel dans le fichier). Ce canal n'est donc pas cassable par cette seule analyse statique : voir konni-native-rat.md §6. Vérification du dropper (h1) pour la source de ce paramètre : FUN_00401000 lance wapsvc.bat sans qu'aucune écriture de registre contenant un mot de passe ne soit visible dans ce binaire précis ; probable construction de la ligne de commande dans wapsvc.bat lui-même, non récupéré dans cette analyse.

I. Preuve de concept et validation#

python3 tools/konni_upload_crypto.py          # auto-test chiffrement/dechiffrement
python3 -c "
from tools.konni_upload_crypto import encrypt_upload
blob = encrypt_upload(b'PK\x03\x04' + b'A'*500, month=3, day=22, hour=14, minute=37, second=52)
open('/tmp/konni_test_blob.bin','wb').write(blob)
"
python3 tools/konni_upload_key_bruteforce.py /tmp/konni_test_blob.bin \
  2024-03-22_14:00:00 2024-03-22_15:00:00

Résultat : clé retrouvée en 2273 essais (fenêtre d'une heure), moins d'une seconde. Test complémentaire sur une fenêtre de 24 heures complètes (86 400 essais) : moins d'une seconde également. Voir konni-native-rat.md §5.

J. Le canal descendant : récupération du script d'installation manquant#

Retour sur le lot MalwareBazaar complet (§A) : un fichier .bat (1162 octets) et un fichier unknown (611 octets) n'avaient pas été récupérés initialement.

h4=b792867d9275c0ed64cbb501431fe0368c08ee3fa284a72590e46881241b2076  # .bat
h5=a56a8217bad8523c3f2f163a69a50fba79142ab0a71b4a1443fb4143847e7146  # unknown, 611 o
for h in $h4 $h5; do
  curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
    -d "query=get_file&sha256_hash=$h" -o "binaries/$h.zip"
  cd binaries && 7z x -pinfected -y "$h.zip" && cd ..
done

$h4.bat (lu directement, script en clair) : installe le composant réseau comme service Windows générique sous le nom wiasvc (masquerade "Windows Image Acquisition Service"), via sc create wiasvc binpath= "%windir%\System32\svchost.exe -k wiasvc" ... et une clé de registre ServiceDll. Copie wiasvc.dll et wiasvc.ini dans System32, supprime les originaux après. Aucun secret ni argument de ligne de commande contenant une clé n'apparaît dans ce script : confirme que le matériau de clé du canal descendant ne vient ni du registre ni de la ligne de commande du service.

$h5.unknown (611 octets, entropie elevée) : taille et profil compatibles avec une instance capturée de wiasvc.ini (le fichier de config chiffré référencé et supprimé par le .bat), sans confirmation directe par nom de fichier (voir §K pour la validation par déchiffrement réussi).

K. Trace de la construction du paramètre secret par émulation CPU (Unicorn)#

Recherche de la source de DAT_18001a4d0 (le matériau de clé du canal descendant) : confirmé écrit depuis param_1 + 0x104 dans FUN_180001440 (voir §H), mais le seul appelant retrouvé par recherche de références statiques était une entrée de table de métadonnées de gestion d'exceptions générée par le compilateur (18011e4e8), pas un site d'appel réel.

Décompilation de ServiceMain en détail : appelle RegisterServiceCtrlHandlerW/SetServiceStatus via des pointeurs de fonction (DAT_1800196b0/DAT_1800196a8) résolus dynamiquement par GetProcAddress, pas via la table d'import statique (explique l'absence de ces symboles dans pefile.DIRECTORY_ENTRY_IMPORT). Juste avant le lancement du worker, ServiceMain appelle FUN_180001250, qui appelle FUN_18011e3b2 :

python3 tools/decompile_addr.py binaries/$h2.exe tools/decomp_hidden.txt 0x18011e3b2

Échec de décompilation : Control flow encountered bad instruction data, halt_baddata(). Lecture du pseudocode partiel : la fonction patche un octet en mémoire à une adresse calculée (*(char*)(ptr+0x1f) -= 0x68) avant de continuer l'exécution dans ce code désormais modifié : code auto-modifiant, anti-désassemblage délibéré.

Construction d'un harnais d'émulation CPU pure (tools/emulate_service_init.py, Unicorn Engine, x86-64) : chargement du PE en mémoire émulée aux adresses d'origine (pas de relocation, image base conservée), TEB/PEB minimal via le registre UC_X86_REG_GS_BASE (offset PEB->ProcessHeap à TEB+0x60+0x30, cohérent avec la structure PEB réelle x64), table de stubs pour toute la table d'import (chaque stub = une instruction RET, hook UC_HOOK_CODE déclenché juste avant son exécution pour simuler un comportement plausible et logger l'appel), stub spécial pour GetProcAddress qui alloue dynamiquement un nouveau stub pour toute fonction demandée au runtime (gère la résolution dynamique observée dans ServiceMain), et un hook UC_HOOK_MEM_WRITE sur toute écriture en mémoire exécutable qui invalide le cache de traduction Unicorn (ctl_remove_cache) pour que le code auto-modifié soit re-décodé correctement au prochain passage.

python3 tools/emulate_service_init.py binaries/$h2.exe --entry 180001020 --max-instr 500000

Démarrage à ServiceMain, interception du premier CreateThread : lpStartAddress=0x180001440 (le worker déjà identifié en §F/§H), lpParameter pointe vers un buffer contenant deux chaînes wide :

[param_1+0x000] = "C:\Windows\System32\wiasvc.ini"
[param_1+0x104] = "wiasvc"

Entre l'appel GetModuleFileNameW (dont la valeur de retour est fournie par le harnais) et ce CreateThread, l'émulation traverse environ 57 000 instructions de bourrage (rotations, xchg/bswap sans effet utile, sauts en cascade) : confirme visuellement qu'il s'agit d'une vraie couche d'obfuscation/virtualisation maison, pas un artefact de l'émulation.

Test de contrôle : relancer l'émulation avec une valeur de chemin de module différente (variable d'environnement KONNI_FAKE_MODULE_PATH) :

KONNI_FAKE_MODULE_PATH='C:\Windows\System32\ZZZZZZZZ.dll' \
  python3 tools/emulate_service_init.py binaries/$h2.exe --entry 180001020 --max-instr 500000

Résultat : param_1+0x000 change en conséquence (ZZZZZZZZ.ini), mais param_1+0x104 reste exactement "wiasvc". Confirme que ce n'est pas une valeur dérivée du nom de fichier réel du module, mais une constante figée indépendante de l'environnement, simplement cachée par l'obfuscation.

L. Validation : déchiffrement du fichier de config capturé#

python3 tools/konni_config_crypto.py binaries/$h5.unknown

Clé SHA-256(UTF16LE("wiasvc")) appliquée au fichier de 611 octets récupéré en §J : déchiffrement réussi, sortie ASCII parfaitement lisible, liste de 21 domaines C2 séparés par \r\n (voir konni-native-rat.md §6 et §8 pour la liste complète). Confirme d'un coup la clé et le schéma de chiffrement du canal descendant, sur un artefact réel et non sur une donnée auto-générée par une réimplémentation Python.

Environnement#

Identique aux autres analyses de ce dépôt : Ghidra headless via PyGhidra, pefile, venv ~/.venvs/ctf-reverse, clé API MalwareBazaar dans .mb_api_key à la racine. pycryptodome (AES-256-CTR) et unicorn+capstone (émulation CPU x86-64, déjà présents dans le venv depuis les travaux sur RagnarLocker/VMProtect) pour les preuves de concept et le harnais d'émulation.