Protección de ejecutables: RELRO

Como vimos en entradas anteriores NX marcaba algunas zonas de memoria como no ejecutables, entre ellas el Stack, para evitar la inyección de shellcode. Por su parte la combinación de ASLR y PIE mitigan las técnicas que utilizan direcciones conocidas de segmentos de memoria como la técnica denominada ROP ‘return-oriented programming’ que reutiliza instrucciones existentes en el programa, que son ejecutables, en lugar de inyectar instrucciones arbitrarias en la memoria y ejecutarlas.

Otras formas de ataque se basan en el uso de las secciones .plt y .got con el fin de poder explotar una vulnerabilidad de una aplicación. Vamos a ver primero como funcionan estas secciones.

Como en las aplicaciones las funciones de la biblioteca compartida se pueden usar muchas veces, resulta practico tener una tabla que haga referencia a todas las funciones. Para este fin se utiliza una sección especial dentro de los programas denominada tabla de desplazamiento global PLT 'Procedure Linkage Table'.

Vamos a ver un volcado de los objetos desensamblando la sección PLT en un programa sencillo, similar al que se usa para mostrar el secuestro de la sección GOT. Primero vemos el código fuente y luego el volcado PLT:

#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
 
int main(int argc, char **argv) 
{ 
    char *puntero = NULL; 
    char array[10]; 
 
    puntero = array; 
 
    if(argc == 3) { 
        strcpy(puntero, argv[1]); 
        printf("El array contiene %s en %p\n", puntero, (void *) &puntero); 
 
        strcpy(puntero, argv[2]); 
        printf("El array contiene %s en %p\n", puntero, (void *) &puntero); 
    } 
 
    exit(EXIT_SUCCESS); 
} 

$ objdump -d -j .plt ./noPIErelro 

./noPIErelro: formato del fichero elf64-x86-64


Desensamblado de la sección .plt:

0000000000400450 <.plt>:
400450: ff 35 b2 0b 20 00 pushq 0x200bb2(%rip) # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
400456: ff 25 b4 0b 20 00 jmpq *0x200bb4(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
40045c: 0f 1f 40 00 nopl 0x0(%rax)

0000000000400460 <strcpy@plt>:
400460: ff 25 b2 0b 20 00 jmpq *0x200bb2(%rip) # 601018 <strcpy@GLIBC_2.2.5>
400466: 68 00 00 00 00 pushq $0x0
40046b: e9 e0 ff ff ff jmpq 400450 <.plt>

0000000000400470 <printf@plt>:
400470: ff 25 aa 0b 20 00 jmpq *0x200baa(%rip) # 601020 <printf@GLIBC_2.2.5>
400476: 68 01 00 00 00 pushq $0x1
40047b: e9 d0 ff ff ff jmpq 400450 <.plt>

0000000000400480 <exit@plt>:
400480: ff 25 a2 0b 20 00 jmpq *0x200ba2(%rip) # 601028 <exit@GLIBC_2.2.5>
400486: 68 02 00 00 00 pushq $0x2
40048b: e9 c0 ff ff ff jmpq 400450 <.plt>

Estas son las instrucciones de salto a las funciones de librería que usa la aplicación. Si pudieramos manipular esta instrucción de salto y apuntar a otra parte, sería fácil dirigirlo a un shellcode. Pero el segmento PLT es de solo lectura:

$ objdump -h ./noPIErelro | grep -A1 "\ .plt\ "
11 .plt 00000040 0000000000400450 0000000000400450 00000450 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE

Aunque, realmente estas instrucciones de salto son punteros a las direcciones que se encuentran en otra sección, la tabla de desplazamiento global GOT 'Global Offset Tablet', y en esta sección si se puede escribir:

$ objdump -h ./noPIErelro | grep -A1 "\ .got\ "
20 .got 00000010 0000000000600ff0 0000000000600ff0 00000ff0 2**3
CONTENTS, ALLOC, LOAD, DATA

Podemos ver estas direcciones mostrando las entradas dinámicas de recolocación del binario:

$ objdump -R ./noPIErelro 

./noPIErelro: formato del fichero elf64-x86-64

DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
0000000000600ff0 R_X86_64_GLOB_DAT __libc_start_main@GLIBC_2.2.5
0000000000600ff8 R_X86_64_GLOB_DAT __gmon_start__
0000000000601018 R_X86_64_JUMP_SLOT strcpy@GLIBC_2.2.5
0000000000601020 R_X86_64_JUMP_SLOT printf@GLIBC_2.2.5
0000000000601028 R_X86_64_JUMP_SLOT exit@GLIBC_2.2.5

Si sobrescribimos las direcciones del .got podemos apuntar a un shellcode, de tal forma que cuando llamemos a una función, por ejemplo exit() estemos llamando al shellcode.

Vamos a ver más detalladamente como funcionan estas secciones. Cuando un binario se carga en un proceso para ejecutarse, el enlazador dinámico tiene que realizar reubicaciones en ese momento. Pero alguna de estas reubicaciones no se llevan acabo hasta que se produce la primera referencia a una localización no resuelta. Esto se conoce como 'lazy binding' algo así como vinculación perezosa:

Uso de PLT y GOT

Como vemos en la figura de arriba, la resolución dinámica utiliza las secciones .plt y .got.plt.

Cuando se produce la primera llamada a la función strcpy desde nuestra función main, esta no se produce directamente sino a través de una llamada a la zona de la sección .plt correspondiente <strcpy@plt> (1). En <strcpy@plt> se produce un salto una dirección presente en la sección .got.plt (2). Al ser la primera vez que se llama a la función, la dirección en .got.plt. tan solo apunta a la siguiente instrucción de <strcpy@plt> en la tabla .plt (3). Esta instrucción es pushq que guarda en el stack un número que corresponde a un identificador para <strcpy@plt>, y salta a la entrada genérica <.plt> (4). En <.plt> se guarda otro identificador y salta al enlazador dinámico (5). Usando los identificadores guardados en el stack el enlazador dinámico resuelve la dirección de la función strcpy y pone esta dirección en la entrada asociada con strcpy@plt en got.plt. De este modo ya queda la resolución de la dirección de la función strcpy lista para futuras llamadas.

En las llamadas posteriores la resolución es mucho más directa. Se produce la llamada desde nuestra función main con una llamada a la tabla de la sección .plt (1). En <strcpy@plt> se produce un salto a una dirección presente en la sección .got.plt (2). En este caso como en .got.plt ya se tienen la dirección de la función strcpy esta se resuelve directamente sin la intervención del enlazador dinámico (3).

Vamos a ver esta resolución PLT con el depurador:

$ gdb -q noPIErelro
Leyendo símbolos desde noPIErelro...hecho.
(gdb) set arg AAAA BBBB
(gdb) l
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <string.h>
4
5 int main(int argc, char **argv)
6 {
7 char *puntero = NULL;
8 char array[10];
9
10 puntero = array;
11
12 if(argc == 3) {
13 strcpy(puntero, argv[1]);
14 printf("El array contiene %s en %p\n", puntero, (void *) &puntero);
15
16 strcpy(puntero, argv[2]);
17 printf("El array contiene %s en %p\n", puntero, (void *) &puntero);
18 }
19
20 exit(EXIT_SUCCESS);

Vamos a poner un par de break points para ver la dirección de strcpy antes y después de su primer uso:

(gdb) b 10
Punto de interrupción 1 at 0x40059d: file pltgot.c, line 10.
(gdb) b 14
Punto de interrupción 2 at 0x4005c5: file pltgot.c, line 14.

Ejecutamos la aplicación hasta el primer break point

(gdb) r
Starting program: /home/david/Proyects/cfunc/clibre/relro/noPIErelro AAAA BBBB

Breakpoint 1, main (argc=3, argv=0x7fffffffdd98) at pltgot.c:10
10 puntero = array;
(gdb)

vemos el código asembler de main:

(gdb) disass main
Dump of assembler code for function main:
0x0000000000400577 <+0>: push %rbp
0x0000000000400578 <+1>: mov %rsp,%rbp
0x000000000040057b <+4>: sub $0x30,%rsp
0x000000000040057f <+8>: mov %edi,-0x24(%rbp)
0x0000000000400582 <+11>: mov %rsi,-0x30(%rbp)
0x0000000000400586 <+15>: mov %fs:0x28,%rax
0x000000000040058f <+24>: mov %rax,-0x8(%rbp)
0x0000000000400593 <+28>: xor %eax,%eax
0x0000000000400595 <+30>: movq $0x0,-0x20(%rbp)
=> 0x000000000040059d <+38>: lea -0x12(%rbp),%rax
0x00000000004005a1 <+42>: mov %rax,-0x20(%rbp)
0x00000000004005a5 <+46>: cmpl $0x3,-0x24(%rbp)
0x00000000004005a9 <+50>: jne 0x400617 <main+160>
0x00000000004005ab <+52>: mov -0x30(%rbp),%rax
0x00000000004005af <+56>: add $0x8,%rax
0x00000000004005b3 <+60>: mov (%rax),%rdx
0x00000000004005b6 <+63>: mov -0x20(%rbp),%rax
0x00000000004005ba <+67>: mov %rdx,%rsi
0x00000000004005bd <+70>: mov %rax,%rdi
0x00000000004005c0 <+73>: callq 0x400460 <strcpy@plt>
0x00000000004005c5 <+78>: mov -0x20(%rbp),%rax
0x00000000004005c9 <+82>: lea -0x20(%rbp),%rdx
0x00000000004005cd <+86>: mov %rax,%rsi
0x00000000004005d0 <+89>: lea 0xdd(%rip),%rdi # 0x4006b4
0x00000000004005d7 <+96>: mov $0x0,%eax
0x00000000004005dc <+101>: callq 0x400470 <printf@plt>
0x00000000004005e1 <+106>: mov -0x30(%rbp),%rax
0x00000000004005e5 <+110>: add $0x10,%rax
0x00000000004005e9 <+114>: mov (%rax),%rdx
0x00000000004005ec <+117>: mov -0x20(%rbp),%rax
0x00000000004005f0 <+121>: mov %rdx,%rsi
0x00000000004005f3 <+124>: mov %rax,%rdi
0x00000000004005f6 <+127>: callq 0x400460 <strcpy@plt>
0x00000000004005fb <+132>: mov -0x20(%rbp),%rax
0x00000000004005ff <+136>: lea -0x20(%rbp),%rdx
0x0000000000400603 <+140>: mov %rax,%rsi
0x0000000000400606 <+143>: lea 0xa7(%rip),%rdi # 0x4006b4
0x000000000040060d <+150>: mov $0x0,%eax
0x0000000000400612 <+155>: callq 0x400470 <printf@plt>
0x0000000000400617 <+160>: mov $0x0,%edi
0x000000000040061c <+165>: callq 0x400480 <exit@plt>
End of assembler dump

y el código <strcpy@plt> en .plt:

(gdb) disass 'strcpy@plt'
Dump of assembler code for function strcpy@plt:
0x0000000000400460 <+0>: jmpq *0x200bb2(%rip) # 0x601018
0x0000000000400466 <+6>: pushq $0x0
0x000000000040046b <+11>: jmpq 0x400450
End of assembler dump.

Vamos a ver la dirección de donde va a saltar la primera instrucción de <strcpy@plt> (que apunta a .got.plt):

(gdb) x/gx 0x601018
0x601018: 0x0000000000400466
(gdb)

Como vemos apunta a la siguiente línea de <strcpy@plt>.

Ejecutamos el códio hasta el siguiente break point que es déspues de la primera llamada a strcpy:

(gdb) c
Continuando.

Breakpoint 2, main (argc=3, argv=0x7fffffffdd98) at pltgot.c:14
14 printf("El array contiene %s en %p\n", puntero, (void *) &puntero);

Miramos de nuevo la dirección de salto de la primera instrucción, que apunta a .got.plt:

(gdb) x/gx 0x601018
0x601018: 0x00007ffff7b5f950

La dirección a cambiado, apuntando ahora a strcpy como podemos ver:

(gdb) info symbol 0x00007ffff7b5f950
__strcpy_ssse3 en la sección .text de /lib/x86_64-linux-gnu/libc.so.6

Uno de los motivos por los que se usa este sistema .plt .got es la seguridad. Lo que conseguimos con estas capas extra de indirección es evitar crear secciones de código en las que se pueda escribir. Si tenemos una vulnerabilidad en una sección de código, sería muy fácil explotarla modificando y ejecutando directamente una sección como .text o .plt. En cambio la sección .got es una sección de datos, por tanto no ejecutable.

Otro motivo para este uso es la necesidad de compartir librerías, que son mapeadas en los espacios de memoria virtual de cada aplicación. La librería que se encuentra en un espacio físico de la memoria es mapeado en una dirección virtual diferente en cada aplicación.

Con el fin de limitar la posibilidad de sobrescribir estas zonas de memoria, lo que facilitaría la posibilidad de explotar una vulnerabilidad en la aplicación, se añadieron unas opciones en la colocación de las secciones y la capacidad de escribir en ellas, conocidas como Relocation Read-Only (RELRO).

Tenemos 3 opciones de uso de RELRO a la hora de compilar nuestra aplicación:

  • No usar relro: gcc -Wl,-z,norelro
  • Uso parcial de RELRO: gcc -Wl,-z,relro
  • RELRO Completo: gcc -Wl,-z,relro,-z,now

Esto solo es correcto actualmente si se compila sin PIE activado (-no-pie):

$ gcc -no-pie -Wl,-z,norelro pltgot.c -o noPIEnorelro

checksec -f noPIEnorelro
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
No RELRO No canary found NX enabled No PIE No RPATH No RUNPATH 63 Symbols No 0 4noPIEnorelro


$ gcc -no-pie -Wl,-z,relro pltgot.c -o noPIErelro

checksec -f noPIErelro
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Partial RELRO No canary found NX enabled No PIE No RPATH No RUNPATH 63 Symbols No 0 4noPIErelro


$ gcc -no-pie -Wl,-z,relro,-z,now pltgot.c -o noPIEfrelro

checksec -f noPIEfrelro
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Full RELRO No canary found NX enabled No PIE No RPATH No RUNPATH 62 Symbols No 0 4noPIEfrelro

Lo que busca checksec para comprobar la presencia de relro en los archivos es la presencia de la etiqueta GNU_RELRO en los encabezados elf:

$ readelf -l noPIEfrelro |grep 'GNU_RELRO'
GNU_RELRO 0x0000000000000dc0 0x0000000000600dc0 0x0000000000600dc0

Para comprobar si es full relro tienen que tener también el valor BIND_NOW en la sección dinámica:

$ readelf -d noPIEfrelro |grep 'BIND_NOW'
0x000000000000001e (FLAGS) BIND_NOW

Las diferencias entre el uso parcial y el completo de RELRO es:

RELRO parcial:

  • Se reordenan las secciones ELF para que las secciones de datos internos ELF (.got, .dtors, etc.) precedan a las secciones de datos del programa (.data y .bss) con el fin de evitar el riesto que un desbordamiento de búffer en una variable global pueda sobrescribir .got
  • no-PLT GOT es de solo lectura
  • se usa 'lazy binding'
  • GOT todavía se puede escribir

RELRO completo:

  • Todas las características de parcial RELRO
  • no se usa 'lazy binding'
  • GOT se asigna como solo lectura

Cuando se compila con PIE activado (actualmente es así por defecto) se activa por defecto con la opción BIND_NOW, es decir, no se utiliza el lazy_binding, las direcciones en .got se rellenan al lanzar la aplicación y se configura como de solo lectura.

Modificado por última vez enViernes, 04 Septiembre 2020 19:16
(0 votos)
Etiquetado como :

Deja un comentario

Asegúrese de introducir toda la información requerida, indicada por un asterisco (*). No se permite código HTML.