jueves, 30 de agosto de 2012

Minicurso de exploiting (parte 7): Introducción a los buffers overflows

Y por fin llegamos a la parte que más jugo nos va a dar y que a mi más me gusta :D, los buffers overflows (BoFs). Este tipo de vulnerabilidades fueron descubiertas mucho tiempo atrás, no sabría decir cuando exactamente (creo que sobre 1986) y desde el principio de los tiempos han sido intensivamente explotadas, el gusano Morris ya se aprovechaba de buffer overflows para extenderse por máquinas UNIX. A pesar de lo conocido que resultan este tipo de vulnerabilidades, se sigue incurriendo en ellas muy a menudo, veamos por ejemplo una de las páginas más grandes a nivel mundial que contabilizan vulnerabilidades, securityfocus, cada día encontraremos nuevas vulnerabilidades publicadas pero mientras estoy escribiendo esto, una de las 10 últimas es un buffer overflow. Además siguen representando un problema grave porque, a pesar de todas las contramedidas que existen (las iremos viendo: canaries, Exec-shield, ASLR...) cuando conseguimos explotar una vulnerabilidad de este tipo las posibilidades suelen ser casi ilimitadas, lo que se suele describir como "ejecución de código" en las descripciones de las vulnerabilidades BoFs. Para poner otro ejemplo, el famoso conficker se basaba en este tipo de vulnerabilidad en un servicio RPC de muchísimas versiones de Windows para propagarse... ¡y vaya si se propagó!.

Pero dejemos ya la historia y datos curiosos y empecemos con esto de los BoFs, y lo primero que debemos saber es ¿qué es una vulnerabilidad de buffer overflow?. Una vulnerabilidad de buffer overflow se da en donde la manipulación de un buffer no se hace correctamente y de alguna forma acabamos escribiendo más alla del límite del buffer. Esto puede conllevar que el programa dé un resultado erróneo, que incurra en un acceso ilegal a memoria o segmentation fault, o, y aquí viene lo divertido, que se acabe ejecutando código en la máquina, un código que no debería ser el que se estuviera ejecutando. Y aún se vuelve más divertido si ese código que se ejecuta son cosas que no hay en la máquina de por sí, sino que inyectamos nosotros for fun and profit.

Veamos primero cómo se ve en código un claro ejemplo de vulnerabilidad BoF.

#include <string.h>
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
    char buffer[16];

    if (argc != 2)
        printf("Usage: %s <param>\n", argv[0]), exit(-1);

    strcpy(buffer, argv[1]);
    printf("%s\n", buffer);
    return 0;
}

Este código tiene la madre de todos los buffer overflows, se copia en un buffer de tamaño fijo (de tamaño 16) una cadena que a priori no sabemos su tamaño y que bien podría tener un tamaño mayor de 16 bytes (el primer parámetro que le pasamos al programa). Así pues, cuando ejecutamos este programa y le pasamos un argumento de tamaño 16 el programa debería explotar, probemos.

$ ./bof 1234
1234
$ ./bof 123456789012345
123456789012345
$ ./bof 1234567890123456
1234567890123456
$ ./bof 12345678901234567
12345678901234567
*** stack smashing detected ***: ./bof terminated
======= Backtrace: =========
/lib/libc.so.6(__fortify_fail+0x50)[0x1f5970]
/lib/libc.so.6(+0xe591a)[0x1f591a]
./bof[0x8048525]
/lib/libc.so.6(__libc_start_main+0xe7)[0x126ce7]
./bof[0x8048411]
======= Memory map: ========
00110000-00267000 r-xp 00000000 fc:03 382855     /lib/libc-2.12.1.so
00267000-00269000 r--p 00157000 fc:03 382855     /lib/libc-2.12.1.so
00269000-0026a000 rw-p 00159000 fc:03 382855     /lib/libc-2.12.1.so
0026a000-0026d000 rw-p 00000000 00:00 0
00423000-00424000 r-xp 00000000 00:00 0          [vdso]
00715000-00731000 r-xp 00000000 fc:03 382771     /lib/ld-2.12.1.so
00731000-00732000 r--p 0001b000 fc:03 382771     /lib/ld-2.12.1.so
00732000-00733000 rw-p 0001c000 fc:03 382771     /lib/ld-2.12.1.so
007ca000-007e4000 r-xp 00000000 fc:03 382848     /lib/libgcc_s.so.1
007e4000-007e5000 r--p 00019000 fc:03 382848     /lib/libgcc_s.so.1
007e5000-007e6000 rw-p 0001a000 fc:03 382848     /lib/libgcc_s.so.1
08048000-08049000 r-xp 00000000 fc:04 884738     /path/bof
08049000-0804a000 r--p 00000000 fc:04 884738     /path/bof
0804a000-0804b000 rw-p 00001000 fc:04 884738     /path/bof
088c6000-088e7000 rw-p 00000000 00:00 0          [heap]
b7862000-b7863000 rw-p 00000000 00:00 0
b7874000-b7877000 rw-p 00000000 00:00 0
bfba3000-bfbc4000 rw-p 00000000 00:00 0          [stack]
Abortado

