Skip to content

Leçon 1.4 · Fondations· 35 min

Comment un binaire est chargé et exécuté

Ce qui se passe entre le double-clic et main() : création du processus, mappage mémoire, loader, imports, ASLR, démarrage du CRT et callbacks TLS.

Objectifs

  • Décrire comment Windows et Linux transforment un fichier sur disque en processus en cours d’exécution
  • Expliquer comment le loader mappe une image, résout les imports et applique les relocations sous ASLR
  • Distinguer le point d’entrée de main et identifier le code qui s’exécute avant l’un comme l’autre
  • Localiser le PEB et les structures de données du loader que les malwares parcourent pour trouver les modules

Un binaire sur disque est un plan, pas un programme. Avant qu’une seule instruction du code de l’auteur ne s’exécute, le système d’exploitation construit un espace d’adressage, y mappe le fichier, charge chaque bibliothèque dont il dépend, corrige des adresses et exécute une quantité surprenante de code qui n’est pas main. Les malwares se cachent dans chacune de ces étapes, et les débogueurs vous montrent le processus à différents moments de ce parcours. Si vous ne connaissez pas la séquence, vous ne saurez pas ce que vous regardez.

Cette leçon suit d’abord Windows, puisque la plupart des échantillons sont des fichiers PE, puis montre l’équivalent sous Linux.

Processus et espaces d’adressage virtuels

Chaque processus dispose de son propre espace d’adressage virtuel : une plage d’adresses privée que le processeur et le noyau traduisent, page par page (généralement 4 Ko), en mémoire physique. Deux processus peuvent tous deux utiliser l’adresse 0x140001000 et y voir des octets complètement différents. Chaque page porte aussi des bits de protection — lecture, écriture, exécution —, ce qui explique pourquoi les sections de code sont typiquement R-X et les sections de données RW- (voir protection mémoire).

Un processus Windows 64 bits typique ressemble à peu près à ceci :

text
  low addresses
  ┌───────────────────────────────┐
  │ (unmapped, catches NULL)      │
  ├───────────────────────────────┤
  │ thread stacks                 │  RW-
  │ process heap(s)               │  RW-
  ├───────────────────────────────┤
  │ sample.exe image              │  headers R--, .text R-X,
  │   (ImageBase, e.g. 0x7FF6…)   │  .rdata R--, .data RW-
  ├───────────────────────────────┤
  │ other allocations, mapped     │
  │ files, PEB/TEB                │
  ├───────────────────────────────┤
  │ kernel32.dll, KernelBase.dll, │  each mapped as an image
  │ ntdll.dll  (0x7FFx…)          │
  └───────────────────────────────┘
  high addresses (kernel space above, not accessible)

L’ordre exact varie avec l’ASLR. Ce qui compte, c’est que l’exécutable, chaque DLL, la pile et le tas sont des régions distinctes, chacune avec sa propre base et ses propres protections. Un visualiseur de carte mémoire comme VMMap ou System Informer les liste.

Mapper une image

Le loader ne lit pas le fichier en mémoire comme un bloc plat. Les en-têtes de section décrivent où chaque morceau du fichier doit aller en mémoire :

text
        file on disk                          image in memory
  ┌─────────────────────┐ 0x000       ┌─────────────────────┐ ImageBase + 0x0000
  │ headers             │             │ headers             │
  ├─────────────────────┤ 0x400       ├─────────────────────┤ ImageBase + 0x1000
  │ .text  (raw data)   │ ──────────► │ .text               │
  ├─────────────────────┤             ├─────────────────────┤ ImageBase + 0x3000
  │ .rdata              │ ──────────► │ .rdata              │
  ├─────────────────────┤             ├─────────────────────┤ ImageBase + 0x5000
  │ .data               │ ──────────► │ .data  (+ zero-fill │
  └─────────────────────┘             │        for .bss)    │
   packed tightly,                    └─────────────────────┘
   FileAlignment (0x200)               page-aligned, SectionAlignment (0x1000)

Chaque section PE possède un offset et une taille bruts dans le fichier, et une adresse et une taille virtuelles en mémoire. Comme les sections sont alignées différemment sur disque et en mémoire, les offsets de fichier et les adresses virtuelles diffèrent, et vous convertirez sans cesse des uns aux autres — un sujet traité dans Les sections PE. Une section dont la taille virtuelle dépasse la taille brute est complétée par des zéros ; les packers s’en servent pour réserver une région vide qu’ils rempliront plus tard avec le code dépaqueté.

