Skip to content

Leçon 4.4 · Désassemblage & analyse de code· 50 min

Les décompilateurs et leurs limites

Comment un décompilateur tire du pseudo-C du code machine, où cette sortie vous induit en erreur, et comment la corriger par types, noms et signatures.

Objectifs

  • Décrire la chaîne de décompilation : remontée vers une IR, SSA, simplification par flot de données, reconstruction des types et structuration du flot de contrôle
  • Reconnaître les façons typiques dont la sortie d’un décompilateur se trompe : types, signatures, conventions d’appel, goto, aliasing et code inliné
  • Améliorer la sortie décompilée dans Ghidra en définissant une structure, en retypant des paramètres et en fixant des signatures de fonctions
  • Expliquer pourquoi des obfuscations comme l’aplatissement, le MBA et la virtualisation mettent en échec les décompilateurs, et quand revenir au désassemblage

Les leçons précédentes de ce module vous ont appris à lire ce que la machine exécute réellement : des instructions retrouvées par désassemblage linéaire ou récursif, regroupées en fonctions et graphes de flot de contrôle, et façonnées par les idiomes du compilateur. Un décompilateur va un cran plus loin et vous fournit quelque chose qui ressemble à du C. C’est le plus gros gain de productivité de l’analyse statique : vous parcourez une fonction de 300 instructions en une minute au lieu de vingt.

C’est aussi l’endroit où il est le plus facile de se tromper soi-même. La sortie d’un décompilateur n’est pas le code source d’origine ; c’est une hypothèse sur ce source, construite à partir d’informations incomplètes. Cette leçon montre comment cette hypothèse est construite, où elle se trompe, et comment vous apportez les faits manquants qui la rendent digne de confiance.

Ce qu’un décompilateur doit reconstruire

La compilation jette de l’information. Le temps qu’un programme C devienne un fichier PE strippé, les éléments suivants ont disparu ou se sont estompés :

Perdu à la compilationCe qui reste dans le binaire
Noms de variables, noms de fonctionsRien (sauf si des symboles, des exports ou la RTTI survivent)
Types, disposition des structTailles et décalages des accès : [rcx+0x24], DWORD PTR
Variables localesRegistres et emplacements de pile, souvent réutilisés pour plusieurs variables
if, while, for, switchSauts conditionnels et inconditionnels, tables de sauts
ExpressionsSuites d’instructions simples, souvent réécrites par l’optimiseur
Bornes des fonctions inlinéesRien : la fonction appelée est fusionnée dans l’appelante

Un décompilateur doit inverser tout cela à partir des instructions et de quelques conventions. Ghidra, Hex-Rays et Binary Ninja suivent tous à peu près la même chaîne de traitement.

La chaîne de décompilation

1. Remontée vers une représentation intermédiaire

Les instructions x86-64 ont des effets de bord : add écrit sa destination et six flags de RFLAGS. La première étape traduit donc chaque instruction en une représentation intermédiaire (IR) petite et explicite, où chaque opération fait une seule chose et où chaque effet de bord est visible.

L’IR de Ghidra s’appelle P-code. Elle compte quelques dizaines d’opérations (COPY, LOAD, STORE, INT_ADD, INT_LEFT, CBRANCH, CALL, CALLIND, …) qui agissent sur des varnodes : un triplet espace d’adressage, décalage et taille. Les registres vivent dans l’espace register, la mémoire dans ram, et les temporaires dans unique. D’autres outils utilisent d’autres IR dans le même but — VEX (issue de Valgrind, utilisée par angr), la pile LLIL/MLIL/HLIL de Binary Ninja, le microcode de Hex-Rays, ou LLVM IR dans des lifters comme McSema et RetDec.

Voici du vrai P-code produit par Ghidra 12.1 pour trois instructions du binaire du lab présenté plus loin dans cette leçon :

text
1400015c0  MOV ECX,EAX
      (register, 0x8, 4) COPY (register, 0x0, 4)
      (register, 0x8, 8) INT_ZEXT (register, 0x8, 4)
