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.
INSTRUCCIONES:
contenido en .text | instrucción
8b 03 mov eax, [ebx]
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
.
DATOS: Compuesto por tres secciones que corresponden a los datos de un programa:
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:
//en .data: variable global inicializada
int a = 5;
//en .bss: variable global no inicializada
int b;
int main(){
//en la pila: variable local
int c=7;
//en .bss: variable estática no inicializada
static int d;
//en la pila: ptr es un puntero local que apunta a memoria dinámica en el heap
int *ptr = (int *) malloc(sizeof(int));
//en el heap
ptr[0]=3;
free(ptr);
return 1;
}
Es posible ver un detalle de las secciones de un binario con readelf -S
:
user@abos:~$ readelf -S programa
There are 35 section headers, starting at offset 0x1120:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 08048134 000134 000013 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 08048148 000148 000020 00 A 0 0 4
(...)
[11] .init PROGBITS 08048294 000294 000023 00 AX 0 0 4
[12] .plt PROGBITS 080482c0 0002c0 000040 04 AX 0 0 16
=> [13] .text PROGBITS 08048300 000300 000192 00 AX 0 0 16
[14] .fini PROGBITS 08048494 000494 000014 00 AX 0 0 4
=> [15] .rodata PROGBITS 080484a8 0004a8 000008 00 A 0 0 4
[16] .eh_frame_hdr PROGBITS 080484b0 0004b0 00002c 00 A 0 0 4
[17] .eh_frame PROGBITS 080484dc 0004dc 0000ac 00 A 0 0 4
[18] .init_array INIT_ARRAY 08049588 000588 000004 00 WA 0 0 4
[19] .fini_array FINI_ARRAY 0804958c 00058c 000004 00 WA 0 0 4
[20] .jcr PROGBITS 08049590 000590 000004 00 WA 0 0 4
[21] .dynamic DYNAMIC 08049594 000594 0000e8 08 WA 6 0 4
[22] .got PROGBITS 0804967c 00067c 000004 04 WA 0 0 4
[23] .got.plt PROGBITS 08049680 000680 000018 04 WA 0 0 4
=> [24] .data PROGBITS 08049698 000698 000008 00 WA 0 0 4
=> [25] .bss NOBITS 080496a0 0006a0 000004 00 WA 0 0 1
[26] .comment PROGBITS 00000000 0006a0 000039 01 MS 0 0 1
....
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:
user@abos:~$ readelf -l programa
Elf file type is EXEC (Executable file)
Entry point 0x8048300
There are 8 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align ; Segmento | Permisos/Flags
PHDR 0x000034 0x08048034 0x08048034 0x00100 0x00100 R E 0x4 ; #00 R E
INTERP 0x000134 0x08048134 0x08048134 0x00013 0x00013 R 0x1 ; #01 R
+-<LOAD 0x000000 0x08048000 0x08048000 0x00588 0x00588 R E 0x1000 ; #02 R E
+-|-<LOAD 0x000588 0x08049588 0x08049588 0x00118 0x0011c RW 0x1000 ; #03 RW
| | DYNAMIC 0x000594 0x08049594 0x08049594 0x000e8 0x000e8 RW 0x4 ; #04 RW
| | NOTE 0x000148 0x08048148 0x08048148 0x00044 0x00044 R 0x4 ; #05 R
| | GNU_EH_FRAME 0x0004b0 0x080484b0 0x080484b0 0x0002c 0x0002c R 0x4 ; #06 R
| | GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10 ; #07 RW
| |
| | Section to Segment mapping:
| | Segment Sections...
| | 00
| | 01 .interp
| +-> 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini
| .rodata .eh_frame_hdr .eh_frame
+---> 03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
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:
sección .got
----------------------- --------------------------
| Nombre de la función | Dirección en biblioteca |
|-----------------------|--------------------------|
| printf() | 0xb7e89d80 (libc.so) |
| exit() | 0xb7e561b0 (libc.so) |
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
:
#include <stdio.h>
int main() {
printf("you win!\n");
exit(0);
}
user@abos:~$ gcc -m32 -no-pie -mpreferred-stack-boundary=2 -o imprimir 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:
user@abos:~$ ldd imprimir
linux-gate.so.1 (0xb7ffd000)
=> libc.so.6 => /lib/i386-linux-gnu/i686/cmov/libc.so.6 (0xb7e46000)
/lib/ld-linux.so.2 (0x80000000)
Y es posible con objdump
ver las direcciones de cada entrada de la tabla GOT:
user@abos:~$ objdump --dynamic-reloc imprimir
imprimir: file format elf32-i386
DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
080496c8 R_386_JUMP_SLOT puts
080496d0 R_386_JUMP_SLOT exit
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.
----------------- ----------------------- -------------------------
| Entrada en .got | Nombre de la función | Dirección en biblioteca |
|-----------------|-----------------------|-------------------------|
| 080496c8 | puts() | ??? (no resuelto) |
| 080496d0 | exit() | ??? (no resuelto) |
printf()
y a exit()
vemos que el compilador genera dos llamados que apuntan a la tabla PLT: puts@plt
y exit@plt
.
user@abos:~$ objdump -M intel -d imprimir
Disassembly of section .text:
0804842b <main>:
804842b: 55 push ebp
804842c: 89 e5 mov ebp,esp
804842e: 68 e0 84 04 08 push 0x80484e0
=> 8048433: e8 b8 fe ff ff call 80482f0 <puts@plt> ; llamado a puts@plt en .plt
8048438: 83 c4 04 add esp,0x4
804843b: 6a 00 push 0x0
=> 804843d: e8 ce fe ff ff call 8048310 <exit@plt> ; llamado a exit@plt en .plt
Si seguimos las direcciones de los respectivos call
vemos que se produce un salto a la sección .plt
.
.plt
buscando las direcciones 80482f0 <puts@plt>
y 8048310 <exit@plt>
:
user@abos:~$ objdump -M intel -d imprimir
Disassembly of section .plt:
080482f0 <puts@plt>:
=> 80482f0: ff 25 c8 96 04 08 jmp DWORD PTR ds:0x80496c8 ; jmp <puts@GOT>
80482f6: 68 00 00 00 00 push 0x0
80482fb: e9 e0 ff ff ff jmp 80482e0 <_init+0x30>
08048310 <exit@plt>:
=> 8048310: ff 25 d0 96 04 08 jmp DWORD PTR ds:0x80496d0 ; jmp <exit@GOT>
8048316: 68 10 00 00 00 push 0x10
804831b: e9 c0 ff ff ff jmp 80482e0 <_init+0x30>
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
.
user@abos:~$ objdump -s imprimir
Contents of section .got.plt:
80496bc d0950408 00000000 00000000 f6820408 ................
80496cc 06830408 16830408 26830408 ^ ........&...
; ^ |
; | |
; | 0x80496c8: 0804286f en little endian
; 0x80496d0: 08043816 en little endian
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:
----------------- ----------------------- ---------------------------
| Entrada en .got | Nombre de la función | Dirección en ¿biblioteca? |
|-----------------|-----------------------|---------------------------|
| 080496c8 | printf() | 0x080482f6 (sección .plt) |
| 080496d0 | exit() | 0x08048316 (sección .plt) |
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()
:
user@abos:~$ objdump -M intel -d imprimir
Disassembly of section .plt:
080482f0 <puts@plt>:
80482f0: ff 25 c8 96 04 08 jmp DWORD PTR ds:0x80496c8 ; jmp <puts@GOT>
=> 80482f6: 68 00 00 00 00 push 0x0 ; addr 0x080482f6
80482fb: e9 e0 ff ff ff jmp 80482e0 <_init+0x30>
08048310 <exit@plt>:
8048310: ff 25 d0 96 04 08 jmp DWORD PTR ds:0x80496d0 ; jmp <exit@GOT>
=> 8048316: 68 10 00 00 00 push 0x10 ; addr 0x08048316
804831b: e9 c0 ff ff ff jmp 80482e0 <_init+0x30>
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:
0x0804842c main+1 mov ebp,esp
0x0804842e main+3 push 0x80484e0
0x08048433 main+8 call 0x80482f0 <puts@plt>
eip=> 0x08048438 main+13 add esp,0x4 ; 1er instrucción después de ejecutar printf()
0x0804843b main+16 push 0x0
0x0804843d main+18 call 0x8048310 <exit@plt>
───────────────────────────────────────────────────────────────────────────────────────────────
>>> x/xw 0x80496c8 ; consultamos la entrada de printf() en la GOT:
0x80496c8 <puts@got.plt>: 0xb7e89d80 ; la entrada de printf() se actualizó
>>> x/xw 0x80496d0 ; consultamos la entrada de exit() en la GOT:
0x80496d0 <exit@got.plt>: 0x08048316 ; la entrada de exit() aún no se actualizó
>>>
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:
----------------- ----------------------- -----------------------------
| Entrada en .got | Nombre de la función | Dirección en biblioteca |
|-----------------|-----------------------|-----------------------------|
| 080496c8 | printf() | 0xb7e89d80 (libc.so) |
| 080496d0 | exit() | 0x08048316 (.plt) |
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:
>>> x/xw 0x80496d0 ; valor anterior de exit() en la GOT
0x80496d0 <exit@got.plt>: 0x08048316
>>> watch *0x80496d0
─── Output/messages ───────────────────────────────────────────────────────────────────────────
Hardware watchpoint 5: *0x80496d0 ... ; la ejecución de detiene
>>> x/xw 0x80496d0
0x80496d0 <exit@got.plt>: 0xb7e561b0 ; valor actualizado de exit() en la GOT
Ya en este punto la tabla GOT contiene las direcciones efectivas de las dos funciones en libc
:
---------------- --------------------- ------------------------
| Entrada en .got | Nombre de la función | Dirección en biblioteca |
|-----------------|-----------------------|-------------------------|
| 080496c8 | printf() | 0xb7e89d80 (libc.so) |
| 080496d0 | exit() | 0xb7e561b0 (libc.so) |
¿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:
--------------------- -------------------------
| Nombre de la función | Dirección en biblioteca |
|----------------------|-------------------------|
| printf() | &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:
user@abos:~$ sudo sysctl -w kernel.randomize_va_space=1 ; habilitamos ASLR
user@abos:~$ ldd imprimir
libc.so.6 => /lib/i386-linux-gnu/i686/cmov/libc.so.6 (0xb75b6000) ; libc.so => 0xb75b6000
user@abos:~$ ldd imprimir
libc.so.6 => /lib/i386-linux-gnu/i686/cmov/libc.so.6 (0xb75ae000) ; libc.so => 0xb75ae000
user@abos:~$ ldd imprimir
libc.so.6 => /lib/i386-linux-gnu/i686/cmov/libc.so.6 (0xb7559000) ; libc.so => 0xb7559000
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)
user@abos:~$ gdb imprimir
(gdb) show disable-randomization
Disabling randomization of debuggee's virtual address space is on.
(gdb) set disable-randomization off
(gdb) show disable-randomization
Disabling randomization of debuggee's virtual address space is off.
; 1er ejecución
(gdb) r
(gdb) x/2i $eip
=> 0x8048433 <main+8>: call 0x80482f0 <puts@plt>
0x8048438 <main+13>: add $0x4,%esp
(gdb) si
0x080482f0 in puts@plt ()
(gdb) x/i $eip
=> 0x80482f0 <puts@plt>: jmp *0x80496c8 ; jmp <puts@GOT>
(gdb) p puts
$1 = {<text variable, no debug info>} 0xb7618d80 <_IO_puts> ; 1er ejecución: 0xb7618d80 addr puts() in libc
(gdb) x/i $eip
=> 0x80482f0 <puts@plt>: jmp *0x80496c8 ; 1er ejecución: 0x80496c8 addr puts() in GOT
; 2da ejecución
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
(...)
(gdb) si
0x080482f0 in puts@plt ()
(gdb) p puts
$2 = {<text variable, no debug info>} 0xb7604d80 <_IO_puts> ; 2da ejecución: 0xb7604d80 addr puts() in libc
(gdb) x/i $eip
=> 0x80482f0 <puts@plt>: jmp *0x80496c8 ; 2da ejecución: 0x80496c8 addr puts() in GOT
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.
Análisis de secciones de un binario
Con los contenidos vistos hasta el momento es posible avanzar con Abo 3, Abo 4, Abo 5 y Abo 6.
Y está disponible una práctica guiada para la resolución de Abo 3 a través del ataque “Smash the stack”. Y del Abo 5 con una ataque de reescritura de la GOT.
También se presenta un ataque usando variables de entorno con el Abo 6.