Explotando un integer overflow

Este es el primer artículo de una serie en la que iremos viendo algunas técnicas que se emplean para llevar a cabo la explotación de vulnerabilidades en aplicaciones. Está escrito a modo de tutorial y en él expondré un pequeño ejemplo de cómo sería una vulnerabilidad producida por el desbordamiento de una variable. A este tipo de vulnerabilidades, por darse fundamentalmente con el tipo de datos entero, se le conoce como Integer Overflow.

Este tipo de vulnerabilidades podría darse ya no sólo por la posibilidad de introducir al programa un valor que exceda la capacidad del tipo de dato, lo cual es muy fácil de controlar, sino que también sería posible por operaciones aritméticas cuyo resultado fuese un desbordamiento, y esto ya es más difícil de controlar.

No voy a explicar mucho más sobre esto porque en la red ya existe abundante material explicativo. También existen magníficos ejemplos como éste, éste o este más parecido. Sin embargo, a diferencia de estos otros artículos, voy a ir un poco más al detalle, analizando a nivel ensamblador y movimiento de registros cómo se produce la condición de desbordamiento.

1. Vayamos al ejemplo

El ejemplo se ha realizado sobre un Ubuntu 12.04LTS de 32 bits. En estos primeros artículos de la serie trabajaremos con arquitectura de 32 bits por ser más sencilla. Compilaremos con gcc y debuggearemos con gdb.

Para hacerlo más didáctico, voy a utilizar el desbordamiento en un byte correspondiente a un tipo de dato char. Como sabemos, 1 byte son 8 bits, lo que permite en binario el rango de 0 a 255 y en complemento a dos de -128 a 127.

Supongamos que un programador novato ha aprendido que el tipo char ocupa menos espacio en memoria que un int y que también puede usarse para almacenar números. Por ello decide "optimizar" su código utilizando un dato cuyo tipo es char para almacenar la longitud de una cadena y así hacer que su programa utilice menos memoria. El programador obviamente no ha leído todavía a Donald Knutt y su famosa frase: "La optimización temprana es la raíz de todo mal" y acaba escribiendo el siguiente código:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>


#define MAX_ARG 30

int main(int argc, char *argv[]){
	char argumento[MAX_ARG];
	char tam = 0;

	if(argc != 2) {
		printf("Uso: %s argumento\n", argv[0]);
		return 2;
	}

	tam = strlen(argv[1]);
	printf("El tam del argumento es: %d\n", tam);
	if(tam > MAX_ARG-1) {
		printf("El argumento excede el max\n");
		return 2;
	}

	strcpy(argumento, argv[1]);
	printf("El argumento: %s\n", argumento);

	return 0;
}

Como vemos en el programa, utiliza un tipo char para almacenar el tamaño de la cadena pasada como argumento y, siguiendo buenas prácticas de programación, posteriormente comprueba que el tamaño no sea mayor que la memoria definida para su almacenamiento.

2. Desbordando el valor de la variable

Un atacante rápidamente comprueba que es posible saltarse la comprobación del tamaño de la cadena simplemente desbordando el tamaño que el tipo de dato char es capaz de almacenar. Veamos las ejecuciones del programa.

Ejecuciones desbordamiento
Figura 1. Ejecuciones que producen desbordamiento
Nótese que estamos usando el intérprete de perl en línea para generar una cadena de entrada con la letra A repetida el número de veces pasado.

Como vemos en la figura, en la primera ejecución se introduce una cadena cuyo tamaño es inferior al buffer reservado para su almacenamiento y se ejecuta correctamente. En la segunda ejecución, se introduce una cadena de tamaño 30, siendo superior al permitido (ya que se incluye el byte nulo de cierre de cadena, lo que hace 31). En la tercera ejecución se ejecuta una cadena de tamaño 127, nuevamente el programa funciona correctamente indicando que el argumento excede el máximo. En la cuarta, se introduce 128, pero en esta ocasión vemos que nos dice que el tamaño es de…​ ¡¡-128!! y muestra un error de violación de segmento. Si en nuestro programa hubiésemos también chequeado que tam sea mayor que cero, habríamos evitado el error. Pero en la quinta ejecución introducimos de 257, mostrándonos un tamaño de 1 y obteniendo nuevamente un error de violación de segmento.

