Skip to content

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 :

  1. 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).
  2. 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.
  3. 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 LoadLibrary et GetProcAddress (voir Imports, exports et IAT).
  4. 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é.
  5. 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.
  6. 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.
  7. 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, GetProcAddress et VirtualProtect/VirtualAlloc dit « 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.

AspectFichier packé, sur disqueAprès l’exécution du stub, en mémoire
Point d’entréeDans le stub, généralement dans la dernière sectionL’exécution est passée à l’OEP dans le code restauré
SectionsNommées par le packer (UPX0, UPX1, .aspack), une ou plusieurs avec une taille brute de 0Sections d’origine présentes à leurs RVA, désormais remplies
Une grande section videSizeOfRawData = 0, grande VirtualSize — espace réservéRemplie avec le code d’origine décompressé
ImportsMinuscules — juste ce dont le stub a besoinIAT complète, résolue vers les vraies adresses d’API
ChaînesPeu nombreuses ; le reste est dans le blobLes vraies chaînes du programme sont lisibles
EntropieUne section dense à forte entropieEntropie 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 :

FormeCe que vous voyezPourquoi elle est utilisée
Saut directjmp <oep> avec une cible dans une autre sectionLa plus simple ; UPX et la plupart des compresseurs
RetourEmpiler l’OEP, puis retCache la cible à un lecteur pressé ; voir call/ret
Indirectjmp eax / call eax après le calcul de l’OEP dans un registreLa cible n’est pas un littéral dans le code
Via un appel à l’OSNtContinue/ZwContinue avec un enregistrement CONTEXT dont le Rip est l’OEPTransfert de contrôle blanchi par le noyau
Par exceptionDéclencher une exception ; le gestionnaire reprend à l’OEPSert 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.
FamilleComplexité du stubGestion des importsCode d’origine en mémoire ?Approche typique
CompresseurPetiteReconstruits en une passeOui, intactUnpacker officiel ou un seul dump
CrypterPetite à moyenneReconstruits, ou charge utile relancéeOui, après déchiffrementArrêt après déchiffrement, dump
ProtecteurGrandeRedirigés/virtualisésParfois jamaisAu 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.

  1. 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
  2. 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.exe
    text
    == 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 UPX1

    Lisez tous les indicateurs de la leçon sur la détection d’un seul coup : UPX0 a 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 dans UPX1, le stub, et non dans le code d’origine ; et le nombre d’imports est passé de 42 à 12. Les quatre imports de KERNEL32.DLL que conserve UPX sont LoadLibraryA, GetProcAddress, VirtualProtect et ExitProcess — la boîte à outils pour reconstruire le reste à l’exécution.

  3. Enregistrez ceci sous tailjmp.py. Il désassemble le stub à partir du point d’entrée packé et signale chaque jmp direct, 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.py
    text
    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 0xc9b9

    Le 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 que inspect.py a indiquée comme point d’entrée de hello.exe. C’est le tail jump, et sa cible est l’OEP.

  4. Regardez les instructions juste avant lui. Relancez avec une tranche autour du tail jump (modifiez tailjmp.py pour afficher les insns proches 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  ret

    Les pop défont les push de l’étape 3, la pile est remise en ordre, et c’est seulement alors que le contrôle part vers l’OEP. L’encodage e9 90 4a ff ff est un jmp near 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.