6 puntos por GN⁺ 2024-02-03 | 1 comentarios | Compartir por WhatsApp
  • 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

1 comentarios

 
GN⁺ 2024-02-03
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

    • Hace poco escribí un archivo ELF directamente: https://github.com/avik-das/garlic/blob/master/recursive/elf...
      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...
    • De forma similar, también recomiendo escribir un cargador ELF simple
      El enlace dinámico tiene bastante complejidad de implementación, pero si solo soportas ELF estáticos resulta bastante directo
    • Modificar un ELF existente también es muy educativo y divertido
      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í
    • Si lo haces tú mismo, en la práctica se parece mucho a ensamblado manual
      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
    • A Magnetized Needle and a Steady Hand de Chris Wellons es un artículo sobre cómo crear un ejecutable ELF desde cero: https://nullprogram.com/blog/2016/11/17/
  • Según entiendo, el símbolo main es algo específico de C
    El símbolo _start es el punto de entrada del binario independiente del lenguaje, y en este caso llama a main
    Si hubiera existido la convención de llamar _start al punto de entrada y pasar ahí argc/argv de main, el formato habría sido mucho menos flexible

    • Estrictamente hablando, ni siquiera el nombre _start es especial
      El 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 _start es solo una convención de C y otros lenguajes, y el enlazador la usa para establecer la entrada al escribir el encabezado ELF
      Si escribes tu propio linker script, puedes ponerle al punto de entrada el nombre que quieras
    • El símbolo main, correctamente, solo se proporciona en hosted C
      En freestanding C puedes tener el punto de entrada que quieras
      _start tambié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 -Wl
      Ademá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 exit para que el proceso finalice limpiamente cuando main devuelve un código de estado
      Al menos en Linux funciona así
    • Depende del runtime del lenguaje, pero una tarea común es inicializar valores estáticos globales no nulos
      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 _start
      Al 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 strings

    • También habría que explicárselo así a los jueces alemanes
      Multaron a un pobre tipo porque encontró una contraseña básicamente de la misma forma que si hubiera ejecutado strings sobre un binario
      Los 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

    • Personalmente no estoy seguro de que los binarios portables sean algo netamente positivo
      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
    • Puede que lo haya entendido mal, pero APE no parece ser en sí un formato binario
      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 cat de un binario en la terminal es un atajo directo a la tristeza
    A mí me gusta | hd, que en esencia es hexdump -C, aunque a simple vista sigue siendo igual de críptico