Skip to content

Leçon 10.1 · Au-delà de l'EXE· 45 min

Analyse de shellcode

Reconnaître un shellcode brut sans en-têtes, suivre ses idiomes get-PC et de résolution d’API, puis le désassembler, l’émuler et en extraire les IOC.

Objectifs

  • Distinguer un shellcode indépendant de la position d’un PE par l’absence d’en-têtes, d’imports et d’adresses fixes
  • Reconnaître où se cache un shellcode ainsi que les motifs d’octets et l’entropie qui le trahissent
  • Lire dans le désassemblage les idiomes get-PC, de parcours du PEB et de résolution d’API par hachage
  • Désassembler un blob brut avec Capstone et l’émuler sans risque avec Unicorn pour journaliser son comportement
  • Extraire des indicateurs d’un shellcode décodé et les consigner dans un rapport

Tôt ou tard, un échantillon vous remet un blob de code brut sans aucun fichier autour : un tableau d’octets collé dans un one-liner PowerShell, un flux à l’intérieur d’un document Word, une ressource qui se déchiffre en quelque chose qui ressemble clairement à des instructions sans être un PE, ou une région de mémoire privée et exécutable dans un dump. C’est du shellcode : un morceau de code machine écrit pour s’exécuter n’importe où, sans loader, sans éditeur de liens et sans que le système d’exploitation ne fasse la mise en place sur laquelle compte un programme normal.

Tout ce qui précède dans ce parcours supposait un format de fichier : des en-têtes PE à analyser, une table des imports à lire, des sections aux adresses connues. Le shellcode se débarrasse de tout cela. Cette leçon porte sur la lecture d’un code dépourvu d’échafaudage : comment savoir que vous avez affaire à du shellcode, comment il trouve ses repères et appelle les API Windows, et comment le désassembler, l’émuler et en extraire des indicateurs sans l’exécuter sur quoi que ce soit auquel vous tenez. L’accent est mis sur l’analyse — reconnaître et comprendre un blob écrit par quelqu’un d’autre, pas en écrire un.

Ce qui distingue un shellcode d’un PE

Un programme Windows normal est un fichier PE : le loader mappe ses sections, applique les relocations, résout ses imports dans la table d’adresses d’import (IAT) et saute à un point d’entrée fixe. Le shellcode ne bénéficie de rien de tout cela. Il en découle trois propriétés, et chacune est un indice de reconnaissance.

PropriétéUn PE aUn shellcode a
En-têtesLes signatures MZ/PE, une table des sections, un point d’entrée déclaréRien — le premier octet est la première instruction
PositionUne adresse de base préférée et des relocations à corrigerL’indépendance de la position : il doit fonctionner à l’adresse où il atterrit, quelle qu’elle soit
Accès aux APIUne table des imports que le loader remplitAucun import ; il doit trouver à l’exécution chaque fonction dont il a besoin

Pas d’en-têtes signifie qu’aucune métadonnée ne vous dit où le code commence ni quelle est sa longueur. On vous remet des octets et un « l’exécution commence à l’offset 0 » implicite.

L’indépendance de la position est la contrainte déterminante. Le shellcode ne peut pas coder en dur l’adresse de ses propres données, car il ne sait pas où il sera copié. Il doit calculer les adresses relativement à l’endroit où il s’exécute — ce qui produit les idiomes révélateurs de la section suivante.

Pas de table des imports signifie qu’il ne peut pas appeler LoadLibrary ou WinHTTPOpen de la manière simple, car personne n’a résolu ces noms pour lui. Il doit localiser la base de kernel32.dll en mémoire et parcourir les tables d’exports à la main. Cette machinerie, absente du code ordinaire, est l’un des signes les plus nets que vous lisez du shellcode.

Où on le trouve et comment le reconnaître

