Leçon 9.5 · Évasion & unpacking· 45 min
Comment fonctionnent les packers
Ce que fait pas à pas le stub d’un packer — décompresser, reconstruire les imports, reloger, sauter à l’OEP — et ce que cela implique pour l’unpacking.
Objectifs
- Décrire ce que fait un stub de dépaquetage à l’exécution, de la décompression des sections au transfert du contrôle vers le point d’entrée original
- Lire la table des sections et la carte mémoire d’un fichier packé, et prévoir comment elles changent une fois le stub exécuté
- Reconnaître le tail jump et les autres formes qu’emprunte un stub pour atteindre l’OEP
- Distinguer compresseurs, crypters et protecteurs selon ce que leurs stubs font au code et aux imports
- Expliquer comment les crypters multi-étapes et ceux qui injectent dans un processus changent l’emplacement final de la charge utile, et ce que chaque famille implique pour l’unpacking
Détecter les packers et l’entropie vous a appris à reconnaître un fichier packé, et Dumper la mémoire et extraire les charges utiles à copier le résultat dépaqueté hors d’un processus vivant et à en réparer l’organisation. Cette leçon comble l’écart entre les deux : ce qui se passe réellement dans un exécutable packé entre le moment où le loader lui passe la main et celui où le vrai code s’exécute. Une fois que vous savez raconter cette séquence, les choix que vous faites lors d’un unpacking manuel — où poser un point d’arrêt, quand dumper, quoi reconstruire — cessent d’être une recette et deviennent évidents.
Nous restons ici dans la détection et la compréhension, pas dans l’unpacking. Rien dans cette leçon n’exécute un véritable échantillon ; le lab packe un programme inoffensif que vous avez compilé vous-même.
Le stub est un loader miniature
Un packer prend un exécutable terminé et en produit un nouveau qui transporte l’original sous forme de données inertes et transformées, accompagnées d’un petit programme appelé stub de dépaquetage (unpacking stub). Le loader du système d’exploitation ignore tout cela. Il mappe le fichier packé, lit le point d’entrée dans l’en-tête PE et y saute — directement dans le stub. Le stub effectue alors, en code utilisateur, les parties du chargement que l’OS a déjà faites pour lui ainsi que celles que l’OS ne peut pas faire, parce que la vraie image est compressée ou chiffrée.
Voyez le stub comme un second loader qui s’exécute à l’intérieur du processus. Un stub complet accomplit les étapes suivantes, à peu près dans cet ordre :
- Décompresser ou déchiffrer le blob packé dans la mémoire que l’en-tête lui a réservée. Les compresseurs appliquent un algorithme comme LZMA ; les crypters appliquent un chiffrement, parfois un simple XOR glissant (voir code auto-modifiant, ce qu’est précisément le déchiffrement en place).
- Placer chaque section d’origine à son RVA. Les en-têtes du fichier packé ont été construits pour que le loader réserve une région de la bonne taille ; le stub y écrit les octets restaurés, section par section, comme Comment un binaire est chargé et exécuté décrivait le travail du loader.
- Reconstruire la table des imports. L’IAT d’origine n’est pas remplie — le loader ne pouvait pas lire un répertoire d’imports packé. Le stub parcourt les informations d’import d’origine et résout lui-même chaque fonction, presque toujours avec
LoadLibraryetGetProcAddress(voir Imports, exports et IAT). - Appliquer les relocations de base. Si l’image n’a pas été chargée à son adresse de base préférée — ce qui, avec l’ASLR, est généralement le cas —, chaque adresse absolue du code restauré est fausse. Le stub lit la table de relocations d’origine et ajoute le delta de chargement à chaque pointeur listé.
- Gérer le TLS. Si le programme d’origine utilisait le stockage local aux threads (TLS) ou des callbacks TLS, le stub doit restaurer le répertoire TLS pour que ces callbacks se déclenchent. Les malwares apprécient les callbacks TLS parce qu’ils s’exécutent avant le point d’entrée ; un stub qui les oublie casse ces échantillons.
- Restaurer ou corriger les en-têtes. Certains packers réparent l’en-tête PE en mémoire pour que l’image dépaquetée paraisse normale — noms de sections, caractéristiques, champ du point d’entrée. D’autres laissent l’en-tête dans sa forme de packer, ce qui constitue en soi un indice.
- Transférer le contrôle au point d’entrée original (OEP). La dernière chose que fait le stub est de sauter à l’adresse où commençait l’exécution du programme d’origine.
Tous les packers ne font pas les sept étapes. Un compresseur peut ignorer entièrement l’anti-analyse ; un protecteur peut ajouter des contrôles d’intégrité, de l’anti-débogage et de la virtualisation autour de chaque étape. Mais le squelette reste toujours « restaurer l’image, raccorder ses imports, sauter dedans ».
Astuce : Les imports dont un stub a besoin pour son propre travail trahissent tout le mécanisme. Une table des imports minuscule contenant
LoadLibrary,GetProcAddressetVirtualProtect/VirtualAllocdit « ce programme résout ses vrais imports à l’exécution » — exactement l’indicateur de la leçon sur la détection, désormais expliqué.
Avant et après, dans la table des sections
Le fichier packé sur disque et le processus après l’exécution du stub sont deux programmes différents qui partagent un même en-tête PE. Lire les deux vues côte à côte est la compétence centrale de cette leçon.
| Aspect | Fichier packé, sur disque | Après l’exécution du stub, en mémoire |
|---|---|---|
| Point d’entrée | Dans le stub, généralement dans la dernière section | L’exécution est passée à l’OEP dans le code restauré |
| Sections | Nommées par le packer (UPX0, UPX1, .aspack), une ou plusieurs avec une taille brute de 0 | Sections d’origine présentes à leurs RVA, désormais remplies |
| Une grande section vide | SizeOfRawData = 0, grande VirtualSize — espace réservé | Remplie avec le code d’origine décompressé |
| Imports | Minuscules — juste ce dont le stub a besoin | IAT complète, résolue vers les vraies adresses d’API |
| Chaînes | Peu nombreuses ; le reste est dans le blob | Les vraies chaînes du programme sont lisibles |
| Entropie | Une section dense à forte entropie | Entropie normale de code là où se trouvait le blob |
Le passage de la section vide au code mérite d’être visualisé concrètement. UPX réserve UPX0 sans aucune donnée brute ; l’original compressé réside dans UPX1. À l’exécution, le stub décompresse UPX1 dans UPX0, si bien qu’une section de zéro octet sur disque devient le .text du programme en mémoire. Tout outil qui se fie à la table des sections sur disque regarde donc les mauvais octets tant que vous n’avez pas dumpé et réparé l’image — c’est précisément le problème d’organisation de Dumper la mémoire et extraire les charges utiles.
La carte mémoire raconte la même histoire de l’autre côté. Dans la Memory Map de x64dbg ou dans l’onglet Memory de System Informer, un processus packé présente une région qui commence en écriture (pour que le stub puisse la remplir) puis devient exécutable (pour que le CPU puisse l’exécuter) — le schéma « écrire puis exécuter » de tout unpacker.
Le tail jump et ses déguisements
L’instruction qui transfère le contrôle du stub à l’OEP s’appelle le tail jump (saut final), et le trouver est la façon de localiser l’OEP pour un dump. Dans les packers les plus simples, il est exactement ce que son nom indique : un unique jmp vers la région que le stub vient de remplir, placé vers la fin du stub après une série de restaurations de registres.
Parce qu’il est si reconnaissable, les packers le déguisent. Surveillez ces formes :
| Forme | Ce que vous voyez | Pourquoi elle est utilisée |
|---|---|---|
| Saut direct | jmp <oep> avec une cible dans une autre section | La plus simple ; UPX et la plupart des compresseurs |
| Retour | Empiler l’OEP, puis ret | Cache la cible à un lecteur pressé ; voir call/ret |
| Indirect | jmp eax / call eax après le calcul de l’OEP dans un registre | La cible n’est pas un littéral dans le code |
| Via un appel à l’OS | NtContinue/ZwContinue avec un enregistrement CONTEXT dont le Rip est l’OEP | Transfert de contrôle blanchi par le noyau |
| Par exception | Déclencher une exception ; le gestionnaire reprend à l’OEP | Sert aussi de vérification anti-débogage |
Le signal fiable n’est pas l’opcode mais la destination : le contrôle quitte la section du stub et atterrit dans une région qui était vide sur disque et qui est désormais remplie de code, après quoi l’état des registres ressemble à celui d’un programme qui démarre (un prologue normal, pas davantage de comptabilité de stub). Dans le lab, vous trouverez un simple jmp dont la cible est exactement le point d’entrée du fichier d’origine — le cas propre qui exerce votre œil pour les cas déguisés.
Compresseurs, crypters et protecteurs
La leçon sur la détection a présenté ces trois familles par leur objectif. Comparez-les maintenant selon ce que font réellement leurs stubs, car c’est cela qui détermine l’effort d’unpacking.
Les compresseurs existent pour rendre les fichiers plus petits. UPX en est l’archétype : un algorithme documenté, un stub petit et prévisible, un vrai tail jump et — ce qui est bien pratique — une option -d intégrée qui inverse la transformation. Le code d’origine est présent d’un seul tenant après décompression, si bien qu’un seul dump bien synchronisé vous donne tout. Voir packing UPX.
Les crypters existent pour mettre en échec les signatures. Le stub déchiffre une charge utile embarquée, souvent avec une clé qui change à chaque build, de sorte que deux échantillons ne partagent aucun octet. Un crypter peut encore passer la main à la charge utile par un tail jump dans le même processus — ou ne pas exécuter la charge utile dans ce processus du tout (voir la section suivante). Le code est récupérable, mais vous devez trouver le moment situé après le déchiffrement et avant tout effacement.
Les protecteurs existent pour résister à la rétro-ingénierie elle-même. Ils superposent tout ce que les deux autres familles omettent : anti-débogage (Anti-débogage), détection de VM et de sandbox (bac à sable), anti-désassemblage (Anti-désassemblage), et transformations du code qui peuvent faire qu’il n’existe aucun OEP propre vers lequel sauter. Trois techniques de protecteurs changent la donne :
- Stubs virtualisés ou mutés. VMProtect et Themida peuvent compiler le code d’origine en bytecode pour une machine virtuelle sur mesure embarquée dans le stub, ou le muter en instructions fonctionnellement équivalentes mais illisibles. Il n’existe aucun moment où le x86 d’origine apparaît en mémoire, donc « dumper à l’OEP » ne s’applique pas. Il s’agit de l’obfuscation par machine virtuelle, et la vaincre relève d’un travail de recherche, pas d’un dump.
- Octets volés (stolen bytes). Le protecteur copie les premières instructions de l’OEP dans son propre stub et les y exécute, puis saute au milieu de la fonction d’origine. Un dump pris à l’« OEP » est amputé de ses premières instructions ; vous devez récupérer les octets volés dans le stub et les greffer de nouveau.
- Redirection des imports. Au lieu d’une IAT normale, chaque appel passe par un stub qui résout l’API au moment de l’appel, souvent par hachage d’API. L’IAT que vous dumpez pointe vers des thunks du packer, pas vers les vraies fonctions, si bien que la reconstruction des imports — la réparation distincte vue dans la leçon sur le dump mémoire — devient la partie difficile.
| Famille | Complexité du stub | Gestion des imports | Code d’origine en mémoire ? | Approche typique |
|---|---|---|---|---|
| Compresseur | Petite | Reconstruits en une passe | Oui, intact | Unpacker officiel ou un seul dump |
| Crypter | Petite à moyenne | Reconstruits, ou charge utile relancée | Oui, après déchiffrement | Arrêt après déchiffrement, dump |
| Protecteur | Grande | Redirigés/virtualisés | Parfois jamais | Au cas par cas ; peut nécessiter de l’instrumentation |
Quand la charge utile quitte le processus
Tous les stubs ne sautent pas à un OEP dans leur propre espace d’adressage. Beaucoup de crypters grand public sont en réalité des loaders : ils déchiffrent un second exécutable et exécutent celui-ci, ce qui change l’endroit où vous devez chercher.
- Multi-étapes. L’étape un déchiffre et exécute l’étape deux, qui peut déchiffrer l’étape trois. Chaque étape peut employer une technique différente. Vous dépaquetez une couche à la fois, en refaisant un triage après chacune, car l’entropie, les imports et même le type de fichier peuvent changer d’une étape à l’autre.
- Exécution depuis la mémoire. Le stub alloue de la mémoire, y écrit le PE de la charge utile, applique lui-même les relocations et les imports (un loader réflectif) et appelle son point d’entrée. Il n’y a pas de tail jump vers une section du packer — l’OEP se trouve dans de la mémoire privée fraîchement allouée, exactement ce que la leçon sur le dump mémoire vous a appris à traquer.
- Injection dans un autre processus. Le stub crée ou ouvre un second processus et y écrit la charge utile — le process hollowing remplace l’image d’un processus suspendu ; d’autres techniques mappent du code dans un processus en cours d’exécution. La charge utile dépaquetée ne s’exécute jamais dans le fichier que vous avez lancé, donc dumper ce processus vous donne le loader, pas le malware. Vous dumpez plutôt le processus cible, au moment où le contrôle lui est transmis.
Voilà pourquoi la leçon sur le dump mémoire insistait sur la surveillance de CreateProcess, WriteProcessMemory et ResumeThread : un stub qui les appelle vous dit que l’OEP est ailleurs.
Ce que cela implique pour l’unpacking
Tout ce qui précède se résume à une seule question de triage, à laquelle vous répondez avant de commencer l’unpacking : où et quand le vrai code existe-t-il sous une forme exécutable ?
- Un compresseur au nom connu : essayez d’abord son unpacker officiel ; sinon, un seul dump au tail jump.
- Un crypter qui exécute la charge utile dans le processus : arrêtez-vous une fois le déchiffrement terminé, avant que la charge utile ne soit utilisée et éventuellement effacée, puis dumpez et reconstruisez les imports.
- Un loader qui relance ou injecte : suivez la nouvelle région ou le nouveau processus, et dumpez là.
- Un protecteur : vérifiez si le code d’origine apparaît un jour intact. Si oui, la méthode du crypter s’applique. S’il est virtualisé, dumper n’aidera pas, et vous passez à l’analyse comportementale ou à des outils de dévirtualisation dédiés.
Une prochaine leçon sur l’unpacking manuel transformera les trois premiers cas en une procédure concrète au débogueur. L’intérêt de celle-ci est que vous pouvez déjà prévoir lequel vous affrontez à partir de la table des sections, des imports et des appels d’API du stub.
Lab : packer un programme et trouver le tail jump
Vous allez packer un programme inoffensif avec UPX, comparer la structure des deux fichiers et trouver avec capstone le saut du stub vers l’OEP — en confirmant que sa cible est égale au point d’entrée d’origine. Il vous faut mingw-w64, upx (vérifiez avec which upx) et un environnement virtuel avec pefile et capstone. Les listings ci-dessous proviennent de MinGW-w64 GCC, UPX 5.2.1, pefile 2024.8.26 et Capstone 5.0.7 ; vos adresses seront différentes.
-
Préparez l’environnement et compilez un programme simple, puis packez-en une copie :
bash python3 -m venv venv && ./venv/bin/pip install pefile capstone printf '#include <stdio.h>\nint main(void){ puts("hello, analyst"); return 0; }\n' > hello.c x86_64-w64-mingw32-gcc -O2 -s -o hello.exe hello.c which upx && upx -q -o hello_upx.exe hello.exe -
Enregistrez ceci sous
inspect.py— il affiche le point d’entrée, la table des sections avec les indicateurs de permission, et les imports de n’importe quel PE :python # inspect.py — section table, entry point and imports for a PE file import sys, pefile for p in sys.argv[1:]: pe = pefile.PE(p) oh = pe.OPTIONAL_HEADER print(f"== {p} ep_rva={oh.AddressOfEntryPoint:#x}") print(f" {'name':8} {'VSize':>8} {'RawSize':>8} flags") for s in pe.sections: n = s.Name.rstrip(b"\x00").decode(errors="replace") c = s.Characteristics f = ("X" if c & 0x20000000 else "") + ("R" if c & 0x40000000 else "") \ + ("W" if c & 0x80000000 else "") print(f" {n:8} {s.Misc_VirtualSize:>#8x} {s.SizeOfRawData:>#8x} {f}") named = [i.name.decode() for d in getattr(pe, "DIRECTORY_ENTRY_IMPORT", []) for i in d.imports if i.name] print(f" imports: {len(named)} named function(s)") ep = oh.AddressOfEntryPoint sec = next(s.Name.rstrip(b"\x00").decode() for s in pe.sections if s.VirtualAddress <= ep < s.VirtualAddress + max(s.Misc_VirtualSize, s.SizeOfRawData)) print(f" entry point is in section {sec}\n")bash ./venv/bin/python inspect.py hello.exe hello_upx.exetext == hello.exe ep_rva=0x1440 name VSize RawSize flags .text 0x1a20 0x1c00 XR .data 0xa0 0x200 RW .rdata 0x9c8 0xa00 R ... .reloc 0x60 0x200 R imports: 42 named function(s) entry point is in section .text == hello_upx.exe ep_rva=0xc750 name VSize RawSize flags UPX0 0xa000 0x000 XRW UPX1 0x2000 0x1c00 XRW UPX2 0x1000 0x400 RW imports: 12 named function(s) entry point is in section UPX1Lisez tous les indicateurs de la leçon sur la détection d’un seul coup :
UPX0a une grande taille virtuelle mais zéro donnée brute — l’espace que le stub va remplir ; les sections sont accessibles en écriture et exécutables ; le point d’entrée se trouve dansUPX1, le stub, et non dans le code d’origine ; et le nombre d’imports est passé de 42 à 12. Les quatre imports deKERNEL32.DLLque conserve UPX sontLoadLibraryA,GetProcAddress,VirtualProtectetExitProcess— la boîte à outils pour reconstruire le reste à l’exécution. -
Enregistrez ceci sous
tailjmp.py. Il désassemble le stub à partir du point d’entrée packé et signale chaquejmpdirect, en marquant celui dont la cible est égale au point d’entrée du fichier d’origine :python # tailjmp.py — find the UPX stub's jump to the original entry point import pefile from capstone import Cs, CS_ARCH_X86, CS_MODE_64 orig_ep = pefile.PE("hello.exe").OPTIONAL_HEADER.AddressOfEntryPoint pe = pefile.PE("hello_upx.exe") ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint sec = next(s for s in pe.sections if s.VirtualAddress <= ep < s.VirtualAddress + s.Misc_VirtualSize) code = sec.get_data()[ep - sec.VirtualAddress:] md = Cs(CS_ARCH_X86, CS_MODE_64) insns = list(md.disasm(code, ep)) print(f"original OEP rva = {orig_ep:#x}; packed entry rva = {ep:#x}\n") print("first stub instructions:") for i in insns[:4]: print(f" {i.address:#08x} {i.mnemonic:5} {i.op_str}") print("\ndirect jumps in the stub:") for i in insns: if i.mnemonic == "jmp" and i.op_str.startswith("0x"): tgt = int(i.op_str, 16) mark = " <== target == original OEP" if tgt == orig_ep else "" print(f" {i.address:#08x} jmp {tgt:#x}{mark}")bash ./venv/bin/python tailjmp.pytext original OEP rva = 0x1440; packed entry rva = 0xc750 first stub instructions: 0x00c750 push rbx 0x00c751 push rsi 0x00c752 push rdi 0x00c753 push rbp direct jumps in the stub: 0x00c7c3 jmp 0xc7cd 0x00c844 jmp 0xc7cd ... 0x00c9ab jmp 0x1440 <== target == original OEP 0x00c9d2 jmp 0xc9b9Le stub commence par sauvegarder les registres — il compte les restaurer et laisser le processus dans un état apparemment intact. La plupart de ses sauts restent à l’intérieur du stub (les boucles de décompression). Un seul part vers
0x1440, l’adresse queinspect.pya indiquée comme point d’entrée dehello.exe. C’est le tail jump, et sa cible est l’OEP. -
Regardez les instructions juste avant lui. Relancez avec une tranche autour du tail jump (modifiez
tailjmp.pypour afficher lesinsnsproches de cette adresse), et vous verrez les restaurations de registres et l’ajustement de la pile qui marquent la fin d’un stub :text 0x00c999 pop rsi 0x00c99a pop rbx 0x00c9a7 sub rsp, -0x80 0x00c9ab jmp 0x1440 ; e9 90 4a ff ff 0x00c9b0 retLes
popdéfont lespushde l’étape 3, la pile est remise en ordre, et c’est seulement alors que le contrôle part vers l’OEP. L’encodagee9 90 4a ff ffest unjmpnear avec un déplacement relatif de 32 bits — il vaut la peine de savoir le reconnaître à l’œil.
Questions : Pourquoi UPX0 a-t-elle une taille brute de zéro sur disque, et qu’est-ce qui occupera cette région après l’exécution du stub ? Si vous dumpiez ce processus à l’instant où le tail jump s’exécute et que vous vous fiiez à la table des sections sur disque, quels octets votre désassembleur afficherait-il au « point d’entrée », et pourquoi ? Le stub ne conserve que quatre imports de KERNEL32 — quelle étape du travail du stub a besoin de chacun d’eux ? Si un packer remplaçait jmp 0x1440 par push 0x1440; ret, comment identifieriez-vous malgré tout l’OEP ?
À retenir
- Le stub de dépaquetage est un second loader à l’intérieur du processus : il décompresse ou déchiffre l’image d’origine, place les sections à leurs RVA, reconstruit l’IAT, applique les relocations, gère le TLS, peut corriger les en-têtes, et saute enfin à l’OEP.
- Le fichier packé sur disque et le processus dépaqueté partagent un même en-tête mais sont des programmes différents ; une section vide sur disque (
UPX0) devient le vrai code en mémoire, ce qui explique pourquoi les dumps nécessitent une réparation de l’organisation. - Le tail jump transfère le contrôle du stub à l’OEP. Son opcode varie —
jmp,ret, indirect,NtContinue, exception — mais sa destination est toujours le code fraîchement restauré. - Les compresseurs gardent le code intact et sont les plus simples ; les crypters déchiffrent une charge utile et peuvent la relancer ; les protecteurs ajoutent de l’anti-analyse et peuvent virtualiser ou voler du code, si bien qu’aucun OEP propre n’existe.
- Les stubs multi-étapes, réflectifs et injecteurs déplacent la charge utile dans une nouvelle mémoire ou un autre processus, si bien que l’OEP — et le dump que vous voulez — peut ne pas se trouver dans le fichier que vous avez lancé.
- Déterminez où et quand le vrai code existe sous une forme exécutable avant l’unpacking ; la table des sections, les imports et les appels d’API du stub vous disent à quelle famille vous avez affaire.