Anatomía de un ELF: cabecera, secciones y segmentos
El binario deja de ser opaco. Esta lección recorre la cabecera ELF campo a campo, separa la tabla de secciones que consume el enlazador de la tabla de programa que consume el cargador, deduce por qué .bss no ocupa espacio en disco y convierte readelf en el instrumento con el que se verifica cada afirmación.
Un ejecutable no es una imagen de memoria volcada a disco: es un documento estructurado que dos lectores distintos interpretan de forma distinta. El enlazador lo lee como una colección de secciones que debe combinar, ordenar y reubicar; el cargador del núcleo lo lee como un puñado de segmentos que debe proyectar en el espacio de direcciones con unos permisos concretos. Ambas vistas describen exactamente los mismos bytes y conviven en el mismo fichero, unidas por una cabecera diminuta que actúa como índice maestro. En cuanto esa doble lectura se vuelve evidente, readelf deja de ser un comando que se copia sin entender y pasa a ser un microscopio.
- Leer la cabecera ELF campo a campo y decidir qué clase de objeto tienes delante.
- Distinguir la tabla de secciones de la tabla de programa, y saber quién consume cada una.
- Situar
.text,.rodata,.datay.bssy justificar el tamaño real del fichero. - Auditar cualquier binario con
readelf,objdumpysizesin recurrir a la intuición.
Los primeros bytes deciden la lectura
Todo fichero ELF empieza por e_ident, dieciséis bytes de identificación que se leen antes de saber nada más. Los cuatro primeros son el número mágico 0x7F seguido de las letras ELF; el quinto declara la clase, ELFCLASS32 o ELFCLASS64, y determina el ancho de todos los campos que vienen después; el sexto fija el orden de bytes. Esa autodescripción es deliberada: el formato no presupone la máquina que lo lee, sino que la anuncia antes de exigir cualquier interpretación.
Tras e_ident viene e_type, el campo que más confusión ahorra cuando se conoce. Sus valores relevantes son ET_REL para un objeto reubicable —un .o recién salido del compilador—, ET_EXEC para un ejecutable de posición fija, ET_DYN para un objeto compartido, y ET_CORE para un volcado. El detalle que sorprende a casi todo el mundo es que un ejecutable moderno compilado con -pie, la opción por defecto en las distribuciones actuales, aparece como ET_DYN y no como ET_EXEC: se construye como si fuera una biblioteca precisamente para que el sistema pueda cargarlo en una dirección aleatoria.
Los tres campos restantes que importan son punteros dentro del propio fichero: e_entry, la dirección virtual de la primera instrucción que se ejecutará; e_phoff, el desplazamiento de la tabla de programa; y e_shoff, el de la tabla de secciones. Todo lo demás del binario se alcanza siguiendo esos dos desplazamientos.
readelf -h ./prog # cabecera completa: clase, tipo, entrada, phoff, shoff
file ./prog # resumen legible: PIE, dinamico, con simbolos o sin ellos
xxd -l 16 ./prog # los dieciseis bytes de e_ident en crudo
Conviene mirar una salida real y reconocer cada línea, porque casi todas las preguntas serias sobre un binario se contestan aquí antes que en ningún otro sitio.
Class: ELF64
Data: 2's complement, little endian
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 14712 (bytes into file)
Number of program headers: 13
Number of section headers: 31
Un matiz que ahorra desconciertos: la dirección de entrada de un binario PIE es un valor pequeño como el del ejemplo porque es un desplazamiento desde la base de carga, no una dirección definitiva. El enlazador dinámico le sumará la base aleatoria elegida en cada ejecución. En un binario enlazado con -no-pie verás en su lugar una dirección alta y fija, y ese contraste es la forma más rápida de saber ante qué modelo de carga estás.
Dos tablas para dos lectores
La tabla de secciones y la tabla de programa no son dos formatos alternativos: son dos índices sobre el mismo contenido, pensados para dos momentos distintos del ciclo de vida del binario.
flowchart LR A[Fichero ELF en disco] --> B[Cabecera ELF] B --> C[Tabla de secciones] B --> D[Tabla de programa] C --> E[Enlazador: combina y reubica] D --> F[Cargador: proyecta y aplica permisos] style C fill:#89b4fa,color:#11111b style D fill:#a6e3a1,color:#11111b
Secciones, la vista de enlazado
Granularidad fina y nombres semánticos. Existen siempre en un .o y son la materia prima del enlazador. Un ejecutable puede perderlas por completo sin dejar de funcionar.
Segmentos, la vista de ejecución
Agrupan secciones contiguas con los mismos permisos en entradas PT_LOAD. El núcleo solo necesita esta tabla: desplazamiento en el fichero, dirección virtual, tamaño y bits de lectura, escritura o ejecución.
La asimetría entre ambas tablas se aprecia mejor comparando los dos extremos del ciclo. Un objeto reubicable recién compilado tiene tabla de secciones y no tiene tabla de programa en absoluto, porque todavía no se ha decidido ninguna dirección y nada hay que cargar: e_phoff vale cero. Un ejecutable tiene ambas. Y una imagen de la que se han eliminado los símbolos conserva la tabla de programa intacta mientras pierde buena parte de la de secciones.
La consecuencia práctica es directa. strip elimina la tabla de símbolos y buena parte de las secciones auxiliares, y el binario sigue arrancando porque el cargador nunca miró ahí. En cambio, corromper la tabla de programa lo mata al instante. Cuando readelf -l imprime el mapeo entre secciones y segmentos, estás viendo precisamente la traducción entre ambos mundos: varias secciones de solo lectura y ejecución caen en un mismo PT_LOAD, y las escribibles en otro.
Además de las entradas PT_LOAD, la tabla de programa contiene descriptores que no cargan nada pero informan al sistema: PT_INTERP con la ruta del enlazador dinámico, PT_DYNAMIC con el índice de la sección dinámica, PT_GNU_STACK cuyos permisos determinan si la pila será ejecutable, y PT_GNU_RELRO con la región que se protegerá tras el arranque. Ninguno aporta bytes al mapa de memoria; todos condicionan cómo se construye.
El paso conceptual que separa a quien ha leído sobre ELF de quien lo entiende es este: el hardware no conoce las secciones. La unidad de protección es la página, y la unidad de descripción para el cargador es el segmento. Por eso el enlazador agrupa .text y .rodata en un segmento sin permiso de escritura y .data con .bss en otro sin permiso de ejecución: no está ordenando por estética, está construyendo la frontera que el gestor de memoria podrá imponer. De esa agrupación nace toda la defensa moderna del binario. NX existe porque hay un segmento escribible que no es ejecutable y uno ejecutable que no es escribible. RELRO existe porque hay datos que deben ser escribibles durante el arranque y de solo lectura después, y se resuelve con un segmento GNU_RELRO que el enlazador dinámico marca como protegido en cuanto termina de reubicar. Incluso la aleatorización de direcciones se apoya en que los segmentos son reubicables en bloque. Cuando alguien pregunta si una mitigación se puede activar, la respuesta está siempre en readelf -l: si no hay un segmento con los permisos adecuados, la mitigación no existe por mucho que la línea de compilación lo sugiera.
Las cuatro secciones que explican tu binario
El grueso de un programa de C se reparte entre cuatro secciones cuya distinción no es organizativa, sino que responde a dos preguntas independientes: si el contenido cambia en ejecución y si hay contenido que almacenar.
.text guarda las instrucciones y es de solo lectura y ejecutable. .rodata guarda las constantes: literales de cadena, tablas de salto de un switch, agregados marcados como const. Es de solo lectura y no ejecutable, y de ahí sale la explicación exacta de por qué escribir en un literal de cadena provoca una violación de segmento en lugar de corromper datos silenciosamente.
.data contiene los objetos de duración estática inicializados a un valor distinto de cero, y se almacena literalmente en el fichero porque el cargador debe copiarlo. .bss contiene los inicializados a cero o sin inicializador explícito, y no ocupa un solo byte en disco: su entrada declara un tipo SHT_NOBITS y un tamaño, y el cargador se limita a proyectar páginas en blanco.
int tabla[1024]; // .bss ocupa 0 bytes en el fichero
int pesos[1024] = {1}; // .data ocupa 4096 bytes en el fichero
const char saludo[] = "hola"; // .rodata
static int contador; // .bss cero implicito por duracion estatica
size ./prog # resumen: text, data, bss y total
readelf -S ./prog # cada seccion con tipo, direccion, tamano y banderas
objdump -s -j .rodata ./prog # volcado hexadecimal de las constantes
El reparto no termina en esas cuatro. Un binario real trae media docena de secciones auxiliares que conviene saber leer aunque rara vez se toquen. .init_array y .fini_array guardan punteros a las funciones que deben correr antes y después de main, y son el mecanismo con el que se implementa __attribute__((constructor)). .eh_frame describe cómo desenrollar la pila y sostiene tanto las trazas de los perfiladores como el manejo de excepciones de otros lenguajes. .note.gnu.build-id contiene un identificador único de la construcción, que es lo que permite emparejar un volcado con sus símbolos de depuración. Y .symtab junto a .strtab guardan los nombres, mientras que las secciones .debug alojan la información DWARF completa.
Esa última distinción explica una práctica habitual en producción: separar los símbolos de depuración a un fichero aparte con objcopy --only-keep-debug, aplicar strip a la imagen que se distribuye y dejar un enlace entre ambos con --add-gnu-debuglink. El binario adelgaza sin perder la capacidad de analizarlo, porque el identificador de construcción los mantiene unívocamente asociados.
La asimetría entre .data y .bss es la razón de que declarar un arreglo global de un millón de enteros a cero deje el ejecutable igual de pequeño, mientras que inicializarlo con un solo elemento distinto de cero lo engorde cuatro megabytes. Pero cuidado con la lectura optimista: en tiempo de ejecución .bss cuesta memoria como cualquier otra región. Lo que ahorras es espacio en disco y tiempo de lectura, no páginas residentes.
Falta una pieza para cerrar el cuadro: las banderas de cada sección, que readelf -S imprime como letras y que determinan su destino. A significa que ocupa memoria en ejecución y por tanto acabará dentro de algún PT_LOAD; X la hace ejecutable; W escribible; y M junto a S marcan una sección de cadenas fusionables. Esa última combinación explica un detalle que parece magia del compilador: dos literales idénticos en unidades distintas acaban compartiendo los mismos bytes porque el enlazador está autorizado a deduplicar el contenido de las secciones marcadas como fusionables. Una sección sin la bandera A, como .comment o cualquiera de las de depuración, existe en el fichero y jamás llega a memoria.
Instrumental de inspección
La disciplina que conviene adquirir es no afirmar nada sobre un binario sin haberlo verificado con una herramienta. readelf es el lector canónico del formato y nunca interpreta de más; objdump desensambla y resulta más cómodo para leer código; nm lista símbolos; strings extrae texto imprimible.
readelf -h ./prog # cabecera
readelf -S -W ./prog # secciones, sin truncar los nombres
readelf -l ./prog # segmentos y su mapeo a secciones
readelf -d ./prog # seccion dinamica: NEEDED, RUNPATH, RELRO
objdump -d --no-show-raw-insn ./prog | less
nm -C ./prog | head # simbolos, con nombres desmangled si los hay
Un ejercicio revelador consiste en comparar los tres estados del mismo programa: el .o recién compilado, que es ET_REL y carece de tabla de programa porque todavía no hay nada que cargar; el ejecutable enlazado, que ya tiene ambas tablas; y el mismo ejecutable tras strip, que conserva los segmentos intactos y ha perdido .symtab. Los tres arrancan o no arrancan por razones que ahora puedes justificar sin ambigüedad.
Hay una advertencia metodológica que ahorra horas. readelf es un lector independiente que decodifica el formato sin consultar bibliotecas del sistema, mientras que objdump se apoya en la infraestructura de descripción de objetos binarios y a veces interpreta con más libertad. Cuando ambas herramientas discrepan sobre un fichero sospechoso, la que describe lo que realmente hay en disco es readelf. Y ante un binario deliberadamente malformado, ninguna sustituye a leer los bytes con xxd y aplicar la especificación a mano.
Vale la pena fijar una rutina de tres comandos que responde a las preguntas más habituales sobre cualquier binario que llegue a tus manos, propio o ajeno, y que se apoya únicamente en lo visto en esta lección.
readelf -hd ./prog # tipo, entrada, dependencias y banderas
readelf -lSW ./prog | less # el mapa completo: segmentos y secciones
readelf -n ./prog # notas: identificador de construccion y ABI minima
objcopy --only-keep-debug ./prog prog.debug # separar los simbolos de depuracion
Una última observación cierra el círculo con lo aprendido sobre secciones y segmentos: el mismo fichero puede tener una tabla de secciones inconsistente o directamente ausente y seguir ejecutándose sin problema, porque el núcleo solo consulta la tabla de programa. Ese hecho, que suena a curiosidad académica, es la base de buena parte de las técnicas de ofuscación de binarios y la razón de que el análisis forense empiece siempre por los segmentos.
- Compila un programa mínimo y ejecuta
readelf -h. Identifica la clase, ele_typey la dirección de entrada, y explica por qué el tipo esET_DYNsi tu compilador genera PIE por defecto; repite con-no-piey contrasta. - Declara los cuatro objetos globales del ejemplo, compila y usa
readelf -Sjunto asizepara confirmar en qué sección aterriza cada uno. Cambia el inicializador detablaa un valor distinto de cero y mide conls -lcuánto engorda el fichero. - Ejecuta
readelf -ly anota qué secciones caen en cadaPT_LOAD. Comprueba que no existe ningún segmento simultáneamente escribible y ejecutable, y localiza el segmentoGNU_RELRO. - Escribe un programa que intente modificar un literal de cadena a través de un
char *, ejecútalo y relaciona la señal recibida con los permisos del segmento que contiene.rodata. - Compara
nmsobre el ejecutable antes y después destrip, y verifica conreadelf -lque la tabla de programa no ha cambiado en absoluto.