Capítulo 1. Fundamentos

Tabla de contenidos
1.1. Introducción al ELF
1.1.1. Descripción del formato
1.1.2. Secciones y "Dynamic Tags"
1.1.3. Relaciones entre las diferentes secciones
1.1.3.1. Resolución de un símbolo
1.1.3.2. Enlazar dinámicamente un símbolo
1.1.4. El "Runtime Linker"
1.2. Descripción y estructura de DITools
1.2.1. Mecanismos usados para interponer código
1.2.2. La librería libhook.so
1.2.3. La librería liblink.so
1.2.4. Tipos de interposiciones
1.3. Limitaciones de DITools

1.1. Introducción al ELF

En esta sección se da una pequeña introducción sobre como se enlaza en tiempo de ejecución objetos en formato ELF. Esta sección no pretende ser un manual detallado de ELF. Es simplemente una descripción de sus mecanismos y estructuras para poder entender más o menos como funciona. Si se desea tener un conocimiento detallado de ELF se recomienda leer la especificación [Sys5ABI]. En ella se encontrará una descripción general y no ligada a la plataforma del estándar. Para terminar de entender la implementación de ELF sobre plataformas MIPS® léase la especificación [Sys5mips], especialmente los capítulos 4 y 5.

1.1.1. Descripción del formato

El formato ELF, o Executable and Linking Format fue originalmente desarrollado y publicado por UNIX System Laboratories como parte de la Application Binary Interface (ABI). Más tarde el Tool Interface Standards committee (TIS) eligió el formato ELF como el formato portable de ficheros objeto en máquinas INTEL® de 32 bits para una amplia variedad de sistemas operativos.

El formato es muy extensible, y no sólo limitado a 32 bits para máquinas INTEL® 386. Actualmente se usa en una amplia variedad de máquinas, desde 32 bits hasta 64 bits pasando por todos los modelos concebibles de microprocesadores. No sólo el formato es ampliamente flexible, sino que además incorpora una serie de estructuras capaces de almacenar la información necesaria para saber sobre que arquitectura trabaja un determinado binario. Por ejemplo, en Itanium® es posible tener hasta cuatro ABI distintos funcionando en un mismo sistema operativo: 32 bit little endian, 32 bit big endian, 64 bit little endian y 64 bit big endian. El formato ELF permite al sistema operativo distinguir de que tipo es un determinado objeto, saber si puede usarlo o no y como usarlo.

La especificación de ELF se divide en dos partes: Los ficheros objeto y Carga del programa y enlace dinámico. En la primera parte se explica la estructura de los ficheros objeto, el formato binario en disco, y como se deben codificar e inicializar las estructuras. La segunda se refiere al formato ELF en tiempo de ejecución, con las estructuras ya descodificadas y con valores válidos, a parte de nuevas estructuras e información generadas por el "Runtime Linker". Casi todas las explicaciones que se dan a continuación giran alrededor de esta parte de la especificación, a no ser que se indique lo contrario. Es importante hacer esta distinción ya que el contenido de algunas secciones varía según este en disco o en memoria.

El motivo de que la primera parte de la especificación no nos interese mucho es porque DITools y pDI-Tools sólo trabajan con estructuras ELF una vez hecha la carga del programa y nunca manipulan ficheros en disco.

Hay muchos tipos de ficheros ELF: "Relocatable files" (ficheros reubicables), ficheros ejecutables, "Shared Objects", "core files", etc.

No obstante sólo dos de ellos intervienen en la ejecución de una aplicación: el fichero ejecutable y los "Shared Objects" (librerías). De hecho el primero contiene el programa y una lista de las librerías que se usan. Se usa esta lista para construir la lista de objetos compartidos que deben cargarse. Debe tenerse en cuenta que los objetos compartidos a su vez pueden requerir otras librerías y de esta forma generar un árbol de dependencias entre ellos.

1.1.2. Secciones y "Dynamic Tags"

Los ficheros ELF, ya sea en disco o en memoria, están divididos en secciones. Virtualmente una sección puede contener cualquier cosa: código, información de depuración, un icono, un MP3, una nota de texto con el copyright de la aplicación, etc. Las secciones son segmentos del fichero ELF que en principio no deberían solaparse (aunque nada se lo impide), con un nombre. Existen unas cuantas secciones predefinidas por el estándar, pero se pueden crear tantas como se quiera.

