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.

· malwarian

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:

text
+------------------------+
| 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 bytes MZ al principio.
  • e_lfanew: el offset (en los últimos 4 bytes del DOS Header, en posición 0x3C) 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.
c
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:

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

c
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) y 0xAA64 (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: 0x10B para PE32 (32 bits), 0x20B para 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 0x400000 para PE32 y 0x140000000 para 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:

ÍndiceEstructuraPara qué sirve
0Export TableFunciones exportadas (DLLs)
1Import TableFunciones importadas de otras DLLs
2Resource TableIconos, strings, diálogos, manifests
3Exception TableManejo de excepciones (x64)
4Certificate TableFirmas digitales
5Base Relocation TablePara reubicar el binario si ASLR mueve la base
6Debug DirectoryInformación de depuración
9TLS TableThread Local Storage callbacks
12Import Address Table (IAT)Tabla resuelta de imports
14CLR Runtime HeaderPara 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:

c
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 a FileAlignment.
  • VirtualSize: lo que ocupa la sección en memoria, alineado a SectionAlignment.

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:

  1. CreateProcess parsea el DOS Header y salta a e_lfanew.
  2. Verifica la firma PE\0\0 y lee los NT Headers.
  3. Reserva memoria virtual del tamaño SizeOfImage en ImageBase (o donde decida ASLR).
  4. Mapea cada sección en su VirtualAddress, copiando SizeOfRawData bytes desde PointerToRawData.
  5. Aplica los permisos definidos en Characteristics a cada región.
  6. Si la base cambió, aplica las relocations usando la Base Relocation Table.
  7. Resuelve la Import Table: carga las DLLs requeridas y rellena la IAT con las direcciones reales.
  8. Ejecuta los TLS callbacks si los hay.
  9. Salta a ImageBase + AddressOfEntryPoint y 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 importa LoadLibrary y GetProcAddress, está resolviendo todo dinámicamente para ocultar su comportamiento.
  • Nombres de sección no estándar: ver .text, .data, .rdata, .rsrc es normal. Ver UPX0, UPX1, .aspack, .themida, o nombres random como .x1 es 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 VirtualSize y SizeOfRawData.
  • 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.

Referencias
  1. 01 Microsoft — PE Format Specification learn.microsoft.com/en-us/windows/win32/debug/pe-format ↗
  2. 02 M. Sikorski & A. Honig — Practical Malware Analysis No Starch Press, 2012 · libro (caps. 1-3)
  3. 03 Russinovich, Solomon & Ionescu — Windows Internals Microsoft Press · libro