Skip to content

Leçon 2.2 · Formats binaires· 40 min

Sections PE et organisation mémoire

Comment la table des sections projette un PE en mémoire, comment convertir RVA et offsets de fichier, et quelles anomalies trahissent packers et loaders.

Objectifs

  • Lire chaque champ d’IMAGE_SECTION_HEADER et décoder les permissions des sections
  • Convertir à la main entre RVA, adresses virtuelles et offsets de fichier
  • Expliquer le rôle des sections courantes produites par MSVC et mingw-w64
  • Repérer les indicateurs de packer : écarts entre taille brute et taille virtuelle, sections RWX, noms étranges et entropie élevée

Les en-têtes vus dans En-têtes PE décrivent le fichier dans son ensemble. La table des sections en décrit les parties : quels octets sont du code, lesquels sont des données en lecture seule, lesquels sont des variables globales modifiables, et — surtout — où chaque partie se trouve sur disque par rapport à en mémoire. Ces deux organisations sont différentes, et presque toutes les confusions des débutants avec les fichiers PE viennent de ce qu’ils les mélangent.

En analyse de malwares (logiciels malveillants), les sections sont aussi l’endroit où les packers laissent leurs empreintes. Une section vide sur disque mais énorme en mémoire, à la fois inscriptible et exécutable, et remplie d’octets d’apparence aléatoire est la forme type d’un échantillon packé.

Deux organisations, un seul fichier

Sur disque, les sections sont serrées les unes contre les autres, alignées sur FileAlignment (généralement 0x200, soit 512 octets). En mémoire, le chargeur les espace sur des frontières de SectionAlignment (généralement 0x1000, une page de 4 Ko) afin que chaque section puisse recevoir ses propres protections de page.

text
        ON DISK (file offsets)              IN MEMORY (RVAs from ImageBase)
  0x0000 ┌──────────────────┐          0x0000 ┌──────────────────┐
         │ headers          │                 │ headers          │ R--
  0x0400 ├──────────────────┤          0x1000 ├──────────────────┤
         │ .text   (0x1C00) │ ───────────────►│ .text   (0x1A20) │ R-X
  0x2000 ├──────────────────┤                 │   ...padding     │
         │ .data   (0x200)  │ ──┐      0x3000 ├──────────────────┤
  0x2200 ├──────────────────┤   └────────────►│ .data   (0xA0)   │ RW-
         │ .rdata  (0xA00)  │ ──┐      0x4000 ├──────────────────┤
  0x2C00 ├──────────────────┤   └────────────►│ .rdata  (0x9C8)  │ R--
         │ ...              │                 │ ...              │
         └──────────────────┘                 └──────────────────┘

Les valeurs ci-dessus proviennent d’un build mingw-w64 64 bits d’un programme hello-world — le même binaire que vous compilerez dans le lab. Remarquez que .text commence à l’offset de fichier 0x400 mais à la RVA 0x1000. La position d’un octet dans le fichier ne vous dit rien de son adresse en mémoire tant que vous n’avez pas consulté la table des sections.

Astuce : Quand le dump mémoire d’un processus semble « désaligné » dans un parseur PE, c’est généralement parce que le dump est dans l’organisation mémoire alors que l’outil attend l’organisation fichier. Des outils comme PE-bear et pe-sieve peuvent reprojeter (« unmap ») un dump vers l’organisation fichier.

L’en-tête de section

La table des sections commence juste après l’en-tête optionnel, à e_lfanew + 4 + 20 + SizeOfOptionalHeader, et contient NumberOfSections entrées de 40 octets chacune :

OffsetChampTailleSignification
0x00Name8 octetsASCII, complété par des NUL, pas forcément terminé par NUL
0x08VirtualSizeDWORDTaille en mémoire (non alignée)
0x0CVirtualAddressDWORDRVA où commence la section en mémoire
0x10SizeOfRawDataDWORDTaille sur disque, multiple de FileAlignment
0x14PointerToRawDataDWORDOffset dans le fichier des données de la section
0x18PointerToRelocationsDWORDFichiers objets uniquement ; 0 dans les images
0x1CPointerToLinenumbersDWORDObsolète ; 0
0x20NumberOfRelocationsWORDFichiers objets uniquement
0x22NumberOfLinenumbersWORDObsolète
0x24CharacteristicsDWORDType de contenu et permissions mémoire

(VirtualSize s’appelle formellement Misc.VirtualSize dans winnt.h, d’où le nom Misc_VirtualSize dans pefile.)

Deux règles régissent l’usage que le chargeur fait de ces champs :

  • Il copie SizeOfRawData octets depuis PointerToRawData vers ImageBase + VirtualAddress.
  • Si VirtualSize est supérieure à SizeOfRawData, le reste de la section est rempli de zéros. C’est ainsi que les données non initialisées (.bss) ne coûtent rien sur disque.

Comme SizeOfRawData est arrondie au multiple supérieur de FileAlignment et que VirtualSize ne l’est pas, de petits écarts dans un sens ou dans l’autre sont normaux. Ci-dessus, .text a SizeOfRawData = 0x1C00 mais VirtualSize = 0x1A20 : les 0x1E0 octets supplémentaires sur disque sont du remplissage.

Characteristics : quoi et comment

Les bits de poids fort sont les permissions mémoire appliquées par le chargeur ; les bits de poids faible décrivent le contenu.

DrapeauValeurSignification
CNT_CODE0x00000020Contient du code
CNT_INITIALIZED_DATA0x00000040Contient des données initialisées
CNT_UNINITIALIZED_DATA0x00000080Contient des données non initialisées
MEM_DISCARDABLE0x02000000Peut être libérée après le chargement (p. ex. .reloc)
MEM_SHARED0x10000000Partagée entre processus
MEM_EXECUTE0x20000000Exécutable
MEM_READ0x40000000Lisible
MEM_WRITE0x80000000Inscriptible

Ainsi, 0x60000020 désigne du code, en lecture + exécution ; 0xC0000040 des données initialisées, en lecture + écriture ; 0x40000040 des données en lecture seule. Toute valeur où 0x20000000 et 0x80000000 sont tous deux activés — par exemple 0xE0000060 ou 0xE0000080 — est RWX. Voir la protection mémoire pour la correspondance avec les protections de page.

Les sections courantes

Les noms de section relèvent de la pure convention. Le chargeur ne les regarde jamais ; il utilise les répertoires de données pour trouver les imports, les ressources, etc. Les compilateurs sont néanmoins cohérents, si bien que les noms servent de repère rapide.

NomPermissions typiquesContenu
.textR-XCode compilé, y compris le code de démarrage de la CRT
.rdataR--Constantes, chaînes littérales, souvent les tables d’imports et d’exports (MSVC)
.dataRW-Variables globales et statiques initialisées
.bssRW-Variables globales non initialisées ; SizeOfRawData vaut 0 (mingw)
.idataR-- ou RW-Tables d’imports dans leur propre section (mingw, anciens éditeurs de liens)
.edataR--Table des exports dans sa propre section (DLL mingw)
.pdataR--Table des fonctions d’exception/de déroulement x64 (RUNTIME_FUNCTION)
.xdataR--Informations de déroulement x64 (mingw)
.rsrcR--Arbre des ressources : icônes, manifestes, informations de version, fichiers embarqués
.relocR--, libérableRelocations de base pour un chargement à une base non préférée
.tlsRW-Modèle du stockage local aux threads
.CRTR--Tables d’initialiseurs du runtime C (MSVC)

Les écarts racontent des histoires. UPX0/UPX1, .aspack, .themida, .vmp0, .enigma1 et .MPRESS1 sont des noms de packers ou de protecteurs. Des chaînes aléatoires, des noms vides ou un nom comme .text présent deux fois suggèrent un packer maison ou un fichier construit à la main. Mais comme le chargeur ignore les noms, un auteur soigneux peut appeler sa charge utile chiffrée .rdata — les noms sont donc des indices, jamais des preuves.

RVA ↔ offset de fichier : un exemple détaillé

Vous ferez cette conversion en permanence : un débogueur vous montre une adresse et vous voulez patcher ou extraire les octets dans le fichier ; ou bien un en-tête vous donne une RVA et vous voulez la retrouver avec xxd.

La formule, pour la section qui contient la RVA :

text
file_offset = RVA - section.VirtualAddress + section.PointerToRawData
VA          = ImageBase + RVA

Une section contient une RVA si VirtualAddress <= RVA < VirtualAddress + VirtualSize. Prenons notre build hello-world, avec ImageBase = 0x140000000 :

SectionVirtualAddressVirtualSizePointerToRawDataSizeOfRawData
.text0x10000x1A200x4000x1C00
.data0x30000xA00x20000x200
.rdata0x40000x9C80x22000xA00
.bss0x70000x1800x00x0

Exemple 1 — le point d’entrée. AddressOfEntryPoint = 0x1440. Il tombe dans .text (0x1000 ≤ 0x1440 < 0x2A20) :

text
file_offset = 0x1440 - 0x1000 + 0x400 = 0x840
VA          = 0x140000000 + 0x1440 = 0x140001440

xxd -s 0x840 -l 16 hello.exe affiche les premiers octets du code de démarrage, et un débogueur s’arrêtera à 0x140001440 si l’ASLR ne déplace pas l’image.

Exemple 2 — le répertoire TLS. Le répertoire de données 9 indique la RVA 0x4040. Elle se trouve dans .rdata : 0x4040 - 0x4000 + 0x2200 = 0x2240.

Exemple 3 — un piège. La RVA 0x7010 est dans .bss. Elle n’a aucun offset de fichier : ces octets n’existent qu’en mémoire, sous forme de zéros. Il en va de même pour la partie de toute section située au-delà de SizeOfRawData. Le code que l’unpacker écrit dans une telle région à l’exécution est introuvable dans le fichier — il faut dumper la mémoire.

Attention : Un vrai parseur doit aussi gérer les en-têtes (les RVA situées avant la première section correspondent 1:1 aux offsets de fichier), les valeurs de PointerToRawData non alignées (le chargeur les arrondit au multiple de 512 inférieur) et les sections qui se chevauchent. Les malwares s’appuient parfois précisément sur ces cas limites pour tromper les outils. Dans pefile, utilisez pe.get_offset_from_rva() plutôt que d’écrire votre propre fonction pour tout usage sérieux.

Anomalies de section et ce qu’elles suggèrent

Taille virtuelle bien supérieure à la taille brute

Un packer compresse le programme d’origine dans une section et réserve une section vide assez grande pour le contenir une fois décompressé. L’exemple classique est UPX : UPX0 a SizeOfRawData = 0 mais une VirtualSize égale à l’image dépaquetée, et UPX1 contient les données compressées ainsi que le stub de décompression. À l’exécution, le stub décompresse dans UPX0, corrige les imports et saute au point d’entrée original (OEP). Voir le packing UPX et les crypters à l’exécution pour la famille plus large.

Une section de type .bss avec une taille brute de 0 est normale. Une section exécutable de taille brute 0 ne l’est pas : du code absent du fichier doit y être écrit à l’exécution.

Sections inscriptibles et exécutables

Les compilateurs normaux ne produisent jamais de sections RWX. Le code est R-X, les données RW-. Une section qui a à la fois les permissions d’écriture et d’exécution existe parce que quelque chose compte écrire du code puis l’exécuter — un unpacker, un crypter ou du code auto-modifiant. C’est l’un des indicateurs statiques de packer les plus fiables.

Point d’entrée dans une section inattendue

Le point d’entrée se trouve normalement dans .text (ou dans la première section exécutable). Un point d’entrée dans la dernière section, dans .rsrc, dans une section portant un nom de packer ou dans une section inscriptible désigne un stub d’unpacking (dépaquetage). Voir l’obfuscation du point d’entrée.

Entropie élevée

L’entropie de Shannon mesure l’imprévisibilité des octets, de 0 (tous les octets identiques) à 8 bits par octet (parfaitement aléatoire). Ordres de grandeur :

EntropieContenu typique
0 – 1Zéros, remplissage, sections vides
4,5 – 6,5Code natif x86/x64, texte, tables
6,5 – 7,2Code dense, certaines ressources
> 7,2Données compressées ou chiffrées

Calculez toujours l’entropie par section, et non sur le fichier entier : une grande section .text non compressée peut masquer un petit blob chiffré dans .data. Rappelez-vous aussi que les images, les installateurs compressés et les certificats ont légitimement une entropie élevée. Detect It Easy et PE-bear tracent tous deux des graphes d’entropie ; l’entrée de glossaire entropie couvre les mathématiques.

Autres bizarreries

  • Des données de section qui s’étendent au-delà de la fin du fichier (échantillon tronqué ou corrompu).
  • Des trous entre les sections sur disque, ou des données après la dernière section — un overlay, traité dans Ressources, overlays et autres cachettes.
  • Une SizeOfImage inférieure à la fin de la dernière section.
  • Une section ou un répertoire TLS dans un petit programme qui n’a aucune raison d’utiliser le stockage local aux threads — cherchez des callbacks TLS.

Lab : un script de triage des sections

Vous allez écrire un petit analyseur de sections, l’exécuter sur un programme inoffensif, puis packer ce programme avec UPX et regarder les indicateurs apparaître. UPX est un packer légitime et open source, et le résultat reste votre programme inoffensif.

  1. Compilez le binaire hello-world de la leçon précédente si vous ne l’avez pas :

    bash
    x86_64-w64-mingw32-gcc -O0 -s -o hello.exe hello.c
  2. Installez les outils : pip install pefile, et UPX via votre gestionnaire de paquets (apt install upx-ucl ou brew install upx).

  3. Enregistrez le script :

    python
    # sections.py
    import math, sys
    import pefile
    
    STANDARD = {".text", ".rdata", ".data", ".pdata", ".xdata", ".rsrc",
                ".reloc", ".tls", ".bss", ".idata", ".edata", ".CRT"}
    R, W, X = 0x40000000, 0x80000000, 0x20000000
    
    def entropy(buf: bytes) -> float:
        if not buf:
            return 0.0
        counts = [0] * 256
        for b in buf:
            counts[b] += 1
        n = len(buf)
        return abs(-sum(c / n * math.log2(c / n) for c in counts if c))
    
    pe = pefile.PE(sys.argv[1])
    ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint
    print(f"{'name':8} {'VA':>8} {'VSize':>8} {'RawOff':>8} {'RawSize':>8} perm  {'H':>5}  flags")
    for s in pe.sections:
        name = s.Name.rstrip(b"\x00").decode(errors="replace")
        c = s.Characteristics
        perm = ("R" if c & R else "-") + ("W" if c & W else "-") + ("X" if c & X else "-")
        h = entropy(s.get_data())
        flags = []
        if c & W and c & X:
            flags.append("RWX")
        if s.SizeOfRawData == 0 and s.Misc_VirtualSize > 0 and c & X:
            flags.append("exec-but-empty-on-disk")
        elif s.SizeOfRawData and s.Misc_VirtualSize > 4 * s.SizeOfRawData:
            flags.append("vsize>>raw")
        if name not in STANDARD:
            flags.append("odd-name")
        if h > 7.2:
            flags.append("high-entropy")
        if s.contains_rva(ep):
            flags.append("<-EP")
        print(f"{name:8} {s.VirtualAddress:#8x} {s.Misc_VirtualSize:#8x} "
              f"{s.PointerToRawData:#8x} {s.SizeOfRawData:#8x} {perm}   {h:5.2f}  "
              + " ".join(flags))
  4. Exécutez-le sur le binaire non packé :

    bash
    python3 sections.py hello.exe
    text
    name           VA    VSize   RawOff  RawSize perm      H  flags
    .text      0x1000   0x1a20    0x400   0x1c00 R-X    5.69  <-EP
    .data      0x3000     0xa0   0x2000    0x200 RW-    0.57
    .rdata     0x4000    0x9c8   0x2200    0xa00 R--    4.03
    .pdata     0x5000    0x21c   0x2c00    0x400 R--    2.30
    .xdata     0x6000    0x1a4   0x3000    0x200 R--    3.31
    .bss       0x7000    0x180      0x0      0x0 RW-    0.00
    .idata     0x8000    0x874   0x3200    0xa00 R--    3.52
    .tls       0x9000     0x10   0x3c00    0x200 RW-    0.00
    .reloc     0xa000     0x60   0x3e00    0x200 R--    1.24

    Les valeurs exactes dépendent de votre version de mingw-w64. C’est la forme qui compte : noms standard, pas de RWX, point d’entrée dans .text, entropie inférieure à 6.

  5. Packez une copie et relancez le script :

    bash
    upx -o hello_upx.exe hello.exe
    python3 sections.py hello_upx.exe
    text
    name           VA    VSize   RawOff  RawSize perm      H  flags
    UPX0       0x1000   0xa000    0x200      0x0 RWX    0.00  RWX exec-but-empty-on-disk odd-name
    UPX1       0xb000   0x2000    0x200   0x1c00 RWX    7.49  RWX odd-name high-entropy <-EP
    UPX2       0xd000   0x1000   0x1e00    0x400 RW-    3.31  odd-name

    Selon la version d’UPX et selon que l’entrée contient ou non des ressources, la troisième section peut s’appeler .rsrc au lieu de UPX2.

  6. Convertissez une RVA à la main. Récupérez la RVA du point d’entrée de chaque fichier avec pefile (pe.OPTIONAL_HEADER.AddressOfEntryPoint), calculez son offset de fichier avec la formule ci-dessus, puis vérifiez votre réponse avec pe.get_offset_from_rva(rva) et examinez les octets avec xxd -s.

  7. Examinez les deux fichiers dans PE-bear. Ouvrez l’onglet Section Hdrs et comparez les colonnes brutes et virtuelles, puis regardez la carte des sections. Remarquez comment UPX0 occupe de l’espace d’adressage mais aucun espace dans le fichier.

  8. Dépaquetez et comparez : upx -d -o hello_unpacked.exe hello_upx.exe, puis relancez le script une dernière fois.

Questions : Pourquoi UPX0 doit-elle être inscriptible et exécutable ? Quelle est la SizeOfImage du fichier packé comparée à celle de l’original, et pourquoi est-elle au moins aussi grande ? À quel indicateur de votre sortie feriez-vous le moins confiance pris isolément, et pourquoi ? hello_unpacked.exe est-il identique octet pour octet à hello.exe (comparez les hashs) ?

À retenir

  • Chaque en-tête de section de 40 octets projette SizeOfRawData octets situés à PointerToRawData sur disque vers VirtualAddress en mémoire ; tout ce qui dépasse la taille brute jusqu’à VirtualSize est rempli de zéros.
  • file_offset = RVA − VirtualAddress + PointerToRawData, pour la section qui contient la RVA ; les RVA situées dans des régions remplies de zéros n’ont pas d’offset de fichier.
  • Les noms de section ne sont qu’une convention ; les permissions viennent de Characteristics.
  • Indicateurs de packer : sections exécutables vides sur disque, VirtualSize très supérieure à la taille brute, permissions RWX, noms inhabituels, point d’entrée hors de .text et entropie par section supérieure à environ 7,2.
  • Aucun indicateur isolé n’est une preuve — combinez-les, puis confirmez dynamiquement.