DITools es un software desarrollado por el CEPBA que implementa un mecanismo de interposición dinámica de código orientado a redirigir las llamadas a funciones entre Dynamic Shared Objects o DSO's.
DITools opera cargando, en el espacio memoria de la aplicación instrumentada, un conjunto de objetos compartidos llamados "backend". Un "backend" es un DSO normal que contiene un conjunto de funciones que serán el destino de las interposiciones hechas con DITools.
DITools se presenta como unas librerías que cooperan entre ellas. En concreto son dos librerías: libhook.so y liblink.so.
El esquema general de funcionamiento dentro del sistema se puede observar en la imagen siguiente. DITools opera exclusivamente en el espacio de trabajo de una aplicación, en modo usuario, y por lo tanto de forma aislada a otras aplicaciones. Como se puede observar en el esquema, casi toda la acción la lleva liblink.so. Es el encargado de implementar los mecanismos de interposición y de manipular las estructuras de ELF y de rld(5).
En este esquema observamos varias aplicaciones, más una instrumentada por DITools. Los objetos compartidos (DSO) de la aplicación se representan como rectángulos. En cambio, los DSO de DITools se muestran como dos elipses.
Seguidamente explicamos los detalles sobre el funcionamiento interno de DITools y observaciones sobre la tarea de ingeniería inversa realizado sobre él.
Todas las acciones de DITools tienen como objetivo final interponer código.
El mecanismo se ha implementado sólo sobre el sistema operativo Irix para MIPS®, tanto en 32 como 64 bits. De hecho, y como curiosidad, el mecanismo es el mismo en ambos modos de funcionamiento, cosa que no es para nada común en otras arquitecturas, donde el ABI de 32 bits es muy distinto al de 64 bits.
En Irix, la única diferencia realmente importante entre el ABI de 32 y el de 64 bits es el tamaño de los punteros y algunos tipos de variable como long int. En el resto ambos modos operan casi de la misma forma.
El mecanismo se basa fundamentalmente en alterar el contenido de la tabla GOT ("Global Offset Table"). Cada DSO tiene su propia tabla de punteros GOT. Esta tabla le indica al DSO la posición absoluta de los símbolos a los que accede y pertenecen a otros DSO. Así, alterando convenientemente estos punteros podemos redirigir la ejecución hacía código de los "backend", tal como se puede ver en el siguiente diagrama:
En este diagrama se puede observar como interponer código en las llamadas a la función read(2) desde el programa principal simplemente alterando una entrada del GOT del ejecutable.
Estas redirecciones y la carga de estos "backend" se configura mediante unos ficheros de texto llamados "ficheros de comandos de reenlace".
Para poder entender completamente el funcionamiento de DITools es necesario conocer bien ELF para MIPS® ya que explotaremos sus estructuras de datos para realizar las interposiciones.
No obstante DITools no suele aprovechar todas las interrelaciones que ofrecen las estructuras ELF. En su lugar, para averiguar datos, usa la interfaz de rld(5), herramienta exclusiva del sistema operativo Irix. rld(5) es un "Runtime Linker" y se encarga de cargar los DSO y resolver los enlaces dinámicos entre objetos en tiempo de ejecución, al igual que hace ld en tiempo de compilación.
rld(5) ofrece un API para manipular los objetos en memoria, consultar estructuras de ELF, variar su propio comportamiento y/o inspeccionar los objetos en memoria. Esta API se usa numerosas veces en el código, relegando las operaciones de consulta a rld(5), lo cual liga seriamente DITools a esta aplicación.
Esta librería en realidad es un pequeño DSO que contiene una única función que se instala en la sección de código de inicialización .init.
Cuando el "Runtime Linker" carga un ejecutable y todas sus librerías en memoria, antes de entregar el control al programa principal, lanza el código de inicialización de los DSO. Cada DSO puede aportar su sección .init. El orden de ejecución de estas secciones depende de dos cosas: de las dependencias entre los DSO y su orden de aparición. Si un DSO B depende de otro A, el código de inicialización de B se ejecutará después de A. Si tenemos dos DSO que dependen de otro, aunque en principio el orden de ejecución sea indistinto, el "Runtime Linker" ejecutará primero el código de inicialización del DSO que haya aparecido primero en la cadena de carga.
Antes de cargar una aplicación es posible forzar a rld(5) a añadir uno o
más DSO a la cadena de carga. Esto se realiza mediante la variable de
entorno _RLD_LIST
. Por ejemplo, ejecutando la línea
_RLD_LIST="mylib.so:DEFAULT" ./myprogram
obligamos a que mylib.so se
cargue antes que las librerías necesarias para ejecutar el programa
myprogram.
El recurso descrito en el párrafo anterior se explota para introducir a libhook.so en la lista de librerías necesarias de una aplicación.
Una vez cargada en memoria, la librería libhook.so, mediante un mecanismo específico de rld(5), inserta la librería liblink.so exactamente después del ejecutable en la cadena de objetos de la aplicación. Esta cadena de objetos se genera en tiempo de carga por rld(5) y recibe el nombre _RLD_LIST (rld(5)).
Normalmente, cuando se inicia un programa, se ejecuta el código de inicialización del binario que se corresponde con el código de inicialización de C Library. Posteriormente se ejecutan, en el orden de aparición de _RLD_LIST, los bloques de inicialización de cada objeto. Añadiendo liblink.so justo después del programa principal se garantiza que su inicialización será antes que el resto de librerías, pero con el programa ya inicializado (aún así el programa instrumentado no ha comenzado a ejecutar código "propio" todavía).
De una forma no explicita, una vez que libhook.so termina su ejecución, el "Runtime Linker" pasa el control al código de inicialización de liblink.so.
Todavía no se ha averiguado el motivo por el cual es necesario usar libhook.so para cargar liblink.so. En principio no es esencial y debería funcionar correctamente cargando liblink.so ejecutando simplemente _RLD_LIST="liblink.so:DEFAULT" ./myprogram. Se supone que libhook.so debe ser un "hack" para rodear algún tipo de limitación o "bug" de rld(5).
Donde está toda la maquinaria de gestión de interposiciones de DITools es en liblink.so. Esta librería es la encargada de procesar los ficheros de comandos, inicializar los "backends", instalar las interposiciones y, al finaliza el programa principal, deshacer todos estos cambios.
El proceso de inicialización de la librería liblink.so sigue el siguiente curso:
Lectura de los ficheros de comandos.
Terminadas las inicializaciones básicas, DITools comienza la
lectura de los ficheros de comandos indicados en variables de
entorno. En concreto son las variables DI_RUNTIME_FILE
y
DI_CFG_FILE
. La primera contiene la ruta al fichero de
comandos de sistema llamado "Runtime Config". Este fichero
contiene los "backends" e interposiciones que se instalan primero
y que deben instalarse en toda sesión de instrumentación. La otra
variable, DI_CFG_FILE
, indica el fichero de comandos de
reenlace (el cual declara también sus "backends" e
interposiciones) creado por el usuario, y específico para sus
instrumentaciones.
Al ser una lectura totalmente secuencial y sin comprobación de dependencias entre "backends", se va construyendo la lista de "backends" y la configuración final de las interposiciones, sin instalarlas todavía.
Y aunque técnicamente sería posible y bastante sencillo, no se comprueba en ningún momento si se producen conflictos entre los comandos de interposición.
Carga e inicialización de los "backend".
En este paso se repite por cada "backend" la siguiente operación:
carga del "backend" en memoria con ayuda de la API de
rld(5) y ejecución de la función de inicialización
di_init_backend()
del "backend". Es importante tener en
cuenta que no es lo mismo este código de inicialización que la
sección .init de ELF.
El orden de carga e inicialización de los "backends" es el orden de la primera aparición de cada uno de ellos en los ficheros de comandos.
Instalación de las interposiciones. La instalación de las interposiciones se realizan siguiendo la configuración final resultante del primer paso. Es durante ese recorrido cuando se comprueba si los DSO usados están presentes o no en memoria, e igualmente, si las funciones referenciadas existen.
Hay tres tipos de interposiciones: reenlaces, redefiniciones e interposiciones mediante "callback".
Más adelante en esta sección se explican con más detalle las interposiciones.
Terminadas todas estas operaciones, DITools devuelve el control al "Runtime Linker" y se continua con la inicialización del resto de las librerías. Posteriormente comienza la ejecución del programa y hasta su finalización DITools no vuelve a coger el control. Tan sólo se ejecutará el programa y el código que se ha interpuesto.
Con la finalización del programa DITools vuelve a activarse aprovechando el código de la sección de finalización de ELF (.fini).
Como el "Runtime Linker" ejecuta el código de finalización de los DSO en el orden inverso en que ejecuto el código de inicialización resulta que DITools será el último DSO en ejecutar su sección de finalización (.fini), y por lo tanto tenemos garantizado que hasta ese punto se habrá instrumentado enteramente la aplicación.
Nótese que si los "backend" tuvieran una sección de finalización .fini, ésta se ejecutaría. Por este motivo no es aconsejable definir las secciones .init y .fini en los "backend": es difícil saber que interacción tendrán con DITools.
El código de finalización de DITools recorre la configuración aplicada en el primer paso y deshace todas las interposiciones de código en el orden en que las creo. Deshechas las interposiciones se puede ejecutar de forma segura las funciones de finalización de los "backend" (que no tiene nada que ver, si existiese, con la sección .fini de los mismos), es decir, sin que se vean afectados por ellas. Esta finalización se realiza en orden inverso a la inicialización.
Finalmente, y más por elegancia que por necesidad, se descargan de memoria los "backend". Hecho esto termina la ejecución de DITools.
Seguidamente se explica, en orden de complejidad creciente, las interposiciones que implementa DITools.
El reenlace, consiste en redirigir una llamada a una función de otra librería (que en realidad es otro DSO) hacia una función de un "backend". Es un cambio puntual que sólo afecta a un determinado objeto.
Su implementación consiste, a grosso modo, en sustituir el puntero del GOT (de dicho DSO) que apunta a la verdadera función por un puntero al código (función) que se interpone.
En este esquema vemos como se instala un reenlace en la aplicación, exactamente sobre el programa principal, sin afectar al resto de objetos en memoria.
El segundo tipo, la definición, en cambio afecta a nivel global (a todos los DSO de la aplicación). Consiste en sustituir una función pública de un DSO por otra función perteneciente a un "backend". Esta interposición además tiene la ventaja de que es heredada también por los DSO cargados en tiempo de ejecución.
Su implementación consiste en modificar la información del símbolo que interceptamos. En el DSO que contiene ese símbolo existe una entrada en la tabla de símbolos de ELF que lo describe. En esa entrada hay un puntero que indica la posición de la función en el DSO. Reajustando ese puntero, haciéndolo apuntar hacia el código interpuesto, conseguimos que cada vez que el "Runtime Linker" resuelva un símbolo, sea él mismo quien haga el reenlace.
No obstante tiene un grave problema: no funciona cuando se redefine un
símbolo que ya ha sido usado[1], ni cuando la variable de entorno
LD_BIND_NOW
está definida y con un valor que no sea 0. Esta
variable de entorno, la cual ha de ser reconocida siempre por el
"Runtime Linker" según el estándar ELF, indica que se desea que todas
las referencias sean resueltas en tiempo de carga, antes de que
comenzar a ejecutar el código del programa. Evidentemente, en este
caso el "Runtime Linker" nunca más vuelve a consultar las tablas de
símbolos ni a resolver llamadas, inutilizando totalmente las
definiciones.
En este esquema vemos como se sustituye la función read(2) de libc.so por nuestra versión de la función. Como se puede ver, esta sustitución afecta a todos los DSO de la aplicación.
El tercer tipo, que no parece funcionar correctamente, recibe el nombre de "callback". Los "callback" espían las llamadas que realiza un determinado DSO a otros DSO de un modo automático y programable.
Los "callback" se instalan como reenlaces o redefiniciones, según diga el usuario. En caso de reenlace se monitorizan todas las funciones de un determinado DSO. En el caso de las redefiniciones el código es muy caótico y no parece ser muy coherente. Deduzco que el resultado de este comando sería monitorizar todas las llamadas que se hacen a un DSO.
Los "callback" se implementan en tres funciones que cooperan con
DITools para gestionar las intercepciones. En concreto son las
funciones di_callback_required()
, di_pre_event_callback()
y
di_post_event_callback()
.
La mecánica es la siguiente: cuando se intercepta la llamada a la
función instrumentada DITools pasa el control a la función
di_callback_required()
. Esta función recibe el nombre de la función que
se ha interceptado y con él debe decidir si desea instrumentar o no
esta llamada. En caso de no quererla interceptar simplemente tiene que
devolver un cero. En caso de quererla interceptar debe devolver un
número distinto de cero que luego será pasado a las otras dos
funciones. Este número suele ser un identificador numérico que permite
identificar que función se está interceptando.
Si se decide no interceptar la llamada, DITools transfiere el control al programa y la función se ejecuta normalmente.
Si en cambio se intercepta la llamada, DITools transfiere el control
a la función di_pre_event_callback()
si esta existe. Cuando esta
termina se ejecuta la función normalmente y, al terminar esta, se
transfiere llama a la función di_post_event_callback()
(si esta
existe).
No obstante, la implementación que analicé solamente llamaba a
di_pre_event_callback()
, ejecutaba la función y terminaba. El código
encargado de la segunda parte estaba desactivado, sin comentar y
aparentemente inacabado.
[1] | DITools dispone de una API que pueden usar los "backends" para realizar interposiciones en tiempo de ejecución. Sería en este caso cuando nos encontraríamos este problema. |