El responsable de la carga de los DSO (objetos) en memoria, de resolver los enlaces dinámicamente y poner en marcha la aplicación es el "Runtime Linker". Se explicará con detalle más adelante en esta sección. De momento sólo se explican los requerimientos genéricos que debe resolver cualquier "Runtime Linker".

Una vez se han cargado los objetos en memoria, el "Runtime Linker" recorre las diferentes secciones y estructuras de datos construyendo una cadena de estructuras dinámicas. Estas estructuras reciben el nombre de "Dynamic Tags" y contienen un identificador numérico que identifica el tipo de la estructura y, dependiendo de este tipo, la estructura puede contener un puntero (tipo ElfW(Addr)) o un valor (tipo ElfW(Word)).

Las estructuras dinámicas que contienen un valor del tipo ElfW(Word) suelen dar información sobre otras secciones. Por ejemplo la sección dinámica DT_STRSZ indica el tamaño en memoria de la sección "String Table".

Las estructuras que incorporan un puntero indican la ubicación en memoria de una sección ELF. Por ejemplo la estructura DT_STRTAB nos indica la posición en memoria de la sección "String Table".

Se ha de tener en cuenta que la cadena de "Dynamic Tags" no es sólo una manera de agrupar la información para simplificar el acceso a ella. Estas estructuras dinámicas sirven para seleccionar la información relevante para el "Runtime Linker" y además pueden aportar información nueva en tiempo de ejecución. Por ejemplo la sección dinámica DT_DEBUG está generada en tiempo de carga y suele apuntar a información de depuración específica según el sistema operativo.

Como se ha dicho, la cadena de "Dynamic Tags" sólo agrupa información de interés para el "Runtime Linker", ignorando toda sección que el éste no use. Esto no perjudica al funcionamiento de DITools o pDI-Tools ya que estos nunca usan estructuras que use el "Runtime Linker".

Según la especificación [Sys5ABI], en tiempo de ejecución sólo se debería usar un conjunto de estructuras dinámicas predefinidas, aunque es posible no usarlas todas o añadir nuevas. Algunas arquitecturas aportan nuevos "Dynamic Tags", ignoran o incluso varían el comportamiento de algunos existentes.

La especificación hace un especial hincapié en que se respete la obligatoriedad y estructura de las secciones. Pero en la realidad ningún fabricante hace caso de la especificación. Así que a la hora de interpretar la especificación debemos ser bastante flexibles y apoyarnos sobre los "System V Application Binary Interface Supplements" específicos para las diferentes arquitecturas.

El problema radica en que ELF es un esfuerzo por definir una interfaz binaria común entre todos los Unix™. Evidentemente ELF entró en juego cuando ya había una gran diversidad de ABI's, y aunque la mayoría derivaron de COFF, las diferencias entre algunas eran realmente radicales. Evidentemente los fabricantes se negaron a reimplementar las ABI desde cero: esto hubiera roto la compatibilidad hacia atrás con sus antiguos productos. Lo que se hizo fue camuflar sus antiguos mecanismos de carga con una interfaz ELF, y sólo en algunos raros casos reimplementarla desde cero. De esta forma los binarios antiguos y ELF podían coexistir en un mismo sistema operativo. Algunos fabricantes lo hicieron implementaciones notablemente excelentes, como es el caso de INTEL® con su [Sys5i386] o Sun Microsystems™ con su [Sys5Sparc] y [Sys5Sparc64]. En cambio encontramos otros fabricantes que realizaron implementaciones que incumplen el estándar en numerosos puntos. Dos claros ejemplos son las especificaciones [Sys5mips] de SGI® y [PPC64ELF] de IBM®[1].

Reuniendo un conocimiento general de gran parte de las arquitecturas conseguimos tener una visión global de las implementaciones de ELF que nos permite ver que es lo que realmente se usa de la especificación, y lo más importante: que es más o menos común entre muchas arquitecturas.

