Skip to content

Leçon 9.1 · Évasion & unpacking· 45 min

Anti-désassemblage

Repérer les astuces qui trompent le désassembleur — sauts à condition fixe, instructions chevauchantes, octets parasites — puis réparer le listing.

Objectifs

  • Expliquer quelles hypothèses du désassembleur chaque famille d’anti-désassemblage attaque
  • Reconnaître dans Ghidra et IDA les sauts à condition constante, les paires de sauts, les octets parasites, les instructions chevauchantes et le flot masqué
  • Interpréter les symptômes visibles — références croisées en rouge, octets non définis, bornes de fonction erronées — comme des alarmes d’anti-désassemblage
  • Réparer un listing en redéfinissant code et données, en remplaçant les octets parasites par des NOP dans la base et en fixant les bornes de fonction à la main
  • Écrire un petit normaliseur qui patche un motif d’anti-désassemblage connu dans une copie de l’échantillon

Désassemblage linéaire vs récursif se terminait sur une promesse : les faiblesses mêmes qui rendent le désassemblage difficile sont celles qu’un auteur exploite délibérément. Cette leçon tient cette promesse. Chaque astuce présentée ici attaque une hypothèse que votre désassembleur est obligé de faire, et chaque astuce a un symptôme visible et une réparation manuelle. Le but n’est pas d’admirer l’ingéniosité ; c’est de remarquer en quelques secondes qu’un listing est une fiction, de nommer la famille qui l’a produit et de revenir à un graphe de flot de contrôle correct.

L’anti-désassemblage coûte peu à l’auteur et ne vous coûte cher que s’il vous surprend. Une fois que vous reconnaissez la poignée de familles ci-dessous, l’essentiel devient une étape de nettoyage mécanique avant que la véritable analyse commence.

Pourquoi l’anti-désassemblage fonctionne

Rappelez-vous les deux choses qu’un désassembleur statique doit déduire sur du code x86-64 à longueur variable : où se trouve le code, et où commence chaque instruction. La descente récursive — le moteur de Ghidra, IDA et Binary Ninja — répond aux deux en suivant le flot de contrôle depuis des points de départ connus. Cela lui donne trois hypothèses exploitables :

  1. Les deux branches d’un saut conditionnel sont atteignables. Le moteur décode la cible du saut et la suite séquentielle (fall-through), car il ne peut prouver statiquement que l’une ou l’autre est morte.
  2. Un call revient à l’instruction qui le suit, et un ret revient à son appelant. Les bornes de fonction et les références croisées reposent sur cette hypothèse.
  3. Les références croisées proviennent des branchements directs. Une cible atteinte uniquement par un registre ou une adresse calculée ne laisse aucune xref visible.

Chaque famille ci-dessous transforme l’une de ces hypothèses en arme. L’encodage des instructions x86 est dense — la plupart des valeurs d’octet se décodent en quelque chose —, si bien qu’un seul octet mal placé ne déclenche pas d’erreur ; il produit silencieusement un non-sens plausible.

Les familles

Sauts à condition constante

L’astuce emblématique, et la première que vous rencontrerez. L’auteur met un flag à zéro puis branche sur ce flag, de sorte que le saut conditionnel est en pratique inconditionnel :

asm
xor  eax, eax        ; ZF = 1, always
jz   real            ; always taken
.byte 0xE8           ; rogue byte the CPU never reaches
real:
lea  eax, [rcx+1]    ; the real body
ret

Le CPU prend toujours le branchement, donc l’octet qui suit le jz ne s’exécute jamais. Mais la descente récursive décode quand même la suite séquentielle, en commençant à 0xE8 — l’opcode d’un call rel32 de cinq octets. Ce call bidon avale les vrais lea/ret, et la cible du branchement atterrit désormais à l’intérieur de celui-ci. C’est un prédicat opaque : une condition dont la valeur est fixe mais pas évidente pour un outil. xor/jz, or eax,eax/jnz, test esp,esp /jns, un cmp contre une constante que le code vient de fixer — toutes sont des variantes.

Deux sauts vers la même cible

Le même effet avec deux branchements au lieu d’un faux flag : jz L immédiatement suivi de jnz L. À eux deux, ils couvrent les deux états du flag, donc L est toujours atteint ; pourtant, le moteur traite la suite séquentielle du jnz comme vivante et décode l’octet parasite qui s’y trouve. Le désassembleur ne combine jamais deux instructions en un seul saut inconditionnel, il ne peut donc pas voir que la suite séquentielle est morte.

