Latest Tweets

Desclavando espinas 1/3: UAD360 SuperSafeWeb writeup (sin wasm2c o wasmdec)

El reto

Descargamos el archivo rev1.zip y obenemos 3 archivos: index.jsp, index.wasn y main.html. El archivo index.jsp es el envoltorio para poder ejecutar las llamadas a bajo nivel escritas en WASM del archivo index.wasm, y no tiene mayor interés. El archivo main.html es un simple formulario web con una caja de texto y un botón que nos insta a introducir una flag:

main.html muestra un simple formulario web.

Si miramos el código por debajo, vemos que hay una llamada de JavaScript a la función check_flag(). Si escribimos cualquier cosa, se hace una llamada a check_flag y, si la flag introducida no es la correcta, se nos devuelve el siguiente error:

Si la flag no es la correcta, mensaje de error.

Depurando con Chrome

La función check_flag() está implementada a bajo nivel en el archivo index.wasm. Para resolver el reto, utilizaremos las opciones de debugging de la consola web de Google Chrome y un poco de reversing. Con Google Chrome no se permite la apertura y ejecución de archivos WASM via file://, así que levantamos un pequeño servicio web con Python en el mismo directorio donde tengamos los 3 archivos descomprimidos:

python -m SimpleHTTPServer

Usando Google Chrome, abrimos la url: http://localhost:8000/main.html y nos aparece el formulario web. Abrimos las “Developer Tools” para poder depurar el código WASM. Antes que nada, tenemos que identificar la función check_flag() dentro de todo el código a bajo nivel para poder poner ahí algunos puntos de interrupción.

Localizando la función check_flag()

Descargamos y compilamos wabt . Convertimos el código binario a formato humano con:

wasm2wat index.wasm -o index.wat

Abrimos el archivo generado con el editor ASCII que más nos guste y buscamos la función check_flag():

(export “_check_flag” (func 19))

Vemos que la función check_flag, dentro del código WASM, está implementada como func 19. Ya sabemos dónde poner el primer punto de interrupción en el navegador, justo en la primera línea de la func 19:

Punto de interrupción en la función check_flag

Identificando los argumentos de check_flag

La función check_flag() recibe 2 argumentos de tipo integer 32 bits. Escribimos cualquier cosa en la caja de texto y pulsamos “Check flag!”. Automáticamente veremos claramente identificados en Chrome los dos valores de estos argumentos. El segundo parece ser la longitud de la cadena introducida; el primero podría ser la dirección dentro de la memoria donde se encuentra la cadena introducida. Eso se puede comprobar escribiendo diferentes cadenas con diferentes longitudes:

Argumentos de check_flag() para “hola”.

El primer argumento siempre tiene el mismo valor, y es la dirección dentro de la memoria dónde se almacena nuestra cadena. Podemos verlo navegando a dicha posición dentro de “globals/memory” en Chrome, tal y como se muestra a continuación (cada carácter ASCII se representa con su valor decimal, así “hola” pasa a ser: 104 111 108 97):

Apoyándonos en la depuración y el código del archivo WAT, podemos ir determinando lo que hace realmente la función, paso a paso.

Longitud de la flag

Los dos argumentos pasados a la función son local 0 y local 1. El siguiente código, líneas 18-21, cargan en el stack el primer argumento y lo asignan a la variable local 31, después carga al stack el segundo argumento y lo asigna a la variable local 42:

get_local 0;    en WASM, local 0 es #arg0.
set_local 31
get_local 1;   en WASM, local 1 es #arg1.
set_local 42

local42 tiene, por tanto, la longitud de la cadena apuntada por local31. La primera comparación tiene lugar poco después. Tras asignar el valor de la longitud a la variable local42, esta se vuelve a cargar en el stack y se asigna a la variable local53. Finalmente, se pone en la pila la constante 20 (i32.const 20) y se comparan ambos valores (i32.ne). Si es diferente, no se entra en el siguiente bloque y se retorna de la función check_flag con un 1 (el resultado de i32.ne, 1, se almacena en local64), lo que implica que la flag no es válida (líneas 23-33):

set_local 53
get_local 53;   ponemos en el stack la longitud de la cadena.
i32.const 20;   ponemos en el stack 20.
i32.ne;            comparamos los 2 valores del stack.
set_local 64:  el resultado lo guardamos en local64.
block
get_local 64
if
i32.const 0
set_local 20
else;           este bloque ya no se ejecuta; se sale retornando error.