Le shellcode arrive rarement sous la forme d’un fichier bien net. C’est généralement une charge utile intégrée dans un autre artefact, et une partie du triage consiste à repérer le blob en premier lieu :

  • Encodé dans des scripts. Un loader PowerShell, JavaScript ou VBScript transporte les octets sous forme de Base64, de chaîne hexadécimale ou de tableau d’entiers séparés par des virgules, les décode, alloue de la mémoire exécutable (VirtualAlloc, .NET Marshal.Copy) et saute dedans. Malwares PowerShell, JavaScript et VBScript couvre ces enveloppes ; ce qu’elles enveloppent, c’est du shellcode.
  • Dans des objets de documents. Les documents malveillants déposent du shellcode dans un flux OLE, un objet RTF ou la charge utile d’un exploit, souvent derrière un NOP sled (une suite de 0x90 ou d’instructions nulles équivalentes qui donne de la marge à un saut imprécis pour atterrir).
  • Dans des ressources et des overlays. Un dropper stocke une étape chiffrée sous forme de ressource PE ou d’overlay ajouté en fin de fichier ; une fois déchiffrées, certaines étapes sont du shellcode plutôt qu’un PE.
  • En mémoire. Pendant l’analyse dynamique, vous trouvez une région privée RWX qui a été écrite puis exécutée. Il n’y a pas de fichier — le code injecté n’existe que dans le processus. Le dump de ces régions fait l’objet de la leçon sur le dump mémoire, plus loin dans ce parcours.

Heuristiques qui signalent un blob candidat :

SignalÀ quoi il ressemble
Idiome get-PCE8 00 00 00 00 (call $+5) suivi d’un pop, ou une paire call/pop
Accès au PEB65 48 8B ... — un mov avec le préfixe gs 0x65 (x64), ou 64 8B ... avec fs (x86)
Offsets fs/gsLectures de gs:[0x60] (PEB x64) ou de fs:[0x30] (PEB x86)
NOP sledUne longue suite de 0x90, ou d’autres instructions nulles d’un octet
Constantes de hachage imprimablesDes valeurs immédiates de 32 bits comparées dans une boucle (recherches d’API par hachage)
EntropieLes stubs encodés font monter l’entropie ; un stub de décodage précède un corps à forte entropie

Aucun de ces signaux n’est une preuve à lui seul. Pour confirmer un candidat, il faut le désassembler comme du code brut (voir plus bas) et vérifier s’il forme un flux d’instructions cohérent qui fait des choses indépendantes de la position. Un blob qui se désassemble en un idiome get-PC, une boucle de décodage et un parcours du PEB est du shellcode ; un blob qui se désassemble en un charabia qui ne se resynchronise jamais est probablement des données.

Astuce : Notez toujours l’architecture et le nombre de bits que vous avez supposés. Les mêmes octets se décodent en instructions complètement différentes en x86 et en x86-64. Si un blob semble absurde d’une façon, essayez l’autre avant de conclure que ce n’est pas du code.

Comment le shellcode trouve ses repères

L’idiome get-PC

Comme il ne peut pas référencer ses propres données par une adresse absolue, le shellcode commence par se demander « où suis-je ? ». La réponse classique détourne call : un call empile l’adresse de l’instruction suivante sur la pile, donc un call vers l’instruction immédiatement suivante, suivi d’un pop, place cette adresse dans un registre.

asm
        call    next          ; pushes address of `next` (the byte after the call)
next:
        pop     rsi           ; rsi now holds the runtime address of `next`

Vous verrez aussi E8 00 00 00 00 écrit en ligne (un call avec un déplacement nul, c’est-à-dire vers l’instruction suivante) juste avant un pop. Une fois que le shellcode connaît une adresse à l’exécution, il calcule toutes les autres comme un décalage par rapport à celle-ci. Un call/pop sans ret correspondant, utilisé uniquement pour charger une adresse, est une signature forte de shellcode. Le lab à la fin de la leçon construit et émule exactement cette forme.

Parcourir le PEB pour trouver les modules

Pour appeler une API, le shellcode a besoin de l’adresse de base de la DLL qui l’exporte, à commencer par kernel32.dll. Il la trouve via le Process Environment Block (PEB), que l’OS place à un offset fixe d’un registre de segment : gs:[0x60] en x86-64, fs:[0x30] en x86. Depuis le PEB, il suit Ldr jusqu’à la liste des modules chargés (InLoadOrderModuleList ou InMemoryOrderModuleList), une liste chaînée de toutes les DLL déjà mappées dans le processus. En parcourant cette liste, il lit l’adresse de base et le nom de chaque module jusqu’à atteindre celui qu’il veut.

