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.
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 :
| Offset | Champ | Taille | Signification |
|---|---|---|---|
0x00 | Name | 8 octets | ASCII, complété par des NUL, pas forcément terminé par NUL |
0x08 | VirtualSize | DWORD | Taille en mémoire (non alignée) |
0x0C | VirtualAddress | DWORD | RVA où commence la section en mémoire |
0x10 | SizeOfRawData | DWORD | Taille sur disque, multiple de FileAlignment |
0x14 | PointerToRawData | DWORD | Offset dans le fichier des données de la section |
0x18 | PointerToRelocations | DWORD | Fichiers objets uniquement ; 0 dans les images |
0x1C | PointerToLinenumbers | DWORD | Obsolète ; 0 |
0x20 | NumberOfRelocations | WORD | Fichiers objets uniquement |
0x22 | NumberOfLinenumbers | WORD | Obsolète |
0x24 | Characteristics | DWORD | Type 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
SizeOfRawDataoctets depuisPointerToRawDataversImageBase + VirtualAddress. - Si
VirtualSizeest 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.
| Drapeau | Valeur | Signification |
|---|---|---|
CNT_CODE | 0x00000020 | Contient du code |
CNT_INITIALIZED_DATA | 0x00000040 | Contient des données initialisées |
CNT_UNINITIALIZED_DATA | 0x00000080 | Contient des données non initialisées |
MEM_DISCARDABLE | 0x02000000 | Peut être libérée après le chargement (p. ex. .reloc) |
MEM_SHARED | 0x10000000 | Partagée entre processus |
MEM_EXECUTE | 0x20000000 | Exécutable |
MEM_READ | 0x40000000 | Lisible |
MEM_WRITE | 0x80000000 | Inscriptible |
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.
| Nom | Permissions typiques | Contenu |
|---|---|---|
.text | R-X | Code compilé, y compris le code de démarrage de la CRT |
.rdata | R-- | Constantes, chaînes littérales, souvent les tables d’imports et d’exports (MSVC) |
.data | RW- | Variables globales et statiques initialisées |
.bss | RW- | Variables globales non initialisées ; SizeOfRawData vaut 0 (mingw) |
.idata | R-- ou RW- | Tables d’imports dans leur propre section (mingw, anciens éditeurs de liens) |
.edata | R-- | Table des exports dans sa propre section (DLL mingw) |
.pdata | R-- | Table des fonctions d’exception/de déroulement x64 (RUNTIME_FUNCTION) |
.xdata | R-- | Informations de déroulement x64 (mingw) |
.rsrc | R-- | Arbre des ressources : icônes, manifestes, informations de version, fichiers embarqués |
.reloc | R--, libérable | Relocations de base pour un chargement à une base non préférée |
.tls | RW- | Modèle du stockage local aux threads |
.CRT | R-- | 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 :
file_offset = RVA - section.VirtualAddress + section.PointerToRawData
VA = ImageBase + RVAUne section contient une RVA si
VirtualAddress <= RVA < VirtualAddress + VirtualSize. Prenons notre build
hello-world, avec ImageBase = 0x140000000 :
| Section | VirtualAddress | VirtualSize | PointerToRawData | SizeOfRawData |
|---|---|---|---|---|
.text | 0x1000 | 0x1A20 | 0x400 | 0x1C00 |
.data | 0x3000 | 0xA0 | 0x2000 | 0x200 |
.rdata | 0x4000 | 0x9C8 | 0x2200 | 0xA00 |
.bss | 0x7000 | 0x180 | 0x0 | 0x0 |
Exemple 1 — le point d’entrée. AddressOfEntryPoint = 0x1440. Il tombe dans
.text (0x1000 ≤ 0x1440 < 0x2A20) :
file_offset = 0x1440 - 0x1000 + 0x400 = 0x840
VA = 0x140000000 + 0x1440 = 0x140001440xxd -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
PointerToRawDatanon 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, utilisezpe.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 :
| Entropie | Contenu typique |
|---|---|
| 0 – 1 | Zéros, remplissage, sections vides |
| 4,5 – 6,5 | Code natif x86/x64, texte, tables |
| 6,5 – 7,2 | Code dense, certaines ressources |
| > 7,2 | Donné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
SizeOfImageinfé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.
-
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 -
Installez les outils :
pip install pefile, et UPX via votre gestionnaire de paquets (apt install upx-ucloubrew install upx). -
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)) -
Exécutez-le sur le binaire non packé :
bash python3 sections.py hello.exetext 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.24Les 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. -
Packez une copie et relancez le script :
bash upx -o hello_upx.exe hello.exe python3 sections.py hello_upx.exetext 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-nameSelon la version d’UPX et selon que l’entrée contient ou non des ressources, la troisième section peut s’appeler
.rsrcau lieu deUPX2. -
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 avecpe.get_offset_from_rva(rva)et examinez les octets avecxxd -s. -
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
UPX0occupe de l’espace d’adressage mais aucun espace dans le fichier. -
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
SizeOfRawDataoctets situés àPointerToRawDatasur disque versVirtualAddressen mémoire ; tout ce qui dépasse la taille brute jusqu’àVirtualSizeest 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,
VirtualSizetrès supérieure à la taille brute, permissions RWX, noms inhabituels, point d’entrée hors de.textet entropie par section supérieure à environ 7,2. - Aucun indicateur isolé n’est une preuve — combinez-les, puis confirmez dynamiquement.