La constante 20 es, evidentemente, la longitud de la flag esperada.

Repeticiones

Aunque WASM es francamente poco amigable de leer, es bastante básico. Si observamos lo que sucede tras la comparación de la longitud de la cadena, podemos ver claramente que hay varios bloques con instrucciones repetitivas. Por ejemplo, si la longitud es de 20 caracteres exactos, entonces en el código WASM se entra en el bloque del “else” del snippet de código anterior. Y ahí tenemos una estructura que se va repitiendo varias veces hasta el final de la función (con ligeros cambios, claro):

get_local 31
set_local 75
get_local 75
i32.load8_s offset=0 align=1
set_local 86
get_local 86
i32.const 24
i32.shl
i32.const 24
i32.shr_s
set_local 97
get_local 97
i32.const 119
i32.ne
set_local 2
get_local 2
if
i32.const 0
set_local 20
br 2
end

A priori puede parecer un código difícil de leer, pero no lo es en absoluto.  La primera línea pone en el stack la dirección de nuestra cadena. Asigna dicho valor a la variable local75, y luego la pone de nuevo en el stack. Y ahora viene la parte interesante: la instrucción i32.load8_s offset=0 align=1 lo que hace es poner en el stack el byte apuntado por la dirección que tenemos en el stack, que es precisamente el primer byte de nuestra cadena. En nuestro caso, y para el ejemplo: “12345678901234567890” cargará en el stack el valor decimal 49 (“1”). Después asigna dicho valor a la variable local86, vuelve a ponerla en el stack, y realiza 2 operaciones de rotación de bits que se anulan entre ellas, por lo que a efectos prácticos es como si no hiciese nada:

get_local 86;   el primer byte de nuestra cadena.
i32.const 24;   ponemos 24 en el stack.
i32.shl;           byte_de_la_cadena << 24. Resultado en stack.
i32.const 24;  ponemos 24 en el stack.
i32.shr_s;      el resultado de la operación anterior se vuelve a rotar 24 bits a la derecha.
set_local 97:  el resultado se guarda en local97.

Por lo tanto, la variable local97 tendrá, en realidad, el mismo valor original que tenía la variable local86, es decir, el primer byte de nuestra cadena. Rotar 24 bits a la izquierda y dicho resultado rotarlo 24 bits a la derecha de nuevo es equivalente a no hacer nada sobre el valor original. Sin duda, esto era para despistar.

Tras esta operación absurda, se hace una comparación con un valor constante, decimal, que es claramente un valor ASCII imprimible:

get_local 97;               local97 se pone en la pila, es nuestro byte.
i32.const 119;             se pone en el stack el valor 119, que es “w”.
i32.ne;                         se comparan los dos valores.

Si los valores coinciden, entonces se pasa al siguiente bloque que es una repetición casi idéntica del código ya estudiado, haciendo exactamente la misma rotación de bits pero esta vez con dos cambios importantes: el byte de la cadena que se usa, y el valor con el que se compara. En caso contrario, se ejecuta la instrucción br 2 y se retorna error:

set_local 2;      el resultado de la comparación se guarda en local2.
get_local 2;     se pone dicho valor en el stack.
if;                    si los valores no son iguales, entramos en este if.
i32.const 0
set_local 20
br 2;                saltamos y retornamos error.
end

Resto de bytes de la cadena

A estas alturas ya nos podemos imaginar que se van a ir comparando todos los bytes de nuestra cadena, desde el primero hasta el último, con diferentes valores constantes, todos ellos valores en decimal que se traducen en códigos ASCII imprimibles. Si coinciden todos, es decir, si la comparación tiene éxito, entonces esa será la única flag correcta. Para cada bloque, las únicas diferencias importantes respecto del bloque ya analizado para el primer byte serán:

get_local 31          ponemos en el stack la dirección de nuestra cadena.
set_local 11;         asignamos la dirección de la cadena en local11 (primer byte).
get_local 11;         la ponemos en el stack.
i32.const <idx>;    ponemos el indice (de 1 hasta 19) en el stack del byte que queremos usar.
i32.add;                 sumamos esto al OFFSET que tenemos ya en el stack.
set_local 12:         guardamos resultado en local12.
get_local 12;        ponemos el valor de local12 en el stack.
i32.load8_s offset=0 align=1;  con esto, cargaremos el byte apuntado por offset+indice en el stack.