Octets parasites après les transferts inconditionnels

Après un jmp, un ret ou un call qui ne revient pas (vers ExitProcess, abort), rien ne devrait être décodé comme suite séquentielle — mais les heuristiques de la descente récursive, et tout balayage linéaire, décoderont les octets qui suivent. Les auteurs y placent un opcode multi-octets (0xE8, 0xE9, 0x0F) pour que l’« instruction » déborde sur le vrai code qui suit et l’absorbe. La défense qu’offre un binaire normal — les bornes de fonction de .pdata sur Windows x64 (voir Sections PE et organisation mémoire et Fonctions et graphes de flot de contrôle) — est précisément ce qui manque aux octets parasites écrits à la main.

Instructions chevauchantes

La famille la plus difficile à représenter. Un même octet est délibérément le dernier octet d’une instruction et le premier octet de l’instruction suivante réellement exécutée. Le germe classique est EB FF : un jmp de deux octets dont la cible est son propre deuxième octet, où FF C0 se décode en inc eax. Aucun listing plat ne peut montrer un octet comme faisant partie de deux instructions, donc toute représentation linéaire doit masquer l’une des deux vraies instructions. C’est un désassemblage impossible au sens strict — la solution n’est pas un listing plus propre mais une note, ou le remplacement de toute la séquence par des nop dans votre base une fois que vous en avez compris l’effet net.

Flot de contrôle par adresse de retour et par exception

call/ret ne servent pas qu’aux fonctions. Un call $+5 empile l’adresse de l’octet suivant ; le code ajoute ensuite une constante à [rsp] et exécute ret, sautant ainsi vers un emplacement calculé sans xref ni relation d’appelant. Le moteur termine la fonction prématurément au ret et ne la relie jamais à la vraie continuation. La gestion structurée des exceptions (SEH) pousse la même idée plus loin : l’échantillon enregistre un gestionnaire, déclenche une faute (une division par zéro, un int3) et reprend dans le gestionnaire — un flot de contrôle que le désassembleur ne peut pas suivre du tout, car le transfert passe par l’OS. Le corps du gestionnaire apparaît comme des données non référencées.

Indirection par pointeur de fonction

Stocker l’adresse d’une fonction dans une variable et l’appeler à travers celle-ci (call [rbp-8], call rax) relève du C ordinaire, et cela masque l’arête. Le moteur enregistre l’unique endroit où l’adresse a été prise, mais pas les endroits où elle est appelée ; la fonction appelée affiche donc trop peu de références croisées et son prototype ne se propage jamais. Combiné à l’aplatissement du flot de contrôle, où un switch répartiteur fait passer chaque bloc de base par un saut indirect, cela transforme une fonction lisible en une soupe de blocs sans ordre visible.

Confusion du cadre de pile

Les désassembleurs avancés reconstruisent les variables locales et les arguments d’une fonction en suivant les ajustements de rsp/rbp. Une arithmétique de pile délibérément déséquilibrée ou trompeuse — une allocation dont la taille dépend d’un branchement, une référence à [rsp+N] avec un N choisi pour que l’outil calcule un mauvais cadre — met en échec cette analyse. Le symptôme est une fonction dont la décompilation est pleine de variables undefined et dont l’outil signale le pointeur de pile comme « déséquilibré » ; cela désactive aussi le décompilateur pour cette fonction.

Lire les symptômes

Vous avez rarement besoin de savoir quelle famille vous affrontez avant de savoir que quelque chose cloche. Chacune laisse une marque visible :

FamilleHypothèse attaquéeCe que vous voyez dans Ghidra / IDA
Saut à condition constanteLes deux branches sont vivantesCible de branchement affichée en rouge (pointe à l’intérieur d’une instruction) ; un call/jmp vers une adresse aberrante
Deux sauts, une cibleLes deux branches sont vivantesMême xref rouge ; deux branchements empilés sur la même zone
Octets parasites après jmp/retLa suite séquentielle est du codeUne instruction trop longue à cheval sur la fin d’une fonction ; octets non définis après elle
Instructions chevauchantesUn octet, une instructionUne cible de branchement sans début d’instruction ; des octets revendiqués par deux décodages
Flot de contrôle par ret/SEHcall revient, ret revient à l’appelantFonction qui se termine trop tôt ; une vraie routine affichée comme données non référencées ; « sp-analysis failed »
Indirection par pointeur de fonctionLes xrefs proviennent des branchements directsFonction appelée avec trop peu de xrefs ; aucun type d’argument propagé
Confusion du cadre de pileCadre équilibré et analysableVariables locales undefined ; avertissement de pile déséquilibrée ; le décompilateur refuse la fonction

