Skip to content

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 VMInstrumentation (DBI)Émulateur
Vrai OS en dessousOuiOuiNon, seulement ce que vous ou l’outil modélisez
Effets de bordRéels : fichiers, registre, réseauRéelsAucun hors de la mémoire de l’émulateur
Où démarre l’exécutionAu 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 instructionPoints d’arrêt, pas-à-pasCallbacks, mais avec un coûtChaque instruction, bloc et accès mémoire peut être hooké
ArchitectureDoit correspondre à la VMDoit correspondre à l’hôteToute architecture prise en charge par l’émulateur
FidélitéParfaite : c’est la vraie machineQuasi parfaiteAussi 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 CreateFileA ou un connect é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_map des régions alignées sur la page avec leurs permissions, et y écrivez des octets avec mem_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_BASE et UC_X86_REG_FS_BASE), ce qui compte parce que le code Windows accède à son TEB et à son PEB via gs/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 comme syscall ou cpuid.
  • Contrôle de l’exécution. emu_start(begin, until, timeout, count) et emu_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, InternetOpenUrlA ou NtAllocateVirtualMemory assez 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 ».

OutilFournitIdéal pour
UnicornCPU, mémoire, registres, hooksAppeler une fonction ; stubs de décodage ; toute architecture
speakeasyUnicorn + loader Windows, PEB/TEB, modèles d’API en Python, rapportTraces d’API sur programme entier pour des PE, du shellcode et des pilotes, sans Windows
QilingUnicorn + loaders multi-OS et couche d’appels systèmeÉchantillons Linux/IoT ; code Windows qui a besoin du comportement réel des DLL
DumpulatorUnicorn + la vraie mémoire et le TEB d’un minidumpFonctions 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 comprendreChaque étape de l’algorithmeSon interface : arguments, sortie, dépendances
ExactitudeVotre bug, ou une bizarrerie de l’auteur, peut diverger en silenceExacte au bit près, car c’est le code de l’auteur
Vitesse par chaîneVitesse native de PythonDes milliers d’instructions émulées
Robustesse face aux variantesCasse si l’auteur change l’algorithmeCasse si l’interface ou les dépendances changent
Idéal quandVous l’exécuterez sur des milliers d’échantillons dans un pipelineVous 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.

  1. 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 CreateMutexA ou InternetConnectA. 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.
  2. 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 ?
  3. 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.
  4. 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 :

text
             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 rsp dedans, 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 ret vers elle. Utilisez une adresse que rien d’autre n’utilise et passez-la comme until à 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, rsp doit valoir 8 modulo 16, comme après un vrai call. Le code compilé qui utilise movaps sur 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 cdecl ou stdcall (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 :

  1. Mappez une petite région de fausses adresses « d’API », chaque octet étant un ret (0xC3).
  2. Écrivez une fausse adresse dans chaque emplacement d’IAT, et retenez quel import elle représente.
  3. 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 le ret revenir à 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. GetProcessHeap peut renvoyer n’importe quelle valeur non nulle si son seul consommateur est HeapAlloc, 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_DATA ou 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 comparer GetTickCount avant et après un Sleep (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 cpuid qui 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_user et le nom d’hôte speakeasy_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_hammering dé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.

  1. 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.

  2. 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_str par 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.c

    Sous 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 par main.

  3. 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
    CreateMutexA

    Seul 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.

  4. 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=0x140002bc0
    text
    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é dans rax passé directement à CreateMutexA comme troisième argument (r8, le nom). Sans symboles, les références croisées de CreateMutexA vous mènent au même appel. L’interface est donc char *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 en 0x140004020, et le dword à +8 de 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.

  5. É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 appelle decrypt_str une 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 vrai call, avec 0x100 octets au-dessus de l’adresse de retour pour le shadow space et d’éventuels arguments sur la pile.

  6. Voyez d’abord ce qui se passe sans stubs. L’option --no-stubs laisse l’IAT telle qu’elle est sur disque :

    bash
    ./venv/bin/python call_decryptor.py --no-stubs
    text
    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 0x8486

    0x8486 n’est pas du tout une adresse. C’est la valeur stockée sur disque dans l’emplacement d’IAT de GetProcessHeap : la RVA de son entrée hint/name, que le loader Windows aurait remplacée par l’adresse réelle de la fonction. Le call 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.

  7. Exécutez-le avec les stubs :

    bash
    ./venv/bin/python call_decryptor.py
    text
    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 main ne 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 à HeapAlloc vaut la longueur de la chaîne plus un, ce qui confirme le champ de longueur lu dans la table.

  8. Laissez maintenant speakeasy exécuter le programme entier :

    bash
    ./venv/bin/speakeasy -t lab.exe -o report.json
    text
    * 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 emulating

    GCC 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’atteindre main. Répondre aux API non modélisées par un stub permet de franchir setvbuf, puis tourne mal d’une façon plus intéressante :

    bash
    ./venv/bin/speakeasy -t lab.exe -o report.json --modules-functions-always-exist
    text
    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)' -> None

    Un 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.

  9. Sautez le code de démarrage. main est à la RVA 0x2b40. Démarrez là :

    bash
    ./venv/bin/speakeasy -t lab.exe -o report.json --entry-point 0x2b40
    text
    * 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 emulating

    Le nom du mutex apparaît comme argument de CreateMutexA et l’URL comme sortie formatée, sans aucun code de harnais. Le GetLastError de 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 : main ne le déchiffre jamais, donc aucune émulation de main ne peut le trouver. Démarrer à main a aussi un coût à connaître : la CRT ne s’est jamais exécutée, donc un main qui utiliserait argc/argv recevrait 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 touche argv.

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 dans ecx »).
  • 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 stdcall 32 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.