2.3. Tipos de interposiciones

En este apartado explicamos los fundamentos y estrategias usadas para implementar las interposiciones de código. Además estos mecanismos han derivado en tres posibles formas de interponer código. Cada una de ellas explota una característica distinta del estándar ELF para llegar al mismo objetivo.

2.3.1. Conceptos básicos para crear una interposición de código

Esta explicación es un recordatorio que enumera y explica superficialmente las principales características de ELF usadas para crear las interposiciones de código. Todos estos conceptos están explicados con mucho más detalle en la sección Introducción al ELF.

Los protagonistas de esta parte son las secciones dinámicas DT_PLTGOT y DT_SYMTAB de ELF, con una importante colaboración por parte de la sección DT_JMPREL (o DT_REL en su defecto). Explotaremos la modificación de los datos contenidos en estas secciones para interponer código en los DSO.

Cada uno de los DSO en memoria disponen de estas tres secciones. La sección DT_JMPREL nos proporciona una estructura ElfW(Rel) por cada función que contiene o es referenciada desde el objeto a la que pertenece. Así pueden haber tanto estructuras ElfW(Rel) con punteros a funciones del DSO como otras que permiten resolver direcciones de funciones en otros DSO que se usan en este DSO.

Las estructuras ElfW(Rel) de funciones externas son interesantes ya que indican donde hacer modificaciones para hacer que el programa use una determinada función u otra. Estas estructuras relacionan la tabla de símbolos (DT_SYMTAB) con las entradas (totalmente dependientes de la arquitectura) de la sección DT_PLTGOT.

De hecho la sección DT_JMPREL se crea a partir de la tabla de símbolos (DT_SYMTAB) a la vez que la sección DT_PLTGOT por el "Runtime Linker" al cargar los objetos en memoria. La sección DT_SYMTAB contiene una relación de todos los símbolos que trae o usa el DSO. De ellos un subconjunto son las funciones. Las estructuras de información que describen las funciones indican varias cosas, que de ellas interesan principalmente dos: si es una función aportada por el DSO (con un puntero a la misma) o si es una función externa que usa el DSO.

El contenido y distribución de la sección DT_PLTGOT es algo totalmente específico para cada arquitectura. No obstante se puede clasificar su contenido en dos clases o construcciones comunes:

El resto de casos suelen ser variantes o híbridos de estos dos casos. Por ejemplo, en la arquitectura INTEL® 386 encontramos ambas tablas en la sección DT_PLTGOT.

El "Runtime Linker" usa la información contenida la tabla de símbolos y la tabla de reubicación para resolver los enlaces en tiempo de ejecución. El "Runtime Linker" sólo suele recibir ElfW(Rel) como toda la información necesaria para resolver un símbolo en tiempo de ejecución: en dicha estructura encuentra el índice del símbolo que describe el objeto que debe resolver. De la información de símbolo extrae el nombre y comienza una búsqueda por todos los objetos recopilando una lista de funciones candidatas. La dirección de estas funciones las encuentra en la información de símbolo de cada una de ellas. Por último se aplica, si es necesario, un esquema de precedencias para elegir la información de símbolo correcta. Una vez conoce la dirección de la función que debe resolver, vuelve a consultar el contenido de la estructura ElfW(Rel) y de ella extrae la posición de la información de enlace en la sección DT_PLTGOT. Y altera el contenido de dicha porción del DT_PLTGOT con la información necesaria (o código necesario) para que en próximas ejecuciones de la función no se tenga que volver a resolver el símbolo.

Por último debemos explicar que existe una tabla "hash" ideada para acelerar las búsquedas de símbolos por su nombre. Esta tabla la encontramos con ayuda del "Dynamic Tag" DT_HASH. Ésta permite hacer la búsqueda de un símbolo en un tiempo mínimo, reduciendo notablemente el tiempo empleado en las búsquedas, y se usa en todas las búsquedas por nombre de símbolo.

2.3.2. Mecanismo de reenlace

Un reenlace consiste en modificar las estructuras ELF de un determinado objeto para que cuando intente llamar a una función externa en realidad llame a una función suministrada por un "backend".

A favor de su simplicidad tenemos que es la interposición más sencilla de usar y más controlable ya que sólo afecta a un determinado objeto.

El reenlace consiste en una modificación puntual de la sección DT_PLTGOT. Para encontrar la entrada que queremos modificar se usa la información de la estructura ElfW(Rel) del símbolo que queremos reenlazar. Esta estructura contiene un puntero a dicha entrada.