Astuce : Une cible de branchement affichée en rouge dans IDA, ou un signet « Instruction does not fall through » / instruction invalide dans Ghidra, est l’alarme d’anti-désassemblage la plus rapide. Allez à cette adresse, regardez les octets, et vous trouverez presque toujours l’une des familles ci-dessus.

Les corrections de l’analyste

Il existe un ordre de préférence strict, du moins au plus invasif. Point crucial : vous modifiez votre base d’analyse, pas l’échantillon sur disque — le fichier doit rester identique octet pour octet pour le hachage, YARA et les exécutions dynamiques ultérieures.

  1. Redéfinir code et données. Dans IDA, U annule la définition, C transforme des octets en code, D en données. Dans Ghidra, Clear Code Bytes puis Disassemble (D), ou Data → choose type. Transformez l’octet parasite en donnée pour que le moteur reprenne le décodage à la vraie cible. Cela suffit à corriger les trois premières familles.
  2. Remplacer les octets parasites par des NOP dans la base. Pour les séquences chevauchantes ou à plusieurs niveaux où la redéfinition ne suffit pas, patchez les octets trompeurs en 0x90 dans l’outil (IDA : Edit → Patch program → Change byte ; Ghidra : Patch Instruction). Notez d’abord l’effet net — une séquence EB FF C0 48 n’est qu’un inc eax suivi d’un dec eax, un no-op élaboré.
  3. Fixer les bornes de fonction à la main. Quand un ret piégé termine une fonction trop tôt, ou que des octets parasites fusionnent deux fonctions, définissez les vraies bornes : IDA Alt+P (Edit function) ou créez une fonction avec P ; Ghidra Create Function / Edit Function. Ajoutez la référence croisée manquante pour que la fonction appelée se reconnecte au graphe.
  4. Scripter la réparation des motifs répétés. Les obfuscateurs appliquent le même motif des centaines de fois. Écrivez un court script qui le trouve et normalise chaque occurrence — un script IDAPython / Ghidra qui patche les octets et redésassemble, ou une passe autonome Capstone + pefile sur une copie du fichier. Le lab en construit un.

Attention : Patcher la base change votre vue, pas le programme. Si vous exécutez ensuite l’échantillon dans un débogueur, ses octets sont ceux d’origine — vos NOP n’y sont pas. Gardez les deux modèles séparés dans vos notes.

Lab : deux désassembleurs, un octet parasite

