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.