Retocando esta entrada adecuadamente se provoca que, cada vez que un determinado DSO llame a una determinada función, el control se transfiera en realidad a una función de un "backend".

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. Por claridad en cada DSO solamente se muestran las partes que intervienen (GOT o el "Code Segment").

Seguidamente añadimos un ejemplo de fichero de comandos de interposición que muestra como instalar unos cuantos reenlaces:


;+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
; Ejemplos de reenlaces
;+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

; Definimos los alias a las librerías y los backends que usaremos
#backend BACKEND        ./testbe.so
#define  SOME_LIB       libtest.so

; Comandos de reenlace
# commands

R MAIN fputc BACKEND fputc_wrapper
R SOME_LIB write BACKEND write_wrapper
R * read BACKEND read_wrapper
      

En este ejemplo primero definimos los objetos que usaremos. Obsérvese que BACKEND es nuestro "backend" y al declararse se usa la directiva #define. El identificador SOME_LIB es un objeto dinámico que usa nuestro ejecutable, cuyo DSO recibe el nombre MAIN.

Seguidamente, separados por la directiva #commands, se listan los comandos de reenlace.

El primero provocará que todas las llamadas a fputc(3) desde MAIN sean redirigidas al "wrapper" (función) fputc_wrapper del "backend".

El siguiente comando redirige las llamadas a la función write(2) realizadas desde la librería libtest.so (SOME_LIB) al "wrapper" write_wrapper del "backend".

Por último se redirigen, en todos los objetos instrumentables (ver en el manual de usuario las opciones de configuración donttouch_backends y donttouch_pdi), la llamada a read(2) hacia al "wrapper" read_wrapper.

2.3.3. Mecanismo de redefinición

Las redefiniciones son totalmente opuestas al mecanismo de reenlace. Un reenlace sólo afecta a un único objeto, mientras que una redefinición afecta a todos los objetos instrumentables (ver nota).

Debido a esto la redefinición necesita una cierta persistencia ante nuevos objetos cargados en memoria en tiempo de ejecución. Es decir, que cuando un nuevo objeto se cargue en memoria, este se vea afectado también por la redefinición automáticamente sin que sea necesario instalar el reenlace a mano.

Para lograr tal objetivo la estrategia usada consiste en modificar la información de símbolo en el objeto que exporta la función.

Como se ha explicado anteriormente el "Runtime Linker", cuando debe resolver un símbolo, busca primero de todo el objeto que exporta la función a resolver. En él busca la información del símbolo que resuelve ya que esta contiene un puntero al código que la implementa. Después copia/aplica esta dirección en la sección DT_PLTGOT.

Modificando dicha información de símbolo se provoca que el "Runtime Linker" siempre resuelva en nuestra nueva definición.

No obstante no se puede confiar en que el símbolo no haya sido resuelto todavía, así que no sólo basta con alterar esa entrada. Por ejemplo consideremos que está definida la variable de entorno LD_BIND_NOW: todos los enlaces ya han sido resueltos en tiempo de carga, y por lo tanto nunca se aplicará nuestra redefinición (las redefiniciones se instalan en tiempo de ejecución).

Esto nos obliga a realizar, antes de terminar, un reenlace incondicional sobre todos los objetos en memoria para garantizar que se antepone siempre nuestra nueva definición.

El resultado de aplicar la redefinición se puede ver en este gráfico:

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.

Nota

Si el parámetro de configuración donttouch_backends está activado (por defecto sí), la redefinición no afectará a los "backend". En caso de activarse esta opción se debe trabajar con mucho cuidado ya que es fácil que se generen intercepciones recursivas. Por último, pDI-Tools sólo se verá afectado por las redefiniciones en caso de desactivarse el parámetro donttouch_pdi.

Seguidamente añadimos un ejemplo de fichero de comandos de interposición que muestra como instalar una redefinición:


;+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
; Ejemplo de redefinición
;+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

; Definimos el libc de GNU/Linux
#define LIBC            /lib/libc.so.6
#backend BACKEND        ./testbe.so

; Comandos de reenlace
# commands

D LIBC read BACKEND read_wrapper
D LIBC write BACKEND write_wrapper
      

Primero de todo definimos un alias a la librería de C. De esta forma declaramos que deseamos usarla y además le asignamos un alias. Los alias son muy prácticos por ejemplo en caso de que cambiar la ruta de la librería.

Seguidamente se instalan dos redefiniciones sobre la librería de C. En estos comandos indicamos que queremos sustituir la funciones read(2) y write(2) de la librería de C por nuestras propias versiones, read_wrapper y write_wrapper en el "backend".

A partir de ahora cualquier llamada desde cualquier DSO a read(2) y/o write(2), con excepción de C Library (no se verá afectado ya que las llamadas dentro de un mismo objeto, por regla general, se realiza mediante saltos relativos) y los objetos no instrumentables, se transferirá en realidad a nuestras funciones "wrapper".

2.3.4. Mecanismo de "callback"

Los "callback" es un tipo de interposición de código muy distinta al resto. Son aptos para realizar una interposición masiva sobre todas las llamadas de un DSO a cualquier otro DSO. Mientras que los reenlaces y las redefiniciones, desde el punto de vista de un DSO, sólo actúan sobre una única función objeto, un "callback" intercepta todas las llamadas que realiza un DSO a otros DSO.

Además con los reenlaces o las redefiniciones, durante la ejecución del programa instrumentado, sólo se ejecuta código de los "backend" ya que pDI-Tools se ha encargado de redirigir el control hacia ellos. En el caso del "callback" interviene tanto código del "backend" como de pDI-Tools y estos cooperan para realizar la interposición. En estas interposiciones quien recibe el control primero es el "callback handler" (también llamado en ocasiones "generic wrapper"), un código que forma parte de pDI-Tools encargado de gestionar la intercepción. Este código exige al "backend" tener implementadas dos o tres funciones:

int PDI_BE_FUNC_CB_REQ(char *func_name);

Esta función debe decidir si la intercepción que ha realizado pDI-Tools interesa o no. Por ello recibe una cadena con el nombre de la función interceptada. Si interesa interceptar esta función debe devolver un valor distinto de cero. Este valor se pasará a las otras funciones por lo que suele ser un identificador numérico que identifica a la función o un grupo de funciones. Si no interesa interceptarla se devuelve el valor 0.

void PDI_BE_FUNC_PRE_CB(int virtual_processor, int event_id, ...);

Esta función recibe un identificador de "thread" en el que se ha interceptado la llamada (para saber más sobre como se calcula este valor ver Capítulo 8), un identificador de evento que es el valor devuelto por PDI_BE_FUNC_CB_REQ y una lista de parámetros variable que son los parámetros de la llamada interceptada.

Esta función se ejecuta justo antes de que se ejecute la función interceptada. Se ha de tener cuidado al modificar los parámetros de llamada ya que, dependiendo de la arquitectura, pueden llegar alterados a la función instrumentada.

void PDI_BE_FUNC_POST_CB(int virtual_processor, int event_id, int retval);

Esta función se ejecuta al finalizar la función interceptada. Recibe un identificador de "thread" en el que se ha interceptado la llamada, un identificador de evento y el valor devuelto por la función interceptada.

El método usado para interponer el código en esta ocasión es totalmente distinto del usado para los reenlaces y definiciones. En lugar de enlazar el "wrapper" directamente, en este método se enlazan unos "stubs" creados dinámicamente encargados de llamar al código que gestiona los "callback" de los "backends".

Podemos observar como el mecanismo de "callback" consiste en reenlazar cada función del objeto instrumentado con unos "stubs" creados por DITools. Cada "stub" se encarga de llamar al "callback handler" con parámetros distintos. Por motivos de espacio y claridad sólo se muestran las llamadas que se hacen a C Library. Podría haber llamadas a otros DSO y se redirigirían también al "callback handler".

El resultado de uno de estos reenlaces es una sucesión de llamadas que se plasma en el siguiente diagrama para el caso de la función read(2):

En este diagrama se observa como al llamar a la función read(2) desde MAIN en realidad se transfiere el control a un "stub" que indica al "generic wrapper" que función estamos instrumentando. Esta función se encarga de transferir el control a las funciones de control de "callback" del "backend" y al verdadero código de read(2).

Los "stubs" se crean en tiempo de ejecución por pDI-Tools. Un "stub" es una pequeña porción de código que deja, ya sea en registros o en la pila, información sobre la llamada interceptada. Acto seguido salta al "callback handler".

El "callback handler" es una función, también llamada "generic wrapper", escrita en ensamblador. Esta función se encarga de llamar a la función interceptada y a las funciones del "backend" encargadas de gestionar el "callback": PDI_BE_FUNC_CB_REQ, PDI_BE_FUNC_PRE_CB y PDI_BE_FUNC_POST_CB.

La función "callback handler" está escrita en ensamblador ya que debe interponer código sin alterar el contenido de la pila ni los registros desde el punto de vista de la llamada a la función interceptada. En INTEL® 386 los parámetros se pasan por la pila y no hay manera de determinar su número ni tipo de una forma genérica. El "callback handler" debe ingeniárselas para ejecutar el código de la función sin que se note en la pila el paso por el "stub" y por el propio código del "callback handler". Además debe ejecutar el código de las funciones PDI_BE_FUNC_PRE_CB y PDI_BE_FUNC_POST_CB.