1400015c9  ADD EAX,ECX
      (register, 0x200, 1) INT_CARRY (register, 0x0, 4) , (register, 0x8, 4)
      (register, 0x20b, 1) INT_SCARRY (register, 0x0, 4) , (register, 0x8, 4)
      (register, 0x0, 4) INT_ADD (register, 0x0, 4) , (register, 0x8, 4)
      (register, 0x0, 8) INT_ZEXT (register, 0x0, 4)
      (register, 0x207, 1) INT_SLESS (register, 0x0, 4) , (const, 0x0, 4)
      (register, 0x206, 1) INT_EQUAL (register, 0x0, 4) , (const, 0x0, 4)
      (unique, 0x58300, 4) INT_AND (register, 0x0, 4) , (const, 0xff, 4)
      (unique, 0x58400, 1) POPCOUNT (unique, 0x58300, 4)
      (unique, 0x58500, 1) INT_AND (unique, 0x58400, 1) , (const, 0x1, 1)
      (register, 0x202, 1) INT_EQUAL (unique, 0x58500, 1) , (const, 0x0, 1)
1400015cb  MOVZX ECX,byte ptr [RDX + -0x1]
      (unique, 0x8e00, 8) INT_ADD (register, 0x10, 8) , (const, 0xffffffffffffffff, 8)
      (unique, 0x23a00, 1) LOAD (const, 0x1b1, 4) , (unique, 0x8e00, 8)
      (register, 0x8, 4) INT_ZEXT (unique, 0x23a00, 1)
      (register, 0x8, 8) INT_ZEXT (register, 0x8, 4)

Le décalage de registre 0x0 est RAX, 0x8 est RCX, 0x10 est RDX, et les décalages 0x2xx sont des flags individuels. La règle selon laquelle l’écriture d’un registre 32 bits met à zéro la moitié haute devient un INT_ZEXT explicite, et un seul add produit dix opérations, dont huit pour les flags. Le shl ecx,5 de la même boucle se traduit en 42 opérations, dont quatre seulement calculent le résultat. L’IR est verbeuse à dessein : rien n’est caché aux passes suivantes.

2. Forme SSA et simplification par flot de données

L’IR est ensuite convertie en forme SSA (static single assignment, affectation unique statique) : chaque variable est affectée exactement une fois, et là où le flot de contrôle se rejoint, un nœud spécial φ (« phi ») choisit quelle version arrive. La SSA rend triviale la question « d’où vient cette valeur ? », ce qui alimente une série de simplifications :

  • Élimination du code mort. Les flags qu’aucune instruction ultérieure ne lit sont simplement supprimés. Presque toutes les 42 opérations du shl disparaissent.
  • Propagation des copies et des constantes. ecx = eax; ecx <<= 5; eax += ecx devient eax = eax + (eax << 5).
  • Repli d’expressions et règles algébriques. x + (x << 5) est reconnu comme x * 33, que Ghidra affiche * 0x21. L’optimiseur du compilateur est allé dans un sens ; le décompilateur refait le chemin inverse.
  • Reconstruction des conditions. Un cmp et le saut conditionnel qui lit ses flags sont fusionnés en une seule comparaison comme if (x < 0x80).

3. Reconstruction des variables et des types

Les valeurs SSA sont ensuite regroupées en variables : les emplacements de pile deviennent des variables locales, et les registres imposés par la convention d’appel deviennent des paramètres. Les types sont déduits de l’usage : une valeur déréférencée comme un octet est probablement un char *, une valeur passée à une API connue hérite du type de paramètre de cette API, des comparaisons signées suggèrent un int. Faute d’indices, Ghidra se rabat sur undefined4 ou undefined8 — « quatre (ou huit) octets, sens inconnu ».

4. Structuration du flot de contrôle

