Leçon 1.3 · Fondations· 30 min
Du code source au binaire
Suivez un programme C à travers prétraitement, compilation, assemblage et édition de liens : ce que symboles, relocations et infos de débogage laissent.
Objectifs
- Nommer les quatre étapes de la compilation d’un programme C et produire chaque fichier intermédiaire avec GCC et MSVC
- Expliquer ce que sont les fichiers objets, les symboles et les relocations, et ce qu’en fait l’éditeur de liens
- Distinguer édition de liens statique et dynamique, informations de débogage et symboles, et prévoir ce que le stripping supprime
- Exploiter les artefacts de compilation, comme les chemins PDB et le code de bibliothèque, pour accélérer l’analyse de malwares
Chaque échantillon que vous analysez a été, à un moment donné, du code source sur la machine d’un attaquant. Un compilateur et un éditeur de liens l’ont transformé en binaire posé sur votre bureau, et chaque étape a à la fois détruit des informations que vous aimeriez avoir et ajouté des informations que l’auteur n’a jamais voulu partager. Connaître cette chaîne vous dit à quoi vous attendre dans un binaire, ce qui est perdu pour de bon, et où se cachent les restes.
La chaîne en un coup d’œil
Compiler un programme C, c’est en réalité exécuter quatre outils à la suite. Les
pilotes de compilation comme gcc et cl le masquent en les lançant tous pour
vous, mais chaque étape peut être interrompue et inspectée :
beacon.c ──► [preprocessor] ──► beacon.i ──► [compiler] ──► beacon.s
(source) #include, (pure C) optimise, (assembly
#define, #if lower to asm text)
│
▼
beacon ◄── [linker] ◄── beacon.o + other .o + libraries ◄── [assembler]
(.exe/ELF) resolve symbols, (object files: machine code
apply relocations, with holes still unfilled)
lay out sections| Étape | Entrée → sortie | GCC | MSVC |
|---|---|---|---|
| Prétraitement | .c → .i | gcc -E | cl /P |
| Compilation | .i → .s / .asm | gcc -S | cl /FA (listing) |
| Assemblage | .s → .o / .obj | gcc -c (ou as) | cl /c (ou ml64) |
| Édition de liens | .o + bibliothèques → exécutable | gcc (lance ld) | link.exe |
MSVC ne passe pas vraiment par un fichier d’assembleur textuel — /FA écrit un
listing à côté du fichier objet qu’il produit directement —, mais les étapes
conceptuelles sont les mêmes.
Étape 1 : prétraitement
Le préprocesseur est un transformateur de texte. Il colle le contenu de chaque
en-tête inclus par #include, développe les macros #define et évalue les blocs
#if/#ifdef. Sa sortie est toujours du C, simplement énorme : un programme de
dix lignes qui inclut <windows.h> donne après prétraitement des centaines de
milliers de lignes.
Pour l’analyste, la conséquence importante est que les macros et la compilation
conditionnelle disparaissent complètement. Un générateur de malware (builder)
qui active ou désactive des fonctionnalités avec #ifdef ENABLE_KEYLOGGER produit
des binaires dans lesquels les fonctionnalités désactivées n’existent tout
simplement pas — ni sous forme de code mort, ni sous forme de chaînes, pas du
tout. Deux échantillons d’une même famille peuvent donc différer sensiblement sans
qu’aucun des deux ne soit obfusqué.
Étape 2 : compilation
Le compilateur à proprement parler analyse le C prétraité, vérifie les types,
l’optimise et émet de l’assembleur pour l’architecture cible. C’est là que se
produit l’essentiel de la perte d’information décrite dans
Qu’est-ce que l’analyse de malwares ? : les
noms de variables locales deviennent des offsets dans la pile ou des registres,
les boucles for deviennent des séquences de comparaisons et de sauts, et les
petites fonctions peuvent être inlinées, au point de ne plus exister du tout en
tant que fonctions distinctes.
Le niveau d’optimisation compte énormément. Une même fonction compilée en -O0 et
en -O2 (ou /Od et /O2 sous MSVC) peut ressembler à deux morceaux de code
sans rapport. Le code non optimisé garde chaque variable en mémoire et se
rapproche facilement du source ; le code optimisé est plus court, garde les
valeurs dans des registres et réordonne le travail. La plupart des malwares
compilés en mode release sont optimisés.
La sortie assembleur contient encore des noms — des labels pour les fonctions, les variables globales, et des labels générés par le compilateur pour les chaînes littérales — parce que l’étape suivante en a besoin.
Étape 3 : assemblage et fichiers objets
L’assembleur traduit le texte assembleur en code machine et l’empaquette sous forme
de fichier objet : COFF .obj sous Windows, ELF relocatable .o sous Linux.
Un fichier objet possède déjà des sections (.text pour le code, .data et
.rodata / .rdata pour les données), mais il n’est pas exécutable, pour une
raison : il a des trous.
Lorsque beacon.c appelle puts, l’assembleur n’a aucune idée de l’endroit où
puts se trouvera — la fonction est dans un tout autre fichier. Même les
références aux propres chaînes du fichier ne peuvent pas être finalisées, car
l’éditeur de liens n’a pas encore décidé où placer chaque section. L’assembleur
écrit donc des octets de remplissage (souvent des zéros) et consigne une note pour
chaque trou :
- Une table des symboles qui liste chaque nom que le fichier définit (comme
main) et chaque nom dont il a besoin sans le définir (commeputs). - Une table de relocations : « à l’offset X de
.text, insérer l’adresse du symbole Y, calculée de la manière Z. » La « manière » est un type de relocation commeR_X86_64_PLT32en ELF ouIMAGE_REL_AMD64_REL32en COFF — qui signifient tous deux, en gros, « un déplacement 32 bits relatif à l’instruction suivante ».
object file .text, before linking (illustrative offsets)
offset bytes instruction relocation record
────── ───────────────────── ─────────────────── ──────────────────────
0x0c 48 8d 05 00 00 00 00 lea rax,[rip+????] → address of string in .rodata
0x16 e8 00 00 00 00 call ???? → address of puts
▲
└── zero placeholders the linker will overwriteUne relocation est donc une promesse : « quelqu’un complétera ceci plus tard. » Vous retrouverez les relocations dans le programme chargé, où le loader Windows applique des relocations de base lorsqu’une image ne peut pas être placée à son adresse préférée — voir Comment un binaire est chargé et exécuté.
Étape 4 : édition de liens
L’éditeur de liens prend tous les fichiers objets ainsi que les bibliothèques et produit un unique exécutable. Il accomplit trois tâches principales :
- Résolution des symboles — associer chaque symbole indéfini à exactement une définition. Une définition manquante produit l’erreur bien connue « unresolved external symbol » (MSVC) ou « undefined reference » (GNU ld).
- Disposition — fusionner les sections de même nom de toutes les entrées
(tous les
.texten un seul.text, et ainsi de suite) et attribuer à chacune une adresse virtuelle définitive. - Relocation — maintenant que les adresses sont connues, combler chaque trou laissé par les assembleurs.
Édition de liens statique ou dynamique
Les fonctions de bibliothèque peuvent parvenir au binaire final de deux façons :
| Édition de liens statique | Édition de liens dynamique | |
|---|---|---|
| Code de la bibliothèque | Copié dans l’exécutable | Reste dans une DLL / un .so séparé |
| Taille du binaire | Plus grande | Plus petite |
| Comment les appels atteignent la bibliothèque | Appels directs au code embarqué | Via la table des imports : IAT sous Windows, GOT/PLT sous Linux |
| Ce que voit l’analyste | Des milliers de fonctions de bibliothèque anonymes | Des imports nommés comme CreateFileW |
Avec l’édition de liens dynamique, l’éditeur de liens ne peut pas inscrire
l’adresse définitive de CreateFileW, car kernel32.dll est chargée à
l’exécution. Il écrit à la place une table des imports qui nomme la DLL et la
fonction, ainsi qu’une table d’emplacements de pointeurs — l’IAT
sous Windows, la GOT sous Linux — que le loader remplit au
démarrage du programme. Ces noms survivent même dans un binaire strippé, et c’est
pourquoi les imports font partie des premières choses que vous examinez pendant le
triage.
L’édition de liens statique est courante dans les malwares pour des raisons pratiques : le binaire s’exécute sans dépendances. Les binaires Go et Rust lient leur runtime statiquement par défaut, et les malwares IoT Linux sont généralement liés statiquement pour s’exécuter sur n’importe quel appareil. Le coût pour l’analyste, c’est le bruit : un binaire strippé et lié statiquement présente des milliers de fonctions sans nom, dont l’auteur n’a peut-être écrit qu’une centaine.
Symboles et informations de débogage
Deux types de métadonnées sont faciles à confondre :
- Les symboles associent des noms à des adresses : «
mainest en0x1149. » C’est ce que listenmet ce dont l’éditeur de liens a besoin. - Les informations de débogage sont bien plus riches : types, noms et emplacements des variables locales, noms des fichiers source et correspondances avec les numéros de ligne. Elles existent pour les débogueurs.
| Linux / ELF | Windows / PE (MSVC) | |
|---|---|---|
| Symboles | Sections .symtab (tous) et .dynsym (imports/exports) | Absents de l’image ; dans le PDB |
| Infos de débogage | DWARF, dans les sections .debug_* du même fichier | PDB, un fichier séparé |
| Produits par | gcc -g | cl /Zi + link /DEBUG |
| Supprimés par | strip | Ne pas livrer le .pdb |
strip supprime .symtab et les sections DWARF, mais pas .dynsym : le
chargeur dynamique a besoin de ces noms pour résoudre les imports et les exports à
l’exécution. Les images PE produites par MSVC ne contiennent aucune table des
symboles ; seuls les exports et les imports sont nommés dans le fichier. (Les
fichiers PE produits avec MinGW sont différents — ils embarquent souvent une table
des symboles COFF, voire du DWARF, s’ils ne sont pas strippés, ce qui est un
cadeau quand vous en croisez un.)
Astuce : Vérifiez toujours si un échantillon a conservé ses symboles avant de vous lancer. Sous Linux,
fileindique « not stripped » ; sous Windows, cherchez un répertoire de débogage et, pour les binaires MinGW, un nombre de symboles non nul dans l’en-tête de fichier COFF.
Les chemins PDB : l’empreinte de l’auteur
Lorsque MSVC fait l’édition de liens avec /DEBUG, il écrit dans le PE une petite
entrée de répertoire de débogage — un enregistrement CodeView « RSDS » contenant
un GUID, un compteur d’âge et le chemin complet du fichier PDB sur la machine de
compilation. Le PDB lui-même reste sur place, mais le chemin est livré avec le
binaire :
C:\Users\dev\source\repos\Updater\x64\Release\Updater.pdbCes chaînes laissent fuiter des noms d’utilisateur, des noms de projet et des arborescences de dossiers. Les équipes de renseignement sur la menace regroupent les échantillons par chemin PDB, et les règles YARA s’appuient souvent dessus. Ils peuvent être falsifiés ou supprimés : considérez-les comme un indice fort plutôt que comme une preuve.
D’autres artefacts de compilation jouent un rôle similaire. Le Rich header, non documenté, que l’éditeur de liens de Microsoft place après le stub DOS, consigne quelles versions du compilateur et de l’éditeur de liens ont produit chaque fichier objet, et son empreinte est largement utilisée pour relier des échantillons compilés avec la même chaîne d’outils.
Pourquoi c’est important pour l’analyste
- Le code de bibliothèque est du bruit à filtrer, pas du code à lire. Reconnaître
qu’une fonction est
memcpyou fait partie de la bibliothèque d’exécution C fait gagner des heures. Des systèmes de signatures le font automatiquement : FLIRT dans IDA et Function ID dans Ghidra calculent l’empreinte des fonctions de bibliothèque connues et renomment les correspondances dans votre échantillon. - L’absence de noms est normale, pas suspecte. Un ELF strippé ou un PE MSVC sans PDB, c’est la norme. Ce qui est suspect, c’est un binaire qui n’a presque aucun import, ce qui suggère que les imports sont résolus à la main — voir résolution dynamique des imports.
- Les choix de compilation changent la forme du code. Le niveau d’optimisation, l’éditeur du compilateur et le langage laissent tous des idiomes reconnaissables, qu’un module ultérieur traite en détail.
- Les métadonnées de compilation sont du renseignement. Chemins PDB, Rich headers et chaînes de version du compilateur relient les échantillons entre eux, et parfois à leurs auteurs.
Lab : interrompre la compilation à chaque étape
Il vous faut un shell Linux, WSL ou conteneur avec gcc et binutils. Une section
optionnelle pour Windows utilise l’invite de commandes développeur de Visual
Studio.
-
Créez un petit programme bénin avec un appel externe, une variable globale et une chaîne locale :
c // beacon.c — harmless: it only prints #include <stdio.h> #define HOST "telemetry.example.net" int interval = 60; static void report(const char *host) { printf("would contact %s every %d s\n", host, interval); } int main(void) { report(HOST); return 0; } -
Prétraitez et cherchez votre macro :
bash gcc -E beacon.c -o beacon.i wc -l beacon.i grep -n "telemetry" beacon.iHOSTa disparu ; seule reste la chaîne littérale, et l’en-tête a fait gonfler le fichier à des centaines de lignes. -
Compilez en assembleur en syntaxe Intel :
bash gcc -O0 -S -masm=intel beacon.i -o beacon.sRepérez les labels de
main, dereportet de la chaîne littérale (généralement.LC0). Recompilez avec-O2et comparez :reporta très probablement été inlinée dansmainet n’existe plus en tant que telle. (Sur les distributions qui activent_FORTIFY_SOURCEpar défaut, comme Ubuntu, vous verrez peut-être aussiprintfremplacé par__printf_chken-O2— une autre façon dont le compilateur réécrit ce que vous avez écrit.) -
Assemblez et inspectez les trous du fichier objet :
bash gcc -O0 -c beacon.c -o beacon.o objdump -d -r -M intel beacon.o readelf -r beacon.o nm beacon.oDans la sortie d’
objdump, lecallversprintfest suivi de zéros et d’une ligneR_X86_64_PLT32 printf-0x4. Dansnm,mainestT(défini dans la section de code),intervalestD(données initialisées),reportestt(minuscule : local, carstatic), etprintfestU— indéfini. -
Faites l’édition de liens et inspectez à nouveau :
bash gcc beacon.o -o beacon objdump -d -M intel beacon | grep -A12 "<main>:" nm beacon | grep -E " (main|interval|report)$" nm -D beaconLes trous sont comblés : le
callcible désormais une adresse réelle (un stub PLT), et chaque symbole a une adresse définitive.nm -Dliste les symboles dynamiques, dontprintf. -
Strippez et observez ce qui survit :
bash cp beacon beacon.stripped && strip beacon.stripped nm beacon.stripped nm -D beacon.stripped file beacon beacon.strippednmne signale aucun symbole, maisnm -Daffiche toujoursprintf— le chargeur dynamique en a besoin. -
Comparez avec l’édition de liens statique :
bash gcc -static -O0 beacon.c -o beacon.static && strip beacon.static ls -l beacon.stripped beacon.static nm -D beacon.staticLe binaire statique est bien plus volumineux et ne possède aucun symbole dynamique.
-
(Optionnel, Windows) Dans une invite de commandes développeur :
bat cl /P beacon.c cl /c /FA /Od beacon.c dumpbin /relocations /symbols beacon.obj cl /Zi /Od beacon.c /link /DEBUG dumpbin /headers beacon.exe | findstr /i "pdb"La dernière commande affiche l’entrée du répertoire de débogage et le chemin PDB complet inscrit dans l’exécutable.
Questions : Lesquels de main, report, interval et printf pourriez-vous
encore nommer dans le binaire dynamique strippé, et pourquoi ? Dans le binaire
statique strippé, comment trouveriez-vous le code qui affiche le message ? Que
révèle votre propre chemin PDB sur votre machine ?
À retenir
- Une compilation, c’est prétraiter, compiler, assembler, lier ; chaque étape peut
être interrompue et inspectée avec
gcc -E/-S/-coucl /P /FA /c. - Les macros et les fonctionnalités
#ifdefdésactivées ne laissent aucune trace ; l’optimisation et l’inlining remodèlent les fonctions au point de les rendre méconnaissables. - Les fichiers objets contiennent du code à trous, une table des symboles et des relocations ; l’éditeur de liens résout les symboles, dispose les sections et comble les trous.
- Le stripping supprime les symboles et les informations de débogage, mais pas les imports et exports dynamiques ; les PE MSVC gardent leurs symboles dans un PDB séparé, mais en laissent fuiter le chemin.
- L’édition de liens statique noie le code de l’auteur dans le code de bibliothèque ; FLIRT et Function ID existent pour filtrer ce bruit.