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 :
- 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.
- Un
callrevient à l’instruction qui le suit, et unretrevient à son appelant. Les bornes de fonction et les références croisées reposent sur cette hypothèse. - 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 :
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
retLe 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 :
| Famille | Hypothèse attaquée | Ce que vous voyez dans Ghidra / IDA |
|---|---|---|
| Saut à condition constante | Les deux branches sont vivantes | Cible de branchement affichée en rouge (pointe à l’intérieur d’une instruction) ; un call/jmp vers une adresse aberrante |
| Deux sauts, une cible | Les deux branches sont vivantes | Même xref rouge ; deux branchements empilés sur la même zone |
Octets parasites après jmp/ret | La suite séquentielle est du code | Une instruction trop longue à cheval sur la fin d’une fonction ; octets non définis après elle |
| Instructions chevauchantes | Un octet, une instruction | Une cible de branchement sans début d’instruction ; des octets revendiqués par deux décodages |
Flot de contrôle par ret/SEH | call revient, ret revient à l’appelant | Fonction 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 fonction | Les xrefs proviennent des branchements directs | Fonction appelée avec trop peu de xrefs ; aucun type d’argument propagé |
| Confusion du cadre de pile | Cadre équilibré et analysable | Variables 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.
- Redéfinir code et données. Dans IDA,
Uannule la définition,Ctransforme des octets en code,Den 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. - 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
0x90dans l’outil (IDA : Edit → Patch program → Change byte ; Ghidra : Patch Instruction). Notez d’abord l’effet net — une séquenceEB FF C0 48n’est qu’uninc eaxsuivi d’undec eax, un no-op élaboré. - Fixer les bornes de fonction à la main. Quand un
retpiégé termine une fonction trop tôt, ou que des octets parasites fusionnent deux fonctions, définissez les vraies bornes : IDAAlt+P(Edit function) ou créez une fonction avecP; Ghidra Create Function / Edit Function. Ajoutez la référence croisée manquante pour que la fonction appelée se reconnecte au graphe. - 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.
-
Enregistrez
adlab.c. L’astuce est écrite dans un blocasmde 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 -
Le balayage linéaire se désynchronise. Désassemblez avec
objdumpet lisezguarded: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 nopLe
jeen...492pointe vers...495, qui se trouve à l’intérieur ducallde 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 lecallbidon vers une adresse aberrante. -
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 0x1490text 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 0x140001494En suivant la cible du
je, le parcours décode le vrailea eax,[rcx+1]; retque le balayage linéaire avait perdu, et marque le chevauchement avec lecallbidon. 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é. -
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 signaturexor eax,eax; jz +1et 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.exetext file offset 0xa94: 0xe8 -> 0x90 1 rogue byte(s) patched -
Lire la copie propre. Le balayage linéaire se resynchronise désormais immédiatement, car le
0xE8parasite est unnopd’un octet et la cible dujetombe 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 retLa copie patchée s’exécute toujours (
wine adlab_clean.exeafficheguarded(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/retse 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.