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.

01-lxheader

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.

01-mem_asig

🔎 Nota
No debemos confundir segmento con sección. Un segmento es la parte del binario que será cargada en memoria para la ejecución del programa. Por otro lado, las secciones representan una estructura lógica que facilita el trabajo de los linkers, debuggers o herramientas de análisis en general. Por esta razón, un binario puede funcionar incluso sin una cabecera de secciones; a esto se le conoce como un ejecutable stripped.

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.