Dans le désassemblage, cela se lit comme un mov via gs/fs, puis une chaîne de déréférencements de pointeurs à de petits offsets constants (des registres généraux portant le parcours), souvent suivie d’une boucle qui compare des noms de modules. Inutile de mémoriser les offsets ; reconnaître la lecture de gs:[0x60]/fs:[0x30] suivie d’un parcours de liste suffit pour savoir que « ce code résout les modules à la main », ce que seuls le shellcode et les loaders réflexifs font.

Résolution d’API par hachage

Une fois kernel32.dll trouvée, le shellcode localise chaque fonction en analysant en mémoire le répertoire d’exports de la DLL. Pour éviter de transporter des chaînes lisibles comme "WinExec" — qui apparaîtraient dans strings et dans les signatures —, la plupart des shellcodes modernes comparent un hash de chaque nom d’export à une constante précalculée. C’est le hachage d’API, et cette page de technique mérite d’être lue en entier, car c’est l’obstacle le plus courant entre vous et l’intention d’un shellcode.

Pour l’analyste, le motif dans le désassemblage est le suivant : une boucle sur le tableau des noms d’exports, une petite routine de hachage (rotation-addition ou un mélange similaire) et une comparaison avec une valeur immédiate de 32 bits. Chacune de ces valeurs correspond à une API. Vous les résolvez en appliquant le même hachage à un dictionnaire de noms d’exports Windows — ou en laissant un émulateur faire la résolution pour vous, ce qui est généralement plus rapide.

Stubs de décodage

Le shellcode transporte souvent son vrai corps sous forme encodée, précédé d’un petit stub de décodage qui réécrit le corps sur place avant de sauter dedans. Le stub utilise l’idiome get-PC pour trouver les octets encodés, boucle dessus en appliquant un XOR, une addition ou un flux de clé RC4, puis enchaîne directement sur le code décodé. C’est du code auto-modifiant en miniature, et la boucle XOR est la même construction que celle rencontrée dans le chiffrement de chaînes par XOR, appliquée à des instructions plutôt qu’à des chaînes. Un désassembleur statique ne vous montre que le stub ; tout ce qui suit n’est que bruit à forte entropie tant que le stub ne s’est pas exécuté. C’est précisément pour cela que l’émulation a toute sa place ici.

Outils et méthode de travail

L’analyse de shellcode alterne entre désassemblage statique et exécution contrôlée, exactement comme l’a décrit Désassemblage linéaire vs récursif, mais sans symboles ni en-têtes sur lesquels s’appuyer au départ.

Désassembler le blob brut. Chargez les octets à une base choisie et décodez à partir de l’offset 0 :

  • Capstone, dans un court script Python, est le moyen le plus rapide d’obtenir un listing et d’automatiser la reconnaissance des idiomes ci-dessus.
  • Ghidra ou IDA peuvent importer un binaire plat : choisissez un chargement brut/binaire, définissez l’architecture (x86-64 ou x86) et une adresse de base, puis désassemblez à partir de l’offset 0. Vous perdez ce que l’analyse automatique tire des en-têtes, si bien que vous marquez souvent le début à la main et laissez la descente récursive suivre à partir de là.

L’émuler sans risque. Comme les stubs de décodage et la résolution par hachage ne se révèlent qu’à l’exécution, l’émulation est l’outil de base — et elle n’exécute jamais la charge utile sur votre hôte :

  • Unicorn exécute les instructions brutes dans un CPU isolé, avec une mémoire que vous mappez vous-même. Vous posez des hooks sur l’exécution, les lectures et les écritures, ce qui vous permet de regarder un stub de décodage réécrire son corps et de dumper le résultat. Unicorn n’émule que le CPU, pas Windows : les appels d’API doivent donc être remplacés par des bouchons ; la leçon sur l’émulation du module d’automatisation développe ce point.
  • speakeasy (Mandiant) émule l’environnement Windows en plus du CPU : il fournit le PEB, de fausses DLL et des implémentations d’API, ce qui lui permet d’exécuter un shellcode à travers son parcours des modules et sa résolution par hachage, et de journaliser les API qu’il appellerait, avec leurs arguments. Pour un échantillon réel, c’est souvent l’étape la plus précieuse de toutes — pointez-le sur le blob, choisissez l’architecture et lisez la trace d’API.
  • scdbg (bâti sur libemu) est un émulateur de shellcode dédié et éprouvé, qui rapporte les appels Win32 effectués par un blob. Il est rapide pour une première passe sur du shellcode 32 bits et ne demande aucun script.