Enfin, le CFG est retransformé en instructions structurées. Le structureur cherche des motifs qu’il connaît — un bloc à deux successeurs qui se rejoignent est un if/else, un arc arrière vers un en-tête dominant est une boucle, une table de sauts est un switch — et les réduit un à un. Quand le graphe ne peut pas être ramené à ces motifs, parce qu’une boucle a deux entrées ou que deux boucles partagent une sortie, le structureur n’a d’autre choix que d’émettre un goto et une étiquette. C’est de là que vient la « soupe de goto ».

Astuce : La plupart des décompilateurs vous laissent voir les étapes intermédiaires. Dans Ghidra, le menu déroulant de la fenêtre Decompiler propose Graph Control Flow et Graph Data Flow ; Binary Ninja vous permet de basculer la vue principale entre désassemblage, LLIL, MLIL et HLIL. Regarder un niveau plus bas est souvent le moyen le plus rapide de comprendre pourquoi le pseudo-code a l’air étrange.

Là où la sortie du décompilateur induit en erreur

Connaître la chaîne de traitement vous dit par où entrent les erreurs. Le tableau liste celles que vous rencontrerez le plus souvent dans les malwares (logiciels malveillants).

SymptômeCauseQue faire
undefined8, undefined1 (*)[16], des casts partoutLa reconstruction des types n’avait aucun indice, ou un indice trompeur comme un memset vectoriséDéfinir le vrai type et retyper la variable
Appel avec trop peu ou trop d’argumentsSignature de la fonction appelée inconnue ; le décompilateur a devinéFixer la signature de la fonction appelée, y compris le caractère variadique
Des arguments apparaissent comme in_R9, in_stack_…, extraout_RAXValeur utilisée sans définition visible : mauvaise convention d’appel, ou registre préservé de façon inhabituelleCorriger la convention, ou indiquer correctement les registres écrasés ou préservés par la fonction appelée
goto LAB_… dans une fonction simpleCFG irréductible, appel noreturn non détecté, ou return anticipé dans une boucleLire le CFG dans le listing ; marquer les fonctions noreturn
Du code après un gestionnaire d’erreur semble atteignableUne fonction comme exit ou un abort maison n’était pas marquée noreturn, et le code en séquence a été fusionnéÉditer la fonction et cocher No Return
Deux valeurs sans rapport partagent un même nom de variableRéutilisation d’un emplacement de pile ou d’un registre fusionnée par la passe de reconstruction des variablesScinder la variable, ou renommer avec prudence et vérifier chaque usage
Comparaisons à des constantes comme x == 0x656d616estrcmp/memcmp inliné contre un littéral courtDécoder la constante comme de l’ASCII little-endian
Une fonction géante qui « fait tout »Inlining agressif par le compilateurLa découper mentalement ; commenter les régions inlinées

Certaines de ces erreurs changent le sens du code, pas seulement sa lisibilité.

Les mauvaises signatures font disparaître des arguments sans bruit. Sous Windows x64, les quatre premiers arguments entiers passent dans rcx, rdx, r8 et r9, le reste sur la pile. Si le décompilateur ne sait pas qu’une fonction appelée est variadique, il peut ne garder que les arguments passés en registre : dans le lab, un appel à printf montre quatre arguments là où le source en passait sept. L’erreur inverse produit des arguments fantômes comme in_R9 à partir de valeurs résiduelles dans les registres. Dans le code 32 bits, où cdecl, stdcall, fastcall et thiscall cohabitent (voir conventions d’appel x86-32), une méthode thiscall traitée comme cdecl perd son pointeur this, et chaque accès à un champ devient un in_ECX + 0x14 inexpliqué.

noreturn est contagieux. Si une fonction ne retourne jamais mais n’est pas marquée comme telle, le décompilateur fusionne dans la fonction courante les octets qui suivent l’appel — souvent le début de la fonction suivante. Les routines maison de type « nettoyer et quitter » des malwares déclenchent cela en permanence.

Aliasing de la pile. Quand du code prend l’adresse d’une variable locale et la fait circuler, le décompilateur ne peut pas savoir avec certitude quelles écritures touchent quelles variables. Il peut découper un tampon unique en scalaires sans rapport comme local_b8 et uStack_b4.