Se usará en todos los bloques el valor de constante correspondiente para operar con el enésimo byte de nuestra cadena. Por ejemplo, el carácter en la posición 13 de nuestra cadena:

get_local 31
set_local 71
get_local 71
i32.const 13;     índice del carácter: 13.
i32.add;            sumamos esto al OFFSET que ya tenemos en el stack.
set_local 72
get_local 72
i32.load8_s offset=0 align=1;  en el stack ahora cargamos el byte de la posición 13.
set_local 73
get_local 73
i32.const 24
i32.shl
i32.const 24
i32.shr_s
set_local 74
get_local 74
i32.const 53;   el carácter 13 de la flag debe ser 53, es decir, “5”.
i32.ne
set_local 76
get_local 76
if
i32.const 0
set_local 20
br 2
end

Sabiendo esto, basta con revisar todos los bloques y los valores constantes con los que se compara para poder saber cuál es la flag correcta. Cuidado que no están totalmente consecutivos; por ejemplo: los dos últimos bloques comparan las posiciones 11 y 14 con el carácter “_” (95 en decimal).

La flag.

La flag del reto.

 

The libvirtd nightmare

Preamble

Coming from outdated OpenVZ containers, one would surely think that migrating every one of those old containers to virtual machines with native and mainstream Linux Kernel KVM technology would be stable, easy to manage and absolutely not prone to corruption, kernel panics and the like.

Dream on.

The chosen setup: one powerful server with lots of RAM and hard-disk space running on Debian Stretch with an AMD Ryzen 7 processor having native SVM and IOMMU support, native KVM support coming right out from the stable Kernel shipped with Debian out-of-the-box, and libvirtd to make things easier to manage. So far so good, we managed (easily) to migrate the old-fashioned OpenVZ containers to native KVM machines, and we tested them successfully using qemu-system. After that, some trivial stuff like using virt-install to install them and virsh list, virsh start and virsh autostart commands to integrate them within the libvirtd daemon. And, by the way, every single VM running as a non-privileged user.

All of the VMs were running like a charm and smoothly thanks to their bare-metal fast solid state disk. Stable and reliable.

Dream on.

And then, all hell broke loose

After some time with all the VMs running with no issues at all, I logged myself to the host server using the non-privileged account the VMs were running as. Everything looked normal: the total amount of physical RAM used, the pagination and so on. Then, by the heck of me, I escalated privileges (I performed a su command) to perform some basic sysadmin stuff on the box. At some point, instead of returning to the non-privileged shell with <CTRL+d>, exit or whatever, I did this:

su – kvmuser

Then, magically, another instance of every single VM started up all at once, for all those VMs marked as “autostart” by libvirtd. So in no time at all, all the VMs started complaining about data integrity and instability on their file systems. No wonder why, because we had two instances of qemu-system per VM, accessing the very same qcow2 image! In no time at all, all the filesystems were mounted read-only for the first VM instances. At some point, funny things started to happen and data corruption became the norm. Chaos aplenty. Data loss and an incredible headache as I have never experienced in my whole life as a Sysadmin.

What the fuck man?

What is wrong with libvirtd

Once the VMs were restored and everything was again under perfect control, I did some research and tests. Of course we had two main problems here, the first one being not having a locking system preventing the VMs from accesing read/write the very same image twice. Heck, as far as I’m concerned, there are different ways to implement that. The second issue here was, of course, why on earth libvirtd was starting the already running VMs twice.

On another box mirroring the exact same setup, I logged as kvmuser, listed the running VMs, then became root and performed the su – kvmuser command and ran virsh list again to list the running VMs. Instead of getting the same ones, I got a suspicious and buggy empty list! See and marvel:

kvmuser@kvmbox:~$ virsh list
Id Name State
—————————————————-
1 Test running

kvmuser@kvmbox:~$ su
Password:
root@kvmbox:/home/kvmuser# su – kvmuser
kvmuser@kvmbox:~$ virsh list
Id Name State
—————————————————-

So that was it. The second virsh list command was not seeing the same VM “Test” running, although with a virst list –all it did see all the installed ones alright.

Tracing the socket

The virsh command connects to the libvirtd daemon, of course. And libvirtd is listening for connections from clients on a socket. Unless you tell libvirtd otherwise, it listens to connections on a UNIX socket. So I used strace to determine if the virsh command was using the same socket, something I suspected was not the case. The first time, when virsh was correctly seeing the “Test” VM running, this is what I got:

connect(5, {sa_family=AF_UNIX, sun_path=”/var/run/nscd/socket”}, 110) = -1 ENOENT (No such file or directory)
connect(5, {sa_family=AF_UNIX, sun_path=”/var/run/nscd/socket”}, 110) = -1 ENOENT (No such file or directory)
connect(6, {sa_family=AF_UNIX, sun_path=”/run/user/1001/libvirt/libvirt-sock”}, 110) = 0

So the socket was /run/user/1001/libvirt/libvirt-sock. Then, I became root and then I performed the su – kvmuser again, and I traced the second virsh list command:

connect(5, {sa_family=AF_UNIX, sun_path=”/var/run/nscd/socket”}, 110) = -1 ENOENT (No such file or directory)
connect(5, {sa_family=AF_UNIX, sun_path=”/var/run/nscd/socket”}, 110) = -1 ENOENT (No such file or directory)
connect(6, {sa_family=AF_UNIX, sun_path=”/home/kvmuser/.cache/libvirt/libvirt-sock”}, 110) = 0

The second time, the UNIX socket used was a different one, which explained why the virsh command did not see the same running VMs as the first one!

A trivial fix

According to libvirtd manpage:

$XDG_RUNTIME_DIR/libvirt/libvirt-sock
The socket libvirtd will use.

If $XDG_RUNTIME_DIR is not set in your environment, libvirtd will use $HOME/.cache

So, the first time, $XDG_RUNTIME_DIR was correctly set, and thus the socket was created on the /run/user/UID/ directory. After executing su and then su – kmvuser, though, this variable was not set at all, therefore libvirtd was using the UNIX socket under /home/$USER/.cache, as shown above.

This absolutely trivial thing rendered all of our KVMs corrupt in a matter of seconds. To fix it, I added the following line to the .bashrc file of the “kvmuser” user:

export XDG_RUNTIME_DIR=/run/user/$UID

And now, everything (but locking) works like a charm.

So far.

UAM’s MARVEL CTF Episodio 2: WriteUp

Obtención del binario y del servidor

Descargamos y descomprimimos el archivo ZIP. Nos encontramos con un volcado de memoria que podemos analizar con Volatility. Listamos los procesos en ejecución y nos encontramos con un Netcat:

0xfffffa800685b860 nc64.exe 1940 2304 2 72 1 0 2018-12-20 15:47:56 UTC+0000

Para obtener el servidor al que se conecta usamos netscan:

volatility -f image.raw –profile=Win7SP1x64 netscan|egrep –color=yes 1940

Volatility Foundation Volatility Framework 2.6
0x13d880880 TCPv4 172.16.233.139:49166 34.247.69.86:9009 ESTABLISHED 1940 nc64.exe

Ya tenemos el servidor y el puerto al que se conecta: 34.247.69.86:9009. Para encontrar el programa, nos dedicamos a listar archivos de la MFT que estén en el directorio del usuario, probamos suerte con Desktop y nos encontramos con el archivo HydralarioHydra y un archivo flag.txt:

volatility -f image.raw –profile=Win7SP1x64 filescan |egrep –color=yes Desktop

0x000000013d563f20 16 0 R–r– \Device\HarddiskVolume1\Users\admin\Desktop\HydralarioHydra
0x000000013dfcb730 16 0 RW—- \Device\HarddiskVolume1\Users\admin\Desktop\flag.txt

Reversing Hydra

Obtención de información sobre el binario

En el mundo del reversing y del exploiting, el primer paso suele ser obtener la máxima información posible sobre el binario a analizar. Podemos tirar de las binutils (readelf, objdump, etc), o apoyarnos en herramientas y scripts más automatizados. Para empezar, obtenemos el tipo de arquitectura de este binario con el comando file:

file hydra.o
hydra.o: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=c03cee4c7f44b1055031fd53980bd22e47873ab1, not stripped

Disponemos de los símbolos (not stripped), lo que nos facilitará muchísimo el reversing. Por otro lado, es un binario dinámico ELF (Linux) con arquitectura Intel X86. Si el binario fuese estático, tendríamos toda la GNU Libc embedida dentro del ejecutable, con lo que el tamaño del mismo sería mayor. Además, durante el reversing, veríamos un montón de símbolos adicionales que podrían despistarnos (si no estamos habituados al reversing).