ELF décrit la même idée avec des en-têtes de programme (segments) : chaque entrée PT_LOAD dit « mappe cette plage du fichier à cette adresse avec ces permissions ». Les sections sont la vue de l’éditeur de liens ; les segments sont celle du loader.

Windows : de CreateProcess à main

Lorsqu’un programme appelle CreateProcess, le travail est réparti entre le noyau et ntdll.dll au sein du nouveau processus :

text
  parent process                        kernel
  CreateProcessW ──► NtCreateUserProcess ──► open file, create image section
                                             create process + address space
                                             map sample.exe and ntdll.dll
                                             create PEB, TEB, initial thread
                                                        │
  ─────────────── new process, user mode ───────────────┼──────────────────
                                                        ▼
  ntdll!LdrInitializeThunk
     └─ loader initialisation
          • load imported DLLs (recursively)
          • resolve imports → write addresses into the IAT
          • apply base relocations if needed
          • run TLS callbacks and DllMain(DLL_PROCESS_ATTACH)
  ntdll!RtlUserThreadStart
     └─ AddressOfEntryPoint  = CRT startup (e.g. mainCRTStartup)
          └─ initialise C runtime, globals, argv
               └─ main / WinMain

Résoudre les imports

Le répertoire d’imports du PE liste des noms de DLL et, pour chacune, les fonctions nécessaires (par nom ou par ordinal). Pour chaque DLL, le loader la trouve déjà chargée ou bien la recherche, la mappe et traite récursivement ses imports. Il recherche ensuite chaque fonction dans la table des exports de la DLL et écrit l’adresse obtenue dans l’emplacement correspondant de l’Import Address Table (IAT). Le code compilé appelle les imports indirectement, à travers ces emplacements :

text
  call qword ptr [rip + IAT_slot_for_CreateFileW]

C’est pourquoi un outil statique peut lister les imports d’un échantillon : ce sont des données que le loader doit pouvoir lire. C’est aussi pourquoi un malware qui veut cacher ses capacités ignore la table des imports et trouve lui-même ses fonctions à l’exécution — voir résolution dynamique des imports et hachage d’API.

Relocations et ASLR

Chaque PE indique une ImageBase préférée. Le code qui contient des adresses absolues n’est correct qu’à cette base. Avec l’ASLR, Windows charge délibérément les images à des bases aléatoires ; le loader doit donc corriger ces adresses absolues à l’aide de la table des relocations de base (.reloc) : une liste d’emplacements de l’image qui contiennent des adresses absolues et doivent être ajustés de la différence entre la base réelle et la base préférée.

Une image active l’ASLR avec le flag IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE (/DYNAMICBASE sous MSVC, le comportement par défaut depuis des années). Quelques détails à connaître :

  • Le code x64 utilise surtout l’adressage relatif à RIP ; il a donc besoin de bien moins de relocations que le code 32 bits.
  • Windows randomise les bases des DLL une fois par démarrage, ce qui permet à tous les processus de partager une seule copie de chaque DLL système. Les piles et les tas, en revanche, sont randomisés par processus.
  • Une image sans table des relocations ne peut pas être déplacée ; elle est donc chargée à sa base préférée. D’anciens malwares ou des malwares construits à la main se présentent parfois ainsi.

Sous Linux, ce rôle est joué par les exécutables indépendants de la position (PIE), désormais la norme sur la plupart des distributions ; voir PIE et ASLR.

Le point d’entrée n’est pas main

AddressOfEntryPoint, dans l’en-tête optionnel, pointe vers la routine de démarrage du CRT, pas vers main. Pour les programmes MSVC, il s’agit de mainCRTStartup, WinMainCRTStartup ou _DllMainCRTStartup, qui mettent en place les cookies de sécurité, initialisent la bibliothèque d’exécution C, exécutent les constructeurs globaux, analysent la ligne de commande et n’appellent main qu’ensuite. Lorsque vous ouvrez un échantillon dans un débogueur et qu’il s’arrête sur le « point d’arrêt d’entrée », vous êtes dans du code standard généré par le compilateur, souvent à plusieurs appels du code de l’auteur.

Cette distinction compte doublement pour les malwares :

  • Dans un binaire normal, trouver main consiste à dépasser le démarrage du CRT jusqu’à l’appel qui transmet argc/argv (ou hInstance pour WinMain).
  • Dans un binaire packé, le point d’entrée est le stub de dépaquetage. Le point d’entrée du vrai programme, une fois dépaqueté, est le point d’entrée original (OEP) — la cible de l’unpacking manuel, plus loin dans ce parcours.

