Skip to content

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.

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

OffsetChampTailleSignification
0x00e_magicWORDDoit valoir 0x5A4D, les octets ASCII MZ
0x3Ce_lfanewDWORDOffset 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 que e_lfanew pointe sur PE\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 :

OffsetChampRemarques
+0x00Machine0x14C x86, 0x8664 x64, 0xAA64 ARM64
+0x02NumberOfSectionsNombre d’entrées de la table des sections
+0x04TimeDateStampSecondes écoulées depuis le 1970-01-01 UTC
+0x08PointerToSymbolTableSymboles COFF ; normalement 0 dans les images
+0x0CNumberOfSymbolsNormalement 0 dans les images
+0x10SizeOfOptionalHeader0xE0 pour PE32, 0xF0 pour PE32+
+0x12CharacteristicsDrapeaux 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 :

DrapeauValeurSignification
RELOCS_STRIPPED0x0001Pas de relocations ; doit être chargé à ImageBase
EXECUTABLE_IMAGE0x0002Image valide, liée
LARGE_ADDRESS_AWARE0x0020Gère les adresses au-delà de 2 Go
32BIT_MACHINE0x0100Machine à mots de 32 bits
DEBUG_STRIPPED0x0200Informations de débogage supprimées
SYSTEM0x1000Fichier système (pilotes)
DLL0x2000L’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 :

MagicNomUtilisé parTaille d’ImageBase
0x10BPE32Images 32 bits4 octets
0x20BPE32+Images 64 bits8 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 :

PE32PE32+ChampIntérêt pour l’analyste
0x000x00Magic32 ou 64 bits
0x020x02Major/MinorLinkerVersionIndice sur la chaîne de compilation
0x100x10AddressOfEntryPointRVA où commence l’exécution
0x1C0x18ImageBaseAdresse de chargement préférée
0x200x20SectionAlignmentAlignement des sections en mémoire (généralement 0x1000)
0x240x24FileAlignmentAlignement des sections sur disque (généralement 0x200)
0x380x38SizeOfImageTaille totale une fois projetée
0x3C0x3CSizeOfHeadersOctets d’en-têtes sur disque
0x400x40CheckSumVérifié uniquement pour les pilotes et certaines DLL système
0x440x44Subsystem2 = GUI, 3 = console, 1 = natif
0x460x46DllCharacteristicsMesures de sécurité
0x5C0x6CNumberOfRvaAndSizesGénéralement 16
0x600x70DataDirectory[]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 :

DrapeauValeurSignification
HIGH_ENTROPY_VA0x0020ASLR 64 bits avec entropie complète
DYNAMIC_BASE0x0040Peut être relogée (ASLR)
FORCE_INTEGRITY0x0080La signature doit être valide
NX_COMPAT0x0100Compatible avec DEP
NO_SEH0x0400Aucun gestionnaire d’exceptions structurées
GUARD_CF0x4000Control Flow Guard activé
TERMINAL_SERVER_AWARE0x8000Compatible 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épertoireCe qu’il localise
0ExportFonctions exportées par cette image
1ImportDLL et fonctions dont cette image a besoin
2ResourceIcônes, boîtes de dialogue, informations de version, blobs embarqués
3ExceptionTables de déroulement x64 (.pdata)
4CertificateSignature Authenticode — un offset de fichier, pas une RVA
5Base relocationCorrections pour un chargement à une autre base
6DebugChemin du PDB, horodatages de débogage
7ArchitectureRéservé, doit valoir zéro
8Global pointerInutilisé sur x86/x64
9TLSStockage local aux threads, y compris les callbacks TLS
10Load configCookie de sécurité, tables CFG, tables SEH
11Bound importAdresses d’import pré-résolues (obsolète)
12IATLa plage de l’import address table
13Delay importImports résolus à la première utilisation
14CLR runtimeMétadonnées .NET — c’est un binaire managé
15ReservedDoit 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.
  • SizeOfImage ou SizeOfHeaders incohérents avec la table des sections, ou NumberOfSections à 0 ou démesuré — des astuces qui font planter les parseurs naïfs.
  • Absence de DYNAMIC_BASE / NX_COMPAT sur 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_lfanew qui 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 pefile et 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.

  1. É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.c

    Sous Windows, dans un Developer Command Prompt : cl /O2 hello.c. Conservez les deux builds si possible — seul celui de MSVC contiendra un Rich header.

  2. Trouvez les en-têtes à la main. Affichez les premiers octets :

    bash
    xxd -l 0x40 hello.exe
    text
    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 lit 80 00 00 00 → e_lfanew = 0x80. Sautez à cet endroit (votre valeur peut différer) :

    bash
    xxd -s 0x80 -l 0x30 hello.exe
    text
    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, puis Machine = 0x8664 (x64), neuf sections, TimeDateStamp = 0x6ABBB8D8, SizeOfOptionalHeader = 0xF0, Characteristics = 0x022E. L’en-tête optionnel commence à 0x98 avec Magic = 0x20B (PE32+), et AddressOfEntryPoint se trouve 0x10 octets plus loin, à 0xA8 : 0x1440.

  3. 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
  4. 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 build

    print(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.

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

  6. 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 à 0x00 et e_lfanew à 0x3C sont les seuls champs de l’en-tête DOS dont Windows a besoin ; e_lfanew mè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.
  • Characteristics indique EXE ou DLL ; DllCharacteristics enregistre 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.