Attention : Ne citez jamais la sortie d’un décompilateur dans un rapport comme « le code du malware ». Écrivez « décompilé comme » et, pour tout ce qui compte dans une conclusion — une clé, un identifiant de commande, un port réseau —, confirmez-le dans le désassemblage ou dans un débogueur.

Réparer la sortie

Le décompilateur est interactif : chaque fait que vous lui fournissez se propage. Dans l’ordre qui rapporte généralement le plus :

  1. Appliquez les types des bibliothèques. Ghidra fournit des archives de types de données (.gdt) contenant les prototypes et structures de l’API Windows, et les applique automatiquement aux fichiers PE — c’est pourquoi les appels importés affichent HMODULE et FARPROC. L’équivalent dans IDA, ce sont les bibliothèques de types (.til). Pour les bibliothèques liées statiquement, Function ID de Ghidra et les signatures FLIRT d’IDA peuvent nommer les fonctions pour vous.
  2. Fixez les signatures de fonctions. Un seul prototype correct répare tous les sites d’appel.
  3. Définissez des structures. Un pointeur accédé à plusieurs décalages constants (+0x20, +0x24, +0x28) est presque toujours un pointeur de struct. Créez-en une, même avec des noms de champs devinés comme field_20, et affinez-la plus tard.
  4. Retypez les variables et les paramètres, pour que *(int *)(param_1 + 0x24) devienne cfg->retries.
  5. Renommez tout ce que vous comprenez. Les noms sont une documentation pour votre futur vous et pour vos collègues.
  6. Corrigez noreturn, les conventions d’appel et les variables fusionnées comme l’indique le tableau.

La règle empirique : corriger les fonctions appelées avant les appelantes, et les types avant les noms. Un bon type change la forme de la sortie ; un nom ne change qu’une étiquette.

Quand revenir au désassemblage

Revenez au listing quand un résultat sera cité dans un rapport ou transformé en règle de détection ; quand la sortie contient des goto, des variables in_ ou des casts que vous ne savez pas expliquer ; quand la fonction utilise rdtsc, cpuid, du SIMD ou des astuces de pile inhabituelles (le décompilateur peut émettre un intrinsèque opaque, ou rien du tout) ; quand le code est obfusqué ; et quand le signe exact compte — jl contre jb décide si -1 passe un contrôle de longueur.

Comment l’obfuscation casse les décompilateurs

Chaque étape de la chaîne repose sur une hypothèse qu’un obfuscateur peut attaquer :

  • L’aplatissement du flot de contrôle remplace le CFG naturel par une boucle de répartition unique et un switch sur une variable d’état. Vous obtenez un énorme switch dans un while(true), dont l’ordre réel se cache dans les affectations de l’état.
  • Les prédicats opaques ajoutent des branchements dont l’issue est fixe mais difficile à prouver. À moins que la propagation des constantes ne puisse les trancher, le décompilateur garde les deux côtés, y compris du code parasite qui ne s’exécute jamais (voir aussi l’insertion de code mort).
  • L’arithmétique booléenne mixte réécrit x + y en expressions imbriquées comme (x ^ y) + 2 * (x & y) que les règles de simplification ne reconnaissent pas ; la substitution d’instructions est la même idée à plus petite échelle.
  • La virtualisation de code traduit le code en bytecode pour une machine virtuelle sur mesure. Le décompilateur montre la boucle de répartition de l’interpréteur ; la vraie logique, ce sont des données.

Les techniques d’anti-désassemblage qui cassent la reconstruction des instructions elles-mêmes (instructions chevauchantes, sauts au milieu d’une instruction) sont traitées dans la future leçon Anti-désassemblage du module 9. Face à toutes ces techniques, la réponse consiste généralement à changer de niveau : travailler dans le listing, scripter une passe de désobfuscation sur l’IR, ou observer le code dynamiquement.

Outils