Callbacks TLS : du code avant le point d’entrée

Le répertoire TLS du PE peut lister des fonctions de callback que le loader invoque pendant l’initialisation du processus — avant le point d’entrée — puis à nouveau à la création et à la fin de chaque thread. Elles existent pour la mise en place légitime du stockage local aux threads (thread-local storage), mais les malwares s’en servent pour exécuter des vérifications anti-débogage ou du déchiffrement avant que le débogueur de l’analyste n’atteigne son point d’arrêt d’entrée. Voir callbacks TLS.

Astuce : Dans x64dbg, ouvrez Options → Preferences → Events et activez l’arrêt sur TLS Callbacks (ainsi que sur le System Breakpoint) pour vous arrêter avant que le moindre code de l’échantillon ne s’exécute. Examinez le répertoire TLS dans PE-bear pendant le triage ; un répertoire TLS avec des callbacks dans un petit programme sans CRT mérite votre attention.

Le PEB et les données du loader

Chaque processus possède un Process Environment Block (PEB), une structure en mode utilisateur que remplit le loader. Chaque thread possède un Thread Environment Block (TEB). Sur x64, le registre de segment gs pointe vers le TEB courant, et le TEB contient un pointeur vers le PEB à l’offset 0x60 ; sur x86 32 bits, fs:[0x30] contient directement le pointeur vers le PEB (voir registres de segment).

Champ du PEB x64OffsetPourquoi l’analyste s’y intéresse
BeingDebugged0x02Lu par IsDebuggerPresent — et directement par les malwares
ImageBaseAddress0x10Adresse à laquelle l’image principale a réellement été chargée
Ldr0x18Pointeur vers PEB_LDR_DATA, la liste des modules chargés
NtGlobalFlag0xBCFlags de débogage du tas activés quand le processus est lancé sous débogueur

PEB_LDR_DATA contient des listes doublement chaînées (par ordre de chargement, ordre en mémoire et ordre d’initialisation) d’enregistrements LDR_DATA_TABLE_ENTRY, un par module, avec l’adresse de base, la taille et le nom de chaque module. Les shellcodes et les malwares qui cachent leurs imports parcourent exactement cette liste pour trouver kernel32.dll sans appeler la moindre API ; la séquence d’instructions « lire gs:[0x60], puis l’offset 0x18 » est donc un signal fort dans le désassemblage. Les champs BeingDebugged et NtGlobalFlag sont à la base des vérifications IsDebuggerPresent et NtGlobalFlag.

Linux : d’execve à main

La séquence Linux a la même forme, avec d’autres noms :

  1. execve demande au noyau de remplacer l’image du processus courant. Le noyau lit l’en-tête ELF et les en-têtes de programme, puis mappe chaque segment PT_LOAD.
  2. Si le binaire possède un en-tête PT_INTERP — c’est le cas de tout binaire lié dynamiquement —, le noyau mappe aussi l’interpréteur indiqué, typiquement /lib64/ld-linux-x86-64.so.2, et démarre l’exécution dans l’interpréteur, pas dans le programme. Il transmet sur la pile un vecteur auxiliaire (AT_ENTRY, AT_PHDR, AT_BASE et d’autres) qui indique où se trouve chaque élément.
  3. Le chargeur dynamique mappe chaque bibliothèque listée en DT_NEEDED, applique les relocations et prépare la GOT. Les imports de fonctions peuvent être résolus immédiatement ou paresseusement, au premier appel via la PLT (voir GOT et PLT), selon des options de l’éditeur de liens comme -z now.
  4. Il exécute les initialiseurs des bibliothèques, puis saute au point d’entrée du programme, _start, qui appelle __libc_start_main, qui exécute les constructeurs (depuis .init_array) et appelle enfin main.

Comme pour les callbacks TLS, les constructeurs s’exécutent avant main : une fonction marquée __attribute__((constructor)) s’exécute avant main. Les malwares Linux s’en servent, tout comme le hooking par LD_PRELOAD, qui injecte une bibliothèque que le loader mappe avant les propres dépendances du programme.

Pour plus de détails sur ELF, voir ELF pour les analystes de malwares.

Lab : observer l’ASLR et le loader

