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 :
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 :
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 :
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 / WinMainRé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 :
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
mainconsiste à dépasser le démarrage du CRT jusqu’à l’appel qui transmetargc/argv(ouhInstancepourWinMain). - 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 x64 | Offset | Pourquoi l’analyste s’y intéresse |
|---|---|---|
BeingDebugged | 0x02 | Lu par IsDebuggerPresent — et directement par les malwares |
ImageBaseAddress | 0x10 | Adresse à laquelle l’image principale a réellement été chargée |
Ldr | 0x18 | Pointeur vers PEB_LDR_DATA, la liste des modules chargés |
NtGlobalFlag | 0xBC | Flags 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 :
execvedemande 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 segmentPT_LOAD.- 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_BASEet d’autres) qui indique où se trouve chaque élément. - 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. - 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 enfinmain.
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.
-
É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 -
Observez l’ASLR. Lancez
./wheretrois 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 -
Comparez avec une version non PIE. Lancez
./where-nopieplusieurs fois.mainetcounterrestent désormais fixes (typiquement autour de0x401000/0x404000), tandis que la pile, le tas etprintfdans la libc continuent de bouger. Seul l’exécutable lui-même a perdu sa randomisation. -
Lisez la carte mémoire. Lancez
./whereet laissez-le en attente. Dans un second terminal :bash cat /proc/$(pgrep -n where)/mapsFaites correspondre chaque adresse affichée à une ligne :
maintombe dans le mappager-xpdewhere,counterdans son mappagerw-p, le tas dans[heap],localdans[stack], etprintfdanslibc.so.6. Remarquez les mappages distincts, avec des permissions différentes, pour un même fichier — ce sont ses segmentsPT_LOAD. -
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 $pcstartis’arrête sur la toute première instruction en mode utilisateur.x/i $pcmontre que vous êtes dansld-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. -
Laissez le loader terminer. Continuez jusqu’à
mainet regardez à nouveau :bash (gdb) break main (gdb) continue (gdb) info proc mappings (gdb) info sharedlibraryLa 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 avecset disable-randomization offpour retrouver la randomisation. Souvenez-vous-en lorsque vous comparez les adresses vues dans le débogueur avec celles d’un processus réel. -
(Optionnel, Windows) Compilez le même programme avec
cl /Od where.cet 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 dansntdll, puis sur le point d’arrêt d’entrée surmainCRTStartup. Utilisez l’onglet Memory Map pour trouver l’image, ses sections et leurs protections, et suivez le chemin versmaindepuis 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.