Leçon 11.2 · Analyse automatisée & avancée· 55 min
Émuler du code avec Unicorn et speakeasy
Pourquoi émuler plutôt qu’exécuter, émulateurs CPU ou de niveau OS, et comment appeler la routine de déchiffrement d’un échantillon avec des API simulées.
Objectifs
- Expliquer ce que l’émulation apporte à l’analyste qu’une VM ou un débogueur n’apporte pas : aucun effet de bord sur l’OS, un contrôle total et la possibilité d’exécuter une fonction isolément
- Distinguer les émulateurs CPU (Unicorn) des émulateurs de niveau OS (speakeasy, Qiling) selon ce que chacun fournit : mémoire, loader, modèles d’API et d’appels système, hooks
- Préparer la mémoire, la pile, les arguments et un piège de retour pour appeler une seule fonction à l’intérieur d’un PE mappé
- Simuler les API importées et hooker l’exécution pour que la routine de déchiffrement de chaînes d’un échantillon s’exécute jusqu’au bout dans un émulateur
- Reconnaître les limites de fidélité et les astuces anti-émulation qui font échouer l’émulation, et décider quand revenir à un débogueur
Dans Analyse de shellcode, vous avez réalisé
votre première émulation : un blob brut mappé dans Unicorn, démarré à
l’offset 0, arrêté sur son propre ret, avec un hook d’écriture qui regardait
une URL se décoder. Ce blob était volontairement simple. Il n’avait pas
d’imports, ne demandait pas d’arguments, et constituait le programme entier.
Les vrais échantillons sont rarement aussi accommodants. La routine qui vous
intéresse se trouve au milieu d’un PE, attend des arguments dans des registres,
lit des tables dans .rdata, et appelle HeapAlloc ou VirtualAlloc à
mi-parcours. Cette leçon porte sur l’émulation de ce cas-là : charger une
image, appeler une fonction à l’intérieur comme le ferait son appelant, et
fournir de fausses réponses au nom du système d’exploitation auquel elle croit
s’adresser. Elle couvre aussi l’autre famille d’outils, les émulateurs qui
modélisent Windows lui-même, et les façons dont les deux familles échouent.
Pourquoi émuler plutôt qu’exécuter
La leçon précédente exécutait réellement l’échantillon et y injectait du code d’analyse. Un débogueur dans votre VM isolée l’exécute aussi réellement. Un émulateur, non : il interprète les instructions à l’intérieur d’un modèle de processeur, et chaque effet de ces instructions atterrit dans de la mémoire qui appartient à votre script.
| Débogueur dans une VM | Instrumentation (DBI) | Émulateur | |
|---|---|---|---|
| Vrai OS en dessous | Oui | Oui | Non, seulement ce que vous ou l’outil modélisez |
| Effets de bord | Réels : fichiers, registre, réseau | Réels | Aucun hors de la mémoire de l’émulateur |
| Où démarre l’exécution | Au point d’entrée (ou là où vous le patchez) | Au point d’entrée | À n’importe quelle adresse choisie, avec n’importe quel état des registres |
| Contrôle par instruction | Points d’arrêt, pas-à-pas | Callbacks, mais avec un coût | Chaque instruction, bloc et accès mémoire peut être hooké |
| Architecture | Doit correspondre à la VM | Doit correspondre à l’hôte | Toute architecture prise en charge par l’émulateur |
| Fidélité | Parfaite : c’est la vraie machine | Quasi parfaite | Aussi bonne que le modèle, pas plus |
Quatre propriétés justifient le coût en fidélité de l’émulation :
- Aucun effet de bord sur l’OS. Un
CreateFileAou unconnectémulé fait ce que dit votre stub. Rien n’est écrit, rien ne quitte l’hôte, et l’OS hôte ne voit jamais un seul appel système de l’échantillon. L’émulation peut donc tourner sans risque là où vous ne feriez jamais détoner un échantillon, y compris dans des pipelines automatisés. - Contrôle total. Vous fixez chaque registre, écrivez n’importe où en mémoire, vous arrêtez après n’importe quelle instruction, prenez un instantané de l’état et y revenez. Vous pouvez forcer un branchement en changeant un flag, ou sauter une fonction en la faisant retourner plus tôt.
- Exécuter une fonction isolément. Vous n’avez besoin ni du point d’entrée du programme, ni du démarrage de sa CRT, ni de ses vérifications anti-débogage, ni de son serveur C2. Si vous connaissez l’adresse et l’interface d’une fonction, vous pouvez l’appeler directement, mille fois, avec n’importe quelles entrées.
- Multi-architecture. Unicorn exécute du code ARM, MIPS ou PowerPC sur un portable x86-64, et c’est souvent ainsi qu’on analyse les malwares IoT et de routeurs (voir ELF pour les analystes de malwares).
Le prix à payer : vous, ou l’outil, devez fournir tout ce que le système d’exploitation fournit normalement. C’est là que les deux familles d’émulateurs diffèrent.
Émulateurs CPU et émulateurs de niveau OS
Émulateurs CPU : Unicorn
Unicorn reprend l’émulation CPU de QEMU et l’expose sous forme de bibliothèque. Il vous donne un processeur et rien d’autre :
- Mappage mémoire. Un espace d’adressage vide. Vous mappez avec
mem_mapdes régions alignées sur la page avec leurs permissions, et y écrivez des octets avecmem_write. Il n’y a pas de loader : les sections d’un PE vont là où vous les mettez. - Registres. Lecture et écriture de n’importe quel registre, y compris les
bases de segment (
UC_X86_REG_GS_BASEetUC_X86_REG_FS_BASE), ce qui compte parce que le code Windows accède à son TEB et à son PEB viags/fs. - Hooks. Des callbacks sur chaque instruction (
UC_HOOK_CODE), chaque bloc de base, les lectures et écritures mémoire, les accès à de la mémoire non mappée, les interruptions, et des instructions précises commesyscalloucpuid. - Contrôle de l’exécution.
emu_start(begin, until, timeout, count)etemu_stop()depuis l’intérieur d’un hook.
Ce qu’il n’a pas : pas de loader PE ou ELF, pas d’imports, pas de tas, pas de TEB ni de PEB, pas d’exceptions distribuées aux gestionnaires SEH, pas de threads, pas d’appels système. Un appel importé saute à l’adresse qui se trouve dans l’emplacement de l’IAT, qui, dans un fichier sur disque, n’est pas du tout une fonction. Tout ce qui se situe au-dessus du processeur est à votre charge, et c’est exactement pourquoi Unicorn est idéal pour appeler une seule fonction dont vous pouvez lister les dépendances.
Émulateurs de niveau OS : speakeasy et Qiling
Un émulateur de niveau OS enveloppe un émulateur CPU (ces deux-là enveloppent Unicorn) dans un modèle de système d’exploitation :
- Un loader qui mappe le PE, applique les relocations et résout les imports vers de fausses DLL.
- Des structures de processus : TEB, PEB, liste des modules du loader, ligne de commande et environnement, pour que le code qui parcourt le PEB (comme le fait le hachage d’API) trouve ce qu’il attend.
- Des modèles d’API ou d’appels système. Des gestionnaires qui implémentent
CreateFileA,RegSetValueExA,InternetOpenUrlAouNtAllocateVirtualMemoryassez bien pour renvoyer des valeurs plausibles, tout en enregistrant chaque appel et ses arguments. - Un rapport d’événements : la trace des API, les fichiers « écrits », les clés de registre touchées, les requêtes réseau tentées et les chaînes trouvées en mémoire.
speakeasy (Mandiant) modélise le mode utilisateur et le mode noyau de Windows, et peut donc aussi exécuter des pilotes. Ses gestionnaires d’API sont écrits en Python, il émule des PE et du shellcode brut, et il produit un rapport JSON. Il fonctionne sans vraies DLL Windows ; les API inconnues sont soit signalées comme non prises en charge, soit, si c’est configuré, servies par un stub générique.
Qiling modélise Windows, Linux, macOS, FreeBSD, UEFI et d’autres au niveau des appels système. Pour Windows, il attend un « rootfs » contenant de vraies DLL issues d’une installation Windows dont vous avez la licence, et émule les appels système sous-jacents. C’est plus fidèle pour le code qui dépend du comportement réel des DLL, et plus lourd à mettre en place.
Une troisième approche mérite d’être connue : Dumpulator émule du code à partir d’un minidump Windows. Vous exécutez l’échantillon dans votre laboratoire jusqu’à ce que l’état intéressant existe (clés dérivées, variables globales initialisées, tas rempli), vous prenez un dump, puis vous appelez des fonctions à l’intérieur dans un émulateur avec tout cet état déjà en place. Cela comble proprement l’écart entre « l’exécuter » et « l’émuler ».
| Outil | Fournit | Idéal pour |
|---|---|---|
| Unicorn | CPU, mémoire, registres, hooks | Appeler une fonction ; stubs de décodage ; toute architecture |
| speakeasy | Unicorn + loader Windows, PEB/TEB, modèles d’API en Python, rapport | Traces d’API sur programme entier pour des PE, du shellcode et des pilotes, sans Windows |
| Qiling | Unicorn + loaders multi-OS et couche d’appels système | Échantillons Linux/IoT ; code Windows qui a besoin du comportement réel des DLL |
| Dumpulator | Unicorn + la vraie mémoire et le TEB d’un minidump | Fonctions qui dépendent d’un état initialisé à l’exécution |
Laissez le malware déchiffrer pour vous
L’usage central d’un émulateur CPU pour l’analyste n’est pas d’exécuter des programmes entiers. C’est celui-ci : l’échantillon contient une routine qui transforme des octets chiffrés en texte clair, et vous voulez tous les textes clairs qu’elle peut produire. Les chaînes chiffrées, la liste de C2 ou la configuration sont dans le fichier ; la clé et l’algorithme sont dans le code. Au lieu de lire l’algorithme et de le réimplémenter en Python, vous appelez la propre fonction de l’échantillon sur les propres données de l’échantillon.
Ce compromis mérite d’être énoncé clairement :
| Réimplémenter l’algorithme | Émuler la routine | |
|---|---|---|
| Vous devez comprendre | Chaque étape de l’algorithme | Son interface : arguments, sortie, dépendances |
| Exactitude | Votre bug, ou une bizarrerie de l’auteur, peut diverger en silence | Exacte au bit près, car c’est le code de l’auteur |
| Vitesse par chaîne | Vitesse native de Python | Des milliers d’instructions émulées |
| Robustesse face aux variantes | Casse si l’auteur change l’algorithme | Casse si l’interface ou les dépendances changent |
| Idéal quand | Vous l’exécuterez sur des milliers d’échantillons dans un pipeline | Vous avez besoin de la réponse aujourd’hui, ou l’algorithme est affreux |
La réimplémentation fait l’objet de Scripter le déchiffrement des chaînes. L’émulation est ce vers quoi vous vous tournez quand l’algorithme est un flux de clé maison, un mélange XOR intensif, ou simplement plus de code que vous n’avez envie d’en lire.
Le flux de travail comporte quatre étapes.
- Trouver le déchiffreur. C’est généralement une fonction appelée depuis de
nombreux endroits avec un index, un pointeur vers des octets chiffrés, ou
les deux, et dont la valeur de retour est ensuite passée à des API comme
CreateMutexAouInternetConnectA. Les références croisées et le flot de données le trouvent rapidement : remontez l’argument d’une API intéressante jusqu’à l’appel qui l’a produit. - Cerner son interface. Combien d’arguments, dans quels registres ou emplacements de pile selon la convention d’appel ? Où va la sortie : une valeur de retour, un tampon fourni par l’appelant, une variable globale ? Quelle longueur peut-elle atteindre ?
- Lister ses dépendances. Lit-elle des variables globales, et celles-ci sont-elles déjà dans le fichier ou remplies à l’exécution ? Appelle-t-elle des imports ? Appelle-t-elle d’autres fonctions internes qui appellent des imports ? Chaque dépendance doit être mappée, simulée par un stub ou pré-initialisée.
- L’appeler pour chaque entrée et récolter la sortie, avec une limite d’instructions pour qu’une erreur ne puisse pas boucler indéfiniment.
C’est à l’étape 3 que la plupart des tentatives échouent. Une routine qui utilise une clé dérivée au démarrage du numéro de série du volume, ou une table décompressée par un initialiseur, produira des déchets si vous l’appelez sur une image fraîche. Les solutions : appeler d’abord l’initialiseur, écrire vous-même la valeur dérivée en mémoire, ou partir d’un dump (Dumpulator) où elle existe déjà.
Préparer un appel à la main
Pour appeler une fonction, vous devez recréer, dans l’émulateur, l’état exact que son appelant aurait créé. Sous Windows x64 :
higher addresses
┌──────────────────────────────┐
│ stack args 5, 6, ... │ rsp+0x28 ...
├──────────────────────────────┤
│ shadow space (4 × 8 bytes) │ rsp+0x08 .. rsp+0x27 reserved by the caller
├──────────────────────────────┤
│ return address = your trap │ rsp (rsp % 16 == 8 on entry)
└──────────────────────────────┘
rcx = arg1 rdx = arg2 r8 = arg3 r9 = arg4 (xmm0-3 for floats)- Mappez l’image à sa base préférée. Si vous mappez un PE à son
ImageBase, chaque pointeur absolu qu’il contient (comme les pointeurs d’une table de chaînes) est déjà correct et vous pouvez ignorer les relocations. Mappez-le ailleurs et vous devez appliquer vous-même les relocations de base, comme le ferait le loader. - Mappez une pile et faites pointer
rspdedans, avec de la place au-dessus pour le shadow space et d’éventuels arguments sur la pile. - Empilez une adresse de retour que vous contrôlez. La fonction finira par
faire un
retvers elle. Utilisez une adresse que rien d’autre n’utilise et passez-la commeuntilàemu_start, pour que l’émulation s’arrête dès que la fonction retourne. C’est le contrat call/ret transformé en condition d’arrêt. - Respectez l’alignement. À l’entrée,
rspdoit valoir 8 modulo 16, comme après un vraicall. Le code compilé qui utilisemovapssur des emplacements de pile plante sur une pile mal alignée, et l’erreur ressemblera à un bug de votre émulation. - Placez les arguments là où la convention l’indique. En code 32 bits,
cela signifie les empiler sur la pile dans l’ordre
inverse, et savoir si la fonction est
cdecloustdcall(conventions d’appel x86-32).
Hooker et simuler les appels
Quand la fonction appelle un import, l’exécution saute à l’adresse contenue dans son emplacement d’IAT. Dans un fichier mappé tel quel depuis le disque, cet emplacement contient encore la RVA d’une entrée hint/name, pas du code, et l’émulation meurt sur une lecture d’instruction en mémoire non mappée (vous verrez exactement cela dans le lab). La solution standard est le stubbing de l’IAT :
- Mappez une petite région de fausses adresses « d’API », chaque octet étant
un
ret(0xC3). - Écrivez une fausse adresse dans chaque emplacement d’IAT, et retenez quel import elle représente.
- Hookez l’exécution sur cette région. Quand une fausse adresse est atteinte,
retrouvez le nom de l’import, lisez ses arguments dans les registres ou sur
la pile, faites le minimum dont l’appelant a besoin (en général : fixer
rax, éventuellement écrire un tampon de sortie), et laissez leretrevenir à l’appelant.
C’est du hooking d’API où l’API réelle a complètement disparu. Trois règles gardent les stubs honnêtes :
- N’implémentez que ce que l’appelant consomme.
GetProcessHeappeut renvoyer n’importe quelle valeur non nulle si son seul consommateur estHeapAlloc, qui est aussi le vôtre. - Nettoyez correctement la pile en x86. Les API Windows 32 bits sont
stdcall: l’appelé dépile ses propres arguments. Un stub qui se contente de retourner laisse la pile déséquilibrée, et l’appelant plante quelques instructions plus loin. Le x64 n’a pas ce problème. - Arrêtez-vous sur tout ce qui est inattendu. Un import inconnu doit afficher son nom et arrêter l’émulation, pas renvoyer 0 en silence. Une mauvaise valeur de retour envoie l’échantillon dans un chemin d’erreur, et vous passerez une heure à chercher pourquoi votre sortie est vide.
Le même mécanisme de hook permet de sauter des fonctions internes : hookez la
première instruction d’une fonction que vous ne voulez pas exécuter (un
wrapper de Sleep, une vérification anti-analyse, une routine réseau), fixez
rax à la valeur qu’espère son appelant, dépilez l’adresse de retour dans
rip, et continuez. Les appels système directs, que certains échantillons
utilisent pour éviter les hooks en mode utilisateur, se traitent de la même
façon avec un hook d’instruction sur syscall : lisez le numéro de service dans
eax et émulez-le ou refusez-le.
Émulation du programme entier avec speakeasy
Quand la question est « que fait cet échantillon ? » plutôt que « que renvoie cette fonction ? », un émulateur de niveau OS est plus rapide que d’écrire des stubs. Vous pointez speakeasy sur un PE ou un blob de shellcode et lisez la trace des API : chaque appel Windows, ses arguments et la valeur de retour émulée. Les chaînes déchiffrées apparaissent en tant qu’arguments, ce qui est souvent le moyen le plus rapide de récupérer un nom de mutex ou une URL de C2 sans même trouver le déchiffreur.
Son rapport liste aussi les chaînes trouvées dans la mémoire émulée, les
fichiers et clés de registre touchés par l’échantillon, et l’activité réseau
qu’il a tentée. L’environnement est configurable (--dump-default-config
affiche toutes les options) : noms d’utilisateur et d’hôte, version de l’OS,
variables d’environnement, ligne de commande, réponses DNS, réponses HTTP et
liste des API autorisées à exister.
Deux options comptent en permanence. --entry-point démarre l’émulation à une
RVA de votre choix au lieu du point d’entrée du PE, ce qui permet de sauter du
code de démarrage que l’émulateur ne sait pas gérer.
--modules-functions-always-exist répond à toute API non modélisée avec un stub
générique au lieu de s’arrêter. Les deux sont utiles, et les deux laissent
l’émulation dériver de la réalité, comme le montre le lab.
Limites et anti-émulation
Tout émulateur est un modèle, et tout modèle a ses bords. Les plus courants :
- API non modélisées ou mal modélisées. Un émulateur d’OS implémente les API dont ses auteurs avaient besoin. Le premier appel non modélisé arrête l’émulation, ou renvoie une valeur générique qui envoie l’échantillon là où il n’irait jamais sous Windows.
- État de processus manquant. Des champs du PEB, du TEB, de
KUSER_SHARED_DATAou des structures du loader que le modèle laisse vides ; le code qui les lit voit des zéros. - Exceptions, threads et callbacks. Le flot de contrôle fondé sur SEH, le travail réparti entre threads, les APC et les boucles de messages de fenêtre sont difficiles à émuler, et souvent les premiers à mal tourner.
- Couverture des instructions. Des instructions rares ou récentes (une partie d’AVX-512, certaines instructions système) peuvent ne pas être prises en charge ou être subtilement fausses.
- Couverture des chemins. Un émulateur, comme une sandbox, ne voit que les chemins qui s’exécutent. Les branches qui dépendent d’une date, d’une commande du C2 ou d’un fichier absent restent dans l’ombre. La leçon suivante et la leçon à venir sur l’exécution symbolique attaquent ce problème sous des angles différents.
Les auteurs de malwares savent tout cela, et certains échantillons le testent délibérément :
- Le timing. L’émulation est lente et ses horloges sont synthétiques.
Mesurer le temps d’exécution d’instructions avec
rdtsc, ou comparerGetTickCountavant et après unSleep(détection de l’accélération du sommeil), révèle une horloge qui ne se comporte pas comme une vraie. - L’identité du processeur. Les feuilles de
cpuidqui signalent un fabricant inhabituel, un bit d’hyperviseur ou des flags de fonctionnalités manquants. - Les empreintes d’environnement. Les valeurs par défaut de speakeasy sont
reconnaissables : l’utilisateur par défaut est
speakeasy_useret le nom d’hôtespeakeasy_host. Les échantillons vérifient aussi la présence de fichiers récents, les mouvements de souris et l’uptime (vérifications d’activité utilisateur). - Le martelage d’API (API hammering). Des milliers d’appels à des API peu
coûteuses avant la charge utile, pour épuiser le budget de temps ou d’API
d’un émulateur. speakeasy dispose d’un réglage
api_hammeringdédié pour cette raison. - Les sondes comportementales. Appeler une API avec des arguments invalides et vérifier que le code d’erreur est exactement celui que renvoie Windows. Un stub générique échoue à ce test.
Les parades sont les mêmes que dans un débogueur, en moins coûteux : hooker la vérification et forcer son résultat, modifier la configuration de l’émulateur, émuler à partir d’un point de départ ultérieur, ou accepter que cet échantillon nécessite une vraie exécution dans le laboratoire. La leçon à venir sur l’évasion anti-VM et anti-sandbox, dans le module 9, approfondit les vérifications elles-mêmes.
Astuce : Quand une émulation s’arrête, lisez les derniers appels d’API et l’instruction fautive avant de toucher à quoi que ce soit. Neuf fois sur dix, la réponse est une valeur de retour que le modèle s’est trompé à fournir, et la correction tient en un stub.
Lab : laisser un échantillon déchiffrer ses propres chaînes
Vous allez construire un programme bénin qui stocke cinq chaînes chiffrées avec
un flux de clé maison et les déchiffre à la demande via une fonction qui
appelle GetProcessHeap et HeapAlloc, exactement la forme d’un vrai
déchiffreur de chaînes. Vous allez ensuite (1) appeler cette fonction
directement dans Unicorn avec des API simulées, et (2) exécuter le programme
entier dans speakeasy. Toutes les sorties ci-dessous sont réelles, obtenues avec
Python 3.12, Unicorn 2.1.4, pefile 2024.8.26, speakeasy 2.0.0b8 et MinGW-w64
GCC 15.2 sur un Mac Apple Silicon. Rien dans ce lab ne contacte le réseau ni ne
fait quoi que ce soit de nuisible.
-
Mise en place. Utilisez un seul environnement virtuel pour tout :
bash python3.12 -m venv venv ./venv/bin/pip install "git+https://github.com/mandiant/speakeasy@v2.0.0b8"speakeasy 2.x installe Unicorn 2, pefile et Capstone. L’ancienne version 1.5.x sur PyPI fige Unicorn 1.0.2, qui sur ce Mac Apple Silicon mourait sur une erreur de bus dès la première instruction émulée — un émulateur peut échouer avant même que l’échantillon en ait l’occasion.
-
Jouez l’auteur du malware. Enregistrez
encrypt_strings.py, qui tient lieu de builder chiffrant les chaînes et produisant un en-tête C :python # encrypt_strings.py - build-time helper: encrypt the lab's strings and # emit a C header. This plays the role of the malware author's builder. SEED = 0x4C414231 # "LAB1" STRINGS = [ b"update.example.com", b"443", b"/api/v1/checkin", b"Global\\LabMutex-7f3a", b"Mozilla/5.0 (LabAgent)", ] def keystream(idx, n): k = (SEED ^ (idx * 0x9E3779B9)) & 0xFFFFFFFF for _ in range(n): k = (k * 1103515245 + 12345) & 0xFFFFFFFF yield (k >> 16) & 0xFF with open("strings_enc.h", "w") as f: for i, s in enumerate(STRINGS): enc = bytes(b ^ k for b, k in zip(s, keystream(i, len(s)))) f.write(f"static const unsigned char s{i}[] = {{" + ",".join(f"0x{b:02x}" for b in enc) + "};\n") f.write("static const struct enc_str { const unsigned char *data; " "unsigned len; } g_strings[] = {\n") for i, s in enumerate(STRINGS): f.write(f" {{ s{i}, {len(s)} }},\n") f.write("};\n")puis le programme,
lab.c:c /* lab.c - benign stand-in for a sample with an encrypted string table. * decrypt_str() is the kind of routine you find in real malware: it takes * an index, allocates a buffer with Windows APIs and decrypts into it. */ #include <windows.h> #include <stdio.h> #include "strings_enc.h" #define SEED 0x4C414231u __attribute__((noinline)) char *decrypt_str(unsigned idx) { const struct enc_str *e = &g_strings[idx]; char *out = HeapAlloc(GetProcessHeap(), 0, e->len + 1); unsigned k = SEED ^ (idx * 0x9E3779B9u); for (unsigned i = 0; i < e->len; i++) { k = k * 1103515245u + 12345u; out[i] = e->data[i] ^ (unsigned char)(k >> 16); } out[e->len] = 0; return out; } int main(void) { char *mutex = decrypt_str(3); HANDLE h = CreateMutexA(NULL, FALSE, mutex); if (GetLastError() == ERROR_ALREADY_EXISTS) return 1; char *host = decrypt_str(0), *port = decrypt_str(1), *path = decrypt_str(2); printf("would contact https://%s:%s%s\n", host, port, path); CloseHandle(h); return 0; }Compilez-le. Les symboles sont conservés pour que vous puissiez vérifier votre travail ; dans un échantillon strippé, vous trouveriez
decrypt_strpar ses appelants, comme le montre l’étape 4.bash ./venv/bin/python encrypt_strings.py x86_64-w64-mingw32-gcc -O2 -o lab.exe lab.cSous Wine, il affiche
would contact https://update.example.com:443/api/v1/checkin. Notez que la cinquième chaîne, l’user-agent, n’est jamais utilisée parmain. -
Constatez que l’analyse statique est aveugle. Cherchez le texte clair dans le binaire :
bash strings -n 6 lab.exe | grep -iE "example|mutex|mozilla|checkin"text CreateMutexA __imp_CreateMutexA CreateMutexASeul le nom de l’import correspond. Aucune des cinq chaînes n’est visible, comme avec un vrai échantillon utilisant des chaînes chiffrées.
-
Lisez l’interface du déchiffreur. Désassemblez d’abord l’appelant :
bash x86_64-w64-mingw32-objdump -d -M intel --no-show-raw-insn lab.exe \ --start-address=0x140002b40 --stop-address=0x140002bc0text 140002b4b: mov ecx,0x3 140002b50: call 1400014c0 <decrypt_str> 140002b55: xor edx,edx 140002b57: xor ecx,ecx 140002b59: mov r8,rax 140002b5c: call QWORD PTR [rip+0x571e] # 140008280 <__imp_CreateMutexA> ... 140002b77: xor ecx,ecx 140002b79: call 1400014c0 <decrypt_str> 140002b7e: mov ecx,0x1 140002b83: mov rsi,rax 140002b86: call 1400014c0 <decrypt_str>Le motif est sans équivoque : une petite constante dans
ecx(le premier argument), un appel à0x1400014c0, et le pointeur renvoyé dansraxpassé directement àCreateMutexAcomme troisième argument (r8, le nom). Sans symboles, les références croisées deCreateMutexAvous mènent au même appel. L’interface est doncchar *f(unsigned index). Le début de la fonction vous indique ses dépendances :text 1400014c7: lea rdi,[rip+0x2b52] # 140004020 <g_strings> 1400014ce: mov eax,ecx 1400014d0: mov rbx,rax 1400014d3: shl rax,0x4 1400014d7: add rdi,rax 1400014da: mov esi,DWORD PTR [rdi+0x8] 1400014dd: call QWORD PTR [rip+0x6dbd] # 1400082a0 <__imp_GetProcessHeap> 1400014e3: xor edx,edx 1400014e5: lea r8d,[rsi+0x1] 1400014e9: mov rcx,rax 1400014ec: call QWORD PTR [rip+0x6db6] # 1400082a8 <__imp_HeapAlloc>L’index est multiplié par 16 (
shl rax,4) pour indexer une table en0x140004020, et le dword à+8de chaque entrée est une longueur. Un dump de la table confirme des enregistrements de 16 octets pointeur, longueur :text 140004020 c0400040 01000000 12000000 00000000 .@.@............ 140004030 b7400040 01000000 03000000 00000000 .@.@............ 140004040 a8400040 01000000 0f000000 00000000 .@.@............ 140004050 90400040 01000000 14000000 00000000 .@.@............ 140004060 70400040 01000000 16000000 00000000 p@.@............Cinq entrées, chacune pointant dans l’image. Dépendances : une table en lecture seule déjà présente dans le fichier, et deux imports. Aucun état initialisé à l’exécution, donc une image fraîche suffit.
-
Écrivez le harnais. Enregistrez
call_decryptor.py. Il mappe le PE à sa base préférée, remplace chaque emplacement d’IAT par une fausse adresse, simule les deux API, et appelledecrypt_strune fois par index avec un piège de retour :python # call_decryptor.py - map lab.exe into Unicorn and call its own # decrypt_str(idx) for every table entry, stubbing the two APIs it needs. import sys import pefile from unicorn import Uc, UcError, UC_ARCH_X86, UC_MODE_64, UC_HOOK_CODE from unicorn.x86_const import (UC_X86_REG_RAX, UC_X86_REG_RCX, UC_X86_REG_R8, UC_X86_REG_RSP, UC_X86_REG_RIP) DECRYPT_RVA = 0x14C0 # decrypt_str, from the disassembly N_STRINGS = 5 # g_strings has 5 entries STUB_BASE = 0x70000000 # fake "API" addresses live here HEAP_BASE = 0x60000000 # bump allocator for HeapAlloc STACK_BASE = 0x50000000 STACK_SIZE = 0x10000 RETURN_TRAP = STUB_BASE + 0xFF0 # fake return address: reaching it = done def align(x, a=0x1000): return (x + a - 1) & ~(a - 1) pe = pefile.PE("lab.exe") base = pe.OPTIONAL_HEADER.ImageBase mu = Uc(UC_ARCH_X86, UC_MODE_64) # 1. Map the image the way the Windows loader would: headers + sections. mu.mem_map(base, align(pe.OPTIONAL_HEADER.SizeOfImage)) mu.mem_write(base, pe.header) for s in pe.sections: data = s.get_data() mu.mem_write(base + s.VirtualAddress, data[:s.Misc_VirtualSize or len(data)]) # 2. Point every IAT slot at a unique stub address and remember its name. mu.mem_map(STUB_BASE, 0x1000) mu.mem_write(STUB_BASE, b"\xC3" * 0x1000) # every stub is a bare `ret` stubs = {} PATCH_IAT = "--no-stubs" not in sys.argv for i, imp in enumerate(e for d in pe.DIRECTORY_ENTRY_IMPORT for e in d.imports): addr = STUB_BASE + i * 8 if PATCH_IAT: mu.mem_write(imp.address, addr.to_bytes(8, "little")) # imp.address = IAT slot VA stubs[addr] = (imp.name or b"?").decode() # 3. Stack and heap. mu.mem_map(STACK_BASE, STACK_SIZE) mu.mem_map(HEAP_BASE, 0x10000) heap_next = HEAP_BASE def on_stub(uc, address, size, _): global heap_next if address == RETURN_TRAP: uc.emu_stop() return name = stubs.get(address, f"unknown@{address:#x}") if name == "GetProcessHeap": uc.reg_write(UC_X86_REG_RAX, 0x1234) # any fake handle elif name == "HeapAlloc": size_req = uc.reg_read(UC_X86_REG_R8) # 3rd arg: dwBytes uc.reg_write(UC_X86_REG_RAX, heap_next) print(f" [stub] HeapAlloc({size_req:#x}) -> {heap_next:#x}") heap_next += align(size_req, 0x10) else: print(f" [stub] unexpected API {name} - stopping") uc.emu_stop() # the stub's `ret` then returns to the caller mu.hook_add(UC_HOOK_CODE, on_stub, begin=STUB_BASE, end=STUB_BASE + 0xFFF) def call(func_va, arg0): """Call func(arg0) with the Windows x64 convention.""" rsp = STACK_BASE + STACK_SIZE - 0x100 rsp -= 8 mu.mem_write(rsp, RETURN_TRAP.to_bytes(8, "little")) # return address mu.reg_write(UC_X86_REG_RSP, rsp) mu.reg_write(UC_X86_REG_RCX, arg0) # 1st argument mu.emu_start(func_va, RETURN_TRAP, count=100_000) # instruction cap return mu.reg_read(UC_X86_REG_RAX) def read_cstr(addr, limit=256): raw = bytes(mu.mem_read(addr, limit)) return raw.split(b"\0", 1)[0].decode("latin1") for idx in range(N_STRINGS): print(f"decrypt_str({idx})") try: ptr = call(base + DECRYPT_RVA, idx) except UcError as e: rip = mu.reg_read(UC_X86_REG_RIP) print(f" emulation error {e} at {rip:#x}") continue print(f" -> {ptr:#x} {read_cstr(ptr)!r}")Le sommet de la pile est aligné sur 16 octets ; soustraire 8 pour l’adresse de retour laisse
rspà 8 modulo 16, comme après un vraicall, avec 0x100 octets au-dessus de l’adresse de retour pour le shadow space et d’éventuels arguments sur la pile. -
Voyez d’abord ce qui se passe sans stubs. L’option
--no-stubslaisse l’IAT telle qu’elle est sur disque :bash ./venv/bin/python call_decryptor.py --no-stubstext decrypt_str(0) emulation error Invalid memory fetch (UC_ERR_FETCH_UNMAPPED) at 0x8486 decrypt_str(1) emulation error Invalid memory fetch (UC_ERR_FETCH_UNMAPPED) at 0x84860x8486n’est pas du tout une adresse. C’est la valeur stockée sur disque dans l’emplacement d’IAT deGetProcessHeap: la RVA de son entrée hint/name, que le loader Windows aurait remplacée par l’adresse réelle de la fonction. Lecall qword ptr [rip+...]y a sauté et Unicorn n’avait rien de mappé à cet endroit. C’est la première erreur la plus fréquente en émulation de PE. -
Exécutez-le avec les stubs :
bash ./venv/bin/python call_decryptor.pytext decrypt_str(0) [stub] HeapAlloc(0x13) -> 0x60000000 -> 0x60000000 'update.example.com' decrypt_str(1) [stub] HeapAlloc(0x4) -> 0x60000020 -> 0x60000020 '443' decrypt_str(2) [stub] HeapAlloc(0x10) -> 0x60000030 -> 0x60000030 '/api/v1/checkin' decrypt_str(3) [stub] HeapAlloc(0x15) -> 0x60000040 -> 0x60000040 'Global\\LabMutex-7f3a' decrypt_str(4) [stub] HeapAlloc(0x17) -> 0x60000060 -> 0x60000060 'Mozilla/5.0 (LabAgent)'Les cinq chaînes, y compris l’user-agent que
mainne déchiffre jamais. Vous n’avez pas lu une seule ligne du flux de clé : l’échantillon a fait le travail. Chaque taille passée àHeapAllocvaut la longueur de la chaîne plus un, ce qui confirme le champ de longueur lu dans la table. -
Laissez maintenant speakeasy exécuter le programme entier :
bash ./venv/bin/speakeasy -t lab.exe -o report.jsontext * exec: module_entry 0x140001151: 'api-ms-win-crt-stdio-l1-1-0.__acrt_iob_func(0x2)' -> 0x2 0xfeedf15c: module_entry: Caught error: unsupported_api ... Unsupported API: api-ms-win-crt-stdio-l1-1-0.setvbuf (ret: 0x140001164) * Finished emulatingGCC 15 lie avec l’Universal CRT, et le démarrage de la CRT appelle
setvbuf, que cette version de speakeasy ne modélise pas. L’émulation se termine avant d’atteindremain. Répondre aux API non modélisées par un stub permet de franchirsetvbuf, puis tourne mal d’une façon plus intéressante :bash ./venv/bin/speakeasy -t lab.exe -o report.json --modules-functions-always-existtext 0x140001164: 'api-ms-win-crt-stdio-l1-1-0.setvbuf(0x2, 0x0, 0x4, 0x0)' -> 0x1 0x140001170: 'api-ms-win-crt-runtime-l1-1-0._crt_atexit(0x140001010)' -> None 0x1400013fd: 'api-ms-win-crt-runtime-l1-1-0.abort(0x140001010, 0x0, 0x4, 0x0)' -> 0x1 ... 0x1400028fc: 'api-ms-win-crt-stdio-l1-1-0.__stdio_common_vfprintf(0x24, 0x2, "runtime error 10\\n")' -> 0x11 0x14000295d: 'api-ms-win-crt-runtime-l1-1-0._exit(0xff)' -> NoneUn stub générique a renvoyé une valeur que la CRT a interprétée comme un échec, et le programme a pris son chemin d’erreur. C’est le danger de « always exist » : la trace reste une vraie émulation, mais d’un chemin que Windows ne prendrait jamais.
-
Sautez le code de démarrage.
mainest à la RVA0x2b40. Démarrez là :bash ./venv/bin/speakeasy -t lab.exe -o report.json --entry-point 0x2b40text * exec: module_entry 0x140001602: 'api-ms-win-crt-runtime-l1-1-0._crt_atexit(0x140001480)' -> None 0x140002b4b: 'api-ms-win-crt-runtime-l1-1-0._crt_atexit(0x140001580)' -> None 0x1400014e3: 'kernel32.GetProcessHeap()' -> 0x89a0 0x1400014f2: 'kernel32.HeapAlloc(0x89a0, 0x0, 0x15)' -> 0x89c0 0x140002b62: 'kernel32.CreateMutexA(0x0, 0x0, "Global\\LabMutex-7f3a")' -> 0x220 0x140002b6b: 'kernel32.GetLastError()' -> 0x0 0x1400014e3: 'kernel32.GetProcessHeap()' -> 0x89a0 0x1400014f2: 'kernel32.HeapAlloc(0x89a0, 0x0, 0x13)' -> 0x89e0 0x1400014e3: 'kernel32.GetProcessHeap()' -> 0x89a0 0x1400014f2: 'kernel32.HeapAlloc(0x89a0, 0x0, 0x4)' -> 0x8a00 0x1400014e3: 'kernel32.GetProcessHeap()' -> 0x89a0 0x1400014f2: 'kernel32.HeapAlloc(0x89a0, 0x0, 0x10)' -> 0x8a10 0x14000288d: 'api-ms-win-crt-stdio-l1-1-0.__acrt_iob_func(0x1)' -> 0x1 0x1400028ab: 'api-ms-win-crt-stdio-l1-1-0.__stdio_common_vfprintf(0x24, 0x1, "would contact https://update.example.com:443/api/v1/checkin\\n")' -> 0x3c 0x140002bba: 'kernel32.CloseHandle(0x220)' -> 0x1 * Finished emulatingLe nom du mutex apparaît comme argument de
CreateMutexAet l’URL comme sortie formatée, sans aucun code de harnais. LeGetLastErrorde speakeasy a renvoyé 0, donc la vérification « déjà en cours d’exécution » est passée. Le rapport collecte aussi les chaînes trouvées dans la mémoire émulée :bash python3 -c "import json; print(json.load(open('report.json'))['strings']['in_memory']['ansi'])"text ['Global\\LabMutex-7f3a', 'update.example.com', '/api/v1/checkin']L’user-agent manque :
mainne le déchiffre jamais, donc aucune émulation demainne peut le trouver. Démarrer àmaina aussi un coût à connaître : la CRT ne s’est jamais exécutée, donc unmainqui utiliseraitargc/argvrecevrait des déchets. Sur un échantillon qui lit sa ligne de commande, ce raccourci échoue avec une lecture invalide dans la première fonction qui toucheargv.
Questions : Pourquoi le fait de mapper l’image à son ImageBase vous
a-t-il permis d’ignorer les relocations, et quelles valeurs de g_strings
auraient été fausses autrement ? À l’étape 6, quelle aurait été l’adresse de
lecture fautive si le premier import appelé avait été HeapAlloc ? Si
decrypt_str avait dérivé sa graine de GetVolumeInformationA au lieu d’une
constante, que devrait renvoyer votre stub, et comment trouveriez-vous la bonne
valeur ? Quelle approche a récupéré le plus d’IOC ici, appeler le déchiffreur
ou émuler main, et pourquoi ? Que devrait faire une version 32 bits
stdcall du stub de HeapAlloc que le vôtre ne fait pas ?
Ce qu’il faut rapporter
Ajoutez les résultats d’émulation au rapport de triage en tant que preuves dérivées, avec assez de détails pour qu’une autre personne puisse les reproduire :
- La routine et son interface : adresse ou RVA, arguments, emplacement de
la sortie, et comment vous l’avez identifiée (par exemple, « appelée avant
chaque
CreateMutexA; index dansecx»). - Les dépendances que vous avez fournies : quelles API ont été simulées et avec quelles valeurs de retour, quelle mémoire vous avez pré-initialisée, et où l’émulation a démarré.
- Le texte clair récupéré : chaque chaîne ou champ de configuration, marqué « récupéré en émulant le déchiffreur de l’échantillon », y compris les valeurs que l’exécution normale de l’échantillon n’utilise jamais.
- L’outil et sa version : émulateur, version et modifications de
configuration (nom d’utilisateur, nom d’hôte,
--entry-point, stubs génériques), puisque chacune peut changer ce que fait l’échantillon. - Où l’émulation s’est arrêtée et pourquoi, si elle n’a pas atteint le comportement voulu. C’est aussi un résultat : une API non prise en charge ou une vérification anti-émulation indique à l’analyste suivant par où commencer.
À retenir
- L’émulation échange de la fidélité contre de la sécurité et du contrôle : aucun effet de bord sur l’OS, n’importe quel point de départ, n’importe quel état des registres, des hooks sur chaque instruction, et n’importe quelle architecture.
- Les émulateurs CPU comme Unicorn ne vous donnent qu’un processeur et de la mémoire ; les émulateurs de niveau OS comme speakeasy et Qiling ajoutent un loader, des structures de processus et des modèles d’API ou d’appels système, et rapportent ce que l’échantillon a fait.
- L’usage central d’un émulateur CPU pour l’analyste est d’appeler la propre routine de déchiffrement d’un échantillon sur ses propres données : la trouver, cerner son interface, satisfaire ses dépendances, puis l’appeler pour chaque entrée.
- Appeler une fonction à la main signifie mapper l’image (idéalement à sa base préférée), construire une pile correctement alignée avec un piège de retour, placer les arguments selon la convention d’appel, et simuler chaque import qu’elle atteint.
- Les stubs doivent renvoyer ce que l’appelant consomme, équilibrer la pile en
stdcall32 bits, et s’arrêter bruyamment sur tout ce qui est inattendu. - Les émulateurs échouent sur les API non modélisées, l’état de processus
manquant, les exceptions et les threads, et sont sondés délibérément par le
timing,
cpuid, les empreintes d’environnement et le martelage d’API. Quand la correction ne tient pas en un stub, revenez à un débogueur dans le laboratoire.