De golpe puede parecer un chorrón de cosas chungas y que no entendemos, pero no es verdad, si nos fijamos hay dos partes bien diferenciadas. Por un lado he ejecutado 4 veces el programa con parámetros de distinto tamaño para demostrar el funcionamiento, y por otro lado un volcado de las regiones de memoria del proceso, que ya nos debería sonar puesto que cuando estuvimos viendo cómo eran vimos el fichero /proc/<pid>/maps, que básicamente es lo que se está mostrando aquí.

Centrémonos en las ejecuciones, hemos ejecutado primero con un parámetro de tamaño 5, luego con parámetro de tamaño 16, luego 17 y luego 18 (para quién se esté despistando, recordemos que la strings en C terminan con el byte '\0', que también es un byte ¬¬).

En las 2 primeras ejecuciones no ha pasado nada, como esperabamos puesto que el tamaño del parámetro se ajusta al del buffer. En la tercera, que pasamos 17 bytes y aún así el programa no explota, ya tenemos trabajo para dudar, ¿por qué no explota?. En la última ejecución vemos claramente que el programa explota (stack smashing detected, como habréis visto en uno de los enlaces que he puesto esto de "smashing the stack" es algo muy utilizado en este ámbito :). Veamos entonces qué está pasando en realidad por dentro para que con un parámetro de tamaño 17 no explote y con 18 sí. Con tranquilidad, que lo que viene ahora puede resultar duro... ¡adentrémonos en matrishshshsh!.

$ gdb -q bof
Leyendo símbolos desde /path/bof...(no se encontraron símbolos de depuración)hecho.
(gdb) disas main
Dump of assembler code for function main:
   0x080484a4 <+0>:    push   %ebp
   0x080484a5 <+1>:    mov    %esp,%ebp
   0x080484a7 <+3>:    and    $0xfffffff0,%esp
   0x080484aa <+6>:    sub    $0x40,%esp
   0x080484ad <+9>:    mov    0xc(%ebp),%eax
   0x080484b0 <+12>:    mov    %eax,0x1c(%esp)
   0x080484b4 <+16>:    mov    %gs:0x14,%eax
   0x080484ba <+22>:    mov    %eax,0x3c(%esp)
   0x080484be <+26>:    xor    %eax,%eax
   0x080484c0 <+28>:    cmpl   $0x2,0x8(%ebp)
   0x080484c4 <+32>:    je     0x80484e9 <main+69>
   0x080484c6 <+34>:    mov    0x1c(%esp),%eax
   0x080484ca <+38>:    mov    (%eax),%edx
   0x080484cc <+40>:    mov    $0x80485f0,%eax
   0x080484d1 <+45>:    mov    %edx,0x4(%esp)
   0x080484d5 <+49>:    mov    %eax,(%esp)
   0x080484d8 <+52>:    call   0x80483a8 <printf@plt>
   0x080484dd <+57>:    movl   $0xffffffff,(%esp)
   0x080484e4 <+64>:    call   0x80483d8 <exit@plt>
   0x080484e9 <+69>:    mov    0x1c(%esp),%eax
   0x080484ed <+73>:    add    $0x4,%eax
   0x080484f0 <+76>:    mov    (%eax),%eax
   0x080484f2 <+78>:    mov    %eax,0x4(%esp)
   0x080484f6 <+82>:    lea    0x2c(%esp),%eax
   0x080484fa <+86>:    mov    %eax,(%esp)
   0x080484fd <+89>:    call   0x8048398 <strcpy@plt>
   0x08048502 <+94>:    lea    0x2c(%esp),%eax
   0x08048506 <+98>:    mov    %eax,(%esp)
   0x08048509 <+101>:    call   0x80483c8 <puts@plt>
   0x0804850e <+106>:    mov    $0x0,%eax
   0x08048513 <+111>:    mov    0x3c(%esp),%edx
   0x08048517 <+115>:    xor    %gs:0x14,%edx
   0x0804851e <+122>:    je     0x8048525 <main+129>
   0x08048520 <+124>:    call   0x80483b8 <__stack_chk_fail@plt>
   0x08048525 <+129>:    leave 
   0x08048526 <+130>:    ret   
