ESTRUCTURA DE UN BINARIO DE LINUX
En éste artículo veremos la estructura de un Binario en formato ELF de Linux
Última actualización
En éste artículo veremos la estructura de un Binario en formato ELF de Linux
Última actualización
Artículo copiado de la Fundación Sadosky escrito por Teresa Alberto.
El formato ELF de un binario de GNU/Linux se encuentra organizado en secciones que estructuran sus instrucciones, sus datos y otra información necesaria para el linker en el proceso de enlazado. Desde la perspectiva del sistema operativo el formato ELF se estructura bajo la forma de segmentos que utilizará para cargar en memoria el proceso.
En esta ocasión sólo será importante considerar algunas de las secciones de un archivo ELF, a las que podremos categorizar dentro de dos grandes grupos: por un lado la sección de las instrucciones y por otro las de los datos del programa.
Sección .text: corresponde a las instrucciones del programa.
Por ejemplo, si el programa cuenta con esta instrucción mov
, en la sección .text
del binario encontraremos el código máquina 0x8b03
.
Compuesto por tres secciones que corresponden a los datos de un programa:
Sección .data: con las variables estáticas y globales inicializadas del proceso.
Sección .rodata: es idéntica a la sección .data pero únicamente para datos de sólo lectura (Read Only data).
Sección .bss: con las variables no inicializadas.
Así como las variables estáticas (declaradas como static
o por fuera de una función) se almacenan en la sección .data
y persisten a lo largo de la ejecución del programa, en cambio las variables locales declaradas dentro de una función son consideradas dinámicas en C y se almacenan en la pila como parte del frame de la función. Por último el heap es el área de memoria reservada para el almacenamiento de memoria dinámica, manipulada a través de malloc(), realloc(), free(), etc.
El gráfico a continuación ilustra cómo el sistema operativo carga en memoria el proceso teniendo en cuenta la estructura del ELF definida previamente:
Para ejemplificar el funcionamiento de las secciones en el siguiente programa de ejemplo se indica en qué sección estará cada variable:
Es posible ver un detalle de las secciones de un binario con readelf -S
:
Y con readelf -l
vemos la estructura del ELF desde la perspectiva de los segmentos utilizados por el sistema operativo para cargar el ejecutable en memoria:
En el ELF se agrupan las secciones .rodata
y .text
-entre otras- en un segmento con permisos de lectura y ejecución (Flags: R E
), y en otro las secciones .data
y .bss
con permisos de lectura y escritura (Flags: RW
).
Otras secciones importantes a la hora de pensar una estrategia de ataque son:
Sección .got: corresponde a la Global Offset Table, una tabla en cuyas entradas están las direcciones efectivas de las funciones de bibliotecas compartidas presentes en el programa.
Sección .plt: corresponde a la Procedure Linkage Table, otra tabla necesaria para la resolución de las direcciones de funciones de bibliotecas compartidas cuyo rol veremos a continuación.
Dada la relevancia que tendrá en los exploits es importante detenerse en la sección .got
que corresponde a la Global Offset Table o “Tabla global de offsets” en español.
En un binario ELF linkeado dinámicamente (ver en el siguiente apartado), cuando se produce un llamado a una función de una biblioteca compartida se recurre a la tabla GOT para resolverlo. Justamente esta tabla es un listado de punteros donde se indican las direcciones efectivas de esas funciones en tiempo de ejecución.
Por ejemplo podriamos ilustrar la tabla GOT de un programa que llama a printf()
y exit()
de la biblioteca libc
como:
El proceso de enlazado para resolver referencias a módulos o a funciones de bibliotecas compartidas se puede resolver a grandes rasgos de dos maneras. Por un lado, incorporando al binario el código de esos módulos o bibliotecas utilizado (enlazado estático), o manteniendo una referencia al código compartido que el sistema operativo se encargará de resolver en tiempo de ejecución (enlazado dinámico). El enlazado dinámico pospone el cálculo de las direcciones de esas funciones hasta que sean efectivamente llamadas en tiempo de ejecución, es decir, el proceso de enlace se produce “a demanda”.
Para evitar que la resolución de estas referencias implique modificar el código de un proceso, se crea una tabla aparte en el binario: la tabla GOT que se ubica en la seccion .got
. Como la dirección de las bibliotecas compartidas es desconocida al momento de compilación, el compilador apunta esas funciones a entradas de la tabla GOT cuya ubicación es conocida y estática. Una vez calculadas sus direcciones efectivas solo será necesario actualizar las entradas de la GOT, sin modificar el código en .text
. Esta estrategia trae enormes ventajas en el manejo de permisos de las secciones de un binario. Esta tabla -y su correspondiente sección- tendrá permisos de escritura (ya que debe actualizarse en tiempo de ejecución) y en cambio la sección de código .text
podrá únicamente tener permisos de lectura y ejecución.
En muchos casos una función incluida en un programa puede ser llamada, o no, de acuerdo, por ejemplo, al input de un usuario. Para no llevar a cabo el proceso de enlazado dinámico de todas las funciones externas de un programa, se recurre a una etapa intermedia en la resolución de esas referencias: la tabla PLT o Procedure Lookup Table en inglés.
Cada función de una biblioteca -presente en el programa- tiene una entrada en la tabla PLT. A su vez, cada una de esas entradas apunta a una entrada en la GOT. En tiempo de ejecución se inicia un proceso por el cual se resuelve la dirección efectiva en memoria de la función dentro de la biblioteca. En un primer llamado a la función, se aprovecha una función trampolin dentro de la sección .plt
que invoca al dynamic linker. Este resuelve la dirección de la función en la biblioteca y la ejecuta. También actualiza la entrada en la GOT con la dirección efectiva de la función dentro de la biblioteca compartida para los subsiguientes llamados.
Por ejemplo, en el siguiente programa imprimir.c
:
Al compilarlo de esta manera con gcc
, libc
va a estar linkeada dinámicamente al binario, es decir no va a estar incluida en él y por lo tanto -en tiempo de compilación- no se va a conocer la dirección de funciones como printf()
o exit()
contenidas en esa biblioteca.
Con ldd
vemos las bibliotecas dinámicas enlazadas a este binario y el directorio en el que se encuentran:
Y es posible con objdump
ver las direcciones de cada entrada de la tabla GOT:
Como en casos anteriores la función llamada es puts()
y no printf()
porque se trata de un string fijo, sin parámetros.
Es posible pensar que en este punto en la tabla GOT aún no está definida la dirección efectiva de las funciones en la biblioteca.
Siguiendo con el programa de ejemplo, en el código assembler del llamado a printf()
y a exit()
vemos que el compilador genera dos llamados que apuntan a la tabla PLT: puts@plt
y exit@plt
.
Si seguimos las direcciones de los respectivos call
vemos que se produce un salto a la sección .plt
.
Analizamos la sección .plt
buscando las direcciones 80482f0
y 8048310
:
En ambos casos, la primer instrucción es un salto dentro del segmento de datos (ds:
) y si miramos detenidamente las direcciones del salto corresponden a las direcciones de cada entrada en la GOT que vimos con objdump --dynamic-reloc
.
Son entonces dos saltos que apuntan a entradas de la tabla GOT: jmp puts@GOT
(jmp ds:0x80496c8
) y el segundo jmp exit@GOT
(jmp ds:0x80496d0
). Corroboramos que efectivamente esas direcciones a dónde se salta corresponden a la tabla GOT, es decir, en este caso pertenecen a la sección .got.plt
.
En la primer columna objdump
nos muestra las direcciones correspondientes a la sección .got.plt
, en las siguientes cuatro columnas sus valores en hexa y en la última su representación en ASCII (se indica un punto en el caso de ser caracteres no imprimibles).
Efectivamente ambos saltos apuntan a la sección .got (o lo que es lo mismo a .got.plt
). Por comodidad graficamos el contenido de la sección .got.plt
que nos mostró objdump -s
de la siguiente manera:
En este punto, viendo el output anterior de objdump -s
nos damos cuenta que aún la tabla GOT no tiene las direcciones efectivas de las funciones sino que almacena direcciones que apuntan nuevamente a la sección .plt
.
Es posible corroborarlo viendo el contenido de la dirección 0x080482f6
en el caso de printf()
y de 0x08048316
en el caso de exit()
:
En ambas entradas de la GOT se apunta a una instrucción dentro de .plt
que hace push
de un valor y un salto a la misma dirección de memoria (jmp 80482e0
). Como se verá a continuación, en ambos casos, se produce un salto a una función trampolín dentro de .plt
.
¿Por qué las entradas en la GOT apuntan a la sección .plt
nuevamente?
En un primer llamado a una función de una biblioteca compartida, en la entrada de la GOT correspondiente se apunta a un sector de la tabla PLT que hace un llamado a una función trampolín. Esa función se encarga de transferirle el control al dynamic linker. Este es el encargado de hacer un llamado a la función invocada y después actualizar la entrada en la GOT con su dirección efectiva en la biblioteca correspondiente.
A partir de entonces la entrada en la GOT se encuentra actualizada, y ya no apuntará a la función trampolín dentro de .plt
sino directamente a una dirección en una biblioteca compartida.
En el ejemplo, el llamado al linker sucede con el jmp 80482e0
que es un salto a una función trampolin también dentro de la sección .plt
que invoca al dynamic linker. Este será el encargado de actualizar la GOT, es decir, reemplazar las direcciones 0x080482f6
en el caso de printf()
y 0x08048316
en el caso de exit()
por sus direcciones efectivas en la libc.so
cargada en memoria.
Es posible ver estos cambios si consultamos el contenido de la GOT después de ejecutar por primera vez cada una de las funciones:
Después de ejecutar printf()
vemos como en la tabla GOT se especifica la dirección de esa función dentro de libc
(0xb7e89d80
). Podriamos ilustrar la tabla GOT en este punto de la siguiente manera:
Si avanzamos con la ejecución de exit()
vemos que también su valor se actualiza en la tabla. Utilizamos un watchpoint en gdb para que la ejecución se detenga cuando el valor de exit
en la GOT se modifique:
Ya en este punto la tabla GOT contiene las direcciones efectivas de las dos funciones en libc
:
¿Cómo es posible aprovecharse de la GOT a la hora de escribir exploits? La dirección de una entrada en la GOT está definida para cada binario, de modo que es independiente de la pila y sus variables de entorno. Se almacena en una posición de memoria estática y conocida y debe ser un sector de memoria que se pueda escribir, porque como se vió antes su contenido se actualiza de manera “diferida” en tiempo de ejecución.
Estas cualidades hacen de la GOT un recurso valioso para la escritura de exploits. Sobretodo en los casos donde no es de utilidad sobreescribir la dirección de retorno de la pila, como por ejemplo en un escenario en el que main()
nunca retorna porque el programa finaliza con exit()
o se mantiene en un loop infinito.
Consideraciones: es necesario recordar para abusar de los permisos de escritura de la tabla GOT la mitigación RELRO o RELocation Read-Only en inglés) debe estar deshabilitada o parcialmente habilitada como sucede por defecto en la mayoría de las distribuciones de Linux.
Si nuestro objetivo es hacer una lectura o escritura arbitraria en memoria, la GOT toma relevancia de la siguiente manera:
En una escritura arbitraria Supongamos que gracias a la vulnerabilidad de un programa podemos escribir un valor arbitrario en algún lugar de la memoria y como dijimos antes una escritura en la dirección de retorno de la pila no es de utilidad. Si optamos por sobreescribir la entrada de alguna función en la GOT (que se encuentra en una sección de memoria con permisos de escritura), al ser llamada esa función ya no se reenvía la ejecución a la biblioteca correspondiente sino a dónde hemos especificado (por ejemplo la dirección de un shellcode).
En una lectura arbitraria
Si podemos filtrar direcciones contenidas en la GOT podemos conocer información relevante para atacar un proceso.
Por ejemplo en un ataque más avanzado con la mitigación ASLR habilitada, una lectura de memoria sería de gran utilidad ya que la dirección de libc
en cada ejecución del binario es diferente.
Los cambios en la dirección de libc
se pueden observar si ejecutamos varias veces el programa anterior con ASLR habilitado:
Pero la dirección de las entradas en la GOT es fija -sin ninguna mitigación extra como una compilación con PIE por ejemplo- y por lo tanto idéntica en cada ejecución.
(Primero habilitamos ASLR, deshabilitada por defecto en gdb)
Observamos como en dos ejecuciones diferentes la dirección de libc
se modifica, no así la dirección de puts()
dentro de la tabla GOT.
En este escenario una lectura arbitraria de la GOT sería útil. Como vimos la dirección de una entrada en la GOT será idéntica aunque ASLR esté habilitada y si leyeramos su contenido obtendremos información de la dirección efectiva de una función en libc
(que cambiará cada vez). Si logramos esa información es posible calcular -sumando siempre el mismo offset- la dirección de otras funciones dentro de esa biblioteca como system()
por ejemplo, aunque la mitigación ASLR se encuentre habilitada.