Il vous faut x86_64-w64-mingw32-gcc (ou clang -target x86_64-w64-windows-gnu), son objdump, Python 3 avec un environnement virtuel contenant capstone et pefile, et éventuellement Wine pour exécuter le résultat. Le programme est inoffensif : il affiche un nombre. Les listings ci-dessous proviennent de MinGW-w64 GCC 15.2.0, de l’objdump de binutils, de Capstone 5.0.7 et de pefile 2024.8.26 ; les adresses diffèrent avec d’autres chaînes de compilation.

  1. Enregistrez adlab.c. L’astuce est écrite dans un bloc asm de premier niveau pour que les octets exacts survivent au compilateur :

    c
    /* adlab.c: a harmless function hidden behind a constant-condition jump.
       guarded(x) returns x + 1; the jz is always taken, so the 0xE8 rogue
       byte after it never executes. Windows x64 passes the first int in ecx. */
    #include <stdio.h>
    
    extern int guarded(int x);
    
    __asm__(
        ".intel_syntax noprefix\n"
        ".text\n"
        ".globl guarded\n"
    "guarded:\n"
        "    xor   eax, eax\n"   /* ZF = 1, unconditionally */
        "    jz    1f\n"         /* always taken: skip the rogue byte     */
        "    .byte 0xE8\n"       /* rogue byte: opcode of call rel32       */
    "1:  lea   eax, [rcx+1]\n"   /* the real body the trick hides          */
        "    ret\n"
        ".att_syntax prefix\n"
    );
    
    int main(void) {
        printf("guarded(41) = %d\n", guarded(41));
        return 0;
    }
    bash
    x86_64-w64-mingw32-gcc -O2 -o adlab.exe adlab.c
    python3 -m venv venv && ./venv/bin/pip install capstone pefile
    wine adlab.exe          # optional: prints  guarded(41) = 42
  2. Le balayage linéaire se désynchronise. Désassemblez avec objdump et lisez guarded :

    bash
    x86_64-w64-mingw32-objdump -d -M intel adlab.exe | grep -A 6 '<guarded>:'
    text
    0000000140001490 <guarded>:
       140001490:	31 c0                	xor    eax,eax
       140001492:	74 01                	je     140001495 <guarded+0x5>
       140001494:	e8 8d 41 01 c3       	call   103015626 <__size_of_stack_reserve__+0x102e15626>
       140001499:	90                   	nop
       14000149a:	90                   	nop

    Le je en ...492 pointe vers ...495, qui se trouve à l’intérieur du call de cinq octets en ...494. Cette cible en plein milieu d’une instruction est l’alarme. Le vrai corps — lea eax,[rcx+1]; ret — a disparu, ses octets (8d 41 01 c3) étant absorbés par le call bidon vers une adresse aberrante.

  3. La descente récursive suit le saut. Enregistrez recdis_pe.py, un parcours récursif minimal qui décode une fonction, suit les cibles des branchements directs et signale deux instructions qui revendiquent le même octet :

    python
    # recdis_pe.py: tiny recursive-descent disassembler for one function in a PE.
    # usage: python recdis_pe.py file.exe <rva-hex>
    import sys
    import pefile
    from capstone import Cs, CS_ARCH_X86, CS_MODE_64, CS_GRP_JUMP, CS_GRP_CALL, CS_GRP_RET
    from capstone.x86 import X86_OP_IMM
    
    STOP = {"jmp", "ret", "hlt"}
    
    pe = pefile.PE(sys.argv[1])
    base = pe.OPTIONAL_HEADER.ImageBase
    start = base + int(sys.argv[2], 16)
    image = pe.get_memory_mapped_image()
    lo, hi = start, start + 0x20              # small window around the function
    
    md = Cs(CS_ARCH_X86, CS_MODE_64)
    md.detail = True
    
    def target(insn):
        op = insn.operands[0] if insn.operands else None
        return op.imm if op and op.type == X86_OP_IMM else None
    
    insns, todo = {}, [start]
    while todo:
        addr = todo.pop()
        while lo <= addr < hi and addr not in insns:
            i = next(md.disasm(image[addr - base:], addr, 1), None)
            if i is None:
                break
            insns[addr] = i
            if i.group(CS_GRP_JUMP) or i.group(CS_GRP_CALL):
                t = target(i)
                if t is not None:
                    todo.append(t)
            if i.mnemonic in STOP or i.group(CS_GRP_RET):
                break
            addr += i.size
    
    covered = {}
    for a, i in insns.items():
        for b in range(a, a + i.size):
            covered.setdefault(b, []).append(a)
    for a in sorted(insns):
        i = insns[a]
        clash = {o for b in range(a, a + i.size) for o in covered[b]} - {a}
        flag = "   <-- overlaps " + ", ".join(hex(c) for c in sorted(clash)) if clash else ""
        print(f"{a:#x}: {i.bytes.hex(' '):<17} {i.mnemonic} {i.op_str}{flag}")
    bash
    ./venv/bin/python recdis_pe.py adlab.exe 0x1490
    text
    0x140001490: 31 c0             xor eax, eax
    0x140001492: 74 01             je 0x140001495
    0x140001494: e8 8d 41 01 c3    call 0x103015626   <-- overlaps 0x140001495, 0x140001498
    0x140001495: 8d 41 01          lea eax, [rcx + 1]   <-- overlaps 0x140001494
    0x140001498: c3                ret    <-- overlaps 0x140001494

    En suivant la cible du je, le parcours décode le vrai lea eax,[rcx+1]; ret que le balayage linéaire avait perdu, et marque le chevauchement avec le call bidon. C’est ce que Ghidra et IDA font en interne — et la raison pour laquelle le conflit apparaît en rouge au lieu d’être silencieusement avalé.

  4. Normaliser le motif. Redéfinir des octets à la main convient pour une fois ; pour tout un échantillon, scriptez-le. Enregistrez normalise.py, qui trouve la signature xor eax,eax; jz +1 et remplace par un NOP l’octet que le saut ignore, dans une copie du fichier :

    python
    # normalise.py: neutralise the "constant-condition jz over a rogue byte" pattern.
    # Finds  xor eax,eax ; jz +1  (31 C0 74 01)  and overwrites the byte the jump
    # skips with a NOP (0x90), in a copy of the file. usage: python normalise.py in.exe out.exe
    import sys
    
    PATTERN = bytes.fromhex("31c07401")     # xor eax,eax ; jz .+3 (skip 1 byte)
    data = bytearray(open(sys.argv[1], "rb").read())
    
    patched, off = 0, 0
    while True:
        off = data.find(PATTERN, off)
        if off == -1:
            break
        rogue = off + len(PATTERN)           # the single byte the jz skips
        if data[rogue] != 0x90:
            print(f"file offset {rogue:#x}: {data[rogue]:#04x} -> 0x90")
            data[rogue] = 0x90
            patched += 1
        off = rogue
    
    open(sys.argv[2], "wb").write(data)
    print(f"{patched} rogue byte(s) patched")
    bash
    ./venv/bin/python normalise.py adlab.exe adlab_clean.exe
    text
    file offset 0xa94: 0xe8 -> 0x90
    1 rogue byte(s) patched
  5. Lire la copie propre. Le balayage linéaire se resynchronise désormais immédiatement, car le 0xE8 parasite est un nop d’un octet et la cible du je tombe sur une vraie instruction :

    bash
    x86_64-w64-mingw32-objdump -d -M intel adlab_clean.exe | grep -A 5 '<guarded>:'
    text
    0000000140001490 <guarded>:
       140001490:	31 c0                	xor    eax,eax
       140001492:	74 01                	je     140001495 <guarded+0x5>
       140001494:	90                   	nop
       140001495:	8d 41 01             	lea    eax,[rcx+0x1]
       140001498:	c3                   	ret

    La copie patchée s’exécute toujours (wine adlab_clean.exe affiche guarded(41) = 42) parce que l’octet parasite était du code mort. C’est tout l’intérêt : votre normaliseur a modifié une copie pour la lisibilité, et laissé l’original intact pour le hachage et l’analyse dynamique.