De este estudio realizado remarcamos como importantes, y bastante comunes, un conjunto de estructuras dinámicas. No obstante no siempre están presentes o su contenido es muy especial en determinadas arquitecturas. Seguidamente las enumeramos y comentamos superficialmente:

  • DT_NULL. No aporta ninguna información, pero su presencia marca el final de la lista de "Dynamic Tags". Su presencia, evidentemente, es muy importante para poder iterar sobre esta lista.

  • DT_PLTGOT. Es de todas la sección más dependiente de la arquitectura. Según el estándar debe estar siempre presente. En algunas plataformas, aún así, puede no llegar a estar presente.

    Según el estándar puede contener punteros (una "Global Offset Table" o GOT) y/o una serie de procedimientos de enlace ("Procedure Linkage Table" o PLT). La presencia de uno, u otro, o ambos, depende de la implementación.

  • DT_SYMTAB. Apunta a la "Symbol Table". Esta tabla sólo contiene estructuras con información de un símbolo. Estas estructuras básicamente contienen un puntero al nombre de la función en la sección "String Table", información sobre el tipo de símbolo y otra información complementaria.

  • DT_STRTAB. Este elemento apunta a la "String Table". Contiene el nombre de los símbolos, el nombre de las librerías, y otras cadenas que residen en esta tabla.

  • DT_HASH. Esta sección se ha encontrado en todas las plataformas y es esencial para poder hacer búsquedas eficientes en la tabla de símbolos.

    Como el estándar es muy explicito en su implementación (incluso llegando a dar porciones de código que se deben usar), todas las plataformas probadas han demostrado ser idénticas en su gestión y creación.

    Su función es acelerar al máximo las búsqueda de una estructura de información de símbolo mediante su identificador. Se describen más adelante estas estructuras en la sección dinámica DT_SYMTAB.

  • DT_REL o DT_RELA. Estos elementos guardan la dirección de su tabla de reubicación. En una arquitectura determinada sólo se usa una de las dos, aunque técnicamente sería posible usar ambas al mismo tiempo. A partir de ahora nos referiremos sólo a DT_REL, aunque todo lo que digamos se aplica también a DT_RELA.

    La diferencia entre una sección y la otra es el tipo de estructura usada. En el primero, DT_REL, se usa una estructura de reubicación ("relocation") absoluta, es decir, cuyo puntero se puede usar tal cual. En el segundo se usa una estructura de reubicación con un "addend" explícito que se ha de aplicar al puntero antes de usarlo.

    Estas estructuras relacionan el índice de una estructura de información símbolo en la tabla de símbolos con un puntero. Generalmente el puntero, o bien es la dirección del símbolo en sí, o bien una referencia dentro de DT_PLTGOT.

  • DT_RELSZ o DT_RELENT. Dan información sobre el tamaño de la DT_REL y el tamaño de la estructura usada en la ella. En el caso de usarse DT_RELA encontraremos también las estructuras dinámicas DT_RELASZ o DT_RELAENT.

  • DT_PLTREL y DT_PLTRELSZ. El primero indica el tipo de estructura de reubicación usada, mientras que el segundo indica el tamaño de dicha estructura. El primero puede tomar los valores DT_REL o DT_RELA.

Dejando de momento de lado las interrelaciones entre estas secciones, se hace un inciso en la nomenclatura usada para los tipos de datos de ELF. En ELF todos los tipos van precedidos por el prefijo Elf32_ o Elf64_, según estemos en una plataforma de 32 o 64 bits, respectivamente. Así tenemos Elf32_Rel, Elf64_Sym, etc. Como la elección del tipo de estructura se hace en tiempo de compilación, y no depende de nosotros, sino del ABI escogido, accedemos a ellas mediante la macro ElfW(x). Esta macro nos permite usar por ejemplo el tipo ElfW(Sym) que se traducirá posteriormente, en tiempo de compilación, en el tipo adecuado (Elf32_Sym o Elf64_Sym).

Igualmente, las macros de ELF llevan todas el prefijo ELF32_ o ELF64_. De la misma forma usamos otra macro para nombrarlas: ELF(x). Así tenemos las macros ELFW(R_SYM), ELFW(R_TYPE), etc.

1.1.3. Relaciones entre las diferentes secciones

