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 a | Un shellcode a |
|---|---|---|
| En-têtes | Les signatures MZ/PE, une table des sections, un point d’entrée déclaré | Rien — le premier octet est la première instruction |
| Position | Une adresse de base préférée et des relocations à corriger | L’indépendance de la position : il doit fonctionner à l’adresse où il atterrit, quelle qu’elle soit |
| Accès aux API | Une table des imports que le loader remplit | Aucun 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,.NETMarshal.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
0x90ou 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
RWXqui 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-PC | E8 00 00 00 00 (call $+5) suivi d’un pop, ou une paire call/pop |
| Accès au PEB | 65 48 8B ... — un mov avec le préfixe gs 0x65 (x64), ou 64 8B ... avec fs (x86) |
| Offsets fs/gs | Lectures de gs:[0x60] (PEB x64) ou de fs:[0x30] (PEB x86) |
| NOP sled | Une longue suite de 0x90, ou d’autres instructions nulles d’un octet |
| Constantes de hachage imprimables | Des valeurs immédiates de 32 bits comparées dans une boucle (recherches d’API par hachage) |
| Entropie | Les 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.
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.
-
Créez un environnement virtuel pour que les bibliothèques restent locales :
bash python3 -m venv venv ./venv/bin/pip install capstone unicornL’assembleur
keystone-engineest 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. -
Enregistrez
build_shellcode.py. Il dispose le stub à la main, encode la chaîne par XOR avec la clé0x5A, écritstub.binet 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
jmpinitial saute en avant vers uncallqui cibledecode; lecallempile l’adresse de l’octet qui le suit — le début des données encodées —, quepop rsicharge. La boucle applique ensuite le XOR à chaque octet sur place. -
Exécutez-le :
bash ./venv/bin/python build_shellcode.pytext 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 0x1002Lisez-le comme un analyste lirait un vrai blob : le
callen0x1013cible0x1002, et l’octet qui suit cecall(0x1018) est l’endroit vers lequelpop rsipointera — les données encodées. Lemov cl, 0x1dfixe 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 lepop. -
Enregistrez
emulate_shellcode.py. Il mappe le blob et une pile, pose un hook sur chaque écriture mémoire et s’arrête auret, 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')}") -
Exécutez-le :
bash ./venv/bin/python emulate_shellcode.pytext === 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/labLa toute première écriture vise la pile, pas les données : c’est le
callqui empile son adresse de retour (0x...018, octet de poids faible0x18) — 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é. -
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.
- Passez-le dans speakeasy (
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.