Skip to content

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èglePourquoi elle commence un bloc
La première instruction de la fonctionPoint d’entrée du graphe
La cible de tout saut, conditionnel ou nonLe contrôle peut arriver ici depuis un autre endroit que l’instruction précédente
L’instruction qui suit immédiatement un saut conditionnelLe 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 rax ou jmp [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

text
      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.

text
   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
                                      └─────┬─────┘
                                            ▼ exit

Une 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égieForme du CFGQuand les compilateurs l’emploient
Chaîne ou arbre de comparaisonsUne échelle de blocs cmp/jcc, parfois une recherche binaire équilibréePeu de cas, valeurs clairsemées, ou pas d’optimisation
Table de sautsUn 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 casBeaucoup 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 :

asm
; 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
ret

Les 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 switch gé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.

  1. 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
  2. Enregistrez cfg.py. Il reprend l’idée de liste de travail de recdis.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 leaders plus le test blocks[current][-1].address in succs, qui démarre un nouveau bloc après tout branchement ou ret. 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.

  3. Construisez le CFG de la version non optimisée :

    bash
    ./venv/bin/python cfg.py lab2_O0 score > score_O0.dot
    text
    score: 22 blocks, 32 edges, back edges: 0x40116d->0x401171
    text
    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é le switch en chaîne de comparaisons : cmp eax, 0x66 / je, puis jg vers 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 vers 0x401171, la condition de boucle ; ce bloc est donc l’en-tête de boucle bien que le corps se trouve à des adresses plus basses.

  4. Construisez le CFG de la version optimisée :

    bash
    ./venv/bin/python cfg.py lab2_O2 score > score_O2.dot
    text
    indirect jmp qword ptr [rax*8 + 0x402008] at 0x40113a -> 5 table targets
    score: 13 blocks, 18 edges, back edges: 0x40114a->0x401130
    text
      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, 0x61 ramène 'a'..'f' à 0..5, cmp al, 5 / ja envoie tout le reste vers le cas par défaut, et le jmp indirect se déploie en éventail. Six entrées de table ne produisent que cinq cibles : GCC a remarqué que total *= 2 et total <<= 1 sont la même opération et a fusionné les cas 'c' et 'f' en un seul bloc add edx, edx. Les variables locales vivent dans des registres, et la boucle est « rotée » : le test en 0x40114a saute en arrière vers le répartiteur.

  5. 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.svg

    Les arcs en pointillés sont les chemins en séquence, l’arc bleu est l’arc arrière.

  6. 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 12
    text
    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 @ 0x607c

    Le fichier strippé n’a aucun symbole, et pourtant .pdata enregistre une fonction exactement à l’adresse que la version non strippée appelle score, avec sa taille. La plupart des 47 entrées appartiennent au runtime C de mingw-w64, pas à lab2.c.

  7. Facultatif : ouvrez lab2_O2 dans Ghidra (Function Graph) ou Cutter (onglet Graph) et comparez avec votre SVG. Désassemblez ensuite score dans lab2.exe avec x86_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), que cfg.py ne sait pas retrouver. Étendre jump_table pour 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_FUNCTION de .pdata donnent 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.