Utilisez un shell Linux ou WSL avec gcc et gdb. Le programme est bénin : il affiche quelques adresses et attend.

  1. Écrivez et compilez le programme :

    c
    // where.c — prints where things live, then waits for Enter
    #include <stdio.h>
    #include <stdlib.h>
    
    int counter = 7;                       /* global in .data */
    
    int main(void) {
        int local = 0;                     /* on the stack    */
        char *buf = malloc(64);            /* on the heap     */
        printf("main    %p\n", (void *)main);
        printf("counter %p\n", (void *)&counter);
        printf("local   %p\n", (void *)&local);
        printf("heap    %p\n", (void *)buf);
        printf("printf  %p\n", (void *)printf);
        getchar();                         /* pause so we can inspect */
        free(buf);
        return 0;
    }
    bash
    gcc -O0 -o where where.c
    gcc -O0 -no-pie -o where-nopie where.c
  2. Observez l’ASLR. Lancez ./where trois fois (appuyez sur Entrée à chaque fois). Les cinq adresses changent d’une exécution à l’autre. Vérifiez le réglage système :

    bash
    cat /proc/sys/kernel/randomize_va_space   # 2 = full randomisation
  3. Comparez avec une version non PIE. Lancez ./where-nopie plusieurs fois. main et counter restent désormais fixes (typiquement autour de 0x401000/0x404000), tandis que la pile, le tas et printf dans la libc continuent de bouger. Seul l’exécutable lui-même a perdu sa randomisation.

  4. Lisez la carte mémoire. Lancez ./where et laissez-le en attente. Dans un second terminal :

    bash
    cat /proc/$(pgrep -n where)/maps

    Faites correspondre chaque adresse affichée à une ligne : main tombe dans le mappage r-xp de where, counter dans son mappage rw-p, le tas dans [heap], local dans [stack], et printf dans libc.so.6. Remarquez les mappages distincts, avec des permissions différentes, pour un même fichier — ce sont ses segments PT_LOAD.

  5. Arrêtez-vous avant que le moindre code du programme ne s’exécute. Lancez le binaire sous gdb :

    bash
    gdb -q ./where
    (gdb) starti
    (gdb) info proc mappings
    (gdb) x/i $pc

    starti s’arrête sur la toute première instruction en mode utilisateur. x/i $pc montre que vous êtes dans ld-linux-x86-64.so.2, et la liste des mappages contient le programme et le loader, mais pas encore la libc — le loader ne l’a pas encore chargée.

  6. Laissez le loader terminer. Continuez jusqu’à main et regardez à nouveau :

    bash
    (gdb) break main
    (gdb) continue
    (gdb) info proc mappings
    (gdb) info sharedlibrary

    La libc est maintenant mappée. Lancez le programme deux fois dans gdb et remarquez que les adresses sont identiques : gdb désactive l’ASLR par défaut (set disable-randomization on). Désactivez ce réglage avec set disable-randomization off pour retrouver la randomisation. Souvenez-vous-en lorsque vous comparez les adresses vues dans le débogueur avec celles d’un processus réel.

  7. (Optionnel, Windows) Compilez le même programme avec cl /Od where.c et lancez-le quelques fois. Les adresses de la pile et du tas changent à chaque exécution ; celles de l’exécutable lui-même restent souvent identiques jusqu’au redémarrage. Ouvrez-le ensuite dans x64dbg : l’exécution s’interrompt d’abord sur le point d’arrêt système dans ntdll, puis sur le point d’arrêt d’entrée sur mainCRTStartup. Utilisez l’onglet Memory Map pour trouver l’image, ses sections et leurs protections, et suivez le chemin vers main depuis le code de démarrage du CRT.

Questions : Quelles adresses la version non PIE a-t-elle fixées, et lesquelles non — et pourquoi ? Pourquoi la libc n’apparaît-elle pas dans la liste des mappages au moment de starti ? Dans x64dbg, combien d’instructions et d’appels séparent le point d’arrêt d’entrée de main, et que vous attendriez-vous à y voir dans un échantillon packé ?

À retenir

  • Chaque processus possède un espace d’adressage virtuel privé ; l’exécutable, chaque bibliothèque, la pile et le tas sont des régions distinctes, avec leurs propres permissions.
  • Le loader mappe les sections ou les segments à leurs adresses virtuelles ; les offsets de fichier et les adresses mémoire diffèrent donc.
  • Les imports sont résolus au chargement dans l’IAT (Windows) ou la GOT (Linux) ; les malwares qui cachent leurs imports les résolvent eux-mêmes, souvent en parcourant le PEB.
  • L’ASLR déplace les images, les piles et les tas ; les relocations de base ou le code indépendant de la position rendent cela possible.
  • Le point d’entrée est le démarrage du CRT, pas main — et les callbacks TLS et les constructeurs s’exécutent encore plus tôt, ce que les malwares exploitent.