En esta sección describimos las interrelaciones que se dan entre las diferentes secciones en tiempo de ejecución. No obstante esta explicación la daremos mediante la descripción de las operaciones de consulta más usuales que se hacen.

Antes de comenzar observemos el siguiente diagrama. Este pequeño mapa nos facilitará tener un imagen global de como se relacionan las estructuras dinámicas más importantes.

Este diagrama muestra como el símbolo read(2) se declara en las estructuras ELF de algún DSO que lo use y/o implemente.

1.1.3.1. Resolución de un símbolo

La primera operación que se plantea es la resolución de un símbolo a partir de su nombre. En esta explicación se simplifica no teniendo en cuenta modificadores como DT_SYMBOLIC[2] ni los símbolos "weak" (que se explican más adelante).

La búsqueda de un símbolo por su identificador se realiza con ayuda de la tabla "hash" contenida en la sección DT_HASH. Esta nos permite acceder rápidamente a la información de un símbolo sin tener que realizar una búsqueda secuencial por toda la tabla de símbolos. La implementación y mecanismos de la tabla "hash" se describen hacia el final de esta sección.

La sección DT_SYMTAB contiene una serie de estructuras de ElfW(Sym). Esta estructura contiene varios campos, de los cuales los más interesantes son la posición del nombre del símbolo en DT_STRTAB, una dirección, el tamaño del símbolo (si es aplicable), y un campo con información especial sobre el símbolo.

En el campo de información especial se indica la visibilidad y el tipo de símbolo. La visibilidad asigna en realidad un nivel de precedencia al símbolo por si hay colisiones con otros símbolos de otros objetos compartidos. Los niveles de precedencia son los siguientes:

  • STB_LOCAL. El símbolo sólo es visible desde este mismo objeto y no debe exportarse. Los símbolos locales con el mismo nombre pueden existir en varios ficheros sin interferir entre ellos.

  • STB_GLOBAL. Los símbolos globales son visibles a todos los objetos en memoria. Una definición global de un símbolo satisfará cualquier referencia no resuelta al mismo símbolo global.

  • STB_WEAK. Un símbolo débil ("weak") es equivalente a un símbolo global. La diferencia estriba en que si existe un símbolo global con el mismo nombre, este toma precedencia y el símbolo "weak" es ignorado.

El tipo de símbolo es un identificador numérico que identifica el tipo de objeto que la estructura declara. Las herramientas DITools y pDI-Tools sólo trabajan con símbolos del tipo STT_FUNC.

En el tipo de símbolo STT_FUNC (sólo en los casos de visibilidad STB_GLOBAL y STB_WEAK) el puntero de la estructura puede tomar dos valores: 0 en caso de que el símbolo sea referido pero no resida en el objeto, o un puntero real al símbolo dentro del objeto.

Con las operaciones hasta aquí descritas se puede escribir una rutina capaz de buscar un símbolo, averiguar de que tipo es, y en el caso de pDI-Tools o DITools, saber si es de nuestro interés y que operaciones podemos hacer sobre él.

1.1.3.2. Enlazar dinámicamente un símbolo

La siguiente operación más común es realizar un enlace en tiempo de ejecución de un objeto con una definición de función en otro objeto.

Esta operación requiere que entre en juego tres nuevas secciones: DT_REL, DT_JMPREL[3] y DT_PLTGOT.

La sección DT_REL contiene estructuras ElfW(Rel). Cada una de estas estructuras relaciona un símbolo con su información de reubicación. La estructura contiene un puntero, el tipo de estructura de reubicación y el índice del símbolo con el que se relaciona.

El uso que se debe hacer del puntero depende del tipo de reubicación que estemos tratando. Estos tipos son dependientes de la arquitectura y se definen en los suplementos de [Sys5ABI].

Pero esta tabla generalmente no la usamos ya que en casi todas las plataformas se separan las estructuras de reubicación relacionadas con funciones en una sección aparte: DT_JMPREL. Esta sección suele contener sólo reubicaciones de un tipo, que indican la posición del código/datos de reubicación dentro de la sección DT_PLTGOT.

