01 - Ejecutables: Formato ELF
¿Qué es un ejecutable?
En la clase de compiladores mencioné que el compilador por defecto generará un binario con una estructura específica, dependiendo del sistema operativo. Hoy exploraremos cómo funciona y se estructura un ejecutable, específicamente el formato ELF (Executable and Linkable Format), utilizado en sistemas basados en Unix.
En esencia, este formato —y cualquier formato de ejecutable— define las directrices bajo las cuales se ejecutará un programa. Imagina el formato de un ejecutable como una película empaquetada para un cine. El cine quiere proyectar la película, pero no se trata solo del video; también hay que controlar el guion, los subtítulos, la música, entre otros aspectos. El formato del ejecutable es el encargado de gestionar todo esto.
Formato ELF
Como mencioné anteriormente, el formato ELF es el estándar para los binarios en sistemas Unix-like. A diferencia del formato PE para Windows, ELF presenta una estructura mucho más modular, fácil de entender y de modificar. Analicemos los componentes de este formato y cómo el cine (nuestro sistema operativo) reproducirá la película. Lo primero que encontramos es la boleta de entrada (ELF Header), que contiene información básica sobre la película (el ejecutable) y es lo primero que el cine lee.
Podemos verificar esto utilizando la herramienta readelf, que viene preinstalada en la mayoría de las distribuciones de Linux. Para consultar el ELF Header, utilizaremos la opción -h junto con el ejecutable. Previamente, podemos confirmar que el ejecutable es de tipo ELF utilizando el comando file.
❯ file /bin/cat
/bin/cat: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=e6ace1fe0a599f3888911e6c775f751de3b93a69, for GNU/Linux 4.4.0, stripped
❯ readelf -h /bin/cat
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x3880
...
De hecho, cada parte del formato ELF está representada por una estructura en C. En este caso, podemos observar la estructura de la cabecera principal, la cual contiene información inicial sobre el programa a ejecutar; por ejemplo, el campo magic es un número exclusivo de los binarios de tipo ELF, este valor permite que las herramientas de análisis puedan identificar el tipo de ejecutable. También se puede encontrar la dirección de memoria del punto de entrada del programa, entre otros datos relevantes.
typedef struct elf64_hdr {
unsigned char e_ident[EI_NIDENT]; /* ELF "magic number" */
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Program header table file offset */
Elf64_Off e_shoff; /* Section header table file offset */
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum;
Elf64_Half e_shentsize;
Elf64_Half e_shnum;
Elf64_Half e_shstrndx;
} Elf64_Ehdr;
A continuación, encontramos las instrucciones para el proyector (Program Headers y Section Headers). Estos indican al cine cómo se debe cargar la película, desde la ubicación de cada fotograma hasta el nivel de volumen del audio en una escena particular. Desde una perspectiva técnica, esto se traduce a las segmentos que se cargarán en memoria, los permisos que tendrá el programa, el descriptor de las secciones del binario, entre otros detalles.
Podemos examinar los Program Headers utilizando la opción -l. Esto nos mostrará en qué dirección de memoria se mapea cada segmento del programa, e incluso definirá la dirección de inicio de ejecución. Esta es una de las partes más importantes del binario, tanto para su funcionamiento como para posibles atacantes, ya que contiene los segmentos de código que definen la lógica completa del programa; o, dicho de forma más simple, el código que escribimos, pero traducido a ensamblador.
❯ readelf -l /bin/cat
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x3880
There are 14 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x0000000000000310 0x0000000000000310 R 0x8
INTERP 0x00000000000003c4 0x00000000000003c4 0x00000000000003c4
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
...
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .note.gnu.property .note.gnu.build-id .interp .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .relr.dyn
03 .init .text .fini
04 .rodata .eh_frame_hdr .eh_frame .note.ABI-tag
05 .init_array .fini_array .data.rel.ro .dynamic .got .data .bss
...
Por otro lado, los Section Headers generalmente no son esenciales para la ejecución del programa en sí. Son utilizados principalmente por compiladores y herramientas de análisis como readelf para depurar información de manera precisa. De hecho, estos encabezados pueden omitirse del binario final sin afectar su funcionamiento. Aun así, estos pueden ser consultados con la opción -S.
Por último, encontramos las secciones de soporte, que contienen utilidades y metadatos que no son estrictamente necesarios para la ejecución del programa, pero que son fundamentales para tareas como el linking, debugging o análisis estático. Algunas de las más importantes son:
-
.symtab: Contiene la tabla de símbolos estáticos, consigo, guarda información sobre funciones y variables del programa, junto a sus direcciones y tamaños. Es útil para el linker y depuradores.
-
.strtab: Es la tabla de strings asociada a .symtab. Almacena los nombres de los símbolos (funciones, variables, etc.) y permite que .symtab los referencie por índice.
-
.shstrtab: Tabla de strings que contiene los nombres de todas las secciones. El Section Header Table usa esta tabla para identificar cada sección por su nombre.
-
.rela.* o .rel.*: Secciones que contienen información de relocación, necesaria para ajustar direcciones de memoria durante el linking o carga dinámica.
-
.debug_*: Secciones utilizadas por herramientas de depuración; incluyen información como nombres de funciones, tipos, líneas de código fuente, etc. Estas secciones pueden eliminarse para generar binarios stripped más livianos.
-
.got y .plt: Participan en el linking dinámico. La Global Offset Table (.got) contiene direcciones de funciones externas, mientras que la Procedure Linkage Table (.plt) actúa como un trampolín que permite al programa llamar funciones externas (como printf, malloc, etc.) y resolverlas en tiempo de ejecución.
Todo estas secciones se pueden consultar con readelf, basta con escribir la opcion -H, esto nos desplegara el manual de uso.