Questions : À l’étape 2, pourquoi objdump n’affiche-t-il pas d’erreur sur le call bidon alors que sa cible est une adresse aberrante ? Si l’octet parasite avait été 0x0F (le début d’un opcode de deux octets) au lieu de 0xE8, combien de vrais octets le listing linéaire aurait-il avalés ? Le normaliseur ne reconnaît que le motif exact 31 C0 74 01 — nommez deux variantes à condition constante qu’il manquerait, et la façon dont vous le généraliseriez. Pourquoi patcher une copie sur disque est-il le mauvais choix pour un échantillon que vous devez encore exécuter dans un débogueur, et que devriez-vous patcher à la place ?

À retenir

  • Chaque famille d’anti-désassemblage attaque une hypothèse précise du désassembleur : les deux branches sont vivantes, call/ret se comportent normalement, ou les xrefs proviennent des branchements directs.
  • Les sauts à condition constante, les paires de sauts et les octets parasites cachent du vrai code derrière une fausse instruction multi-octets ; une cible de branchement qui tombe à l’intérieur d’une instruction (en rouge dans IDA) est l’alarme la plus rapide.
  • Les instructions chevauchantes et le flot de contrôle par ret/SEH ne peuvent pas toujours être représentés dans un listing plat ; identifiez l’effet net et notez-le plutôt que de courir après une vue parfaite.
  • Corrigez la base, pas le fichier : redéfinissez code et données, remplacez les octets parasites par des NOP dans l’outil, fixez les bornes de fonction et ajoutez à la main les références croisées manquantes.
  • Pour les motifs appliqués à grande échelle, scriptez la normalisation sur une copie — et gardez les octets d’origine intacts pour le hachage et l’analyse dynamique.