- Incluso un pequeño programa C
hello se convierte en un ejecutable ELF en Linux, y se puede revisar directamente su estructura interna con readelf, nm y objdump
- Los ejes clave para entender un ejecutable son los símbolos, las secciones y los segmentos; se encargan, respectivamente, de enlazar funciones, separar código/datos y definir la distribución en memoria durante la ejecución
- Con
objdump y readelf se pueden revisar los bytes y atributos de secciones como .text, .rodata, .data, .bss e .interp
- Un programa no empieza directamente en
main, sino que entra por _start y, tras varias tareas de inicialización, llama a main
- Un ejecutable no es un “bloque ilegible”, sino un archivo con un formato definido; con las herramientas adecuadas se puede rastrear paso a paso el código, las cadenas de texto y la información de linking
Un ejecutable es un formato de archivo legible
- Al principio, un ejecutable compilado puede parecer un “binario mágico” ilegible, pero en realidad es un formato de archivo que se puede entender
- El ejemplo se centra en los binarios ELF de Linux; como los binarios dependen de la plataforma, la explicación también está ligada a esa plataforma
- El ejemplo usado es el siguiente programa en C
#include <stdio.h>
int main() {
printf("Penguin!\n");
}
- Se compila con
gcc -o hello hello.c para crear el ejecutable hello, y luego se inspecciona su interior
- El recorrido gira en torno a tres conceptos
- Símbolos (symbols): se usan para encontrar la ubicación al llamar a funciones definidas en otro lugar, como
printf
- Secciones (sections): unidades que separan código y datos, como
.text, .data, .rodata, etc.
- Segmentos (segments): agrupan secciones como unidades de distribución en memoria durante la ejecución
Incluso al abrirlo como texto aparecen pistas
- Si se abre el ejecutable tal cual, por ejemplo con
cat hello, la mayor parte se imprime como caracteres corruptos
- Aun así, dentro de la salida se pueden encontrar cadenas como
Penguin! y ELF
- ELF es el nombre del formato de archivo de este binario
- La razón por la que la mayor parte de la salida es difícil de leer para una persona es que el ejecutable contiene datos binarios
La tabla de símbolos permite revisar nombres de funciones y enlaces
readelf --symbols hello imprime la tabla de símbolos del ejecutable
- En la salida del ejemplo aparecen símbolos importantes
main: la dirección de la función main() escrita por nosotros
puts@@GLIBC_2.2.5: parece ser una referencia relacionada con el printf llamado en el código; se presume que el compilador lo cambió a puts como optimización
_start: un símbolo importante relacionado con el inicio del programa
- El programa no empieza directamente en
main; en realidad entra por _start
_start realiza varias tareas importantes, entre ellas llamar a main
Los símbolos hacen posible el enlace
- Si se escribe una función llamada
hello en un programa, el código de esa función dentro del binario compilado recibe el símbolo hello
- Para llamar a una función de biblioteca como
printf, se necesita una forma de encontrar la ubicación del código de esa función
- El proceso de encontrar la ubicación de una función es el linking
- Si ocurre justo después de compilar, es linking estático
- Si ocurre al momento de ejecutar el programa, es linking dinámico
libc contiene las funciones de la biblioteca estándar de C
- Aunque
nm imprima “no symbols” para libc, se pueden ver los símbolos con objdump -tT /lib/x86_64-linux-gnu/libc-2.15.so
- En la tabla de símbolos de libc se pueden revisar funciones como
sprintf, strlen, fork y exec
- Se puede imaginar cómo funciona el linking dinámico siguiendo el flujo en el que
hello llama a puts y se busca la ubicación de puts en la tabla de símbolos de libc
Las secciones separan código y datos
objdump -s hello imprime los bytes de cada sección del ejecutable en hexadecimal y ASCII
- Las secciones principales son las siguientes
.text: contiene el código real del programa, es decir, el ensamblador, e incluye _start y main
.rodata: contiene datos de solo lectura; en el ejemplo está la cadena "Penguin!"
.interp: contiene el nombre del archivo del linker dinámico
- Las secciones y los segmentos se usan en momentos distintos
- Las secciones las usa
ld durante el linking
- Los segmentos se usan durante la ejecución
- Con
readelf --sections hello se pueden ver con más detalle los metadatos de las secciones
- Las banderas del ejemplo muestran la naturaleza de cada sección
.text: ejecutable y de solo lectura
.rodata: de solo lectura
.data: lectura/escritura
.bss: área de datos escribible
Con el desensamblado vemos el código máquina como ensamblador
- La sección
.text contiene bytes que la CPU interpreta y ejecuta como código
- Como los bytes iniciales de
.text en el ejemplo, 31 ed, son difíciles de entender directamente para una persona, se necesita un desensamblador
objdump -d ./hello desensambla la sección .text y la muestra como instrucciones de ensamblador
- En la salida del ejemplo,
31 ed aparece como xor %ebp,%ebp
- De esta forma se puede comprobar qué instrucción de ensamblador corresponde a cada byte de código dentro del binario
Los segmentos definen la distribución en memoria al ejecutar
- Un ejecutable también está compuesto por segmentos o program headers
readelf --segments hello muestra los segmentos del programa y el mapeo entre secciones y segmentos
- Los segmentos se usan para decidir cómo distribuir en memoria cada parte del programa
- En el ejemplo hay dos segmentos
LOAD principales
- Primer
LOAD: marcado como R E, por lo que permite lectura/ejecución
- Segundo
LOAD: marcado como RW, por lo que permite lectura/escritura
.text debe poder leerse y ejecutarse, pero no escribirse, así que va en el primer segmento
.data y .bss deben poder escribirse, pero no necesitan ejecutarse, así que van en el segundo segmento
Herramientas y recursos para profundizar
- Los ejecutables ELF no son magia especial, sino un formato de archivo común; los binarios de Linux se pueden investigar con
readelf, nm y objdump
- Recursos relacionados
1 comentarios
Comentarios de Hacker News
Como se dijo en otros hilos https://news.ycombinator.com/item?id=38847750#38862450, recomiendo muchísimo escribir un ELF a mano al menos una vez
Es un gran ejercicio para entender los componentes básicos de un ejecutable, y también ayuda si, a diferencia de este artículo, quieres abordarlo de abajo hacia arriba en vez de de arriba hacia abajo
También hay muchas buenas discusiones en varios hilos de esa otra publicación de HN
También hice una visualización interactiva que muestra los bytes del archivo para explicarme el formato a mí mismo y a otras personas
Si haces clic en un byte, aparece una explicación, y se resaltan los bytes relacionados dentro del archivo para ayudar a entenderlo: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
El enlace dinámico tiene bastante complejidad de implementación, pero si solo soportas ELF estáticos resulta bastante directo
Al principio es frustrante porque depurarlo es prácticamente imposible cuando no funciona, pero cuando por fin empieza a funcionar, se siente increíble
ELF se puede parchear de formas interesantes, y con el vector auxiliar también es posible inspeccionarse a sí mismo en tiempo de ejecución
Como Linux te da la dirección de la tabla de encabezados del programa, desde ahí puedes llegar a cualquier parte, y basta con expandir el segmento LOAD para que cubra todo el binario
Por ejemplo, hice una herramienta para insertar módulos y código Lisp directamente dentro del ejecutable de mi intérprete Lisp
El segmento insertado se carga automáticamente desde el ELF, y el intérprete lo encuentra y lo ejecuta
Me gustó tanto esta pequeña función que hasta escribí sobre ella: https://www.matheusmoreira.com/articles/self-contained-lone-...
Ojalá los lenguajes más mainstream también adoptaran algo así
Lees la documentación, eliges los bytes necesarios con la hoja de datos del procesador, los colocas en orden en varias secciones, llenas los campos de ELF y al final todo termina reduciéndose a teclearlo todo
En entornos anteriores a ELF, como la Apple II de 8 bits, los monitores de código máquina te permitían introducir directamente los bytes del programa, y esos bytes se ejecutaban
Guardarlo en disco solo es un poco más complejo, y ahí también hay otra oportunidad
Puedes crear un archivo con un editor de sectores de disco, y así sigue la cosa
Según entiendo, el símbolo
maines algo específico de CEl símbolo
_startes el punto de entrada del binario independiente del lenguaje, y en este caso llama amainSi hubiera existido la convención de llamar
_startal punto de entrada y pasar ahíargc/argvdemain, el formato habría sido mucho menos flexible_startes especialEl binario escribe la dirección del punto de entrada en el encabezado, y el sistema operativo empieza a ejecutar desde esa dirección
Llamar a ese símbolo
_startes solo una convención de C y otros lenguajes, y el enlazador la usa para establecer la entrada al escribir el encabezado ELFSi escribes tu propio linker script, puedes ponerle al punto de entrada el nombre que quieras
main, correctamente, solo se proporciona en hosted CEn freestanding C puedes tener el punto de entrada que quieras
_starttambién es solo el valor por defecto del enlazador, y puedes especificar un símbolo mejor con-Wl,--entry="${symbol}"; GCC también soporta configurarlo directamente sin el feo-WlAdemás, el punto de entrada en realidad no es un símbolo sino un puntero
El enlazador simplemente toma la dirección del símbolo especificado y la establece como la entrada ELF
En la pila no solo están la cantidad de argumentos y el vector de argumentos, sino también el vector de entorno y el vector auxiliar
El código de arranque del proceso puede ser tan simple como tomar esos valores de la pila, ponerlos en los registros adecuados y llamar a la función C deseada
El punto de entrada en sí no es una función, así que no hay adónde regresar
El código del punto de entrada debe terminar con la llamada al sistema
exitpara que el proceso finalice limpiamente cuandomaindevuelve un código de estadoAl menos en Linux funciona así
En lenguajes como Rust/C/C++ incluso puedes inyectar variables para inicializar mediante flags del enlazador
Si el programa está enlazado dinámicamente, según entiendo el runtime del enlazador se ejecuta antes de
_start, resuelve los enlaces y luego le entrega el control a_startAl final, todo esto son hackeos sobre hackeos que se fueron agregando orgánicamente para dar extensibilidad, y como socialmente se aceptaron lo suficiente y funcionan lo bastante bien, se siguen usando
Empecé un blog en 2012, cuando cambié mi trayectoria académica de matemáticas a ciencias de la computación, y este tema fue literalmente de las primeras cosas que estudié: https://heinrichhartmann.com/archive/Dissecting-Hello-World....
Nunca me he arrepentido de meterme en esa madriguera
Si no recuerdo mal, Julia también tiene formación en matemáticas
Quizá la razón por la que la gente de matemáticas se siente atraída por este tipo de experimentos sea el deseo de razonar desde los fundamentos
Me alegra que ella haya hecho este contenido accesible para tanta gente
Los textos de Julia siempre son excelentes
Cuando enseñaba que el código compilado no puede ocultar secretos, siempre funcionaba muy bien hacer una demostración de
stringsMultaron a un pobre tipo porque encontró una contraseña básicamente de la misma forma que si hubiera ejecutado
stringssobre un binarioLos jueces consideraron que había “eludido” una medida de seguridad del software: https://www.theregister.com/2024/01/19/germany_fine_security...
No es una crítica ni una objeción, solo un pensamiento que se me vino a la cabeza
La frase “como los binarios son casi la definición de algo específico de una plataforma, todo esto también es específico de una plataforma” me hizo recordar cuando Actually Portable Executable mostró que el mismo binario podía ejecutarse en múltiples plataformas
Todavía no me recupero del todo, a nivel mental, de ese momento surrealista
Durante décadas intentamos resolver el problema multiplataforma con todo tipo de enfoques fractales como Java, bibliotecas multiplataforma y demás, y resulta que la solución estuvo frente a nosotros todo el tiempo
En la era de las computadoras rápidas, me parece mejor distribuir el código fuente y compilar localmente que distribuir binarios
Por desgracia, gran parte del software del que dependemos es demasiado grande y los compiladores son relativamente lentos, así que la distribución de binarios termina siendo una especie de mal necesario
Me gustaría que se invirtiera más esfuerzo, en vez de en binarios portables, en componentes de software más simples que compilen rápido de manera natural y en compiladores más veloces
Es un script que puede ejecutarse en cualquier sistema, y ese script puede cargar un binario
Si no recuerdo mal, la versión original tenía que decodificarse desde base64 antes de cargarse
Así que se parece más a un cargador de binarios ejecutable
A principios de los años 90 me fascinaron los formatos de archivos ejecutables, y pasé varias semanas creando en Modula 2 un visor de ejecutables de DOS y Windows, al que llamé VEXE, y lo distribuí como shareware en 1991
La herramienta llegó a tener una popularidad de nicho entre crackers, al punto de ser mencionada en el tutorial de +ORC: https://gist.github.com/callowaysutton/48bdf0245e17e72d41a15...
Probablemente porque podía detectar varios esquemas de cifrado y compresión usados para dificultar la ingeniería inversa de programas
Si te da curiosidad qué tan pequeños pueden llegar a ser los archivos binarios ELF, puede gustarte este artículo divertido: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
También podrías revisar mi herramienta para explorar ELF con SQL: https://github.com/fzakaria/sqlelf
¿Alguien puede recomendar materiales o libros prácticos de introducción a la programación de bajo nivel para alguien con una base fuerte en Python?
Hace poco empecé a aprender Rust y me di cuenta de que tengo mucho por ponerme al día
Nunca llevé un curso de compiladores, así que quizá me estoy perdiendo mucha información
Por ejemplo, ni siquiera sabía que existían los símbolos en los binarios, ni conocía la diferencia entre ELF y Mach-O
Hacer
catde un binario en la terminal es un atajo directo a la tristezaA mí me gusta
| hd, que en esencia eshexdump -C, aunque a simple vista sigue siendo igual de críptico