End of assembler dump.

He marcado por un lado la cantidad de espacio que se reserva para variables locales (0x40 = 72 bytes) en el prólogo de la función. También he marcado la instrucción call que salta a strcpy() (donde se produce la sobreescritura). Y finalmente he marcado la llamada a una función que nosotros no hemos puesto, __stack_chk_fail() (stack check fail). WTF?! ¡Si nosotros no hemos usado esa función!, ¿¡qué hace ahí!?. Lo dije en el primer capítulo y lo vuelvo a repetir ahora, no te creas nada de nadie ni de nada, comprueba las cosas por ti mismo. Lo aclaro rápidamente, desde hace ya bastante tiempo, las versiones de Ubuntu traen una versión de GCC que automáticamente nos mete esta función si estamos manejando bufferes estáticos precisamente para detectar si ha habido bof, y ahora mismo estoy sobre una Ubuntu. Debido a esta función el programa explota con toda esa cantidad de información, luego veremos que no es lo normal. Pero de nuevo nos debería asaltar una pregunta ¿por qué si hay sobreescritura (debería haberla) cuando ejecutamos con un parámetro de 17 bytes, no está explotando?. Pues seguimos el análisis.

(gdb) br *main+89
Punto de interrupción 1 at 0x80484fd
(gdb) run 1234567890123456
Starting program: /path/bof 1234567890123456

Breakpoint 1, 0x080484fd in main ()
(gdb) x/30xw $esp
0xbffff320:    0xbffff34c    0xbffff5c5    0xbffff338    0x08048364
0xbffff330:    0x0011eb60    0x08049ff4    0xbffff368    0xbffff414
0xbffff340:    0x00288324    0x00287ff4    0x08048540    0xbffff368
0xbffff350:    0x0015e985    0x0011eb60    0x0804854b    0x3005f400
0xbffff360:    0x08048540    0x00000000    0xbffff3e8    0x00145ce7
0xbffff370:    0x00000002    0xbffff414    0xbffff420    0xb7fff848
0xbffff380:    0xbffff4c8    0xffffffff    0x0012cff4    0x0804828e
0xbffff390:    0x00000001    0xbffff3d0
(gdb) print /x $ebp
$1 = 0xbffff368
(gdb) x/s 0xbffff5c5
0xbffff5c5:     "1234567890123456"

Paramos el programa justo antes de la llamada a strcpy y miramos la pila, como esto aún puede resultar un poco loco para los iniciados lo he coloreado. Ha saber, los números que usan un color más suave son punteros y los que usan un color más fuerte son los datos. A su vez los punteros y sus datos los he pintado usando el mismo tipo de color (rojo, naranja y azul).

Vayamos por partes. Lo que aquí tenemos es el stack frame (que ya los conocemos de antes) de main() justo antes de llamar a strcpy(), en la cima de la pila tenemos los parámetros para strcpy(), primero el puntero a nuestro buffer estático (rojo suave), que además vemos que está un poco más abajo en la pila (en rojo fuerte) y que contiene porquería. Justo después está el puntero argv[1] (naranja suave), que como muestro al final contiene el parámetro (naranja fuerte). Para saber el límite del stack frame de main() he mostrado también el contenido del registro EBP (azul suave), que está apuntando al saved EBP del stack frame anterior (azul oscuro). Como recordatorio de los stack frames también he marcado en verde suave la dirección de retorno, pero no viene al caso.