Le déboguer dans le laboratoire. Quand l’émulation cale — une API inhabituelle, une vérification anti-analyse ou du code qui lit un état que l’émulateur ne modélise pas —, exécutez le blob sous un vrai débogueur dans votre VM isolée. Un loader comme BlobRunner (OALabs) alloue de la mémoire exécutable, y copie le blob, affiche l’adresse et attend, ce qui vous permet d’attacher x64dbg, de poser un point d’arrêt sur l’entrée et d’avancer en pas-à-pas. Ne le faites que dans le laboratoire : BlobRunner exécute réellement le shellcode.

Extraire les IOC. Une fois le shellcode décodé et émulé, récoltez les indicateurs : URL, hôtes et ports de C2, chaînes user-agent, chemins de fichiers, noms de mutex, et l’ensemble des API qu’il a résolues (les hashs, reconvertis en noms). L’ensemble des API constitue à lui seul un résumé des capacités — VirtualAlloc + WinHttpConnect + CreateThread raconte l’histoire d’un downloader avant même que vous ne lisiez une ligne de la charge utile.

Lab : décoder une chaîne à partir d’un blob x86-64 brut

Vous allez construire une petite routine inoffensive et indépendante de la position, dont le seul rôle est de décoder sur place par XOR une chaîne intégrée en forme d’URL — une forme de stub de décodage sans aucun appel d’API —, puis la désassembler avec Capstone et l’émuler avec Unicorn, en posant un hook sur les écritures mémoire pour voir apparaître le texte en clair. Les sorties ci-dessous sont réelles, obtenues avec Capstone 5.0.7 et Unicorn 2.1.4 dans un environnement virtuel Python 3.14 sous macOS. Rien n’est installé au niveau du système et rien de malveillant ne s’exécute : la « charge utile » est une boucle de décodage qui s’arrête sur son propre ret.

  1. Créez un environnement virtuel pour que les bibliothèques restent locales :

    bash
    python3 -m venv venv
    ./venv/bin/pip install capstone unicorn

    L’assembleur keystone-engine est le moyen naturel de transformer de l’assembleur en octets, mais sa liaison Python ne compile pas avec Python 3.14 ; ce lab assemble donc le stub à la main et vérifie chaque octet avec Capstone — exactement la discipline à adopter quand vous ne pouvez pas faire confiance à un assembleur.

  2. Enregistrez build_shellcode.py. Il dispose le stub à la main, encode la chaîne par XOR avec la clé 0x5A, écrit stub.bin et désassemble la partie code :

    python
    # build_shellcode.py - assemble a benign PIC XOR-decoder stub by hand,
    # then verify the bytes with Capstone.
    from capstone import Cs, CS_ARCH_X86, CS_MODE_64
    
    KEY = 0x5A
    PLAIN = b"http://update.example.com/lab"
    DATALEN = len(PLAIN)
    
    encoded = bytes(b ^ KEY for b in PLAIN)
    
    stub = bytes([
        0xEB, 0x11,              # 0x00  jmp   short call_dec
        0x5E,                    # 0x02  decode: pop rsi   (rsi = &data)
        0x48, 0x31, 0xC9,        # 0x03  xor   rcx, rcx
        0xB1, DATALEN,           # 0x06  mov   cl, DATALEN
        0x80, 0x36, KEY,         # 0x08  next: xor byte ptr [rsi], KEY
        0x48, 0xFF, 0xC6,        # 0x0B  inc   rsi
        0xFE, 0xC9,              # 0x0E  dec   cl
        0x75, 0xF6,              # 0x10  jnz   next
        0xC3,                    # 0x12  ret            (emu stops here)
        0xE8, 0xEA, 0xFF, 0xFF, 0xFF,  # 0x13  call_dec: call decode
    ])
    blob = stub + encoded
    open("stub.bin", "wb").write(blob)
    
    print(f"blob hex : {blob.hex()}")
    print("=== Capstone disassembly (code portion) ===")
    md = Cs(CS_ARCH_X86, CS_MODE_64)
    for insn in md.disasm(stub, 0x1000):
        print(f"0x{insn.address:04x}: {insn.bytes.hex(' '):<20} "
              f"{insn.mnemonic} {insn.op_str}")

    La disposition est celle d’un stub de décodage get-PC : le jmp initial saute en avant vers un call qui cible decode ; le call empile l’adresse de l’octet qui le suit — le début des données encodées —, que pop rsi charge. La boucle applique ensuite le XOR à chaque octet sur place.

  3. Exécutez-le :

    bash
    ./venv/bin/python build_shellcode.py
    text
    blob hex : eb115e4831c9b11d80365a48ffc6fec975f6c3e8eaffffff322e2e2a60...
    === Capstone disassembly (code portion) ===
    0x1000: eb 11                jmp 0x1013
    0x1002: 5e                   pop rsi
    0x1003: 48 31 c9             xor rcx, rcx
    0x1006: b1 1d                mov cl, 0x1d
    0x1008: 80 36 5a             xor byte ptr [rsi], 0x5a
    0x100b: 48 ff c6             inc rsi
    0x100e: fe c9                dec cl
    0x1010: 75 f6                jne 0x1008
    0x1012: c3                   ret
    0x1013: e8 ea ff ff ff       call 0x1002

    Lisez-le comme un analyste lirait un vrai blob : le call en 0x1013 cible 0x1002, et l’octet qui suit ce call (0x1018) est l’endroit vers lequel pop rsi pointera — les données encodées. Le mov cl, 0x1d fixe la longueur (29 octets). Il n’y a ni imports, ni en-têtes, ni adresses de données absolues : tout est relatif au compteur ordinal récupéré par le pop.

  4. Enregistrez emulate_shellcode.py. Il mappe le blob et une pile, pose un hook sur chaque écriture mémoire et s’arrête au ret, de sorte que rien au-delà de la boucle de décodage ne s’exécute :

    python
    # emulate_shellcode.py - run the stub under Unicorn, hook writes, dump result.
    from unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UC_HOOK_MEM_WRITE
    from unicorn.x86_const import UC_X86_REG_RSP
    
    BASE, STACK, PAGE = 0x1000000, 0x2000000, 0x1000
    blob = open("stub.bin", "rb").read()
    DATA_OFF, DATALEN = 0x18, 29
    RET_ADDR = BASE + 0x12                 # the `ret`; emulation stops here
    
    mu = Uc(UC_ARCH_X86, UC_MODE_64)
    mu.mem_map(BASE, PAGE); mu.mem_map(STACK, PAGE)
    mu.mem_write(BASE, blob)
    mu.reg_write(UC_X86_REG_RSP, STACK + PAGE // 2)
    
    def region(addr):
        if BASE <= addr < BASE + PAGE:   return f"blob+0x{addr-BASE:02x}"
        if STACK <= addr < STACK + PAGE: return "stack"
        return f"0x{addr:x}"
    
    def on_write(uc, access, address, size, value, _):
        b = value & 0xFF
        ch = chr(b) if 32 <= b < 127 else "."
        print(f"  write {region(address):<12} = 0x{b:02x} '{ch}'")
    
    mu.hook_add(UC_HOOK_MEM_WRITE, on_write)
    mu.emu_start(BASE, RET_ADDR)
    
    data = bytes(mu.mem_read(BASE + DATA_OFF, DATALEN))
    print(f"\ndecoded (hex)   : {data.hex()}")
    print(f"decoded (ascii) : {data.decode('latin1')}")
  5. Exécutez-le :

    bash
    ./venv/bin/python emulate_shellcode.py
    text
    === emulating (memory writes hooked) ===
      write stack        = 0x18 '.'
      write blob+0x18    = 0x68 'h'
      write blob+0x19    = 0x74 't'
      write blob+0x1a    = 0x74 't'
      write blob+0x1b    = 0x70 'p'
      write blob+0x1c    = 0x3a ':'
      write blob+0x1d    = 0x2f '/'
      ...
      write blob+0x33    = 0x61 'a'
      write blob+0x34    = 0x62 'b'
    
    decoded (hex)   : 687474703a2f2f7570646174652e6578616d706c652e636f6d2f6c6162
    decoded (ascii) : http://update.example.com/lab

    La toute première écriture vise la pile, pas les données : c’est le call qui empile son adresse de retour (0x...018, octet de poids faible 0x18) — l’idiome get-PC pris sur le fait. Toutes les écritures suivantes tombent dans la région des données, un octet à la fois, et l’URL s’assemble d’elle-même en mémoire. Dumper la région ensuite vous donne l’IOC en clair, http://update.example.com/lab, qu’un examen purement statique du blob encodé n’aurait jamais révélé.

  6. Pour un échantillon réel, la même démarche passe à l’échelle, mais vous ne l’exécuteriez pas sur votre hôte. À la place :

    • Passez-le dans speakeasy (speakeasy -t blob.bin -a x64 -r) ou dans scdbg pour les blobs 32 bits, et lisez les appels d’API journalisés et leurs arguments — le parcours des modules et la résolution par hachage se déroulent dans l’émulateur, et l’URL de C2 apparaît généralement comme argument d’une API réseau.
    • Quand l’émulation cale, chargez le blob avec BlobRunner dans votre VM isolée, attachez x64dbg à l’adresse affichée et avancez en pas-à-pas dans le décodeur et dans la boucle de résolution d’API.

Questions : Dans le désassemblage, comment savez-vous que 0x1018 contient des données et non du code, alors qu’aucun en-tête ne vous le dit ? Pourquoi la première écriture mémoire vise-t-elle la pile plutôt que la chaîne, et quelle instruction l’a provoquée ? Si vous changiez la clé XOR dans build_shellcode.py sans modifier l’octet de longueur, à quoi ressemblerait la sortie émulée, et comment retrouveriez-vous la clé à partir du seul blob encodé ? Pour un vrai blob, pourquoi le journal d’API de speakeasy est-il souvent plus utile qu’un désassemblage statique parfait des mêmes octets ?

Ce qu’il faut rapporter

Intégrez les résultats sur le shellcode à la structure du rapport de triage que vous utilisez déjà :

  • Où il a été trouvé et comment il a été extrait : le support (script, document, ressource, région mémoire), l’offset et la longueur, ainsi que les étapes de décodage ou de dump — hachez séparément le blob extrait et étiquetez-le comme artefact dérivé.
  • Architecture et forme : x86 ou x86-64 ; s’il utilise un idiome get-PC, un stub de décodage (avec l’algorithme et la clé), un parcours du PEB et le hachage d’API.
  • API résolues : les constantes de hachage reconverties en noms de fonctions, qui donnent le résumé des capacités.
  • IOC : URL, hôtes, ports, user-agents, mutex, chemins et noms de fichiers récupérés après décodage, chacun accompagné de la façon dont vous l’avez obtenu afin qu’un autre analyste puisse le reproduire.
  • Étape suivante : si le shellcode télécharge ou dépaquette une charge utile supplémentaire, notez d’où elle provient et hachez-la dès que vous l’avez.

À retenir

  • Le shellcode est du code indépendant de la position, sans en-têtes, sans relocations et sans table des imports ; ces trois absences permettent de le distinguer d’un PE.
  • On le trouve encodé dans des scripts, dans des objets de documents, dans des ressources et des overlays, et sous forme de mémoire exécutable dans des dumps ; les idiomes get-PC, les lectures du PEB via gs:[0x60] / fs:[0x30], les NOP sleds et les constantes de hachage signalent un blob candidat.
  • Il trouve ses repères avec l’idiome get-PC call/pop, localise les DLL en parcourant la liste des modules du PEB et résout les API par hachage pour éviter les chaînes lisibles — lisez la page sur le hachage d’API pour cette étape.
  • Désassemblez les blobs bruts avec Capstone ou par un chargement plat dans Ghidra/IDA, émulez-les sans risque avec Unicorn, speakeasy ou scdbg pour révéler les stubs de décodage et journaliser les appels d’API, et ne les déboguez en direct que sous BlobRunner, dans une VM isolée.
  • Rapportez l’origine du blob, sa forme et son algorithme, les API qu’il résout et les IOC que vous décodez, chacun avec des étapes reproductibles.