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 compilation | Ce qui reste dans le binaire |
|---|---|
| Noms de variables, noms de fonctions | Rien (sauf si des symboles, des exports ou la RTTI survivent) |
Types, disposition des struct | Tailles et décalages des accès : [rcx+0x24], DWORD PTR |
| Variables locales | Registres et emplacements de pile, souvent réutilisés pour plusieurs variables |
if, while, for, switch | Sauts conditionnels et inconditionnels, tables de sauts |
| Expressions | Suites d’instructions simples, souvent réécrites par l’optimiseur |
| Bornes des fonctions inlinées | Rien : 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 :
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
shldisparaissent. - Propagation des copies et des constantes.
ecx = eax; ecx <<= 5; eax += ecxdevienteax = eax + (eax << 5). - Repli d’expressions et règles algébriques.
x + (x << 5)est reconnu commex * 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
cmpet le saut conditionnel qui lit ses flags sont fusionnés en une seule comparaison commeif (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ôme | Cause | Que faire |
|---|---|---|
undefined8, undefined1 (*)[16], des casts partout | La 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’arguments | Signature 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_RAX | Valeur utilisée sans définition visible : mauvaise convention d’appel, ou registre préservé de façon inhabituelle | Corriger la convention, ou indiquer correctement les registres écrasés ou préservés par la fonction appelée |
goto LAB_… dans une fonction simple | CFG irréductible, appel noreturn non détecté, ou return anticipé dans une boucle | Lire le CFG dans le listing ; marquer les fonctions noreturn |
| Du code après un gestionnaire d’erreur semble atteignable | Une 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 variable | Réutilisation d’un emplacement de pile ou d’un registre fusionnée par la passe de reconstruction des variables | Scinder la variable, ou renommer avec prudence et vérifier chaque usage |
Comparaisons à des constantes comme x == 0x656d616e | strcmp/memcmp inliné contre un littéral court | Décoder la constante comme de l’ASCII little-endian |
| Une fonction géante qui « fait tout » | Inlining agressif par le compilateur | La 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 :
- 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 affichentHMODULEetFARPROC. 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. - Fixez les signatures de fonctions. Un seul prototype correct répare tous les sites d’appel.
- Définissez des structures. Un pointeur accédé à plusieurs décalages
constants (
+0x20,+0x24,+0x28) est presque toujours un pointeur destruct. Créez-en une, même avec des noms de champs devinés commefield_20, et affinez-la plus tard. - Retypez les variables et les paramètres, pour que
*(int *)(param_1 + 0x24)deviennecfg->retries. - Renommez tout ce que vous comprenez. Les noms sont une documentation pour votre futur vous et pour vos collègues.
- 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
switchsur une variable d’état. Vous obtenez un énormeswitchdans unwhile(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 + yen 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.
-
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; } -
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.ctext 4 keys: name=lab-demo port=8080 retries=3 flags=1 checksum=d97eebd9 -
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. -
Trouvez
mainsans symboles : Search > For Strings, filtrez surname=, double-cliquez sur le résultat, puis double-cliquez sur l’annotationXREFde 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 wrapperprintfde MinGW) est variadique. Lastructest devenue un tableau de 32 octets plus unuintisolé. -
Double-cliquez sur
FUN_1400014a0pour 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 dumemsetinliné, 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],xmm0La reconstruction des types a pris cette taille d’accès comme type d’élément. Les appels à
strcmpont disparu eux aussi : GCC les a inlinés en comparaisons d’entiers.0x656d616elu comme des octets little-endian donnename;0x73656972746572donneretriessuivi d’un NUL. Ces accès de 4 et 8 octets découpent aussi le tampon de ligne enlocal_b8etuStack_b4. -
Définissez la structure. Ouvrez Window > Data Type Manager, faites un clic droit sur l’archive
config.exeet choisissez New > Structure. Nommez-laconfiget ajoutez les champs :char[32] name,int port,int retries,uint flags,uint checksum(taille 0x30) ; les décalages+0x20à+0x2cdu pseudo-code sont vos indices. File > Parse C Source fonctionne aussi. -
Corrigez la signature du parseur. Dans la fenêtre Decompiler, faites un clic droit sur le nom de fonction
FUN_1400014a0et choisissez Edit Function Signature. Saisissezint 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
* 0x21est leshl ecx,5plus lesadd, replié en multiplication par 33 : un hachage de type djb2 sur chaque octet qui précèdechecksum. Les casts sont maladroits parce qu’unconfig *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 lememsetinliné et ajoutez un commentaire (clic droit, Comments) au lieu de le lire ligne par ligne. -
Corrigez la fonction appelée qui cassait
main. Revenez àFUN_140002c10, faites un clic droit surFUN_140002920, choisissez Edit Function Signature et saisissezint printf(char *fmt, ...). RenommezFUN_140002c10enmain(cliquez sur le nom, appuyez surL). 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. -
Terminez le travail vous-même : renommez
local_b8enline, retypez-la enchar[128](le test0x7f < _Sizevous donne la taille), renommezpcVar4envaletiVar6encount. 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 surgotoquand il n’y parvient pas. - Sa sortie est une hypothèse. Types, signatures, conventions d’appel, attributs
noreturnet 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.