3. Explicación del desbordamiento

Veamos qué ha ocurrido, haciendo uso de la herramienta gdb.

(gdb) disass main
Dump of assembler code for function main:
   0x08048424 <+0>:	push   %ebp
   0x08048425 <+1>:	mov    %esp,%ebp
   0x08048427 <+3>:	push   %edi
   0x08048428 <+4>:	and    $0xfffffff0,%esp
   0x0804842b <+7>:	sub    $0x40,%esp
   0x0804842e <+10>:	movb   $0x0,0x3f(%esp)
   0x08048433 <+15>:	cmpl   $0x2,0x8(%ebp)
   0x08048437 <+19>:	je     0x8048459 <main+53>
   0x08048439 <+21>:	mov    0xc(%ebp),%eax
   0x0804843c <+24>:	mov    (%eax),%edx
   0x0804843e <+26>:	mov    $0x80485c0,%eax
   0x08048443 <+31>:	mov    %edx,0x4(%esp)
   0x08048447 <+35>:	mov    %eax,(%esp)
   0x0804844a <+38>:	call   0x8048320 <printf@plt>
   0x0804844f <+43>:	mov    $0x2,%eax
   0x08048454 <+48>:	jmp    0x80484e5 <main+193>
   0x08048459 <+53>:	mov    0xc(%ebp),%eax
   0x0804845c <+56>:	add    $0x4,%eax
   0x0804845f <+59>:	mov    (%eax),%eax
   0x08048461 <+61>:	movl   $0xffffffff,0x1c(%esp)
   0x08048469 <+69>:	mov    %eax,%edx
   0x0804846b <+71>:	mov    $0x0,%eax
   0x08048470 <+76>:	mov    0x1c(%esp),%ecx
   0x08048474 <+80>:	mov    %edx,%edi
   0x08048476 <+82>:	repnz scas %es:(%edi),%al
   0x08048478 <+84>:	mov    %ecx,%eax
   0x0804847a <+86>:	not    %eax
   0x0804847c <+88>:	sub    $0x1,%eax
   0x0804847f <+91>:	mov    %al,0x3f(%esp)
   0x08048483 <+95>:	movsbl 0x3f(%esp),%edx
   0x08048488 <+100>:	mov    $0x80485d3,%eax
   0x0804848d <+105>:	mov    %edx,0x4(%esp)
   0x08048491 <+109>:	mov    %eax,(%esp)
   0x08048494 <+112>:	call   0x8048320 <printf@plt>
   0x08048499 <+117>:	cmpb   $0x1d,0x3f(%esp)
   0x0804849e <+122>:	jle    0x80484b3 <main+143>
   0x080484a0 <+124>:	movl   $0x80485f0,(%esp)
   0x080484a7 <+131>:	call   0x8048340 <puts@plt>
   0x080484ac <+136>:	mov    $0x2,%eax
   0x080484b1 <+141>:	jmp    0x80484e5 <main+193>
   0x080484b3 <+143>:	mov    0xc(%ebp),%eax
   0x080484b6 <+146>:	add    $0x4,%eax
   0x080484b9 <+149>:	mov    (%eax),%eax
   0x080484bb <+151>:	mov    %eax,0x4(%esp)
   0x080484bf <+155>:	lea    0x21(%esp),%eax
   0x080484c3 <+159>:	mov    %eax,(%esp)
   0x080484c6 <+162>:	call   0x8048330 <strcpy@plt>
   0x080484cb <+167>:	mov    $0x804860b,%eax
   0x080484d0 <+172>:	lea    0x21(%esp),%edx
   0x080484d4 <+176>:	mov    %edx,0x4(%esp)
   0x080484d8 <+180>:	mov    %eax,(%esp)
   0x080484db <+183>:	call   0x8048320 <printf@plt>
   0x080484e0 <+188>:	mov    $0x0,%eax
   0x080484e5 <+193>:	mov    -0x4(%ebp),%edi
   0x080484e8 <+196>:	leave
   0x080484e9 <+197>:	ret
End of assembler dump.

Las cuatro primeras líneas son el preámbulo de la función, en ellas se inicializan correctamente los registros de gestión de la pila y se reservan 0x40 bytes. A continuación vemos que realiza la inicialización a 0 de la variable tam con la instrucción:

movb   $0x0,0x3f(%esp)

Como se ve, esta variable reside en la pila y está usando el movimiento a byte al offset 0x3f de la cima de la pila.

Las variables locales definidas en una función se implementan en la pila, para ello el compilador genera en el llamado preámbulo de la función el código que reservará e inicializará si es preciso la memoria.

En la siguiente parte, se realiza el cálculo del tamaño de cadena (como vemos la función strlen se implementa inline, ya que no hay llamada).

   0x08048476 <+82>:	repnz scas %es:(%edi),%al
   0x08048478 <+84>:	mov    %ecx,%eax
   0x0804847a <+86>:	not    %eax
   0x0804847c <+88>:	sub    $0x1,%eax
   0x0804847f <+91>:	mov    %al,0x3f(%esp)
   0x08048483 <+95>:	movsbl 0x3f(%esp),%edx

Como vemos, se realiza la asignación de los últimos 8 bits del registro EAX usando AL sobre el valor de la variable tam en la pila.

Las funciones inline son funciones cuyo código es directamente incluido por el compilador en cada llamada a la función, evitando así el desperdicio de recursos en tiempo de ejecución que una llamada a una función convencional implica. La parte negativa es que su uso generará más código de programa al tener que incluirse una y otra vez cada vez que esta función sea llamada, generando un programa más grande. Por ello sólo se hace con funciones que tengan muy poco código o que sean llamadas desde muy pocos lugares.

Veamos el desbordamiento en tiempo de ejecución. Para ello pondré puntos de interrupción y ejecutaré con desbordamiento, de la siguiente forma:

(gdb) break *main+15
Punto de interrupcion 1 at 0x8048433
(gdb) break *main+95
Punto de interrupcion 2 at 0x8048483
(gdb) run `perl -e 'print "A"x257'`
Starting program: /home/luis/pec/intoverflow `perl -e 'print "A"x257'`

Breakpoint 1, 0x08048433 in main ()

Veíamos que el offset en la pila de la variable era 0x3f. Además de ser direccionamiento a byte, dicho offset no está alineado. Por ello Comprobaré el valor de su palabra y la adyacente Además comprobaré el valor con formato de byte de la siguiente forma:

(gdb) x/2xw $esp+0x38
0xbffff608:	0x080484f9	0x00fd1ff4
(gdb) x/xb $esp+0x3f
0xbffff60f:	0x00

Así vemos el estado de la memoria tras la inicialización a cero inicial. Pasemos a ver qué pasa en el segundo punto de interrupción.

(gdb) c
Continuando.

Breakpoint 2, 0x08048483 in main ()
(gdb) x/2xw $esp+0x38
0xbffff608:	0x080484f9	0x01fd1ff4
(gdb) x/xb $esp+0x3f
0xbffff60f:	0x01

Como vemos, el valor del byte es 0x01. Pero cuál es el valor del registro EAX en el que se había calculado la longitud de la cadena y el valor del registro AL (el byte más bajo).

(gdb) info registers eax
eax            0x101	257
(gdb) info registers al
al             0x1	1

Como vemos, el cálculo se hizo correctamente, el registro EAX contenía el valor correcto, sin embargo cuando se mueve únicamente la información del byte menos significativo usando AL (truncamos la información), el valor trasladado a la memoria es el 0x01.

En la captura de pantalla siguiente vemos qué ocurre cuando se introduce una cadena de 128. En este caso, vemos que el valor es exactamente el mismo (0x80) y no se pierde información pero al tratarse la representación en complemento a dos, al estar el bit más significativo a valor alto indica que se trata de un número negativo.

Integer overflow negativo
Figura 2. Desbordamiento número negativo

4. Más allá del desbordamiento

Hemos visto cómo hemos sido capaces de cambiar el flujo de un programa utilizando un desbordamiento de enteros (aunque en este caso se ha empleado el tipo char). La explotación de este fallo nos ha conducido a que la condición sea evaluada como falsa, el programa continúe y se produzca una violación de segmento. Esto nos va a permitir posteriormente la explotación de una vulnerabilidad de tipo buffer overflow en la pila…​ Pero eso ya es otra historia.