Ghidra (NSA, open source) décompile toutes les architectures qu’il prend en charge et se scripte en Java et en Python, avec un mode headless pour le traitement par lots ; le lab l’utilise. IDA Pro avec Hex-Rays (commercial) est la référence historique pour x86 et ARM — dans les tutoriels, vous verrez N pour renommer, Y pour fixer un type et X pour les références croisées. Binary Ninja (commercial, avec une offre gratuite) expose directement ses IL en couches, ce qui le rend agréable pour scripter des analyses.

Pourquoi .NET se décompile si bien

Un assembly .NET contient du bytecode CIL plus des métadonnées : chaque type, chaque signature de méthode et chaque champ est stocké dans le fichier, car le runtime en a besoin pour la vérification, la réflexion et la compilation JIT. Le CIL repose sur une pile et n’est presque pas optimisé avant d’être enregistré. dnSpyEx et ILSpy produisent donc un C# proche de l’original, jusqu’aux noms de classes — sauf si un obfuscateur a tout renommé. Les échantillons .NET ont leur propre leçon dans le module 10.

Lab : améliorer un parseur de configuration décompilé

Vous allez compiler un parseur de configuration inoffensif, le stripper, puis réparer la sortie de Ghidra pas à pas. Le pseudo-code montré est la sortie réelle de Ghidra 12.1.4 sur un build MinGW-w64 GCC 15.2 ; d’autres versions diffèrent dans les détails.

  1. Enregistrez ceci sous config.c :

    c
    /* config.c: parse "key=value" lines into a struct and checksum it */
    #include <stdio.h>
    #include <string.h>
    #include <stdlib.h>
    #include <stddef.h>
    
    struct config {
        char     name[32];
        int      port;
        int      retries;
        unsigned flags;
        unsigned checksum;
    };
    
    static unsigned checksum(const unsigned char *p, size_t n) {
        unsigned h = 0x1505;
        for (size_t i = 0; i < n; i++)
            h = h * 33 + p[i];
        return h;
    }
    
    __attribute__((noinline))
    int parse_config(struct config *cfg, const char *text) {
        char line[128];
        int count = 0;
        memset(cfg, 0, sizeof(*cfg));
        while (*text) {
            size_t len = strcspn(text, "\n");
            if (len >= sizeof(line)) return -1;
            memcpy(line, text, len);
            line[len] = 0;
            text += len + (text[len] == '\n');
            char *eq = strchr(line, '=');
            if (!eq) continue;
            *eq = 0;
            const char *val = eq + 1;
            if (strcmp(line, "name") == 0)         strncpy(cfg->name, val, sizeof(cfg->name) - 1);
            else if (strcmp(line, "port") == 0)    cfg->port = atoi(val);
            else if (strcmp(line, "retries") == 0) cfg->retries = atoi(val);
            else if (strcmp(line, "verbose") == 0) cfg->flags |= 1;
            else continue;
            count++;
        }
        cfg->checksum = checksum((const unsigned char *)cfg, offsetof(struct config, checksum));
        return count;
    }
    
    int main(void) {
        static const char text[] = "name=lab-demo\nport=8080\nretries=3\nverbose=1\n";
        struct config cfg;
        int n = parse_config(&cfg, text);
        printf("%d keys: name=%s port=%d retries=%d flags=%u checksum=%08x\n",
               n, cfg.name, cfg.port, cfg.retries, cfg.flags, cfg.checksum);
        return 0;
    }
  2. Compilez-le optimisé et strippé, puis exécutez-le (sous Windows ou sous Wine) :

    bash
    x86_64-w64-mingw32-gcc -O2 -s -o config.exe config.c
    text
    4 keys: name=lab-demo port=8080 retries=3 flags=1 checksum=d97eebd9
  3. Dans Ghidra, choisissez File > New Project (non partagé), puis File > Import File et sélectionnez config.exe ; acceptez le format PE détecté. Double-cliquez sur le fichier pour l’ouvrir dans le CodeBrowser et répondez Yes à « analyze now », avec les analyseurs par défaut.

  4. Trouvez main sans symboles : Search > For Strings, filtrez sur name=, double-cliquez sur le résultat, puis double-cliquez sur l’annotation XREF de la chaîne dans le Listing. La fenêtre Decompiler affiche :

    c
    undefined8 FUN_140002c10(void)
    
    {
      uint uVar1;
      undefined1 local_38 [32];
      uint local_18;
    
      FUN_140001710();
      uVar1 = FUN_1400014a0((undefined1 (*) [16])local_38,
                            "name=lab-demo\nport=8080\nretries=3\nverbose=1\n");
      FUN_140002920("%d keys: name=%s port=%d retries=%d flags=%u checksum=%08x\n",(ulonglong)uVar1,
                    local_38,(ulonglong)local_18);
      return 0;
    }

    La chaîne de format a six conversions, mais seules trois valeurs la suivent : Ghidra ne sait pas que FUN_140002920 (le wrapper printf de MinGW) est variadique. La struct est devenue un tableau de 32 octets plus un uint isolé.

  5. Double-cliquez sur FUN_1400014a0 pour ouvrir le parseur. Les premières lignes sont :

    c
    int FUN_1400014a0(undefined1 (*param_1) [16],char *param_2)
    
    {
      ...
      *param_1 = (undefined1  [16])0x0;
      param_1[1] = (undefined1  [16])0x0;
      param_1[2] = (undefined1  [16])0x0;

    et les comparaisons de clés ressemblent à ceci :

    c
            if ((local_b8 == 0x656d616e) && ((char)uStack_b4 == '\0')) {
              strncpy((char *)param_1,pcVar4,0x1f);
            }
            else if ((local_b8 == 0x74726f70) && ((char)uStack_b4 == '\0')) {
              iVar3 = atoi(pcVar4);
              *(int *)param_1[2] = iVar3;
            }
            else if (CONCAT44(uStack_b4,local_b8) == 0x73656972746572) {
              iVar3 = atoi(pcVar4);
              *(int *)(param_1[2] + 4) = iVar3;
            }

    Le type de paramètre undefined1 (*)[16] (« pointeur vers des tableaux de 16 octets ») vient du memset inliné, que GCC a émis sous forme de trois écritures SSE :

    text
    pxor   xmm0,xmm0
    movups XMMWORD PTR [rcx],xmm0
    movups XMMWORD PTR [rcx+0x10],xmm0
    movups XMMWORD PTR [rcx+0x20],xmm0

    La reconstruction des types a pris cette taille d’accès comme type d’élément. Les appels à strcmp ont disparu eux aussi : GCC les a inlinés en comparaisons d’entiers. 0x656d616e lu comme des octets little-endian donne name ; 0x73656972746572 donne retries suivi d’un NUL. Ces accès de 4 et 8 octets découpent aussi le tampon de ligne en local_b8 et uStack_b4.

  6. Définissez la structure. Ouvrez Window > Data Type Manager, faites un clic droit sur l’archive config.exe et choisissez New > Structure. Nommez-la config et ajoutez les champs : char[32] name, int port, int retries, uint flags, uint checksum (taille 0x30) ; les décalages +0x20 à +0x2c du pseudo-code sont vos indices. File > Parse C Source fonctionne aussi.

  7. Corrigez la signature du parseur. Dans la fenêtre Decompiler, faites un clic droit sur le nom de fonction FUN_1400014a0 et choisissez Edit Function Signature. Saisissez int parse_config(config *cfg, char *text) et validez. (Pour une seule variable, vous utiliseriez Retype Variable, Ctrl+L, et Rename Variable, L.) Le corps se lit maintenant :

    c
            if ((local_b8 == 0x656d616e) && ((char)uStack_b4 == '\0')) {
              strncpy(cfg->name,pcVar4,0x1f);
            }
            else if ((local_b8 == 0x74726f70) && ((char)uStack_b4 == '\0')) {
              iVar3 = atoi(pcVar4);
              cfg->port = iVar3;
            }
            else if (CONCAT44(uStack_b4,local_b8) == 0x73656972746572) {
              iVar3 = atoi(pcVar4);
              cfg->retries = iVar3;
            }
            else {
              if (CONCAT44(uStack_b4,local_b8) != 0x65736f62726576) break;
              cfg->flags = cfg->flags | 1;
            }

    et la boucle de checksum devient :

    c
      uVar2 = 0x1505;
      pcVar5 = cfg;
      do {
        pcVar4 = pcVar5->name;
        uVar2 = uVar2 * 0x21 + (uint)(byte)pcVar5->name[0];
        pcVar5 = (config *)(pcVar4 + 1);
      } while ((config *)(pcVar4 + 1) != (config *)&cfg->checksum);
      cfg->checksum = uVar2;

    Le * 0x21 est le shl ecx,5 plus les add, replié en multiplication par 33 : un hachage de type djb2 sur chaque octet qui précède checksum. Les casts sont maladroits parce qu’un config * est avancé d’un octet à la fois.

    Une chose a empiré : les trois écritures de mise à zéro apparaissent maintenant sous forme de 36 affectations séparées (de cfg->name[0] = '\0'; à cfg->checksum = 0;), découpées selon les nouveaux champs. Reconnaissez le memset inliné et ajoutez un commentaire (clic droit, Comments) au lieu de le lire ligne par ligne.

  8. Corrigez la fonction appelée qui cassait main. Revenez à FUN_140002c10, faites un clic droit sur FUN_140002920, choisissez Edit Function Signature et saisissez int printf(char *fmt, ...). Renommez FUN_140002c10 en main (cliquez sur le nom, appuyez sur L). L’appel est réparé :

    c
      uVar1 = parse_config(&local_38,"name=lab-demo\nport=8080\nretries=3\nverbose=1\n");
      printf("%d keys: name=%s port=%d retries=%d flags=%u checksum=%08x\n",(ulonglong)uVar1,&local_38,
             (ulonglong)(uint)local_38.port,local_38.retries,local_38.flags,local_38.checksum);

    La variable locale est maintenant un config, et les six valeurs apparaissent. Une seule signature a réparé une autre fonction — c’est pourquoi les fonctions appelées passent en premier.

  9. Terminez le travail vous-même : renommez local_b8 en line, retypez-la en char[128] (le test 0x7f < _Size vous donne la taille), renommez pcVar4 en val et iVar6 en count. Comparez le résultat avec le source.

Questions : Parmi les types du premier passage du décompilateur, lesquels venaient d’indices réels et lesquels d’une optimisation du compilateur ? Pourquoi la correction de la signature de printf a-t-elle changé le type d’une variable locale de main ? Si c’était un malware et que la struct contenait l’hôte et le port d’un C2, quelles valeurs confirmeriez-vous dans le désassemblage ou dans un débogueur avant de les mettre dans un rapport, et pourquoi ?

À retenir

  • Un décompilateur remonte les instructions vers une IR (P-code, VEX, microcode, LLIL), la simplifie en forme SSA, reconstruit variables et types, et restructure le CFG en if/while/switch — en se rabattant sur goto quand il n’y parvient pas.
  • Sa sortie est une hypothèse. Types, signatures, conventions d’appel, attributs noreturn et bornes des variables sont les points de défaillance habituels, et certains changent le sens sans bruit (arguments perdus).
  • Réparez la sortie en appliquant les types des bibliothèques, en fixant les signatures des fonctions appelées, en définissant des structs, en retypant et en renommant — à peu près dans cet ordre, les appelées avant les appelantes.
  • L’obfuscation vise les hypothèses de la chaîne : l’aplatissement et la virtualisation cachent le flot de contrôle, le MBA et la substitution mettent en échec la simplification, les prédicats opaques maintiennent en vie le code mort.
  • Le code managé (.NET) se décompile bien mieux parce que le fichier conserve ses métadonnées de types ; pour le code natif, c’est à vous de les fournir.
  • Tout ce qui alimente une conclusion ou une détection doit être confirmé dans le désassemblage ou dynamiquement.