La operación comienza por buscar el símbolo a reenlazar en la tabla de símbolos según se ha explicado más arriba. Una vez encontrado, nos aseguramos de que el puntero del símbolo sea cero (o en otras palabras que el símbolo no pertenezca al objeto). Una vez conocemos el índice del símbolo dentro de la tabla de símbolos, buscamos en DT_JMPREL una estructura de reubicación con el índice de este símbolo.

En la mayor parte de las arquitecturas esta búsqueda se puede realizar de forma dicotómica debido a que estas estructuras están ordenadas según el índice del símbolo.

Con la estructura de reubicación encontramos el lugar de DT_PLTGOT que debemos alterar para ejecutar el reenlace. El contenido de esta sección es muy variable, incluso en la misma plataforma según se use un ABI de 32 o 64 bits. Podemos encontrar desde porciones de código escrito en tiempo de ejecución hasta una simple tabla de punteros (y a veces incluso mezclas de los dos).

Ponemos como ejemplo de sección DT_PLTGOT el caso de la arquitectura INTEL® 386 donde cada entrada de la sección DT_JMPREL se corresponde con una entrada del PLT y contiene varios "stub" que realizan el salto desde un DSO a otro DSO. Por ejemplo, si un objeto A desea llamar a una función de B, A simplemente ejecuta una llamada al "stub" en su sección DT_JMPREL. Este "stub" a su vez consulta otra sección dinámica, el GOT, donde encontrará la dirección donde está la función de B, saltando de inmediato allí.

1.1.4. El "Runtime Linker"

La función del "Runtime Linker" es ejecutar operaciones similares a las anteriores para resolver las llamadas entre objetos. Estas resoluciones las puede hacer en tiempo de carga, es decir todas de golpe, o bajo demanda, es decir cada vez que se llama a un símbolo por primera vez.

El primer caso se explica por si mismo. En cambio la resolución dinámica de símbolos, o "Lazy Binding", es algo más compleja de entender. El "Lazy Binding" consiste en no resolver ninguna referencia en tiempo de carga. En su lugar se dejan referencias a un punto de entrada especial del "Runtime Linker". Este código, cuando toma el control, averigua a que símbolo se estaba intentando acceder. Con esta información resuelve una dirección al verdadero símbolo (en otro DSO), que luego usa para sustituir su entrada en el DT_PLTGOT. Una vez ha terminado de modificar el DT_PLTGOT restaura el estado de la aplicación y salta a la función que se estaba intentando llamar. A partir de este momento los futuros accesos a este símbolo resolverán directamente en el símbolo sin hacer falta la intervención del "Runtime Linker".

Evidentemente este modelo presenta dos problemas. El primer problema radica en la posibilidad de que se intente resolver un símbolo que no existe en ningún DSO en memoria. Si ocurriese esto la aplicación terminaría bruscamente. El segundo problema surge de la lentitud de la resolución de un símbolo: aunque el resto de las llamadas tarden un tiempo razonable y constante, la primera vez que se resuelve el símbolo es difícil de predecir.

Normalmente no se realiza la resolución en tiempo de carga ya que este tipo de resolución hace más lento el proceso de carga de una aplicación. No obstante en algunos casos un software no puede tolerar la impredecibilidad de la resolución de símbolos en tiempo de ejecución. En este caso se puede declarar la variable de entorno LD_BIND_NOW que garantiza que todos los símbolos se resolverán en tiempo de carga.

Hasta ahora nos hemos referido en muchas ocasiones al "Runtime Linker", pero no hemos definido todavía exactamente lo que es. El "Runtime Linker" es un objeto compartido, que se presenta en forma de librería, con algunas características especiales. El estándar ELF recomienda que el "Runtime Linker" sea la misma librería de C libc.so quien lo implemente. No obstante las implementaciones no hacen mucho caso de esta indicación del estándar y cada una usa un "Runtime Linker" distinto. Por ejemplo GNU/Linux usa ld-linux.so como "Runtime Linker", mientras que Solaris usa ld.so.1.

Se ha dicho que el "Runtime Linker" es un DSO especial ya que será usado directamente por el sistema operativo para cargar una aplicación. Cuando el sistema operativo desea cargar un ejecutable en memoria, lo único que hace es mapear el ejecutable en memoria (con un mmap(2)), mapear el "Runtime Linker", y transferir el control a él en modo usuario. Este se encargará de completar la carga del resto de objetos y de darle el control al programa.