Ahora sabemos que cuando ejecutemos strcpy() se copiará el parámetro "123456..." en la zona pintada rojo fuerte. Veámoslo.

(gdb) nexti
0x08048502 in main ()
(gdb) x/30xw $esp
0xbffff320:    0xbffff34c    0xbffff5c5    0xbffff338    0x08048364
0xbffff330:    0x0011eb60    0x08049ff4    0xbffff368    0xbffff414
0xbffff340:    0x00288324    0x00287ff4    0x08048540    0x34333231
0xbffff350:    0x38373635    0x32313039    0x36353433    0x3005f400
0xbffff360:    0x08048540    0x00000000    0xbffff3e8    0x00145ce7
0xbffff370:    0x00000002    0xbffff414    0xbffff420    0xb7fff848
0xbffff380:    0xbffff4c8    0xffffffff    0x0012cff4    0x0804828e
0xbffff390:    0x00000001    0xbffff3d0

Efectivamente ha ocurrido lo que esperábamos, lo que antes era porquería ahora es una copia del parámetro que pasamos (0x30 en ASCII = '0', 0x31 = '1' y así sucesivamente... ale, a aprender la tabla ASCII xD). Sin embargo nótese un pequeño detalle, el último byte que he pintado de rojo fuerte va más allá de los 16 del buffer estático, está en 0x3005f400 y es el último byte, el '\0' con el que terminan las strings en C. Volvamos un poco atrás y comprobemos que ese byte antes de la llamada a strcpy() también era 0. Ese byte realmente ha sido sobreescrito por el \0 de la string, pero al ser igual no está produciendo nada raro, y es por ello que la ejecución con un parámetro de tamaño 17 no explota. Sin embargo, como ya se supondrá, si ejecutamos con tamaño 18.

(gdb) run 12345678901234567
The program being debugged has been started already.
Start it from the beginning? (y o n) y
Starting program: /path/bof 12345678901234567

Breakpoint 1, 0x080484fd in main ()
(gdb) x/30xw $esp
0xbffff320:    0xbffff34c    0xbffff5c4    0xbffff338    0x08048364
0xbffff330:    0x0011eb60    0x08049ff4    0xbffff368    0xbffff414
0xbffff340:    0x00288324    0x00287ff4    0x08048540    0xbffff368
0xbffff350:    0x0015e985    0x0011eb60    0x0804854b    0x52d69a00
0xbffff360:    0x08048540    0x00000000    0xbffff3e8    0x00145ce7
0xbffff370:    0x00000002    0xbffff414    0xbffff420    0xb7fff848
0xbffff380:    0xbffff4c8    0xffffffff    0x0012cff4    0x0804828e
0xbffff390:    0x00000001    0xbffff3d0
(gdb) nexti
0x08048502 in main ()
(gdb) x/30xw $esp
0xbffff320:    0xbffff34c    0xbffff5c4    0xbffff338    0x08048364
0xbffff330:    0x0011eb60    0x08049ff4    0xbffff368    0xbffff414
0xbffff340:    0x00288324    0x00287ff4    0x08048540    0x34333231
0xbffff350:    0x38373635    0x32313039    0x36353433    0x52d60037
0xbffff360:    0x08048540    0x00000000    0xbffff3e8    0x00145ce7
0xbffff370:    0x00000002    0xbffff414    0xbffff420    0xb7fff848
0xbffff380:    0xbffff4c8    0xffffffff    0x0012cff4    0x0804828e
0xbffff390:    0x00000001    0xbffff3d

Vemos que ahora el valor que es sobreescrito sí que cambia, antes de la ejecución era 0x52d69a00 y después es 0x52d60037. Hemos sobreescritos los 2 últimos bytes con 0x37, '7' en ASCII, y con el 0 de terminación de string. Fijémonos en un detalle curioso, aunque el valor que había justo después de nuestro buffer estático ha sido diferente en cada una de las ejecuciones que he hecho, el último byte en ambas ha sido 0. Interesante.