Teniendo en cuenta que debemos realizar exploiting sobre este binario, vamos a comprobar el tipo de protecciones que tiene activadas. Para esto se pueden usar infinidad de herramientas (readelf, rabin2, etc) pero yo suelo utilizar checksec.sh (https://www.trapkit.de/tools/checksec.html). Lanzamos el script contra el binario y observamos que tiene activadas 2 protecciones (Partial RELRO y NX):

Comprobando las protecciones del binario hydra.o

Vamos a determinar sobre que versión de Sistema Operativo se compiló el binario. Los compiladores suelen añadir un comentario a la sección .comment del binario ELF con un string identificativo. GCC, por ejemplo, usa esa convención. Utilizamos el comando readelf para leer la sección:

objdump -s –section .comment hydra.o

hydra.o: file format elf32-i386

Contents of section .comment:
0000 4743433a 20285562 756e7475 20372e33 GCC: (Ubuntu 7.3
0010 2e302d32 37756275 6e747531 7e31382e .0-27ubuntu1~18.
0020 30342920 372e332e 3000 04) 7.3.0.

Tal y como podemos observar, el binario se ha compilado usando GCC 7.3 sobre un Ubuntu 18.04 (Bionic Beaver). Tenemos incluso el nombre del paquete del compilador exacto: gcc-7.3.0-27ubuntu1~18.04.debEsto nos puede ser útil para determinar con mayor exactitud el tipo de sistema que está ejecutando el servidor de la UAM (versión de Sistema Operativo, compilador, versión de la GNU Libc6, … )

Estrategia de exploiting

A partir de las protecciones del binario, podemos ir haciéndonos una idea de las diferentes posibilidades de explotación disponibles a grosso modo, sin entrar todavía en detalles.

Con Partial RELRO podríamos llegar a sobrescribir la tabla .got.plt para, por ejemplo, substituir cualquier llamada a una función de la GNU Libc dentro del binario por otra. Este es el clásico ejercicio de exploiting para lograr ejecutar una shell haciendo un bypass de la protección NX (que está activada). Por ejemplo, si este binario llama a printf(), bastaría con substituir la llamada a printf() por system(), por ejemplo, y asegurarse de manipular correctamente el stack para que system() recibiera como parámetro un puntero a la cadena “/bin/sh”. Esto se conoce como ret2libc, un caso particular de ROP (ver siguiente párrafo).

Como tenemos protección NX, no podemos dejar un shell-code en la pila y ejecutarlo (obtendríamos un segmentation fault). Pero sí podríamos utilizar la técnica ROP para, a partir de byte-codes existentes en el binario, montar nuestra propia pila de ejecución de tal modo que logremos ejecutar código bajo nuestro control a pesar de la protección NX. ROP es bastante complejo, por eso podemos apoyarnos de herramientas que nos ayuden en la búsqueda de los ROP gadgets.

Ahora toca analizar el binario con Radare2, para ver qué vulnerabilidades tiene.

Reversing con Radare2

Abrimos el binario dentro de radare2 y lanzamos el comando aa para que ejecute su analizador más básico a la búsqueda de las funciones disponibles en el binario. Una vez tenemos las funciones identificadas por r2 podemos ver una lista de las mismas con el comando afl:

Listado de funciones en hydra.o

En reversing, algunas veces se encuentran funciones que no son llamadas por el flujo principal del programa. La función main() es la que siempre se ejecuta primero, así que podemos generar el Function Call Graph de la función main() para determinar el flujo del programa y ver si alguna función de las identificadas por r2 no es llamada nunca. Para ello usaremos el comando agc con el OFFSET de la función main:

Function Call Graph de la función main() de hydra.o

Si nos fijamos en el listado obtenido de funciones con el comando afl, nos damos cuenta de que la función sym.a() no es llamada nunca por el programa. Observando el Function Call Graph anterior, vemos que el programa llama a read_flag(), luego imprime algo por pantalla con una llamada a la función importada de la libc puts(), sigue con una llamada a la función check_age(), imprime algo por pantalla con la importación de la función printf() de la libc, y finalmente llama a tell_me_a_secret(). Nos aseguramos de que realmente no hay ninguna referencia a sym.a() con el comando axt @ sym.a:

Las funciones indicadas como sym.imp en r2 son funciones importadas de librerías externas al código del binario analizado.

¿Qué tiene la función sym.a()? Podemos ver su código desensamblado mediante el comando pdf y el correspondiente OFFSET en r2:

La función a() que nunca es llamada en hydra.o

Esta función, a priori, nos dice poco. Pero analizarla nos puede ir muy bien para más tarde. Tal y como se ve en el código de arriba, esta función recibe un parámetro (arg_8h). r2 lo identifica como integer porque será un puntero de 32 bits (4 bytes) a una dirección de memoria. Este valor se apilará en la pila justo antes de hacer una llamada a printf (fijaos en los OFFSETS 0x084142f4 y 0x084142f7). Por supuesto, r2 identifica este parámetro pasado a a() como el primer parámetro de la función printf(). La convención de llamadas en X86 es de tipo cdecl, por lo que los argumentos a las funciones se pasan a través de la pila de derecha a izquierda (como en el código de printf(), aunque sólo tiene un parámetro).

La siguiente función que nos miramos es read_flag(). Es código desensamblado habitual para abrir un archivo, gestionar el error si no existe o tiene 0 bytes, y leer el contenido del archivo a una posición de memoria. Podemos ver el código de la función read_flag con el comando pdf y su OFFSET. Mostramos la parte dónde, si se ha podido leer del archivo, se hace una llamada a strcpy() para copiar sobre obj.flag el contenido de local_14h, que contiene los bytes leídos del archivo “flag.txt”:

En las siguientes instrucciones se copia el valor leído del flag en la posición de memoria obj.flag.

Ya hemos comentado que la convención de llamadas es cdecl. En este caso, para llamar a strcpy() que se define como strcpy(char *dst, const char *src) , el código nos muestra como se apila el segundo parámetro primero (const char *src, esto es local_14h) en la pila y después se hace lo propio con el primer parámetro (char *dst, o sea obj.flag). En resumen, la llamada a read_flag() nos dejará el valor del flag en la posición de memoria apuntada por obj.flag.

Program Independent Code (-fPIC)

Si nos fijamos en el código desensamblado de las funciones main, read_flag, a, etc, veremos que siempre hay una llamada que se nos antoja, a priori, algo esotérica. Estamos hablando del call __x86.get_pc_thunk.bx. Por ejemplo, si miramos el código de la función main con: pdf @ main, veremos lo siguiente:

Llamada a sym.__x86.get_pc_thunk.bx para direccionar memoria a partir del PC.

Esto simplemente indica que este binario ha sido compilado con el flag -fPIC (Program Independent Code). Un binario PIC puede ser cargado en cualquier posición de memoria, y cualquier direccionamiento a posiciones de memoria se harán relativas al Program Counter o dirección de instrucción a ejecutar. Esto es así porque el código no puede saber, a priori, en que posición de memoria se estará ejecutando. Por ejemplo, el código de la función main de arriba. Tras la ejecución de read_flag(), el argumento a la función puts() que es apilado en el stack se obtiene a partir del direccionamiento [ebx-0x1aac], y el registro ebx en este momento, tras las dos primeras instrucciones del snippet anterior, contiene la dirección del Program Counter. Por regla general, las 2 primeras instrucciones se pueden leer en código de alto nivel como get_pc(program counter). 

Primer escollo: check_age()

Seguimos con el análisis de la siguiente función que es llamada por main(), check_age(). Antes de mirarnos esta función con detalle, observamos que es llamada desde main() y, después, su valor de retorno es guardado en la posición de memoria local_9h y comparada con 0. Si el valor es 0, entonces salta al OFFSET 0x08414428 dónde imprime algo por pantalla y termina la ejecución del programa (en el OFFSET 0x0841443a se puede leer lo que equivaldría a un return 0; desde main en C). Por lo tanto, si el valor devuelto por check_age() es 0, se salta la llamada a la función “tell_me_a_secret()”, lo que a priori no parece una buena idea:

Si check_age() == 0, entonces nos saltamos la llamada a tell_me_a_secret().

Si obtenemos el código desensamblado de check_age() con pdf @ check_age, veremos que en el OFFSET 0x0841427c se asigna 1 a EAX (valor de retorno). Esto es lo que nos interesa, para poder caer en “tell_me_a_secret”. ¿Cómo llegamos a este OFFSET? Analicemos el código: primero, la función lee con una llamada a scanf() el valor entero introducido como parámetro al programa y lo almacena en local_10h. Luego, hace esta comparación 9>local_10h< 99999. El valor 99999 es simplemente la conversión del hexadecimal 0x1869f a decimal (0x1869f = 99999).

Comparando el valor introducido en check_age().

Si local_10h es mayor que 9 y menor que 99999, el flujo del programa cae en el siguiente OFFSET donde la “magia” ocurre:

Comprobación de la parte baja del valor entero introducido.

Se guarda nuestro valor entero en el registro de 32 bits EAX. Después, los 16 bits más bajos de dicho registro (representado por AX) se guardan en la variable local_ah. Así que ahora la variable local_ah contiene los 16 bits menos significativos de nuestro valor entero de 32 bits introducido como parámetro de check_age(). Después, estos 16 bits son guardados en EAX, con los 16 bits altos puestos a 0 (esto es lo que hace la instrucción movzx). Finalmente, para caer en mov eax, 1, que es lo que nos interesa, se comprueba si local_ah es 0. Esta variable sólo será 0 cuando los 16 bits menos significativos de nuestro valor entero sean 0. Por ejemplo, con 0 (0x0000) serían 0. Pero debemos tener un número mayor que 9, así que 0 queda descartado. Si tomamos la representación hexadecimal de un número, cada una de sus cifras representan 4 bits. Por tanto, construimos el siguiente valor hexadecimal: 0x10000. Los últimos 4 ceros son los 16 bits bajos puestos a 0. Si convertimos este número a decimal, obtenemos 65536. Este es el número que hará que la función check_age() retorne 1, en lugar de 0, tal y como se muestra en el código más arriba.

Tell_me_a_secret()

Retornando al código desensamblado de la función main(), después de la llamada a check_age() y únicamente si ésta retorna 1, se hará la llamada a la función tell_me_a_secret(). Si desensamblamos esta función con pdf y su OFFFSET (pdf @ sym.tell_me_a_secret), observamos el código siguiente:

El código desemsamblado de tell_me_a_secret()

Esta función imprime algo mediante printf() y después espera la introducción de datos por parte del usuario con una llamada a scanf(). Recordemos que la convención de llamadas es cdecl, con lo que tendremos 2 parámetros pasados a la función scanf() a través del stack en orden inverso, de derecha a izquierda. Por lo que primero se apila la dirección local_10h (puntero dónde almacenar el valor leído del stdin) y luego [ebx-0x1af2] (el argumento format de scanf) en la pila. Local_10h es una variable de esta función, y como tal, está en la pila. Si observamos la información que nos da r2 al principio (prólogo de la función), local_10h se encuentra en la dirección de pila apuntada por el registro EBP-0x10. Recordemos que la pila crece hacia valores más pequeños de memoria y decrece hacia valores más altos.  Recordemos también que el registro EBP define el nuevo contexto de pila para la función correspondiente. Expresiones dentro de dicha función como EBP-OFFSET siempre definirán variables locales a la función, mientras que expresiones como EBP+OFFSET siempre definirán argumentos pasados a esta función.

Una de las cosas buenas de r2 es que permite depurar y simular la ejecución del código (esto último con el lenguaje ESIL). Durante el reversing, uno puede hacer comprobaciones mediante depuración o emulación, para ver si la lectura estática del código ha sido debidamente entendida. Por ejemplo, ¿que valor de formato se pasa a scanf? Podemos verlo mediante depuración. Abrimos r2 con el flag “-d” para depurar. Después ejecutamos el comando aa. Miramos el OFFSET de la función tell_me_a_secret donde se apila el parámetro “format” de scanf, y asignamos ahí un punto de interrupción con el comando db OFFSET. Finalmente, ejecutamos con el comando dc, introducimos nuestro número mágico para saltarnos el primer escollo de check_age(), 65536, y la ejecución del código se interrumpe. Con dr imprimimos el valor que contiene ahora el registro EAX, y con ps imprimimos la cadena que hay en esa dirección. Tal y como podemos ver, el formato es “%s”, así que scanf() leerá tantos caracteres como deseemos introducir, sin  límite:

Sesión de depuración mediante r2.

Buffer Overflow de manual

Por lo visto en el código anterior, la función tell_me_a_secret() leerá tantos caracteres como nos plazca, sin ningún tipo de comprobación de tamaño, sobre el buffer local_10h. Esta variable está ubicada en EBP-0x10. Es decir, 16 bytes respecto de EBP. Por encima hay otra variable local, de 4 bytes, en EBP-0x4 (local_4h). Por lo tanto, el búffer reservado es de 12 bytes. ¿Que pasa si nuestros datos de entrada son superiores a 12 caracteres? Estaremos escribiendo fuera de la variable de búfer local_10h, por supuesto. Aquí tenemos, pues, un Buffer Overflow (BOF) que debemos explotar. En exploiting, lo primero siempre es conseguir que el programa haga un segmentation fault en una dirección de memoria no válida controlada, por ejemplo 0x41414141, para estar seguros de que controlamos el valor del registro EIP. Abrimos una consola y lanzamos el siguiente comando para escribir más de 16 bytes, y vamos incrementando el número de bytes hasta que logramos que el programa genere el error. Empezaremos con 16 bytes:

Provocando el segmentation fault.

Claramente el valor al que apunta EIP no nos sirve de mucho. Deberemos añadir 8 bytes más, teniendo en cuenta que deberemos sobreescribir EBP y RET (la dirección de retorno). Hasta ahora sólo hemos sobreescrito local_10h y local_4h:

Logramos que EIP valga 0x41414141 (AAAA).

Claramente, con 24 bytes logramos controlar el valor que tendrá el registro EIP. Para casos más complejos, es bueno utilizar herramientas como pattern_create de Metasploit, o pwn tools como PEDA, etc, para saber exactamente el tamaño que necesitamos para controlar EIP. Para este caso, agrupamos unos pocos bytes de 4 en 4 (32 bits) usando As, Bs, y Cs y lo probamos manualmente:

EIP = 0x43434343 (o sea, CCCC). Sobreescribimos el valor de retorno después de 20 bytes.

Es lógico; los primeros 16 bytes sobreescriben local_10h y local_4h; llegamos entonces al valor del contexto de pila apuntado por el registro EBP. Sobreescribimos EBP con 0x42424242 (BBBB) y, después, el valor de retorno con 0x43434343 (CCCC). Ahora ya podemos controlar dónde retornará la función tell_me_a_secret. Recordemos que la función sym.a() no es llamada por el código. Recordemos también que recibe un argumento que imprimirá por pantalla. Recordemos también que la convención cdecl define que los argumentos a funciones se pasen por la pila. Entonces, ¿que podemos hacer? Parecería que podríamos:

  • Hacer que tell_me_a_secret retorne a la función a().
  • Que la función a() imprima lo que queramos (dentro, claro, del espacio de memoria virtual del proceso).

El ROP más simple

¿Que nos gustaría que la función a() nos imprimiera? El valor de la flag, por supuesto. Sabemos que la flag se almacena en obj.flag, así que usando r2 podemos obtener la información sobre la dirección de memoria donde se encuentra obj.flag:

Obtenemos la dirección de memoria de obj.flag

El símbolo está marcado como GLOBAL, y se encuentra en el OFFSET 0x084160a0. En el formato ELF, un símbolo marcado como GLOBAL es accesible desde cualquier parte del binario. Esto significa que podemos referenciar (y de-referenciar) obj.flag desde sym.a(). Ahora es cuando debemos montar nuestro particular ROP. Lo que nos hace falta es alterar el stack de tal manera que:

  • Retornemos a sym.a() desde tell_me_a_secret.
  • sym.a() reciba como parámetro el OFFSET 0x084160a0.
  • Después, para ser precisos, deberíamos hacer que sym.a() retornase a una dirección donde el programa no pete, pero para este caso nos da lo mismo.

En resumen, vamos a preparar nuestra propia pila de ejecución. Nos hace falta saber la dirección de la función sym.a(), se lo preguntamos a r2:

Obtención de la dirección de sym.a()

Ya tenemos todo lo necesario para lanzar nuestro payload. La pila de ejecución que montaremos a nuestro antojo seguirá este esquema:

padding + sym.a() + retorno de sym.a() + obj.flag (parámetro sym.a())

Como estamos en arquitectura Intel,debemos recordar que las direcciones se escriben al revés, por lo que sym.a() pasará a ser 0xcd42418 y obj.flag 0xa060418. El retorno puede ser cualquier cosa que se nos antoje, aunque si queremos evitar provocar un segmentation fault podríamos hacer que salte a una dirección controlada dentro del binario (arreglando la pila adecuadamente, claro). Lanzamos nuestro payload:

Nuestro humilde payload modifica la pila de ejecución e imprime el flag.

Lo probamos contra el servidor:

Obtención de la flag en el servidor usando el mismo payload.

Si lanzamos una sesión con gdb, veremos como, tras imprimirnos el valor de la flag, sym.a() retorna a 0x43434343 (CCCC), tal y como hemos indicado en nuestro payload, provocando un segmentation fault: