Leçon 4.2 · Désassemblage & analyse de code· 50 min
Fonctions et graphes de flot de contrôle
Découper le code en blocs de base, lire les formes de CFG des if, boucles et switch, trouver les fonctions x64 Windows et bâtir un CFG avec Capstone.
Objectifs
- Définir les blocs de base et appliquer les règles des leaders pour découper une fonction en blocs
- Reconnaître les formes de CFG produites par if/else, les boucles et les switch, y compris les arcs arrière
- Repérer les bornes des fonctions sur Windows x64 à l’aide des prologues, des épilogues et des entrées de déroulement de .pdata
- Construire un CFG en Python avec Capstone, l’exporter vers Graphviz et comparer la sortie -O0 à la sortie -O2
Dans Désassemblage linéaire vs récursif, vous avez obtenu une liste d’instructions. Une liste n’est pas la bonne forme pour comprendre du code. Les programmes branchent, bouclent et aiguillent, et un listing linéaire cache tout cela derrière des adresses qu’il faut rapprocher à l’œil.
Tout désassembleur sérieux regroupe donc les instructions en blocs de base, les relie en un graphe de flot de contrôle (CFG), et regroupe les graphes en fonctions reliées par un graphe d’appels. Ce sont ces structures que vous parcourez réellement dans IDA, Ghidra, Binary Ninja ou Cutter. Cette leçon explique comment elles sont construites, comment lire les formes courantes, et pourquoi la forme d’un graphe peut vous révéler qu’un échantillon est obfusqué avant même que vous ayez lu une seule instruction.
Blocs de base
Un bloc de base est une suite maximale d’instructions avec une seule entrée et une seule sortie : l’exécution ne peut entrer que par la première instruction et, une fois lancée, exécute toutes les instructions dans l’ordre jusqu’à la dernière. Aucun saut n’arrive au milieu, aucun branchement n’en part.
La méthode standard pour trouver les blocs consiste à marquer les leaders, les instructions qui commencent un bloc :
| Règle | Pourquoi elle commence un bloc |
|---|---|
| La première instruction de la fonction | Point d’entrée du graphe |
| La cible de tout saut, conditionnel ou non | Le contrôle peut arriver ici depuis un autre endroit que l’instruction précédente |
| L’instruction qui suit immédiatement un saut conditionnel | Le chemin en séquence (fall-through) du branchement |
L’instruction qui suit immédiatement un saut inconditionnel ou un ret (si elle est atteignable) | Atteignable uniquement depuis ailleurs, elle est donc forcément la cible de quelque chose |
Un bloc s’étend ensuite d’un leader jusqu’au suivant, exclu. La dernière
instruction d’un bloc est généralement un jmp, un
saut conditionnel ou un ret ; s’il s’agit
d’une instruction ordinaire, le bloc se poursuit simplement dans le leader
suivant.
Et call ? Dans le CFG d’une fonction, la plupart des
outils traitent un appel comme une instruction ordinaire qui revient à la
suivante ; il ne termine donc pas un bloc. La relation d’appel va plutôt dans
le graphe d’appels. L’exception est l’appel à une fonction connue pour ne pas
retourner, comme exit ou ExitProcess, qui termine le bloc sans successeur.
Graphes de flot de contrôle
Le CFG d’une fonction a un nœud par bloc de base et un arc orienté pour chaque transfert de contrôle possible :
- Arc pris : d’un bloc qui se termine par un saut vers la cible du saut.
- Arc en séquence : d’un bloc qui se termine par un saut conditionnel (ou par aucun saut) vers le bloc qui le suit en mémoire.
- Arcs indirects : d’un
jmp raxoujmp [table + reg*8]vers chaque cible que l’outil a pu retrouver, typiquement à partir d’une table de switch.
Un bloc sans successeur est une sortie : il se termine par ret, un appel
terminal (tail call) ou l’appel d’une fonction qui ne retourne pas.
Le graphe d’appels applique la même idée un niveau au-dessus : un nœud par
fonction, un arc par site d’appel. C’est ainsi que vous trouvez « tout ce qui
finit par atteindre CreateRemoteThread » ou « qui appelle la routine de
déchiffrement des chaînes ». Les appels indirects (call rax, call [rbx+18h],
méthodes virtuelles, callbacks) y laissent des trous, exactement comme les sauts
indirects en laissent dans un CFG. Les traquer est le sujet de
Références croisées et flot de données.
Lire les formes courantes
Avec la pratique, vous lisez un CFG comme une silhouette. Les mêmes structures de code source se compilent en mêmes formes, quels que soient le compilateur et l’architecture.
if et if/else
if without else if / else
┌─────────┐ ┌─────────┐
│ cmp; jcc│ │ cmp; jcc│
└──┬───┬──┘ └──┬───┬──┘
│ ▼ ▼ ▼
│ ┌──────┐ ┌──────┐ ┌──────┐
│ │ then │ │ then │ │ else │
│ └──┬───┘ └──┬───┘ └──┬───┘
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐
│ join │ │ join │
└─────────┘ └─────────┘
"triangle" "diamond"Les compilateurs inversent généralement la condition :
if (x == 0) { ... } devient test eax, eax; jne skip, de sorte que l’arc pris
saute le corps. Lisez le saut comme « sauter le bloc sauf si... ».
cmp et test positionnent les flags ; le saut
conditionnel désigne la relation.
Boucles et arcs arrière
Une boucle est un cycle dans le graphe. L’arc qui ferme le cycle, de la fin du corps vers le bloc qui décide s’il faut refaire un tour, est un arc arrière (back edge) ; sa cible est l’en-tête de boucle.
while / for at -O0 at -O2 (rotated loop)
┌───────┐ ┌───────────┐
│ init │── jmp ──┐ │ init; test│── jcc ──► exit (0 iterations)
└───────┘ ▼ └─────┬─────┘
┌───────┐ ┌──────────┐ ▼
│ body │◄───│ cond; jcc│ ┌───────────┐
└───┬───┘ └────┬─────┘ │ body │◄──┐
└── back ─────┘ ▼ exit │ cond; jcc │───┘ back edge
└─────┬─────┘
▼ exitUne définition précise s’appuie sur les dominateurs : le bloc A domine le
bloc B si tout chemin allant de l’entrée de la fonction à B passe par A. Un arc
arrière est un arc B → A où A domine B, et la boucle naturelle est A plus tous
les blocs qui peuvent atteindre B sans passer par A. On calcule rarement les
dominateurs à la main, mais l’idée explique deux faits utiles. D’abord, l’en-tête
de boucle est le bloc par lequel l’entrée doit passer, ce qui n’est pas
toujours le bloc à l’adresse la plus basse : à -O0, GCC saute souvent en
avant vers une condition placée après le corps. Ensuite, la question « quels
blocs sont dans cette boucle » a une réponse nette, et c’est ainsi que les
décompilateurs retransforment un cycle en while ou en for.
Instructions switch
Un switch se compile en l’une de deux formes, et le compilateur choisit pour
chaque switch :
| Stratégie | Forme du CFG | Quand les compilateurs l’emploient |
|---|---|---|
| Chaîne ou arbre de comparaisons | Une échelle de blocs cmp/jcc, parfois une recherche binaire équilibrée | Peu de cas, valeurs clairsemées, ou pas d’optimisation |
| Table de sauts | Un bloc avec un test de bornes (cmp reg, N / ja default), puis un bloc terminé par un jmp indirect qui se déploie vers de nombreux blocs de cas | Beaucoup de valeurs de cas denses, optimisation activée |
La forme en table de sauts est caractéristique : un nœud unique avec cinq, dix ou
deux cents arcs sortants, qui convergent tous vers un même bloc de jonction (le
break). Un interpréteur de bytecode, un répartiteur de commandes dans une
backdoor et une machine à états ont tous cet aspect. Une fonction aplatie aussi,
comme vous allez le voir.
Bornes des fonctions sur Windows x64
Tout ce qui précède suppose que vous savez où une fonction commence et se termine. Dans un binaire strippé, vous ne le savez pas ; les outils combinent donc plusieurs sources d’indices.
Prologues et épilogues. Une fonction MSVC x64 typique sauvegarde les registres non volatils qu’elle utilise, réserve l’espace de pile en une fois, et défait les deux avant de retourner :
; prologue
mov [rsp+8], rbx ; save a non-volatile register in the home space
push rdi
sub rsp, 20h ; shadow space for callees (+ locals)
; ... body ...
; epilogue
mov rbx, [rsp+30h] ; restore rbx from the home space
add rsp, 20h
pop rdi
retLes prologues à pointeur de cadre (push rbp; mov rbp, rsp) sont courants dans
la sortie de GCC -O0 et dans le code 32 bits, mais facultatifs en x64. Les
fonctions feuilles optimisées peuvent n’avoir aucun prologue. La recherche de
prologues est une heuristique utile, pas une preuve. La page
conventions d’appel indique quels registres
sont volatils, présente l’espace d’ombre (shadow space) de 32 octets, et
explique comment les arguments arrivent dans rcx, rdx, r8 et r9 sous
Windows, contre rdi, rsi, rdx, rcx, r8 et r9 sous Linux ; voir aussi
la pile.
.pdata et informations de déroulement. L’ABI Windows x64 ne parcourt pas
la pile au moyen des pointeurs de cadre. À la place, toute fonction qui touche à
la pile ou appelle quoi que ce soit doit avoir une entrée RUNTIME_FUNCTION dans
le répertoire des exceptions (normalement la section .pdata) : une RVA de
début, une RVA de fin et un pointeur vers une structure UNWIND_INFO dans
.xdata qui décrit le prologue. La table est triée par adresse et doit être
exacte, sinon la gestion des exceptions casse ; c’est donc l’une des listes de
fonctions les plus fiables que vous obtiendrez d’un PE strippé. Seules les
fonctions feuilles (qui n’appellent rien, n’allouent pas d’espace de pile et ne
sauvegardent aucun registre non volatil) peuvent se passer d’entrée.
Deux réserves comptent pour les malwares (logiciels malveillants). Une fonction
peut être découpée en plusieurs morceaux (chunks), avec des entrées
.pdata séparées et chaînées, quand le compilateur éloigne le code froid
(chemins d’erreur) du chemin chaud. Et le code écrit en mémoire à l’exécution,
comme un shellcode injecté ou une charge utile dépaquetée, n’a aucune entrée, à
moins que son auteur n’en ait enregistré une.
ELF n’a pas d’équivalent obligatoire. Sous Linux, .eh_frame liste souvent
les plages des fonctions pour le déroulement, et les symboles aident quand ils
sont présents, mais un ELF strippé rend les outils plus dépendants de la descente
récursive et de la recherche de prologues.
La forme du CFG comme empreinte
Comme les compilateurs produisent des formes très régulières, une forme irrégulière saute aux yeux. Trois motifs méritent d’être reconnus d’un coup d’œil ; un module ultérieur traite la désobfuscation en profondeur :
- Flot de contrôle aplati. L’aplatissement du flot de contrôle
réécrit une fonction de sorte que chaque bloc d’origine revienne à un
répartiteur central qui choisit le bloc suivant d’après une variable d’état. Le
graphe devient un moyeu unique avec des dizaines de rayons, chacun revenant au
moyeu. On dirait un
switchgéant à l’intérieur d’une boucle, et la boucle naturelle couvre presque toute la fonction. - Prédicats opaques. Les prédicats opaques ajoutent des branchements conditionnels dont l’issue est fixe. Le graphe gagne des arcs vers des blocs qui ne s’exécutent jamais, souvent remplis d’octets parasites ou de code mort.
- Code virtualisé. La virtualisation de code remplace une fonction par du bytecode et un interpréteur : le CFG que vous voyez est la boucle lecture-décodage-répartition de l’interpréteur, identique pour chaque fonction protégée.
La forme sert aussi de mesure de similarité. Les outils de diff à base de graphes apparient les fonctions de deux builds en comparant le nombre de blocs, les arcs et les voisins dans le graphe d’appels ; c’est ainsi que les analystes suivent une famille de malwares d’une version à l’autre, même après recompilation.
Les vues en graphe en pratique
- IDA : appuyez sur Espace pour basculer entre le listing linéaire et la vue en graphe. Les arcs sont colorés : vert pour un saut conditionnel pris, rouge pour le chemin en séquence, bleu pour le flot inconditionnel.
- Ghidra : ouvrez Window → Function Graph pour la fonction courante ; Window → Function Call Graph affiche appelants et appelés.
- Cutter (l’interface graphique de Rizin) : l’onglet Graph affiche le CFG de la fonction sous le curseur.
Dans tous ces outils, commencez par la silhouette : combien de sorties, où sont les boucles, y a-t-il un bloc en éventail. Ensuite seulement, lisez les instructions, en commençant par les blocs qui font des appels.
Astuce : Renommez les blocs au fur et à mesure. Étiqueter l’en-tête de boucle, le répartiteur du switch et les sorties d’erreur transforme un graphe anonyme en une carte que vous pourrez parcourir des jours plus tard.
Lab : construire et comparer des CFG
Il vous faut l’environnement virtuel Python du lab précédent (capstone,
pyelftools), plus pefile, un GCC Linux x86-64 (natif ou
x86_64-linux-gnu-gcc), et en option x86_64-w64-mingw32-gcc et Graphviz. La
fonction étudiée est un calculateur de score de chaîne inoffensif, avec une
boucle et un switch.
-
Enregistrez le programme et compilez-le deux fois :
c /* lab2.c - a harmless scoring function with a loop and a switch */ #include <stdio.h> int score(const char *s) { int total = 0; for (int i = 0; s[i] != '\0'; i++) { switch (s[i]) { case 'a': total += 1; break; case 'b': total += 3; break; case 'c': total *= 2; break; case 'd': total -= 4; break; case 'e': total ^= 5; break; case 'f': total <<= 1; break; default: total -= 1; break; } } return total; } int main(int argc, char **argv) { printf("%d\n", score(argc > 1 ? argv[1] : "abcdef")); return 0; }bash x86_64-linux-gnu-gcc -O0 -o lab2_O0 lab2.c x86_64-linux-gnu-gcc -O2 -o lab2_O2 lab2.c ./venv/bin/pip install pefile -
Enregistrez
cfg.py. Il reprend l’idée de liste de travail derecdis.py, mais reste dans une seule fonction, ne suit pas les appels, enregistre les successeurs de chaque branchement, retrouve les tables de sauts simples à entrées de 8 octets, puis découpe le résultat en blocs :python # cfg.py - split one function into basic blocks and emit a Graphviz CFG import struct, sys from capstone import Cs, CS_ARCH_X86, CS_MODE_64, CS_GRP_JUMP, CS_GRP_RET from capstone.x86 import X86_OP_IMM, X86_OP_MEM from elftools.elf.elffile import ELFFile def find_function(elf, name): for sym in elf.get_section_by_name(".symtab").iter_symbols(): if sym.name == name and sym["st_info"]["type"] == "STT_FUNC": return sym["st_value"], sym["st_size"] sys.exit(f"no function {name}") def read_va(elf, va, size): """Read `size` bytes at virtual address `va` from whichever section holds it.""" for sec in elf.iter_sections(): start = sec["sh_addr"] if start and start <= va < start + sec["sh_size"] and sec["sh_type"] != "SHT_NOBITS": return sec.data()[va - start: va - start + size] return None def jump_table(elf, insn, prev, lo, hi): """Recover targets of `jmp [reg*8 + table]`, bounded by a preceding `cmp x, N`.""" op = insn.operands[0] if op.type != X86_OP_MEM or op.mem.scale != 8 or op.mem.base != 0: return [] bound = next((p.operands[1].imm for p in reversed(prev) if p.mnemonic == "cmp" and p.operands[1].type == X86_OP_IMM), None) if bound is None: return [] raw = read_va(elf, op.mem.disp, 8 * (bound + 1)) or b"" targets = [struct.unpack_from("<Q", raw, i)[0] for i in range(0, len(raw), 8)] return [t for t in targets if lo <= t < hi] def recover(elf, start, size): text = elf.get_section_by_name(".text") base, code = text["sh_addr"], text.data() md = Cs(CS_ARCH_X86, CS_MODE_64) md.detail = True lo, hi = start, start + size insns, succs, leaders = {}, {}, {start} todo = [start] while todo: addr = todo.pop() while lo <= addr < hi and addr not in insns: insn = next(md.disasm(code[addr - base:], addr, 1)) insns[addr] = insn nxt = addr + insn.size if insn.group(CS_GRP_RET) or insn.mnemonic == "hlt": succs[addr] = [] break if insn.group(CS_GRP_JUMP): op = insn.operands[0] if op.type == X86_OP_IMM: targets = [op.imm] else: # indirect: try a switch table before = [insns[a] for a in sorted(insns) if a < addr][-4:] targets = jump_table(elf, insn, before, lo, hi) print(f"indirect {insn.mnemonic} {insn.op_str} at {addr:#x}" f" -> {len(set(targets))} table targets", file=sys.stderr) if insn.mnemonic != "jmp": # conditional: also falls through targets = targets + [nxt] succs[addr] = targets leaders.update(targets) todo.extend(targets) break addr = nxt # calls fall through, not followed # split the reached instructions into basic blocks blocks, current = {}, None for a in sorted(insns): if current is None or a in leaders or blocks[current][-1].address in succs: current = a blocks[a] = [] blocks[current].append(insns[a]) edges = [] for b, body in blocks.items(): last = body[-1] if last.address in succs: outs = succs[last.address] else: # block ends because the next one is a leader outs = [last.address + last.size] for t in dict.fromkeys(outs): # de-duplicate, keep order kind = "fall" if t == last.address + last.size and last.mnemonic != "jmp" else "jump" edges.append((b, t, kind)) return blocks, edges def back_edges(blocks, edges, entry): """Edges whose target is still on the DFS stack: the signature of a loop.""" graph = {b: [t for s, t, _ in edges if s == b] for b in blocks} state, found = {}, set() def dfs(n): state[n] = "open" for m in graph.get(n, []): if state.get(m) == "open": found.add((n, m)) elif m not in state: dfs(m) state[n] = "done" dfs(entry) return found def to_dot(name, blocks, edges, back): out = [f'digraph "{name}" {{', ' node [shape=box fontname="monospace" fontsize=10];'] for b, body in blocks.items(): text = "\\l".join(f"{i.address:x}: {i.mnemonic} {i.op_str}" for i in body) out.append(f' b{b:x} [label="{text}\\l"];') for s, t, kind in edges: style = "color=blue" if (s, t) in back else ("style=dashed" if kind == "fall" else "") out.append(f" b{s:x} -> b{t:x} [{style}];") out.append("}") return "\n".join(out) if __name__ == "__main__": elf = ELFFile(open(sys.argv[1], "rb")) start, size = find_function(elf, sys.argv[2]) blocks, edges = recover(elf, start, size) back = back_edges(blocks, edges, start) print(to_dot(sys.argv[2], blocks, edges, back)) print(f"{sys.argv[2]}: {len(blocks)} blocks, {len(edges)} edges, " f"back edges: {', '.join(f'{s:#x}->{t:#x}' for s, t in sorted(back))}", file=sys.stderr)Les règles des leaders du début de la leçon correspondent à l’ensemble
leadersplus le testblocks[current][-1].address in succs, qui démarre un nouveau bloc après tout branchement ouret. Les arcs arrière sont trouvés par un parcours en profondeur : un arc vers un bloc encore en cours d’exploration ferme un cycle. Sur les graphes réductibles comme la sortie d’un compilateur, cela coïncide avec la définition par les dominateurs. -
Construisez le CFG de la version non optimisée :
bash ./venv/bin/python cfg.py lab2_O0 score > score_O0.dottext score: 22 blocks, 32 edges, back edges: 0x40116d->0x401171text digraph "score" { node [shape=box fontname="monospace" fontsize=10]; b4010e7 [label="4010e7: push rbp\l4010e8: mov rbp, rsp\l ... 4010fd: jmp 0x401171\l"]; b4010ff [label="4010ff: mov eax, dword ptr [rbp - 8]\l ... 401112: cmp eax, 0x66\l401115: je 0x401163\l"]; b401117 [label="401117: cmp eax, 0x66\l40111a: jg 0x401168\l"]; b40111c [label="40111c: cmp eax, 0x65\l40111f: je 0x40115d\l"]; ... b4010e7 -> b401171 []; b4010ff -> b401163 []; b4010ff -> b401117 [style=dashed]; b401117 -> b401168 []; ... b40116d -> b401171 [color=blue]; b401171 -> b4010ff []; b401171 -> b401189 [style=dashed]; }À
-O0, GCC a compilé leswitchen chaîne de comparaisons :cmp eax, 0x66/je, puisjgvers le cas par défaut, en descendant jusqu’à0x61. Chaque variable locale vit sur la pile, en[rbp - 4]et[rbp - 8]. Remarquez l’arc arrière : le bloc d’entrée saute en avant vers0x401171, la condition de boucle ; ce bloc est donc l’en-tête de boucle bien que le corps se trouve à des adresses plus basses. -
Construisez le CFG de la version optimisée :
bash ./venv/bin/python cfg.py lab2_O2 score > score_O2.dottext indirect jmp qword ptr [rax*8 + 0x402008] at 0x40113a -> 5 table targets score: 13 blocks, 18 edges, back edges: 0x40114a->0x401130text b401130 [label="401130: sub eax, 0x61\l401133: cmp al, 5\l401135: ja 0x40117d\l"]; b401137 [label="401137: movzx eax, al\l40113a: jmp qword ptr [rax*8 + 0x402008]\l"]; b401148 [label="401148: add edx, edx\l"]; b40114a [label="40114a: movzx eax, byte ptr [rdi]\l40114d: add rdi, 1\l401151: test al, al\l401153: jne 0x401130\l"]; ... b401137 -> b401170 []; b401137 -> b401168 []; b401137 -> b401148 []; b401137 -> b401160 []; b401137 -> b401178 []; b40114a -> b401130 [color=blue];Il y a maintenant une table de sauts.
sub eax, 0x61ramène'a'..'f'à 0..5,cmp al, 5/jaenvoie tout le reste vers le cas par défaut, et lejmpindirect se déploie en éventail. Six entrées de table ne produisent que cinq cibles : GCC a remarqué quetotal *= 2ettotal <<= 1sont la même opération et a fusionné les cas'c'et'f'en un seul blocadd edx, edx. Les variables locales vivent dans des registres, et la boucle est « rotée » : le test en0x40114asaute en arrière vers le répartiteur. -
Générez le rendu des deux graphes et placez-les côte à côte :
bash dot -Tsvg score_O0.dot -o score_O0.svg dot -Tsvg score_O2.dot -o score_O2.svgLes arcs en pointillés sont les chemins en séquence, l’arc bleu est l’arc arrière.
-
Trouvez les bornes des fonctions dans un PE strippé. Compilez le même programme pour Windows, strippez-le et lisez son répertoire des exceptions avec ce
pdata.py:python # pdata.py - list function starts recorded in a PE32+ exception directory import sys import pefile pe = pefile.PE(sys.argv[1]) base = pe.OPTIONAL_HEADER.ImageBase entries = getattr(pe, "DIRECTORY_ENTRY_EXCEPTION", []) print(f"{len(entries)} RUNTIME_FUNCTION entries") for e in entries[:int(sys.argv[2]) if len(sys.argv) > 2 else 10]: s = e.struct print(f" {base + s.BeginAddress:#x} - {base + s.EndAddress:#x}" f" ({s.EndAddress - s.BeginAddress:4} bytes) unwind @ {s.UnwindData:#x}")bash x86_64-w64-mingw32-gcc -O2 -o lab2.exe lab2.c x86_64-w64-mingw32-strip -o lab2_stripped.exe lab2.exe x86_64-w64-mingw32-nm lab2.exe | grep -E " T (score|main)$" ./venv/bin/python pdata.py lab2_stripped.exe 12text 0000000140002ac0 T main 0000000140001490 T score 47 RUNTIME_FUNCTION entries 0x140001000 - 0x140001001 ( 1 bytes) unwind @ 0x6000 0x140001010 - 0x140001017 ( 7 bytes) unwind @ 0x6004 ... 0x140001480 - 0x140001481 ( 1 bytes) unwind @ 0x6068 0x140001490 - 0x14000150d ( 125 bytes) unwind @ 0x606c 0x140001510 - 0x140001552 ( 66 bytes) unwind @ 0x607cLe fichier strippé n’a aucun symbole, et pourtant
.pdataenregistre une fonction exactement à l’adresse que la version non strippée appellescore, avec sa taille. La plupart des 47 entrées appartiennent au runtime C de mingw-w64, pas àlab2.c. -
Facultatif : ouvrez
lab2_O2dans Ghidra (Function Graph) ou Cutter (onglet Graph) et comparez avec votre SVG. Désassemblez ensuitescoredanslab2.exeavecx86_64-w64-mingw32-objdump -d -M intel: la version Windows utilise une table de décalages 32 bits (movsxd rax, dword ptr [r8+rax*4]; add rax, r8; jmp rax), quecfg.pyne sait pas retrouver. Étendrejump_tablepour la gérer est un bon exercice.
Questions : Quelle règle des leaders a créé le bloc d’une seule instruction
en 0x401148 dans le graphe -O2 ? Pourquoi l’en-tête de boucle -O0 est-il à
une adresse plus haute que le corps de la boucle, et comment la définition par
les dominateurs tranche-t-elle quel bloc est l’en-tête ? Que deviendrait le CFG
-O2 si cfg.py ne pouvait pas lire la table de sauts, et en quoi une fonction
aplatie aurait-elle un aspect différent de ce switch légitime dans une boucle ?
Pourquoi .pdata est-il plus fiable que la recherche de prologues pour un PE
compilé normalement, et quand serait-il absent ?
À retenir
- Un bloc de base a une entrée et une sortie ; les leaders sont le début de la
fonction, chaque cible de saut et chaque instruction qui suit un branchement ou
un
ret. - Un CFG relie les blocs par des arcs pris, en séquence et indirects ; un graphe d’appels relie les fonctions. Les sauts et appels indirects laissent des trous dans les deux.
- Les if/else forment des triangles et des losanges, les boucles forment des cycles fermés par des arcs arrière vers un en-tête qui domine le corps, et les switch forment des échelles de comparaisons ou un bloc unique en éventail piloté par une table de sauts.
- Sur Windows x64, les entrées
RUNTIME_FUNCTIONde.pdatadonnent des bornes de fonctions fiables, même dans les fichiers strippés ; la recherche de prologues n’est qu’une heuristique. - L’optimisation remodèle le graphe : moins de blocs, des registres au lieu d’emplacements de pile, des boucles rotées, des tables de sauts et des cas fusionnés.
- Un graphe en moyeu et rayons couvrant toute une fonction, des arcs vers des blocs jamais exécutés, ou des boucles d’interpréteur identiques partout signalent un aplatissement, des prédicats opaques ou une virtualisation.