Skip to content

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 :

text
  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
ÉtapeEntrée → sortieGCCMSVC
Prétraitement.c → .igcc -Ecl /P
Compilation.i → .s / .asmgcc -Scl /FA (listing)
Assemblage.s → .o / .objgcc -c (ou as)cl /c (ou ml64)
Édition de liens.o + bibliothèques → exécutablegcc (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 (comme puts).
  • 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 comme R_X86_64_PLT32 en ELF ou IMAGE_REL_AMD64_REL32 en COFF — qui signifient tous deux, en gros, « un déplacement 32 bits relatif à l’instruction suivante ».
text
  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 overwrite

Une 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 :

  1. 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).
  2. Disposition — fusionner les sections de même nom de toutes les entrées (tous les .text en un seul .text, et ainsi de suite) et attribuer à chacune une adresse virtuelle définitive.
  3. 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èqueCopié dans l’exécutableReste dans une DLL / un .so séparé
Taille du binairePlus grandePlus petite
Comment les appels atteignent la bibliothèqueAppels directs au code embarquéVia la table des imports : IAT sous Windows, GOT/PLT sous Linux
Ce que voit l’analysteDes milliers de fonctions de bibliothèque anonymesDes 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 : « main est en 0x1149. » C’est ce que liste nm et 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 / ELFWindows / PE (MSVC)
SymbolesSections .symtab (tous) et .dynsym (imports/exports)Absents de l’image ; dans le PDB
Infos de débogageDWARF, dans les sections .debug_* du même fichierPDB, un fichier séparé
Produits pargcc -gcl /Zi + link /DEBUG
Supprimés parstripNe 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, file indique « 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 :

text
C:\Users\dev\source\repos\Updater\x64\Release\Updater.pdb

Ces 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 memcpy ou 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.

  1. 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;
    }
  2. Prétraitez et cherchez votre macro :

    bash
    gcc -E beacon.c -o beacon.i
    wc -l beacon.i
    grep -n "telemetry" beacon.i

    HOST a disparu ; seule reste la chaîne littérale, et l’en-tête a fait gonfler le fichier à des centaines de lignes.

  3. Compilez en assembleur en syntaxe Intel :

    bash
    gcc -O0 -S -masm=intel beacon.i -o beacon.s

    Repérez les labels de main, de report et de la chaîne littérale (généralement .LC0). Recompilez avec -O2 et comparez : report a très probablement été inlinée dans main et n’existe plus en tant que telle. (Sur les distributions qui activent _FORTIFY_SOURCE par défaut, comme Ubuntu, vous verrez peut-être aussi printf remplacé par __printf_chk en -O2 — une autre façon dont le compilateur réécrit ce que vous avez écrit.)

  4. 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.o

    Dans la sortie d’objdump, le call vers printf est suivi de zéros et d’une ligne R_X86_64_PLT32 printf-0x4. Dans nm, main est T (défini dans la section de code), interval est D (données initialisées), report est t (minuscule : local, car static), et printf est U — indéfini.

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

    Les trous sont comblés : le call cible désormais une adresse réelle (un stub PLT), et chaque symbole a une adresse définitive. nm -D liste les symboles dynamiques, dont printf.

  6. Strippez et observez ce qui survit :

    bash
    cp beacon beacon.stripped && strip beacon.stripped
    nm beacon.stripped
    nm -D beacon.stripped
    file beacon beacon.stripped

    nm ne signale aucun symbole, mais nm -D affiche toujours printf — le chargeur dynamique en a besoin.

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

    Le binaire statique est bien plus volumineux et ne possède aucun symbole dynamique.

  8. (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/-c ou cl /P /FA /c.
  • Les macros et les fonctionnalités #ifdef dé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.