Leçon 4.1 · Désassemblage & analyse de code· 45 min
Désassemblage linéaire vs récursif
Comment un désassembleur repère le code, où balayage linéaire et descente récursive échouent chacun, et comment coder un désassembleur récursif.
Objectifs
- Expliquer les deux décisions que tout désassembleur statique doit prendre face au code x86 de longueur variable
- Comparer balayage linéaire et descente récursive, et prévoir où chacun se trompe
- Décrire les heuristiques qu’IDA, Ghidra et Binary Ninja ajoutent à la descente récursive
- Écrire un désassembleur à descente récursive en Python avec Capstone et le comparer à objdump
Jusqu’ici, vous avez traité le code d’un échantillon comme une boîte noire : vous
avez lu ses en-têtes, ses imports et ses chaînes, et vous avez mesuré son
entropie. À partir de ce module, vous ouvrez la boîte. Le premier outil que vous
prenez en main est un désassembleur, qui retransforme les octets de code
machine en instructions telles que mov, call et ret.
On est tenté de voir le désassemblage comme une simple table de correspondance :
l’octet C3 est ret, point. Traduire une instruction est effectivement
mécanique. La difficulté est de décider quels octets traduire, et où commence
chaque instruction. Si vous vous trompez là-dessus, le listing que vous passez
des heures à lire est une fiction. Cette leçon présente les deux stratégies
classiques, les points où chacune casse, et pourquoi la réponse compte autant
quand l’auteur du binaire cherche activement à vous tromper.
Ce qu’un désassembleur doit décider
Une section de code n’est qu’une suite d’octets. Rien n’y indique « une instruction commence ici » ou « ces huit octets sont un pointeur, pas du code ». Un désassembleur statique doit en déduire deux choses :
- Où se trouve le code. Quelles plages d’octets contiennent des instructions, et lesquelles contiennent des données (tables de sauts, constantes, chaînes, bourrage d’alignement) qui se trouvent simplement dans une section exécutable ?
- Où commence chaque instruction. Sur x86 et x86-64, une instruction fait entre 1 et 15 octets. Sa longueur dépend des préfixes, de l’opcode, des octets ModRM et SIB, et de la taille de l’éventuel déplacement et de la valeur immédiate (voir l’encodage des instructions x86). On ne connaît la longueur d’une instruction qu’en la décodant : un seul point de départ erroné décale donc toutes les instructions qui suivent.
Les architectures à largeur fixe comme AArch64 rendent la seconde question presque gratuite (chaque instruction fait 4 octets, alignés), ce qui explique que les données mêlées au code y posent un problème bien moindre. Le x86 est dense : presque toutes les valeurs d’octet sont un opcode ou un préfixe valide, si bien qu’un désassembleur qui part du mauvais endroit tombe rarement sur une erreur évidente. Il affiche simplement un charabia d’apparence plausible.
Voici les sept premiers octets d’une fonction jouet, décodés de deux façons :
bytes: eb 01 e8 8d 47 01 c3
follow the jmp (what the CPU does) decode the next byte (linear sweep)
+0 eb 01 jmp +3 +0 eb 01 jmp +3
+3 8d 47 01 lea eax,[rdi+1] +2 e8 8d 47 01 c3 call rel32 0xc301478d
+6 c3 retLes deux colonnes sont du x86 valide. Seul le flot de contrôle vous dit laquelle le CPU exécute réellement. Cet exemple n’a rien d’hypothétique : c’est la première fonction que vous construirez dans le lab.
Balayage linéaire
La stratégie la plus simple décode une section de code du début à la fin :
décoder l’instruction au premier octet, avancer de sa longueur, recommencer
jusqu’au bout. objdump -d fonctionne ainsi, tout comme ndisasm et la boucle
« désassemble ce tampon » que la plupart des gens écrivent en premier.
.text ┌────┬──────┬────┬───────────┬────┬─────┐
│ i1 │ i2 │ i3 │ i4 │ i5 │ ... │ decode, advance, decode...
└────┴──────┴────┴───────────┴────┴─────┘
───────────────────────────────────────►Point fort : la couverture. Le balayage linéaire examine chaque octet ; il ne rate donc jamais une fonction simplement parce que rien ne l’appelle de façon visible. Le code atteint uniquement via un pointeur de fonction, un callback enregistré auprès de l’OS ou une table de méthodes virtuelles apparaît quand même dans le listing.
Point faible : il ignore tout des données. Chaque fois que des octets qui ne sont pas du code se trouvent dans une section exécutable, le balayage linéaire les décode quand même :
- Tables de sauts. Une instruction
switchcompilée en saut indirect utilise souvent une table d’adresses ou de décalages. GCC et Clang placent ces tables dans une section de données en lecture seule, mais MSVC en a historiquement placé certaines dans.text, juste après la fonction. - Constantes en ligne et literal pools. L’assembleur écrit à la main, certaines bibliothèques d’exécution et beaucoup de shellcode gardent leurs données à côté du code qui les utilise.
- Bourrage. Les compilateurs alignent le début des fonctions avec des
séquences de
nopou des octetsint3(0xCC). Ceux-ci se décodent sans dommage, mais d’autres octets de remplissage non. - Octets parasites délibérés. Des octets insérés par un obfuscateur précisément *parce qu’*ils font dérailler un balayage linéaire.
Décoder des données comme du code cause deux types de dégâts. Les données elles-mêmes deviennent des instructions absurdes, ce qui est gênant mais généralement reconnaissable. Pire, la dernière « instruction » absurde peut déborder de la fin des données et avaler les premiers octets du vrai code. Le désassembleur est alors désynchronisé : il décode du vrai code à partir du mauvais décalage.
Le x86 se resynchronise — tôt ou tard
La bonne nouvelle, c’est que le décodage x86 a tendance à se recaler tout seul. Un décodage mal aligné produit quelques instructions bidon, et tôt ou tard l’une d’elles se termine pile sur une vraie frontière d’instruction ; à partir de là, les deux flux concordent. Dans le lab, vous mesurerez ce phénomène sur un vrai binaire : la plupart des points de départ mal alignés se recalent après une ou deux instructions bidon, et rares sont ceux qui en demandent plus de cinq.
La mauvaise nouvelle, c’est ce qui se perd entre-temps. Ces quelques instructions
sont justement celles que les données ont avalées, et l’une d’elles peut être le
call ou le saut conditionnel qui compte. Une cible de branchement qui tombe au
milieu d’une instruction de votre listing est le symptôme classique : le code
dit « va à l’adresse X », mais le désassembleur n’affiche aucune instruction
commençant en X.
Astuce : Les symboles masquent la désynchronisation.
objdumpreprend le décodage à chaque symbole qu’il connaît : un binaire non strippé se resynchronise donc gratuitement au début de chaque fonction. Strippez le binaire, et le même octet parasite peut corrompre la fin d’une fonction et le début de la suivante.
Descente récursive
La seconde stratégie décode en suivant le flot de contrôle, comme le ferait le CPU. Elle part d’adresses dont on sait qu’elles contiennent du code et traite chaque branchement comme un pointeur vers d’autre code :
- Graines : le point d’entrée indiqué par l’en-tête du fichier, les fonctions exportées, les symboles si le binaire en a (voir ELF pour les analystes de malwares et Imports, exports et IAT).
- Décoder les instructions les unes après les autres à partir d’une graine.
- Sur un
jmp, mettre la cible en file d’attente et arrêter ce parcours ; les octets qui suivent un saut inconditionnel ne sont pas atteints depuis ce point. - Sur un saut conditionnel, mettre la cible en file d’attente et continuer, car les deux issues sont possibles.
- Sur un
call, mettre la fonction appelée en file d’attente et (en général) continuer, en supposant qu’elle retourne. - Sur
retouhlt, arrêter ce parcours. - Recommencer jusqu’à ce que la file de travail soit vide.
entry ──► push rbx
...
call add_one ─────────────► add_one: jmp L1 ──┐
... (junk) │ never decoded
ret L1: lea ◄───┘
retComme elle ne décode jamais d’octets qu’aucun chemin n’atteint, la descente récursive enjambe les données en ligne et les octets parasites placés après un saut inconditionnel. C’est pourquoi tous les désassembleurs interactifs sérieux reposent sur elle.
Point faible : elle ne voit que ce qu’elle peut suivre.
- Branchements indirects.
jmp rax,call [rip+X]etcall [r12+rbx*8]ont des cibles qui n’existent qu’à l’exécution. Les tables de switch, les appels virtuels, les callbacks et tous les appels via la table des imports entrent dans cette catégorie. - Code non atteint. Une fonction dont la seule référence est un pointeur
stocké dans les données (routine de démarrage d’un thread, procédure de
fenêtre, entrée d’une table de gestionnaires) n’a aucun
callentrant ; elle ne sert donc jamais de graine. - Fonctions qui ne retournent pas. Après
call exitoucall abort, le compilateur peut placer quelque chose sans rapport : la fonction suivante, du bourrage ou une table de sauts. Un désassembleur qui suppose que tout appel retourne le décode comme du code en séquence ; un désassembleur qui sait que la fonction appelée ne retourne jamais s’arrête correctement. Se tromper sur cette liste, dans un sens comme dans l’autre, produit des erreurs. - Les deux branches d’un saut sont supposées vivantes. Si un saut conditionnel ne peut en réalité aller que d’un côté, l’« autre » branche mène à des octets que le CPU n’exécute jamais, et le désassembleur les décode quand même.
C’est ce dernier point que les obfuscateurs exploitent le plus, comme vous le verrez plus bas.
Ce que font les vrais outils : descente récursive plus heuristiques
IDA, Ghidra et Binary Ninja partent tous de la descente récursive, puis comblent les trous qu’elle laisse à l’aide d’heuristiques :
| Heuristique | Ce qu’elle trouve | Comment elle peut se tromper |
|---|---|---|
| Recherche de prologues | Les fonctions non atteintes qui commencent par un prologue typique (push rbp; mov rbp, rsp, sub rsp, N, ou un motif de compilateur connu) | Le code optimisé n’a souvent aucun prologue reconnaissable ; des données peuvent en avoir l’air |
| Reconstruction des tables de switch | Les cibles de jmp [table + reg*8], en trouvant le test de bornes (cmp reg, N / ja default) et en lisant N+1 entrées de la table | Dispositions de table inhabituelles, décalages relatifs ou tables construites à l’exécution |
| Recherche de pointeurs de code | Les constantes et mots de données qui pointent dans une section de code, traités comme débuts de fonction possibles | Des valeurs qui ressemblent par hasard à des adresses |
| Métadonnées d’exception | Sur Windows x64, chaque fonction non feuille a une entrée RUNTIME_FUNCTION dans .pdata qui donne sa RVA de début et de fin | Ne couvre que les fonctions dotées d’informations de déroulement ; le code écrit à la main ou injecté peut manquer |
| Signatures | Les fonctions de bibliothèque identifiées par des motifs d’octets (IDA FLIRT, Ghidra Function ID) | Des versions et options de compilateur différentes changent les octets |
| Listes d’appels sans retour | exit, ExitProcess, abort, __stack_chk_fail, plus les fonctions dont on déduit qu’elles ne retournent pas | Une entrée manquante provoque une exécution en séquence dans des octets absurdes |
Le point sur .pdata mérite d’être souligné pour les analystes Windows. L’ABI
Windows x64 exige des informations de déroulement (unwind) pour que les
exceptions puissent parcourir la pile ; un PE 64 bits normal embarque donc une
liste exacte des bornes de ses fonctions. Les désassembleurs s’en servent pour
amorcer l’analyse, et vous pouvez la lire vous-même ; la
leçon suivante le fait précisément. Vous avez
croisé .pdata comme « une section parmi d’autres » dans
Sections PE. C’est l’une des plus utiles.
Ces heuristiques expliquent aussi pourquoi deux outils peuvent être en désaccord sur un même binaire. Quand IDA affiche une fonction que Ghidra ne voit pas, ou inversement, l’un des deux a appliqué une heuristique que l’autre a ignorée. Connaître les algorithmes sous-jacents vous dit auquel faire confiance pour une région donnée.
Attention : Des heuristiques agressives échangent des faux négatifs contre des faux positifs. L’« Aggressive Instruction Finder » de Ghidra et les options similaires trouvent davantage de code dans les binaires strippés, mais transforment aussi des données en fonctions bidon. Utilisez-les à dessein, sur une copie de votre projet, et vérifiez ce qu’elles ajoutent.
Comment les obfuscateurs exploitent les deux stratégies
Les auteurs de malwares (logiciels malveillants) savent quelles hypothèses fait chaque stratégie, et les techniques d’anti-désassemblage sont conçues pour casser précisément ces hypothèses. Un module ultérieur traite l’anti-désassemblage en profondeur ; en voici la version courte, pour que vous reconnaissiez les motifs quand votre listing a l’air faux :
- Octet parasite après un saut toujours pris. Un
jmpinconditionnel par-dessus un octet comme0xE8(l’opcode decall rel32). Le balayage linéaire décode l’octet parasite comme uncallde cinq octets, avalant les quatre octets suivants de vrai code. La descente récursive gère ce cas correctement. - Prédicat opaque avec des octets parasites sur la branche morte. Remplacez
le
jmppar un saut conditionnel dont l’issue est fixe, commexor ecx, ecxsuivi dejz. Le CPU prend toujours le branchement, mais un désassembleur suppose les deux branches vivantes : la descente récursive décode donc aussi les octets parasites, et les deux décodages entrent en collision. Voir prédicats opaques. - Instructions chevauchantes. Un octet qui est à la fois le dernier octet
d’une instruction et le premier de l’instruction réellement exécutée ensuite.
La séquence de deux octets
EB FFest unjmpdont la cible est son propre second octet, oùFF C0se décode eninc eax. Aucun listing linéaire ne peut montrer les deux interprétations d’un même octet à la fois. - Code généré à l’exécution. Le code déchiffré ou patché à l’exécution (code auto-modifiant, packers) n’est tout simplement pas dans le fichier ; aucune stratégie statique ne peut le retrouver. C’est le domaine de l’unpacking (dépaquetage) et de l’analyse dynamique.
Lab : deux désassembleurs, un octet parasite
Il vous faut un GCC Linux x86-64 (natif sous Linux, ou un compilateur croisé
comme x86_64-linux-gnu-gcc sous macOS), binutils, Python 3 et un environnement
virtuel avec capstone et pyelftools. Ghidra est facultatif. Le programme est
inoffensif : il affiche deux nombres.
-
Créez le fichier C et un fichier assembleur écrit à la main contenant deux fonctions jouets :
c /* lab1.c - a harmless program whose helpers live in tricks.S */ #include <stdio.h> int add_one(int x); /* jmp over a junk byte */ int times_two(int x); /* always-taken jz over a junk byte */ int main(void) { printf("%d %d\n", add_one(41), times_two(21)); return 0; }asm /* tricks.S - two toy functions with an inserted junk byte */ .intel_syntax noprefix .text .globl add_one add_one: jmp 1f /* unconditional: skip the junk */ .byte 0xE8 /* first byte of "call rel32" */ 1: lea eax, [rdi+1] ret .globl times_two times_two: xor ecx, ecx /* ZF = 1, so the jz is always taken */ jz 2f .byte 0xE8 2: lea eax, [rdi+rdi] ret .section .note.GNU-stack,"",@progbits -
Compilez, gardez une copie strippée et préparez Python :
bash x86_64-linux-gnu-gcc -O1 -o lab1 lab1.c tricks.S # or plain gcc on Linux x86_64-linux-gnu-strip -o lab1_stripped lab1 python3 -m venv venv && ./venv/bin/pip install capstone pyelftoolsPour faire la même chose sous Windows, compilez avec
x86_64-w64-mingw32-gccet désassemblez avecx86_64-w64-mingw32-objdump -d. Notez que le premier argument entier y arrive dansecxet non dansedi: les fonctions jouets renvoient donc d’autres nombres, mais la leçon de désassemblage est identique. -
Balayage linéaire : désassemblez la copie strippée avec objdump et trouvez les deux fonctions juste après
main(les adresses seront différentes sur votre système) :bash x86_64-linux-gnu-objdump -d -M intel lab1_strippedtext 401116: 5b pop rbx 401117: c3 ret 401118: eb 01 jmp 40111b <printf@plt+0xeb> 40111a: e8 8d 47 01 c3 call ffffffffc34158ac <printf@plt+0xffffffffc301487c> 40111f: 31 c9 xor ecx,ecx 401121: 74 01 je 401124 <printf@plt+0xf4> 401123: e8 8d 04 3f c3 call ffffffffc37f15b5 <printf@plt+0xffffffffc33f0585> 401128: 0f 1f 84 00 00 00 00 nop DWORD PTR [rax+rax*1+0x0]Les vrais
leaetretdes deux fonctions ont disparu, avalés par deuxcallimpossibles vers des adresses hors du programme. Deux cibles de branchement,40111bet401124, pointent au milieu de ces appels : c’est votre alarme de désynchronisation. Le flux se recale une instruction plus loin. -
Enregistrez le désassembleur récursif sous
recdis.py:python # recdis.py - a tiny recursive-descent disassembler for x86-64 ELF files import sys 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 from elftools.elf.elffile import ELFFile STOP = {"jmp", "ret", "hlt", "ud2"} # no fall-through after these def load(path): elf = ELFFile(open(path, "rb")) text = elf.get_section_by_name(".text") seeds = {elf.header.e_entry} symtab = elf.get_section_by_name(".symtab") # absent once stripped if symtab: seeds |= {s["st_value"] for s in symtab.iter_symbols() if s["st_info"]["type"] == "STT_FUNC" and s["st_value"]} return text["sh_addr"], text.data(), seeds def direct_target(insn): """Return the target of a direct jmp/jcc/call, or None if indirect.""" op = insn.operands[0] if insn.operands else None return op.imm if op is not None and op.type == X86_OP_IMM else None def disassemble(base, code, seeds, code_ptr_heuristic=False): md = Cs(CS_ARCH_X86, CS_MODE_64) md.detail = True end = base + len(code) insns = {} # address -> instruction todo = [a for a in seeds if base <= a < end] while todo: addr = todo.pop() while base <= addr < end and addr not in insns: insn = next(md.disasm(code[addr - base:], addr, 1), None) if insn is None: # undecodable bytes: give up here print(f" ! invalid opcode at {addr:#x}") break insns[addr] = insn if insn.group(CS_GRP_JUMP) or insn.group(CS_GRP_CALL): target = direct_target(insn) if target is not None: todo.append(target) else: print(f" ? indirect {insn.mnemonic} {insn.op_str} at {addr:#x}") elif code_ptr_heuristic: # an immediate that points into .text may be a code pointer for op in insn.operands: if op.type == X86_OP_IMM and base <= op.imm < end: todo.append(op.imm) if insn.mnemonic in STOP or insn.group(CS_GRP_RET): break addr += insn.size # fall through (also after call) return insns def report(insns): covered = {} # byte address -> owning insns 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(' '):<24} {i.mnemonic} {i.op_str}{flag}") if __name__ == "__main__": base, code, seeds = load(sys.argv[1]) insns = disassemble(base, code, seeds, "--code-pointers" in sys.argv) report(insns) print(f"{len(insns)} instructions, {sum(i.size for i in insns.values())}" f" of {len(code)} .text bytes reached")Le cœur est la liste de travail de
disassemble: dépiler une adresse, décoder jusqu’à une instruction d’arrêt, empiler chaque cible de branchement direct. La fonctionreportsignale toute paire d’instructions qui revendiquent le même octet. -
Lancez-le sur le binaire strippé, avec le seul point d’entrée comme graine :
bash ./venv/bin/python recdis.py lab1_strippedtext ? indirect call qword ptr [rip + 0x2f6e] at 0x401064 0x401040: 31 ed xor ebp, ebp ... 0x40105d: 48 c7 c7 e7 10 40 00 mov rdi, 0x4010e7 0x401064: ff 15 6e 2f 00 00 call qword ptr [rip + 0x2f6e] 0x40106a: f4 hlt 12 instructions, 43 of 353 .text bytes reachedLa descente récursive pure a trouvé
_startet rien d’autre._startpasse l’adresse demainà la bibliothèque C dansrdi, puis appelle__libc_start_mainindirectement via la GOT. Il n’y a aucuncall maindirect à suivre. -
Activez l’heuristique des pointeurs de code, qui traite les valeurs immédiates pointant dans
.textcomme du code candidat :bash ./venv/bin/python recdis.py lab1_stripped --code-pointerstext 0x401118: eb 01 jmp 0x40111b 0x40111b: 8d 47 01 lea eax, [rdi + 1] 0x40111e: c3 ret 0x40111f: 31 c9 xor ecx, ecx 0x401121: 74 01 je 0x401124 0x401123: e8 8d 04 3f c3 call 0xffffffffc37f15b5 <-- overlaps 0x401124, 0x401127 0x401124: 8d 04 3f lea eax, [rdi + rdi] <-- overlaps 0x401123 0x401127: c3 ret <-- overlaps 0x401123 ... 70 instructions, 221 of 353 .text bytes reachedmov rdi, 0x4010e7sert maintenant de graine pourmain, et de là pour les deux fonctions. Dansadd_one, l’octet parasite est simplement sauté : la descente récursive l’emporte. Danstimes_two, elle décode les deux branches dujetoujours pris, si bien que lecallparasite et les vraislea/retse chevauchent. Votre outil signale au moins le conflit ; un listing linéaire devrait choisir l’un des deux. -
Mesurez l’autosynchronisation. Enregistrez ceci sous
resync.py, compilez un programme ordinaire sans astuce, et lancez-le :python # resync.py - how many bogus instructions before a misaligned decode lines up again? import sys from collections import Counter from capstone import Cs, CS_ARCH_X86, CS_MODE_64 from elftools.elf.elffile import ELFFile text = ELFFile(open(sys.argv[1], "rb")).get_section_by_name(".text") code, base = text.data(), text["sh_addr"] md = Cs(CS_ARCH_X86, CS_MODE_64) boundaries = {i.address for i in md.disasm(code, base)} # the true stream cost = Counter() for off in range(len(code) - 32): if base + off in boundaries: continue # only wrong starts for n, insn in enumerate(md.disasm(code[off:], base + off), 1): if insn.address + insn.size in boundaries: # back in step cost[n] += 1 break else: cost["never"] += 1 # hit undecodable bytes total = sum(cost.values()) for n in sorted(k for k in cost if k != "never"): print(f"back in step after {n} bogus instruction(s): {cost[n]:4} starts") print(f"hit an invalid opcode first: {cost['never']:4} starts") print(f"{total} misaligned starts tested")bash cat > plain.c <<'EOF' #include <stdio.h> int main(int argc, char **argv) { unsigned h = 5381; for (int i = 1; i < argc; i++) for (char *p = argv[i]; *p; p++) h = h * 33 + (unsigned char)*p; printf("%08x\n", h); return 0; } EOF x86_64-linux-gnu-gcc -O2 -o plain plain.c ./venv/bin/python resync.py plaintext back in step after 1 bogus instruction(s): 119 starts back in step after 2 bogus instruction(s): 51 starts back in step after 3 bogus instruction(s): 26 starts back in step after 4 bogus instruction(s): 9 starts back in step after 5 bogus instruction(s): 9 starts back in step after 6 bogus instruction(s): 5 starts back in step after 7 bogus instruction(s): 2 starts hit an invalid opcode first: 56 starts 277 misaligned starts testedLa sortie de GCC ne place aucune donnée dans
.text: son décodage linéaire constitue donc une vérité de référence honnête. Capstone s’arrête sur un opcode invalide, ce qui explique que certains départs ne se recalent jamais ici ;objdump, lui, affiche(bad)et continue. -
Facultatif, dans Ghidra : importez
lab1_stripped, lancez l’auto-analyse avec les options par défaut et allez aux adresses des deux fonctions. Vérifiez si l’octet parasite deadd_oneest laissé en donnée non définie, comment le conflit detimes_twoest affiché (regardez dans la fenêtre Bookmarks les signets d’erreur), et si Ghidra a trouvémainsans aucun symbole.
Questions : objdump reprend au symbole times_two dans le lab1 non
strippé. Cette reprise aurait-elle compté si l’instruction parasite avait été plus
longue d’un octet ? Quelle heuristique a retrouvé main à l’étape 6, et quel faux
positif la même heuristique pourrait-elle produire ? Si vous remplaciez l’octet
parasite 0xE8 par 0x90 (nop), qu’afficherait chaque désassembleur, et
l’astuce cacherait-elle encore quelque chose ? Pourquoi aucune des deux
stratégies ne peut-elle déterminer statiquement que le je de times_two est
toujours pris ?
À retenir
- Désassembler une instruction est facile ; décider où se trouve le code et où commence chaque instruction est le vrai problème, surtout sur le x86 à longueur variable.
- Le balayage linéaire voit chaque octet mais décode les données comme du code, ce qui désynchronise le flux ; le x86 se recale généralement en quelques instructions, mais les instructions avalées sont perdues.
- La descente récursive suit le flot de contrôle et saute les données, mais rate les cibles des branchements indirects et le code référencé uniquement depuis les données, et gère mal les appels sans retour et les branches jamais prises.
- IDA, Ghidra et Binary Ninja, c’est la descente récursive plus des
heuristiques : recherche de prologues, reconstruction des tables de switch,
recherche de pointeurs de code, signatures et, sur Windows x64, débuts de
fonction tirés de
.pdata. - Des cibles de branchement qui tombent à l’intérieur d’une instruction, et des instructions qui se chevauchent, sont des signes forts d’anti-désassemblage ; examinez ces octets à la main.