Desde el punto de vista de la ejecución, al ser el "Runtime Linker" un objeto compartido más, es posible acceder a sus estructuras internas, a pesar de ser algo muy específico y poco portable entre diferentes sistemas operativos.

No obstante es necesario acceder a ellas para resolver unos cuantos problemas básicos. El estándar ELF sólo da mecanismos para poder acceder a las estructuras ELF de un objeto desde el mismo objeto. Es decir, un objeto puede consultar sus estructuras ELF, pero no tiene manera de navegar a través de las estructuras de los otros objetos.

Evidentemente el "Runtime Linker" suele guardar un inventario de los objetos que tiene en memoria, con referencias a sus estructuras. Como las herramientas DITools y pDI-Tools deben poder inspeccionar cualquier objeto en memoria requieren hacer uso de estas estructuras.

El acceso al inventario se hace de formas distintas según el sistema operativo: directamente, mediante protocolos de depuración o incluso mediante una API.

A veces se ofrecen mecanismos verdaderamente potentes y flexibles como es el caso de Irix, o parcos y escuetos como el de GNU/Linux y Solaris. En el primer caso no sólo se ofrecen mecanismos para acceder a las estructuras ELF, sino que además es posible manipular objetos en memoria, funciones para resolver símbolos, funciones de búsqueda, etc.

No obstante no se dispone siempre de una API tan potente como la de Irix ni tampoco está contemplado por el estándar. Por ese motivo es necesario poder iterar sobre los objetos para poder hacer las búsquedas nosotros mismos. El mecanismo usado para buscar un símbolo en un objeto se basa en usar una tabla "hash" nombrada antes más arriba.

El estándar ELF obliga a disponer de la sección DT_HASH, que contiene una tabla "hash" que permite hacer búsquedas de símbolos en tiempo casi constante, junto a la información necesaria para poder decodificarla. Esta sección se divide en dos partes, que aparecen en este orden y sin separación alguna:

  • "bucket". Contiene la tabla "hash". Cada entrada contiene un puntero a una entrada de la siguiente sección o NULL.

  • "chain". Es una tabla donde se contienen todas las colisiones de la tabla "hash". Cada entrada de esta tabla contiene un puntero a la próxima colisión de símbolo o NULL en caso de no haber más colisiones.

La sección DT_HASH está organizada de tal manera que cada entrada se corresponde directamente con su entrada en la tabla de símbolos. De esta forma, una vez encontrado el primer símbolo sólo tenemos que ir siguiendo la cadena hasta encontrar el símbolo que resuelve nuestra búsqueda o fracasar al agotar la cadena de búsqueda.

La función usada para calcular el "hash" de un símbolo está definida por el estándar y se respeta en todos los sistemas. Esta función devuelve un número de 32 bits. El valor final de "hash" lo obtenemos el resto de dividir el resultado de la función "hash" entre el número de entradas en el "bucket". Este valor es un índice del "bucket" y posiblemente la primera entrada de una cadena de colisiones.

Esta tabla nos permite reducir notablemente el tiempo empleado en las búsquedas y se debería usar (en el caso de pDI-Tools siempre) en todas las búsquedas por nombre de símbolo.

Notas

[1]

En realidad nunca se ha llegado a considerar un suplemento de [Sys5ABI] ya que incumple muchos requisitos mínimos. Al parecer, esta implementación para PowerPC™ 64 del ABI es en realidad un burdo disfraz de ELF para la especificación [POPEN32].

[2]

La presencia de este elemento altera el algoritmo de resolución del enlazador dinámico para referencias desde este DSO. En lugar de comenzar la búsqueda de la referencia en el ejecutable, la búsqueda comienza en el mismo objeto. En caso de que este DSO no aporte una definición del símbolo referenciado, el "Runtime Linker" comenzará de nuevo la búsqueda por el ejecutable y seguirá con el resto de objetos como suele ser habitual.

[3]

En algunas plataformas como MIPS® esta sección no existe, aunque por regla general existe en todas las plataformas.