Formato PE de Windows: Estructura Completa del Ejecutable
Guia completa del formato Portable Executable (PE) de Windows. DOS header, PE signature, COFF header, Optional header, secciones, imports, exports y resources. Fundamentos para analisis de malware.
El formato que domina el analisis de malware
Mas del 90% del malware en circulacion apunta a sistemas Windows. Y todo ejecutable de Windows, desde un simple .exe hasta un driver del kernel, sigue el formato PE (Portable Executable). Entender su estructura no es opcional para un analista de malware: es el primer paso obligatorio antes de abrir cualquier desensamblador.
El formato PE fue introducido con Windows NT en 1993 como evolucion del formato COFF (Common Object File Format) heredado de Unix. A pesar de tener mas de 30 anos, la estructura fundamental no ha cambiado. Lo que si ha cambiado es como los autores de malware abusan de cada campo para evadir detecciones, ocultar payloads y dificultar el analisis.
Este articulo cubre la estructura completa del PE, desde el primer byte hasta las tablas de imports y exports. Al terminar, sabras leer cualquier cabecera PE con herramientas como PEStudio, PE-bear o la libreria pefile de Python.
Estructura general del formato PE
Un archivo PE se organiza en capas, cada una construida sobre la anterior:
| Offset | Componente | Tamano tipico | Funcion |
|---|---|---|---|
| 0x00 | DOS Header | 64 bytes | Compatibilidad DOS, puntero a PE header |
| 0x3C | (e_lfanew) | 4 bytes | Offset al PE Signature |
| Variable | DOS Stub | ~100 bytes | Programa DOS "This program cannot be run in DOS mode" |
| e_lfanew | PE Signature | 4 bytes | "PE\0\0" (0x50450000) |
| +4 | COFF File Header | 20 bytes | Arquitectura, numero de secciones, timestamp |
| +24 | Optional Header | 96/112 bytes | Entry point, image base, data directories |
| Variable | Section Headers | 40 bytes cada una | Descripcion de cada seccion |
| Variable | Section Data | Variable | Codigo, datos, recursos, imports |
La lectura siempre empieza por el DOS Header, que apunta al PE Signature. Desde ahi se accede al COFF Header y al Optional Header. Los Section Headers describen donde esta cada bloque de datos en el archivo.
DOS Header: el legado de MS-DOS
Los primeros 64 bytes de todo archivo PE son el DOS Header, definido por la estructura IMAGE_DOS_HEADER. Dos campos son relevantes para el analisis de malware:
e_magic (offset 0x00, 2 bytes): El magic number "MZ" (0x4D5A), las iniciales de Mark Zbikowski, uno de los arquitectos de MS-DOS. Si un archivo no empieza con "MZ", no es un PE valido.
e_lfanew (offset 0x3C, 4 bytes): El offset absoluto dentro del archivo donde empieza el PE Signature. Este es el campo mas importante del DOS Header porque conecta la cabecera DOS con la cabecera PE moderna.
Ejemplo con pefile en Python:
import pefile
pe = pefile.PE("sample.exe")
print("e_magic:", hex(pe.DOS_HEADER.e_magic))
print("e_lfanew:", hex(pe.DOS_HEADER.e_lfanew))
Anomalias que delatan malware:
- e_lfanew con valor inusual: Normalmente apunta a 0x80 o 0xF0. Valores muy grandes (por ejemplo 0x1000 o mas) pueden indicar que el autor inserto datos entre el DOS Stub y el PE Header, una tecnica para ocultar shellcode o confundir parsers.
- DOS Stub modificado: El stub normalmente contiene el mensaje "This program cannot be run in DOS mode". Algunos packers lo eliminan o lo sustituyen por datos cifrados.
- Campos DOS Header con valores no estandar: Los campos intermedios (e_cblp, e_cp, e_crlc, etc.) no se usan en Windows moderno. El malware a veces los usa como almacenamiento de flags o valores de configuracion.
DOS Stub: el programa fantasma
Entre el DOS Header y el PE Signature hay un pequeno programa de 16 bits que se ejecutaria si alguien intentara correr el archivo en MS-DOS puro. Su unica funcion es imprimir "This program cannot be run in DOS mode" y salir.
En la practica, ningun sistema moderno ejecuta el DOS Stub. Pero su presencia (o ausencia) puede ser un indicador:
- Stub presente y estandar: Comportamiento normal, compilado con MSVC o similar.
- Stub ausente o minimo: Compilado con MinGW, packers como UPX, o herramientas de generacion de shellcode.
- Stub con contenido personalizado: Puede contener datos ocultos o incluso un loader secundario.
PE Signature: la firma de identidad
En el offset indicado por e_lfanew, encontramos 4 bytes que deben ser exactamente "PE\0\0" (0x50450000). Esta firma confirma que el archivo sigue el formato PE.
Si la firma no coincide, el loader de Windows rechaza el archivo. Algunas herramientas de analisis buscan esta firma en offsets no estandar para detectar PE embebidos dentro de otros archivos (un PDF con un PE dentro, por ejemplo).
COFF File Header: la identidad del binario
Inmediatamente despues del PE Signature, los siguientes 20 bytes forman el COFF File Header (IMAGE_FILE_HEADER). Contiene informacion fundamental sobre el binario:
| Campo | Tamano | Descripcion |
|---|---|---|
| Machine | 2 bytes | Arquitectura: 0x14C (x86), 0x8664 (x64), 0xAA64 (ARM64) |
| NumberOfSections | 2 bytes | Cuantas secciones tiene el PE |
| TimeDateStamp | 4 bytes | Fecha de compilacion (epoch Unix) |
| PointerToSymbolTable | 4 bytes | Normalmente 0 en ejecutables |
| NumberOfSymbols | 4 bytes | Normalmente 0 en ejecutables |
| SizeOfOptionalHeader | 2 bytes | Tamano del Optional Header que sigue |
| Characteristics | 2 bytes | Flags: ejecutable, DLL, stripped, etc. |
Machine: identificar la arquitectura
El campo Machine indica para que procesador fue compilado el binario:
| Valor | Arquitectura | Contexto malware |
|---|---|---|
| 0x014C | Intel 386 (x86) | Mayoria del malware legacy |
| 0x8664 | AMD64 (x64) | Malware moderno, drivers |
| 0x01C4 | ARM Little-Endian | Malware IoT portado |
| 0xAA64 | ARM64 | Malware para Windows on ARM |
TimeDateStamp: cuando fue compilado
Este campo almacena la fecha y hora de compilacion como un valor epoch Unix (segundos desde el 1 de enero de 1970). Es uno de los metadatos mas utiles y mas manipulados:
import datetime
timestamp = pe.FILE_HEADER.TimeDateStamp
compile_date = datetime.datetime.utcfromtimestamp(timestamp)
print("Compilado:", compile_date)
Indicadores de manipulacion:
- Fecha en el futuro: el autor modifico el timestamp.
- Fecha anterior a 2000: probablemente manipulada o corrupta.
- Fecha exacta a medianoche: timestamp puesto a un valor round.
- APT28 y APT29 son conocidos por setear timestamps falsos para dificultar la atribucion.
Characteristics: flags del binario
Las flags mas relevantes para analisis:
| Flag | Valor | Significado |
|---|---|---|
| IMAGE_FILE_EXECUTABLE_IMAGE | 0x0002 | Es un ejecutable |
| IMAGE_FILE_LARGE_ADDRESS_AWARE | 0x0020 | Puede usar mas de 2GB de memoria |
| IMAGE_FILE_DLL | 0x2000 | Es una DLL |
| IMAGE_FILE_SYSTEM | 0x1000 | Es un driver de sistema |
Un .exe con la flag DLL activa o un .dll sin ella es sospechoso.
Optional Header: el corazon del PE
A pesar de su nombre, el Optional Header no es opcional en ejecutables (solo lo es en archivos objeto .obj). Es la parte mas rica en informacion del PE y la mas relevante para el analisis.
Campos criticos del Optional Header
Magic (2 bytes): Distingue entre PE32 (0x10B) y PE32+ (0x20B).
AddressOfEntryPoint (4 bytes): La RVA (Relative Virtual Address) donde el loader empieza a ejecutar codigo. En malware empaquetado, este punto apunta al stub del packer, no al codigo original.
print("Entry Point:", hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint))
ImageBase (4/8 bytes): La direccion preferida donde el PE quiere cargarse en memoria. Valores tipicos:
- 0x00400000 para .exe
- 0x10000000 para .dll
- 0x00010000 para drivers
SectionAlignment: Alineacion de secciones en memoria (tipicamente 0x1000, una pagina).
FileAlignment: Alineacion de secciones en disco (tipicamente 0x200, un sector).
SizeOfImage: Tamano total del PE cuando esta cargado en memoria.
SizeOfHeaders: Tamano combinado de todas las cabeceras hasta el inicio de la primera seccion.
Subsystem: Indica el tipo de ejecutable:
| Valor | Subsistema | Ejemplo |
|---|---|---|
| 1 | Native | Drivers .sys |
| 2 | Windows GUI | Aplicaciones graficas |
| 3 | Windows Console | Herramientas CLI |
DllCharacteristics: Flags de seguridad habilitadas:
| Flag | Valor | Proteccion |
|---|---|---|
| IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE | 0x0040 | ASLR habilitado |
| IMAGE_DLLCHARACTERISTICS_NX_COMPAT | 0x0100 | DEP habilitado |
| IMAGE_DLLCHARACTERISTICS_NO_SEH | 0x0400 | No usa SEH |
| IMAGE_DLLCHARACTERISTICS_GUARD_CF | 0x4000 | Control Flow Guard |
Indicador clave: Si ASLR y DEP estan deshabilitados en un binario moderno, es sospechoso. El malware a veces desactiva estas protecciones para facilitar la explotacion de memoria.
Data Directories: punteros a estructuras clave
El Optional Header termina con un array de 16 Data Directories. Cada entrada es un par (RVA, Size) que apunta a una estructura importante dentro del PE:
| Indice | Nombre | Contenido |
|---|---|---|
| 0 | Export Table | Funciones exportadas (DLLs) |
| 1 | Import Table | DLLs y funciones importadas |
| 2 | Resource Table | Iconos, strings, manifests, datos embebidos |
| 3 | Exception Table | Tabla de excepciones (x64) |
| 4 | Certificate Table | Firma digital Authenticode |
| 5 | Base Relocation Table | Relocaciones para ASLR |
| 6 | Debug Directory | Informacion de debug, rutas PDB |
| 9 | TLS Table | Thread Local Storage callbacks |
| 12 | IAT | Import Address Table |
| 14 | CLR Runtime Header | Metadata .NET |
Para el analisis de malware, los directorios mas importantes son Import Table (que APIs usa), Resource Table (datos embebidos), Debug Directory (pistas sobre el entorno de compilacion) y TLS Table (codigo que se ejecuta antes del entry point).
Section Headers: el mapa del contenido
Despues del Optional Header vienen los Section Headers, uno por cada seccion del PE. Cada header tiene 40 bytes con esta informacion:
| Campo | Tamano | Descripcion |
|---|---|---|
| Name | 8 bytes | Nombre de la seccion (.text, .data, etc.) |
| VirtualSize | 4 bytes | Tamano en memoria |
| VirtualAddress | 4 bytes | RVA donde se carga |
| SizeOfRawData | 4 bytes | Tamano en disco |
| PointerToRawData | 4 bytes | Offset en el archivo |
| Characteristics | 4 bytes | Permisos: read, write, execute |
Secciones estandar
| Seccion | Contenido | Permisos normales |
|---|---|---|
| .text | Codigo ejecutable | Read + Execute |
| .data | Variables globales inicializadas | Read + Write |
| .rdata | Datos de solo lectura, imports | Read |
| .bss | Variables no inicializadas | Read + Write |
| .rsrc | Recursos (iconos, strings) | Read |
| .reloc | Tabla de relocaciones | Read |
Las secciones se cubren en profundidad en el siguiente articulo de la serie.
Import Table: que funcionalidad necesita el binario
La Import Table es probablemente la estructura mas reveladora para el analisis de malware. Lista todas las DLLs que el binario necesita y las funciones especificas que importa de cada una.
Por ejemplo, un binario que importa CreateRemoteThread de kernel32.dll y VirtualAllocEx esta practicamente declarando que va a inyectar codigo en otro proceso.
La tabla se organiza en dos niveles:
- Import Directory Table: Un array de estructuras IMAGE_IMPORT_DESCRIPTOR, una por cada DLL importada.
- Import Lookup Table (ILT) / Import Address Table (IAT): Arrays paralelos que listan las funciones importadas de cada DLL.
for entry in pe.DIRECTORY_ENTRY_IMPORT:
print(entry.dll.decode())
for imp in entry.imports:
if imp.name:
print(" ", imp.name.decode())
La Import Table se analiza en detalle en el articulo 3 de esta serie.
Export Table: funciones que ofrece el binario
La Export Table es el reverso de la Import Table: lista las funciones que este binario expone para que otros modulos las llamen. Es comun en DLLs y rara en ejecutables .exe.
En el contexto de malware, la Export Table revela:
- DLLs maliciosas: Que funciones exporta una DLL sideloaded.
- Nombres sospechosos: Exports como
ServiceMain,DllRegisterServero nombres genericos que imitan DLLs del sistema. - Exports por ordinal: Funciones exportadas solo por numero, sin nombre, para dificultar el analisis.
if hasattr(pe, "DIRECTORY_ENTRY_EXPORT"):
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
print(exp.ordinal, exp.name)
Resource Table: datos embebidos
La Resource Table contiene datos no ejecutables embebidos en el PE: iconos, cursores, dialogos, manifests, tablas de strings y, en el caso de malware, a menudo payloads cifrados o configuraciones.
La estructura es un arbol de tres niveles:
- Tipo de recurso (icono, string, version, RT_RCDATA para datos arbitrarios)
- ID o nombre del recurso
- Idioma del recurso
Los recursos RT_RCDATA son especialmente interesantes en malware porque permiten embeber datos arbitrarios: shellcode cifrado, configuraciones de C2, o incluso PEs completos que se desempaquetan en runtime.
for resource_type in pe.DIRECTORY_ENTRY_RESOURCE.entries:
print("Tipo:", resource_type.id)
for entry in resource_type.directory.entries:
data = pe.get_data(
entry.directory.entries[0].data.struct.OffsetToData,
entry.directory.entries[0].data.struct.Size
)
print(" Tamano:", len(data), "bytes")
Herramientas esenciales para analisis PE
PEStudio (Windows, gratuito)
La herramienta de referencia para un primer vistazo rapido. Abre el PE y muestra inmediatamente:
- Indicadores de anomalias (entropia alta, imports sospechosos)
- Strings con ponderacion de relevancia
- Informacion de compilador/packer detectado
- Verificacion de firmas digitales
- Comparacion con VirusTotal
PE-bear (Windows/Linux, open source)
Editor visual que permite navegar toda la estructura PE de forma grafica. Ideal para explorar headers, secciones y tablas de imports/exports con una interfaz intuitiva.
pefile (Python, open source)
Libreria Python para parsear PEs programaticamente. Indispensable para automatizar analisis:
import pefile
pe = pefile.PE("sample.exe")
# Informacion basica
print("Machine:", hex(pe.FILE_HEADER.Machine))
print("Secciones:", pe.FILE_HEADER.NumberOfSections)
print("Entry Point:", hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint))
# Imports
for entry in pe.DIRECTORY_ENTRY_IMPORT:
print(entry.dll.decode())
# Entropia por seccion
for section in pe.sections:
print(
section.Name.decode().rstrip("\x00"),
"Entropia:", round(section.get_entropy(), 2)
)
CFF Explorer (Windows, gratuito)
Editor avanzado de PE que permite modificar cualquier campo de las cabeceras. Util para:
- Corregir cabeceras danadas por packers
- Modificar el entry point para analisis
- Explorar el Resource Directory en detalle
Detect It Easy (DIE)
Detecta el compilador, linker y packer utilizado analizando patrones en el PE. Soporta firmas para cientos de compilers y packers.
Anomalias PE que delatan malware
Tras entender la estructura normal, estas son las anomalias mas comunes que senalan actividad maliciosa:
Entry Point fuera de .text: El punto de entrada normalmente esta en la seccion .text. Si apunta a otra seccion (especialmente una con nombre no estandar), probablemente esta empaquetado.
Secciones con permisos Read+Write+Execute: Las secciones legitimas rara vez necesitan los tres permisos simultaneamente. RWX indica codigo auto-modificable o desempaquetado en runtime.
Tamano en disco vs tamano en memoria: Si VirtualSize es mucho mayor que SizeOfRawData, la seccion se expande en memoria (tipico de packers que descomprimen codigo).
Numero de secciones inusual: Los binarios compilados con MSVC tipicamente tienen 4 a 7 secciones. Un PE con 1 o 2 secciones suele estar empaquetado. Mas de 10 secciones puede indicar un packer personalizado.
Imports minimas: Un ejecutable que solo importa LoadLibrary y GetProcAddress esta resolviendo todas sus funciones en runtime para ocultar su funcionalidad.
Timestamp cero o fecha futura: Manipulacion deliberada para dificultar la atribucion temporal.
Checksum invalido: El checksum del Optional Header no coincide con el contenido real del archivo. Habitual en binarios modificados post-compilacion.
De la teoria a la practica: primer analisis
Para consolidar estos conceptos, analiza cualquier ejecutable legitimo de tu sistema con pefile:
import pefile
import datetime
pe = pefile.PE("C:/Windows/System32/notepad.exe")
# DOS Header
print("=== DOS Header ===")
print("Magic:", hex(pe.DOS_HEADER.e_magic))
print("e_lfanew:", hex(pe.DOS_HEADER.e_lfanew))
# COFF Header
print("\n=== COFF Header ===")
print("Machine:", hex(pe.FILE_HEADER.Machine))
print("Secciones:", pe.FILE_HEADER.NumberOfSections)
ts = pe.FILE_HEADER.TimeDateStamp
print("Compilado:", datetime.datetime.utcfromtimestamp(ts))
# Optional Header
print("\n=== Optional Header ===")
print("Magic:", hex(pe.OPTIONAL_HEADER.Magic))
print("Entry Point:", hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint))
print("Image Base:", hex(pe.OPTIONAL_HEADER.ImageBase))
print("Subsystem:", pe.OPTIONAL_HEADER.Subsystem)
# Secciones
print("\n=== Secciones ===")
for s in pe.sections:
name = s.Name.decode().rstrip("\x00")
print(
name,
"VA:", hex(s.VirtualAddress),
"VS:", hex(s.Misc_VirtualSize),
"Raw:", hex(s.SizeOfRawData),
"Entropy:", round(s.get_entropy(), 2)
)
Compara los resultados con un binario sospechoso y las diferencias saltaran a la vista: entry points anomalos, secciones con entropia alta, imports minimalistas.
Conclusion
El formato PE es la base sobre la que se construye todo el analisis estatico de malware en Windows. Conocer cada campo de sus cabeceras permite identificar anomalias sin ejecutar el binario, detectar packers por las caracteristicas de sus secciones, y anticipar el comportamiento del malware por sus tablas de imports.
En los siguientes articulos de esta serie profundizaremos en las secciones PE, la Import Address Table y las tecnicas que el malware usa para manipular cada una de estas estructuras.
Preguntas frecuentes
Libros recomendados
Artículos relacionados
Secciones PE: .text, .data, .rsrc, .reloc y Anomalias
Import Address Table: APIs Sospechosas y Resolucion Dinamica
Analisis Estatico Basico: Strings, Hashes y Metadatos
Rich Header, Timestamps y Metadatos Ocultos en PE
Este contenido tiene fines exclusivamente educativos y de investigación en ciberseguridad defensiva. No se proporcionan binarios maliciosos ni payloads ejecutables. El uso indebido de esta información es responsabilidad exclusiva del usuario. Leer disclaimer completo.