Esto implica a bajo nivel una gran diferencia respecto a los reenlaces y redefiniciones. Los "wrapper", para interceptar y ejecutar una llamada, deben pasar de nuevo los parámetros a la función. Con los "callback" esto no es necesario, y además, no sólo se conserva casi todo el aspecto que debería tener la pila si no hubiese sido instrumentada, sino que además se conservan los valores de los registros.

Para entender esto bien, seguidamente detallamos las diferencias entre reenlace y "callback".

La interposición de código en los reenlaces y redefiniciones se consigue mediante la creación de funciones que copian los parámetros y transfieren el control a la función interceptada. Por ejemplo:


/* read() wrapper */
ssize_t read_wrapper(int fd, void *buf, size_t nbyte)
{
  ++read_no;
  return read(fd, buf, nbyte);
}
      

Obsérvese que este "wrapper", una vez incrementado el contador read_no, copia los parámetros de entrada y transfiere el control a la función read(2). Una vez ésta acaba, devuelve un valor que el "wrapper" devuelve a su vez. Si parásemos la ejecución justo al comienzo de la función interceptada, tendríamos el siguiente contenido en la pila (nos referimos al caso del INTEL® 386, la única arquitectura actualmente que soporta el mecanismo de "callback") donde el top de la pila es la posición más alta:

Como podemos ver en el gráfico sólo se conocen los tamaños de los elementos apilados por el "wrapper". No hay una manera única y simple de averiguar cuantos parámetros habrá apilado el llamador. De igual forma, read(2) no podrá consultar bien los parámetros y pila del llamador.

De aquí se desprenden varios problemas. El primer problema es que la función a interceptar (y su llamador) no respete el ABI de la arquitectura e intente consultar el contenido de la pila que no le corresponde encontrando en su lugar un fragmento de pila del "wrapper" y no el de la función que le llamaba. Otro posible problema es que espere parámetros por registro, los cuales habrán sido machacados anteriormente por el "wrapper". El primer caso, aunque improbable, es posible encontrarlo. El segundo caso en cambio es común en rutinas programadas en ensamblador.

El "callback handler" es una rutina programada a bajo nivel capaz de solventar dichos problemas. Es una rutina compleja y habilidosa ya que salva el estado de ejecución (pila y registros) y así interpone código sin que se vea afectada la llamada interceptada.

El "callback handler" una vez toma el control lo primero que hace es decodificar la información proporcionada por el "stub" y apilarla en una pila secundaria e interna. Esta pila la usa el "callback handler" para guardar su estado en caso de llamadas reentrantes y para guardar parte del estado de la pila y los registros en el momento de la intercepción.

El siguiente paso es llamar a la función PDI_BE_FUNC_CB_REQ del "backend". Esta función debe decidir si se desea procesar, o no, la función interceptada. Esta función devuelve, en caso de quererse interceptar la función, un valor distinto de cero que luego se entrega a las funciones PDI_BE_FUNC_PRE_CB y PDI_BE_FUNC_PRE_CB en concepto de identificador de evento. En caso de devolver cero se restaura el estado del programa y se transfiere el control a la función interceptada.

La ejecución continua con la llamada de la función PDI_BE_FUNC_PRE_CB (si esta existe) pasándole el identificador asignado anteriormente. Una vez terminada la ejecución de PDI_BE_FUNC_PRE_CB se ejecuta la función original. Cuando esta termina de ejecutarse se transfiere el control a PDI_BE_FUNC_POST_CB (si esta existe). Y por último se restaura la ejecución normal del programa.

A pesar de su potencia, los "callback" tienen una limitación: no pueden alterar la ejecución del programa. Debido a que el mecanismo salva el estado de ejecución antes y después de la llamada a la función interceptada, las funciones PDI_BE_FUNC_PRE_CB y PDI_BE_FUNC_PRE_CB no pueden alterar ni los parámetros ni el valor devuelto por ella.

Aviso

Si se instala un "callback" sobre un DSO luego ya no se puede instalar ningún otro tipo de interposición sobre cualquier función del mismo DSO ya que ambas entrarían en conflicto.

Para finalizar mostramos un fichero de comandos que instala una interposición mediante "callback" con pDI-Tools:


;+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
; Ejemplo de redefinición
;+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

; Definimos el libc de GNU/Linux
#backend BACKEND        ./testcb.so

; Comandos de reenlace
# commands

C MAIN * BACKEND
      

Con este comando, cualquier llamada a otros objetos que realice el programa principal será redirigida hacía las rutinas de gestión del "callback" en el "backend" testcb.so.