Leçon 2.1 · Formats binaires· 40 min
En-têtes PE : en-tête DOS, en-tête NT et en-tête optionnel
Parcourez octet par octet les en-têtes de tout exécutable Windows : les champs fiables, ceux dont on doute et ceux que les malwares détournent.
Objectifs
- Localiser l’en-tête DOS, la signature PE, l’en-tête de fichier et l’en-tête optionnel dans un hexdump brut
- Interpréter Machine, TimeDateStamp, Characteristics, AddressOfEntryPoint, ImageBase et DllCharacteristics
- Distinguer PE32 de PE32+ et expliquer vers quoi pointe chaque répertoire de données
- Utiliser le Rich header et les horodatages comme indices de regroupement tout en les sachant falsifiables
- Repérer les anomalies d’en-tête typiques des malwares packés ou modifiés
Presque tous les échantillons Windows que vous trierez un jour sont des fichiers
Portable Executable (PE) : .exe, .dll, .sys, .scr, .cpl, .ocx —
l’extension est cosmétique, le format est le même. Avant qu’une seule
instruction d’un PE ne s’exécute, le chargeur Windows lit quelques centaines
d’octets d’en-têtes qui décrivent comment projeter le fichier en mémoire, où
commencer l’exécution et ce que le programme attend du système.
Ces en-têtes sont aussi la première chose que lit un analyste. Ils indiquent le processeur cible, si le fichier est un EXE ou une DLL, quand il a (prétendument) été compilé, quel compilateur l’a produit, et si quelque chose semble anormal. Cette leçon les parcourt dans l’ordre où le chargeur les lit.
Vue d’ensemble
Un fichier PE est une pile d’en-têtes suivie de sections. Les en-têtes sont petits et de forme fixe ; les sections contiennent le code, les données et les ressources.
file offset
0x0000 ┌─────────────────────────────┐
│ IMAGE_DOS_HEADER (64 bytes) │ "MZ" ... e_lfanew ──┐
0x0040 ├─────────────────────────────┤ │
│ DOS stub (16-bit program) │ │
├─────────────────────────────┤ │
│ Rich header (MSVC only) │ │
e_lfanew├─────────────────────────────┤ ◄────────────────────┘
│ "PE\0\0" signature (4 bytes) │ ┐
│ IMAGE_FILE_HEADER (20 bytes) │ │ IMAGE_NT_HEADERS
│ IMAGE_OPTIONAL_HEADER │ │
│ ... DataDirectory[16] │ ┘
├─────────────────────────────┤
│ Section table (40 B each) │
├─────────────────────────────┤ SizeOfHeaders
│ .text .rdata .data ... │
└─────────────────────────────┘Tous les champs de plusieurs octets sont en little-endian (voir
boutisme) : les octets 4d 5a se lisent comme la
valeur 16 bits 0x5A4D. Les adresses dans les en-têtes sont pour la plupart des
RVA — relative virtual addresses, des décalages par rapport à l’endroit où
l’image se retrouve en mémoire — et non des offsets de fichier. La leçon
suivante, Sections PE et organisation mémoire, montre
comment passer de l’un à l’autre.
L’en-tête DOS et le stub
IMAGE_DOS_HEADER est une relique de 64 octets héritée de MS-DOS. Windows en
ignore presque tout. Deux champs comptent :
| Offset | Champ | Taille | Signification |
|---|---|---|---|
0x00 | e_magic | WORD | Doit valoir 0x5A4D, les octets ASCII MZ |
0x3C | e_lfanew | DWORD | Offset dans le fichier de la signature PE\0\0 |
Tout ce qui se trouve entre ces deux champs (e_cblp, e_cp, e_ss, e_sp,
etc.) décrit le programme DOS et n’a aucune importance pour le chargeur Windows.
C’est donc de l’espace libre : les packers et les fichiers « tiny PE » façonnés
à la main y superposent d’autres en-têtes, et certains malwares (logiciels malveillants) y cachent
quelques octets de configuration.
Juste après l’en-tête DOS vient le stub DOS, un petit programme 16 bits qui
affiche This program cannot be run in DOS mode. puis se termine. Sa présence
relève de la convention, pas de l’obligation. Un stub absent ou inhabituel ne
prouve pas la malveillance, mais il suggère que le fichier a été produit par une
chaîne de compilation inhabituelle ou modifié après l’édition de liens.
Astuce : Les outils de carving (extraction de fichiers) trouvent les exécutables embarqués en cherchant
MZ, puis en vérifiant quee_lfanewpointe surPE\0\0. Cette vérification en deux temps est exactement ce que vous devez faire à la main quand vous soupçonnez un PE caché dans un autre fichier, un dump mémoire ou un document.
Le Rich header : une empreinte officieuse
Entre le stub et la signature PE, les binaires liés par l’éditeur de liens de
Microsoft contiennent un bloc non documenté appelé Rich header. Il est facile
à repérer car il se termine par le marqueur ASCII Rich suivi d’une clé XOR de
4 octets ; le marqueur de début DanS est chiffré par XOR avec cette même clé.
Une fois décodé, c’est une liste d’entrées (product ID, build number, count) :
quelles versions de compilateur, d’assembleur et d’éditeur de liens ont produit
combien de fichiers objets. Pour un analyste, c’est de l’or :
- Deux échantillons aux Rich headers identiques ont très probablement été compilés dans le même environnement de build, ce qui aide à regrouper une famille ou à la relier à un acteur.
- Les versions de la chaîne de compilation donnent une borne inférieure de la date d’installation de la machine de build.
- YARA et plusieurs plateformes de threat intelligence peuvent hacher le Rich header pour pivoter.
Attention : Le Rich header n’est ni signé ni vérifié par qui que ce soit. Les attaquants peuvent le supprimer ou copier intégralement celui d’un autre groupe — un cas documenté de false flag concernait un échantillon dont le Rich header avait été transplanté pour attribuer l’attaque à un autre acteur. Le vrai signal, c’est l’incohérence : un Rich header qui ne liste qu’un éditeur de liens Visual Basic 6 devant du code 64 bits compilé avec un compilateur moderne est un faux.
Les chaînes de compilation non Microsoft (mingw-w64, Go, Delphi, Rust avec la cible GNU) ne produisent aucun Rich header ; son absence vous renseigne donc surtout sur la chaîne de compilation.
La signature PE et l’en-tête de fichier
À e_lfanew se trouvent quatre octets, 50 45 00 00 (PE\0\0), suivis de
l’IMAGE_FILE_HEADER de 20 octets, aussi appelé en-tête COFF :
| Offset | Champ | Remarques |
|---|---|---|
+0x00 | Machine | 0x14C x86, 0x8664 x64, 0xAA64 ARM64 |
+0x02 | NumberOfSections | Nombre d’entrées de la table des sections |
+0x04 | TimeDateStamp | Secondes écoulées depuis le 1970-01-01 UTC |
+0x08 | PointerToSymbolTable | Symboles COFF ; normalement 0 dans les images |
+0x0C | NumberOfSymbols | Normalement 0 dans les images |
+0x10 | SizeOfOptionalHeader | 0xE0 pour PE32, 0xF0 pour PE32+ |
+0x12 | Characteristics | Drapeaux binaires, voir ci-dessous |
(Les offsets sont relatifs au début de l’en-tête de fichier, c’est-à-dire
e_lfanew + 4.)
Characteristics
Les drapeaux les plus utiles :
| Drapeau | Valeur | Signification |
|---|---|---|
RELOCS_STRIPPED | 0x0001 | Pas de relocations ; doit être chargé à ImageBase |
EXECUTABLE_IMAGE | 0x0002 | Image valide, liée |
LARGE_ADDRESS_AWARE | 0x0020 | Gère les adresses au-delà de 2 Go |
32BIT_MACHINE | 0x0100 | Machine à mots de 32 bits |
DEBUG_STRIPPED | 0x0200 | Informations de débogage supprimées |
SYSTEM | 0x1000 | Fichier système (pilotes) |
DLL | 0x2000 | L’image est une DLL |
C’est le bit DLL, et non l’extension du fichier, qui décide si Windows traite
l’image comme une bibliothèque. Les malwares livrent couramment des DLL renommées
en .dat, .png ou .tmp et les chargent avec rundll32.exe ou
regsvr32.exe : vérifiez donc ce bit sur chaque « fichier de données » déposé
par un échantillon.
TimeDateStamp : utile, jamais fiable
L’éditeur de liens y écrit la date de compilation. À l’échelle d’une campagne, les horodatages aident à ordonner les échantillons, à repérer les horaires de travail d’une machine de build et à regrouper des fichiers liés. Mais le champ se modifie trivialement, et plusieurs raisons bénignes peuvent le rendre étrange :
- Des valeurs nulles ou manifestement fausses (
0x00000000, dates en 1970 ou en 2099) sont courantes dans les malwares qui effacent le champ pour brouiller les chronologies. - Les binaires Delphi portaient historiquement un horodatage constant du 19 juin 1992, quelle que soit la vraie date de compilation.
- Les builds reproductibles (MSVC
/Brepro, utilisé pour de nombreux fichiers système Windows) remplacent l’horodatage par un hash, qui se décode donc en date aléatoire, souvent dans le futur.
Recoupez-le avec les autres horodatages du fichier — le répertoire de débogage, le répertoire d’exports et le répertoire de ressources ont chacun le leur. Un en-tête qui indique 2014 et un répertoire de débogage qui indique 2023 signifient que quelqu’un a modifié l’un des deux.
L’en-tête optionnel
Malgré son nom, l’en-tête optionnel est obligatoire pour les exécutables. Il
existe sous deux formes, distinguées par son premier champ, Magic :
Magic | Nom | Utilisé par | Taille d’ImageBase |
|---|---|---|---|
0x10B | PE32 | Images 32 bits | 4 octets |
0x20B | PE32+ | Images 64 bits | 8 octets |
PE32+ supprime le champ BaseOfData et élargit ImageBase ainsi que les quatre
tailles de pile et de tas à 64 bits ; tout le reste s’aligne. Les champs clés,
avec leurs offsets depuis le début de l’en-tête optionnel :
| PE32 | PE32+ | Champ | Intérêt pour l’analyste |
|---|---|---|---|
0x00 | 0x00 | Magic | 32 ou 64 bits |
0x02 | 0x02 | Major/MinorLinkerVersion | Indice sur la chaîne de compilation |
0x10 | 0x10 | AddressOfEntryPoint | RVA où commence l’exécution |
0x1C | 0x18 | ImageBase | Adresse de chargement préférée |
0x20 | 0x20 | SectionAlignment | Alignement des sections en mémoire (généralement 0x1000) |
0x24 | 0x24 | FileAlignment | Alignement des sections sur disque (généralement 0x200) |
0x38 | 0x38 | SizeOfImage | Taille totale une fois projetée |
0x3C | 0x3C | SizeOfHeaders | Octets d’en-têtes sur disque |
0x40 | 0x40 | CheckSum | Vérifié uniquement pour les pilotes et certaines DLL système |
0x44 | 0x44 | Subsystem | 2 = GUI, 3 = console, 1 = natif |
0x46 | 0x46 | DllCharacteristics | Mesures de sécurité |
0x5C | 0x6C | NumberOfRvaAndSizes | Généralement 16 |
0x60 | 0x70 | DataDirectory[] | 16 × (RVA, taille) |
Point d’entrée et adresse de base
AddressOfEntryPoint est une RVA. L’adresse virtuelle de la première
instruction est ImageBase + AddressOfEntryPoint — à condition que l’image ait
été chargée à sa base préférée. Les valeurs par défaut typiques sont 0x400000
pour les EXE 32 bits, 0x140000000 pour les EXE 64 bits et 0x10000000 ou
0x180000000 pour les DLL.
Notez que le point d’entrée n’est pas main. Il pointe vers du code du
runtime du compilateur (mainCRTStartup ou équivalent) qui initialise le
runtime C puis appelle main. Pour une DLL, le point d’entrée est l’enveloppe
de DllMain et peut valoir zéro si la DLL n’a pas de code d’initialisation. Et
le point d’entrée n’est pas forcément le premier code exécuté : les
callbacks TLS s’exécutent avant lui, un
emplacement classique pour les vérifications anti-débogage.
DllCharacteristics : drapeaux de protection
Malgré son nom, ce champ s’applique aussi aux EXE :
| Drapeau | Valeur | Signification |
|---|---|---|
HIGH_ENTROPY_VA | 0x0020 | ASLR 64 bits avec entropie complète |
DYNAMIC_BASE | 0x0040 | Peut être relogée (ASLR) |
FORCE_INTEGRITY | 0x0080 | La signature doit être valide |
NX_COMPAT | 0x0100 | Compatible avec DEP |
NO_SEH | 0x0400 | Aucun gestionnaire d’exceptions structurées |
GUARD_CF | 0x4000 | Control Flow Guard activé |
TERMINAL_SERVER_AWARE | 0x8000 | Compatible Terminal Server |
Les compilateurs modernes activent DYNAMIC_BASE et NX_COMPAT par défaut. Leur
absence suggère une chaîne de compilation ancienne ou inhabituelle, un binaire
construit à la main, ou un packer qui a reconstruit les en-têtes. L’ASLR
lui-même est traité dans PIE et ASLR.
Répertoires de données
L’en-tête optionnel se termine par un tableau d’entrées IMAGE_DATA_DIRECTORY,
chacune étant une paire (RVA, Size). Elles forment la table des matières de
tout ce dont le chargeur et les outils ont besoin au-delà des sections brutes :
| # | Répertoire | Ce qu’il localise |
|---|---|---|
| 0 | Export | Fonctions exportées par cette image |
| 1 | Import | DLL et fonctions dont cette image a besoin |
| 2 | Resource | Icônes, boîtes de dialogue, informations de version, blobs embarqués |
| 3 | Exception | Tables de déroulement x64 (.pdata) |
| 4 | Certificate | Signature Authenticode — un offset de fichier, pas une RVA |
| 5 | Base relocation | Corrections pour un chargement à une autre base |
| 6 | Debug | Chemin du PDB, horodatages de débogage |
| 7 | Architecture | Réservé, doit valoir zéro |
| 8 | Global pointer | Inutilisé sur x86/x64 |
| 9 | TLS | Stockage local aux threads, y compris les callbacks TLS |
| 10 | Load config | Cookie de sécurité, tables CFG, tables SEH |
| 11 | Bound import | Adresses d’import pré-résolues (obsolète) |
| 12 | IAT | La plage de l’import address table |
| 13 | Delay import | Imports résolus à la première utilisation |
| 14 | CLR runtime | Métadonnées .NET — c’est un binaire managé |
| 15 | Reserved | Doit valoir zéro |
Repérer rapidement quels répertoires sont non nuls est déjà du triage : une
entrée CLR signifie qu’il vous faut un décompilateur .NET, pas un désassembleur ;
une entrée de certificat signifie qu’il faut vérifier la signature ; une entrée
TLS signifie qu’il faut chercher des callbacks ; une entrée de débogage peut
laisser fuiter un chemin de PDB tel que
C:\Users\dev\Desktop\stealer\Release\stealer.pdb. Les imports et les exports
ont leur propre leçon, Imports, exports et IAT.
Les anomalies d’en-tête qui méritent un second regard
Aucune ne prouve à elle seule la malveillance, mais chacune doit soulever une question :
- Point d’entrée hors de la section de code — dans la dernière section, dans
.rsrc, dans une section inscriptible ou dans les en-têtes eux-mêmes. Les packers font généralement pointer le point d’entrée vers leur stub d’unpacking (dépaquetage) dans une section ajoutée ; voir l’obfuscation du point d’entrée et le packing UPX. - Horodatages nuls, futurs ou impossibles, ou désaccord entre l’horodatage de l’en-tête et celui du répertoire de débogage.
SizeOfImageouSizeOfHeadersincohérents avec la table des sections, ouNumberOfSectionsà 0 ou démesuré — des astuces qui font planter les parseurs naïfs.- Absence de
DYNAMIC_BASE/NX_COMPATsur un binaire qui revendique une version récente de l’éditeur de liens. - Un Rich header qui contredit la version de l’éditeur de liens ou le code que vous observez.
- Un
e_lfanewqui pointe très loin dans le fichier ou qui chevauche d’autres structures.
Il existe une anomalie de plus, visible uniquement en mémoire. Après le
chargement, certains malwares mettent à zéro leurs propres en-têtes MZ et PE
pour que les scanners de mémoire qui parcourent un processus ne reconnaissent
pas le module injecté comme un PE. Voir
l’effacement d’en-tête PE. Quand vous dumpez
une telle région, vous devez reconstruire les en-têtes à la main — ce qui n’est
possible que si vous les connaissez aussi bien que le décrit cette leçon.
Astuce : Windows lui-même est tolérant. Il charge des fichiers qui violent la spécification d’une façon que les outils d’analyse n’anticipent pas. Quand
pefileet PE-bear ne sont pas d’accord sur un fichier, ce désaccord est une observation qui mérite d’être notée.
Lab : parcourir les en-têtes de votre propre PE
Vous allez compiler un programme console Windows 64 bits inoffensif et lire ses
en-têtes de trois façons. Les étapes fonctionnent sous Linux, macOS ou WSL avec
mingw-w64 (apt install mingw-w64 ou brew install mingw-w64) ; sous
Windows, vous pouvez utiliser MSVC à la place.
-
Écrivez et compilez le programme :
c // hello.c #include <stdio.h> int main(void) { puts("hello from a benign PE"); return 0; }bash x86_64-w64-mingw32-gcc -O0 -s -o hello.exe hello.cSous Windows, dans un Developer Command Prompt :
cl /O2 hello.c. Conservez les deux builds si possible — seul celui de MSVC contiendra un Rich header. -
Trouvez les en-têtes à la main. Affichez les premiers octets :
bash xxd -l 0x40 hello.exetext 00000000: 4d5a 9000 0300 0000 0400 0000 ffff 0000 MZ.............. 00000010: b800 0000 0000 0000 4000 0000 0000 0000 ........@....... 00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000030: 0000 0000 0000 0000 0000 0000 8000 0000 ................Le dernier DWORD, à
0x3C, se lit80 00 00 00→e_lfanew = 0x80. Sautez à cet endroit (votre valeur peut différer) :bash xxd -s 0x80 -l 0x30 hello.exetext 00000080: 5045 0000 6486 0900 d8b8 bb6a 0000 0000 PE..d......j.... 00000090: 0000 0000 f000 2e02 0b02 022e 001c 0000 ................ 000000a0: 0020 0000 0002 0000 4014 0000 0010 0000 . ......@.......Décodez-le vous-même :
PE\0\0, puisMachine = 0x8664(x64), neuf sections,TimeDateStamp = 0x6ABBB8D8,SizeOfOptionalHeader = 0xF0,Characteristics = 0x022E. L’en-tête optionnel commence à0x98avecMagic = 0x20B(PE32+), etAddressOfEntryPointse trouve 0x10 octets plus loin, à0xA8:0x1440. -
Automatisez le même parcours avec
struct. Aucune bibliothèque — seulement les offsets des tableaux ci-dessus :python # headers.py import struct, sys, datetime data = open(sys.argv[1], "rb").read() e_lfanew, = struct.unpack_from("<I", data, 0x3C) assert data[:2] == b"MZ" and data[e_lfanew:e_lfanew + 4] == b"PE\0\0" fh = e_lfanew + 4 machine, nsec, stamp, _, _, opt_size, chars = struct.unpack_from("<HHIIIHH", data, fh) print(f"Machine {machine:#x} sections {nsec} chars {chars:#06x}") print("Built", datetime.datetime.fromtimestamp(stamp, datetime.timezone.utc)) oh = fh + 20 magic, = struct.unpack_from("<H", data, oh) entry, = struct.unpack_from("<I", data, oh + 0x10) if magic == 0x20B: image_base, = struct.unpack_from("<Q", data, oh + 0x18) else: image_base, = struct.unpack_from("<I", data, oh + 0x1C) subsystem, dllchars = struct.unpack_from("<HH", data, oh + 0x44) print(f"Magic {magic:#x} EP {image_base + entry:#x} " f"subsystem {subsystem} DllCharacteristics {dllchars:#06x}")bash python3 headers.py hello.exe -
Comparez avec
pefile. Installez-le (pip install pefile) et laissez-le faire l’analyse :python import pefile pe = pefile.PE("hello.exe") print(hex(pe.FILE_HEADER.Characteristics), hex(pe.OPTIONAL_HEADER.DllCharacteristics)) for i, d in enumerate(pe.OPTIONAL_HEADER.DATA_DIRECTORY): if d.VirtualAddress: print(i, d.name, hex(d.VirtualAddress), hex(d.Size)) print(pe.parse_rich_header()) # None for a mingw buildprint(pe.dump_info())affiche tout d’un coup — parcourez la sortie et comparez chaque champ avec les valeurs que vous avez décodées à la main. -
Ouvrez le fichier dans PE-bear. Parcourez DOS Header, NT Headers → File Header et Optional Header. PE-bear décode les drapeaux pour vous et affiche le Rich header (pour le build MSVC) dans son propre onglet.
-
Modifiez une copie. Faites une copie, mettez l’horodatage à zéro et effacez le bit
NX_COMPAT, puis rouvrez-la :python pe = pefile.PE("hello.exe") pe.FILE_HEADER.TimeDateStamp = 0 pe.OPTIONAL_HEADER.DllCharacteristics &= ~0x0100 pe.write("hello-tampered.exe")Remarquez que rien ne vous en empêche, et que le SHA-256 change — chaque champ d’en-tête fait partie du hash du fichier.
Questions : Quels bits de Characteristics composent 0x022E ? Quels
répertoires de données sont non nuls dans votre build, et pourquoi un programme
aussi petit possède-t-il un répertoire TLS ? Si vous avez compilé avec MSVC,
quels outils le Rich header liste-t-il ? Quels champs utiliseriez-vous pour
regrouper deux échantillons, et lesquels un attaquant modifierait-il en premier ?
À retenir
e_magicà0x00ete_lfanewà0x3Csont les seuls champs de l’en-tête DOS dont Windows a besoin ;e_lfanewmène àPE\0\0, à l’en-tête de fichier et à l’en-tête optionnel.Magic(0x10B/0x20B) détermine la structure de l’en-tête optionnel ; le point d’entrée est une RVA ajoutée àImageBase.Characteristicsindique EXE ou DLL ;DllCharacteristicsenregistre les protections comme l’ASLR et DEP ; les 16 répertoires de données cartographient imports, exports, ressources, TLS et plus encore.- Les horodatages et le Rich header sont d’excellents indices de regroupement et trivialement falsifiables — fiez-vous à la cohérence, pas aux valeurs isolées.
- Un point d’entrée hors de la section de code, des horodatages nuls et des tailles incohérentes sont des signes classiques de packing ou de modification.