Análisis de malware: visión general de la estructura PE
Análisis de la estructura interna de los archivos PE de Windows. Desde el DOS Header hasta las secciones, entendiendo cómo Windows carga y ejecuta binarios.
Introducción
Cada vez que abres un .exe en Windows, el sistema operativo no está haciendo magia. Está leyendo una estructura muy concreta, definida desde los 90, que le dice exactamente qué hacer: qué cargar en memoria, dónde, con qué permisos, qué librerías necesita y por dónde empezar a ejecutar código. Esa estructura es el formato PE (Portable Executable).
Si te dedicas a analizar malware, hacer reversing o simplemente quieres entender qué pasa cuando haces doble click en un binario, entender los PE Headers es un big plus. Casi todas las técnicas de evasión, packing, inyección y persistencia que se ven en muestras reales tocan estos headers de alguna manera.
¿Qué es un archivo PE?
PE significa Portable Executable, y es el formato que usa Windows para sus ejecutables (.exe), librerías dinámicas (.dll), drivers (.sys), y unos cuantos más. Microsoft lo introdujo con Windows NT en 1993 como evolución del formato COFF de Unix, y desde entonces no ha cambiado tanto como se podría pensar.
La idea principal del formato es separar el archivo en dos partes lógicas:
- Los headers, que son metadatos: le dicen al loader de Windows cómo tratar el archivo.
- Las secciones, que contienen el contenido real: código, datos, recursos, etc.
Cuando ejecutas un binario, el cargador (ntdll.dll para ser exactos) parsea los headers, mapea las secciones a memoria según lo que indiquen, resuelve las importaciones y finalmente salta al EntryPoint. Todo lo que pase mal en ese proceso es donde aparecen las cosas interesantes.
Estructura general
Un PE válido tiene esta forma a grandes rasgos:
+------------------------+
| DOS Header (MZ) | <- Compatibilidad legacy
+------------------------+
| DOS Stub | <- "This program cannot be run in DOS mode"
+------------------------+
| NT Headers |
| - Signature (PE\0\0) |
| - File Header |
| - Optional Header |
+------------------------+
| Section Headers | <- Una entrada por cada seccion
+------------------------+
| Section 1 (.text) |
| Section 2 (.data) |
| Section 3 (.rdata) |
| ... |
+------------------------+Vamos parte por parte.
DOS Header
Lo primero que te encuentras al abrir un PE en un editor hexadecimal son los bytes 4D 5A, que en ASCII son MZ. Esas iniciales son de Mark Zbikowski, uno de los desarrolladores originales de MS-DOS. Si esos dos bytes no están ahí, no es un PE válido.
El DOS Header es una estructura llamada IMAGE_DOS_HEADER y ocupa 64 bytes. Casi todos sus campos son irrelevantes hoy en día (están ahí por compatibilidad con DOS), salvo dos:
e_magic: los famosos bytesMZal principio.e_lfanew: el offset (en los últimos 4 bytes del DOS Header, en posición0x3C) que apunta al inicio de los NT Headers. Este campo es crítico, porque el loader lo usa para saltar directamente a la parte que de verdad importa.
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; // 0x5A4D ("MZ")
WORD e_cblp;
WORD e_cp;
// ... campos irrelevantes ...
LONG e_lfanew; // Offset a IMAGE_NT_HEADERS
} IMAGE_DOS_HEADER;DOS Stub
Después del DOS Header viene un pequeño programa de DOS que simplemente imprime “This program cannot be run in DOS mode” y termina. Existe por si alguien intenta ejecutar un PE moderno en DOS real, no para que se cuelgue sin más.
Es completamente prescindible. Puedes reemplazarlo por lo que quieras (algunos malwares lo usan para esconder shellcode, payloads cifrados o mensajes) y el binario seguirá funcionando perfectamente en Windows. Es una zona del archivo que casi nadie mira, lo que la hace atractiva para esconder cosas.
NT Headers
Aquí empieza lo serio. Los NT Headers (IMAGE_NT_HEADERS) contienen toda la información que Windows necesita para cargar el ejecutable. La estructura tiene tres partes:
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER OptionalHeader;
} IMAGE_NT_HEADERS;Signature
Cuatro bytes: 50 45 00 00, que en ASCII son PE\0\0. Es el segundo magic number del archivo. Si los bytes MZ te dicen “esto puede ser un ejecutable de Windows”, el PE\0\0 confirma “esto es definitivamente un PE moderno”.
File Header (IMAGE_FILE_HEADER)
20 bytes con metadatos básicos del archivo:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // Arquitectura (x86, x64, ARM...)
WORD NumberOfSections; // Cuantas secciones hay
DWORD TimeDateStamp; // Fecha de compilacion (UNIX timestamp)
DWORD PointerToSymbolTable; // Tipicamente 0 en binarios release
DWORD NumberOfSymbols; // Tipicamente 0 en binarios release
WORD SizeOfOptionalHeader; // Tamaño del Optional Header
WORD Characteristics; // Flags del archivo
} IMAGE_FILE_HEADER;Algunos campos que vale la pena conocer:
- Machine: identifica la arquitectura. Los valores comunes son
0x014C(x86),0x8664(x64) y0xAA64(ARM64). Si analizas malware en bulk, este campo te ayuda a filtrar rápido. - TimeDateStamp: el timestamp de cuando se compiló. En teoría. En la práctica los atacantes lo modifican constantemente para entorpecer el análisis y la atribución. Técnica conocida como timestomping.
- NumberOfSections: cuántas secciones tiene el binario. Un PE normal suele tener entre 3 y 8 secciones. Si ves 20+ o solo 1, sospecha.
- Characteristics: bitmask con flags como
IMAGE_FILE_EXECUTABLE_IMAGE,IMAGE_FILE_DLL,IMAGE_FILE_LARGE_ADDRESS_AWARE, etc.
Optional Header (IMAGE_OPTIONAL_HEADER)
A pesar del nombre, el Optional Header no es opcional en absoluto para los ejecutables. Es donde está la información más importante para cargar el binario. Tiene dos versiones: IMAGE_OPTIONAL_HEADER32 (224 bytes) e IMAGE_OPTIONAL_HEADER64 (240 bytes), dependiendo de la arquitectura.
Los campos clave:
- Magic:
0x10Bpara PE32 (32 bits),0x20Bpara PE32+ (64 bits). - AddressOfEntryPoint: el RVA (Relative Virtual Address) donde empieza la ejecución. Es decir, la primera instrucción que se ejecuta cuando lanzas el binario, relativa a
ImageBase. - ImageBase: la dirección virtual donde el loader prefiere cargar el binario. Por defecto suele ser
0x400000para PE32 y0x140000000para PE32+. Si ASLR está activo (lo normal hoy en día), esto se ignora y se elige una dirección aleatoria. - SectionAlignment: alineamiento de las secciones en memoria (típicamente
0x1000, una página). - FileAlignment: alineamiento de las secciones en el archivo (típicamente
0x200). - SizeOfImage: tamaño total del binario una vez cargado en memoria, alineado a
SectionAlignment. - SizeOfHeaders: tamaño de todos los headers juntos, alineado a
FileAlignment. - Subsystem: si es una aplicación de consola (
3), GUI (2), driver (1), etc. - DllCharacteristics: flags de seguridad como
DYNAMIC_BASE(ASLR),NX_COMPAT(DEP),GUARD_CF(Control Flow Guard). - DataDirectory: array de 16 entradas que apunta a estructuras importantes dentro del binario.
Data Directories
Esto es importante. El array DataDirectory contiene punteros (RVA + tamaño) a 16 estructuras opcionales, cada una con un propósito específico:
| Índice | Estructura | Para qué sirve |
|---|---|---|
| 0 | Export Table | Funciones exportadas (DLLs) |
| 1 | Import Table | Funciones importadas de otras DLLs |
| 2 | Resource Table | Iconos, strings, diálogos, manifests |
| 3 | Exception Table | Manejo de excepciones (x64) |
| 4 | Certificate Table | Firmas digitales |
| 5 | Base Relocation Table | Para reubicar el binario si ASLR mueve la base |
| 6 | Debug Directory | Información de depuración |
| 9 | TLS Table | Thread Local Storage callbacks |
| 12 | Import Address Table (IAT) | Tabla resuelta de imports |
| 14 | CLR Runtime Header | Para binarios .NET |
Las más importantes para reversing y análisis de malware son la Import Table, la Export Table y la TLS Table (esta última muy usada para anti-debugging porque sus callbacks se ejecutan antes del EntryPoint).
Section Headers
Justo después de los NT Headers viene un array de IMAGE_SECTION_HEADER, una estructura por cada sección del binario. Cada section header ocupa 40 bytes:
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[8]; // Nombre (".text", ".data", etc.)
DWORD VirtualSize; // Tamaño en memoria
DWORD VirtualAddress; // RVA donde se carga
DWORD SizeOfRawData; // Tamaño en disco
DWORD PointerToRawData; // Offset en el archivo
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; // Permisos y flags
} IMAGE_SECTION_HEADER;El nombre tiene un máximo de 8 bytes y NO necesita estar terminado en null. Si la sección se llama exactamente .text (5 caracteres), los 3 bytes restantes son nulls. Si se llama .textbss (8 caracteres), no hay null terminator. Esto es un detalle que hay que tener en cuenta al parsear PE manualmente.
Secciones típicas
- .text: contiene el código ejecutable. Permisos: lectura y ejecución.
- .data: variables globales y estáticas inicializadas. Permisos: lectura y escritura.
- .rdata: datos de solo lectura, incluyendo strings, tablas de imports y constantes. Permisos: solo lectura.
- .bss: variables no inicializadas (a menudo fusionada con
.data). - .rsrc: recursos como iconos, diálogos, version info y manifests.
- .reloc: información de relocation para cuando ASLR cambia la base.
- .idata: tabla de imports (a veces fusionada con
.rdata). - .edata: tabla de exports (en DLLs).
- .tls: callbacks de Thread Local Storage.
Characteristics y permisos
El campo Characteristics de cada sección define los permisos en memoria. Los flags relevantes son:
IMAGE_SCN_MEM_EXECUTE(0x20000000): ejecutable.IMAGE_SCN_MEM_READ(0x40000000): legible.IMAGE_SCN_MEM_WRITE(0x80000000): escribible.IMAGE_SCN_CNT_CODE(0x00000020): contiene código.IMAGE_SCN_CNT_INITIALIZED_DATA(0x00000040): datos inicializados.IMAGE_SCN_MEM_SHARED(0x10000000): sección compartida entre procesos.
Diferencia entre VirtualSize y SizeOfRawData
Esta es una de las cosas que más confusión genera al principio. Las secciones tienen dos tamaños distintos:
SizeOfRawData: lo que ocupa la sección en el archivo en disco, alineado aFileAlignment.VirtualSize: lo que ocupa la sección en memoria, alineado aSectionAlignment.
Pueden ser diferentes. Si VirtualSize > SizeOfRawData, la diferencia se rellena con ceros en memoria (esto es común en .bss y partes no inicializadas). Si VirtualSize < SizeOfRawData, la sección ocupa menos en memoria que en disco, lo cual es raro.
Cuando este desajuste es muy grande, es bandera roja. Los packers tipo UPX comprimen el código en una sección pequeña en disco que se expande masivamente al ejecutarse. Por ejemplo: una sección con SizeOfRawData = 0x200 pero VirtualSize = 0x50000 te está diciendo “voy a generar 320KB de algo en runtime”.
Cómo Windows carga un PE
El proceso simplificado es este:
CreateProcessparsea el DOS Header y salta ae_lfanew.- Verifica la firma
PE\0\0y lee los NT Headers. - Reserva memoria virtual del tamaño
SizeOfImageenImageBase(o donde decida ASLR). - Mapea cada sección en su
VirtualAddress, copiandoSizeOfRawDatabytes desdePointerToRawData. - Aplica los permisos definidos en
Characteristicsa cada región. - Si la base cambió, aplica las relocations usando la
Base Relocation Table. - Resuelve la
Import Table: carga las DLLs requeridas y rellena la IAT con las direcciones reales. - Ejecuta los
TLS callbackssi los hay. - Salta a
ImageBase + AddressOfEntryPointy empieza a ejecutar el código.
Cualquier cosa rara que ocurra en este proceso (imports manuales, secciones que se descifran a sí mismas, callbacks TLS con código malicioso) es donde los atacantes meten sus técnicas.
Indicadores de manipulación
A la hora de analizar un binario sospechoso, hay cosas en los headers que deberían llamarte la atención:
- TimeDateStamp con valores absurdos (futuro, 0, valores muy redondos, fechas anteriores a 1995).
- Pocos imports o ninguno: un PE legítimo importa decenas de funciones de
kernel32.dll,user32.dll, etc. Si solo importaLoadLibraryyGetProcAddress, está resolviendo todo dinámicamente para ocultar su comportamiento. - Nombres de sección no estándar: ver
.text,.data,.rdata,.rsrces normal. VerUPX0,UPX1,.aspack,.themida, o nombres random como.x1es señal de packer. - Secciones con permisos
RWX. - Entropía alta (>7.0) en una sección: indica datos cifrados o comprimidos.
- Discrepancia grande entre
VirtualSizeySizeOfRawData. - EntryPoint fuera de la sección
.text(apuntando a.data, a una sección del packer, o incluso fuera de cualquier sección). - Overlay grande: datos después del final de la última sección (puede ser un installer, pero también malware con un payload extra).
- Tabla de imports vacía o malformada combinada con código que claramente hace syscalls.
Herramientas para analizar PEs
Si quieres meterle mano a un PE para inspeccionar todo esto:
- PE-bear y PEStudio: GUI, muy cómodos para una primera mirada.
- CFF Explorer: clásico, permite editar campos directamente.
- pefile (Python): para análisis automatizado, scripts y triage masivo.
- LIEF (Python/C++): más potente que pefile, soporta múltiples formatos.
- dumpbin: viene con Visual Studio, útil para inspección rápida desde línea de comandos.
- rabin2 (de radare2): si ya usas radare2, lo tienes a mano.
Para una primera aproximación, PE-bear es probablemente la opción más cómoda. Para automatizar, pefile suele ser el estándar.
- 01 Microsoft — PE Format Specification learn.microsoft.com/en-us/windows/win32/debug/pe-format ↗
- 02 M. Sikorski & A. Honig — Practical Malware Analysis No Starch Press, 2012 · libro (caps. 1-3)
- 03 Russinovich, Solomon & Ionescu — Windows Internals Microsoft Press · libro