Bueno, pues ya que hemos visto lo que está pasando por debajo voy a descubrir el pastel. Todo el sistema de comprobación que usa __stack_chk_fail() y que mete el GCC de Ubuntu sin que se lo pidamos se basa en poner después de los bufferes estáticos un númerito, que se conoce como canario y que sirve para detectar los buffers overflows si cambia. Casualmente al ser su último byte un 0 y terminar las strings de C en 0, aunque se estaba incurriendo en sobreescritura incluso con un parámetro de 17 bytes, al no cambiar ese canario __stack_chk_fail() no lo estaba detectando.

Pues esto es una mierda para empezar a estudiar buffers overflows puesto que "impide" hacerlos (se puede saltar, ya veremos cómo), así que vamos a quitar estas ñapas para dejar nuestros programas pelados y que podamos ir avanzando de finales de los 80 a nuestros días. Para evitar que GCC meta __stack_chk_fail() en nuestros programas, a la hora de compilar tenemos que pasar la opción -fno-stack-protector.

$ gcc -fno-stack-protector -o bof bof.c 
$ objdump -d bof | egrep -A 27 "<main>"
08048454 <main>:
 8048454:    55                       push   %ebp
 8048455:    89 e5                    mov    %esp,%ebp
 8048457:    83 e4 f0                 and    $0xfffffff0,%esp
 804845a:    83 ec 20                 sub    $0x20,%esp
 804845d:    83 7d 08 02              cmpl   $0x2,0x8(%ebp)
 8048461:    74 22                    je     8048485 <main+0x31>
 8048463:    8b 45 0c                 mov    0xc(%ebp),%eax
 8048466:    8b 10                    mov    (%eax),%edx
 8048468:    b8 70 85 04 08           mov    $0x8048570,%eax
 804846d:    89 54 24 04              mov    %edx,0x4(%esp)
 8048471:    89 04 24                 mov    %eax,(%esp)
 8048474:    e8 eb fe ff ff           call   8048364 <printf@plt>
 8048479:    c7 04 24 ff ff ff ff     movl   $0xffffffff,(%esp)
 8048480:    e8 ff fe ff ff           call   8048384 <exit@plt>
 8048485:    8b 45 0c                 mov    0xc(%ebp),%eax
 8048488:    83 c0 04                 add    $0x4,%eax
 804848b:    8b 00                    mov    (%eax),%eax
 804848d:    89 44 24 04              mov    %eax,0x4(%esp)
 8048491:    8d 44 24 10              lea    0x10(%esp),%eax
 8048495:    89 04 24                 mov    %eax,(%esp)
 8048498:    e8 b7 fe ff ff           call   8048354 <strcpy@plt>
 804849d:    8d 44 24 10              lea    0x10(%esp),%eax
 80484a1:    89 04 24                 mov    %eax,(%esp)
 80484a4:    e8 cb fe ff ff           call   8048374 <puts@plt>
 80484a9:    b8 00 00 00 00           mov    $0x0,%eax
 80484ae:    c9                       leave 
 80484af:    c3                       ret

Y ahora sí, vemos como no está la función de protección, he incluso que la cantidad de espacio reservado para variables locales se ha reducido de 0x40 a 0x20 bytes, y también el tamaño de la función. Veamos ahora cómo se comporta el programa sin la protección.

$ ./bof 1234
1234
$ ./bof 123456789012345
123456789012345
$ ./bof 1234567890123456
1234567890123456
$ ./bof 12345678901234567
12345678901234567
$ ./bof 123456789012345678
123456789012345678
$ ./bof 123456789012345678901234567890
123456789012345678901234567890
Violación de segmento

¡Bien, ya empezamos a violar segmentos! ¿Qué significa eso?, genéricamente hablando significa que el programa ha intentando acceder a una zona de memoria para hacer algo (leer, escribir o ejecutar) que no tiene los permisos necesarios (lectura, escritura o ejecución). ¿Puedes intuir qué está pasando y por qué el fallo no está ocurriendo cuando ejecutamos con el parámetro de 17 bytes?. Bueno, eso lo dejo en el aire para que práctiques... esto era un blog práctico, ¿recuerdas? ;).

Saludos.

No hay comentarios:

Publicar un comentario