Como explicaba en la entrada anterior, la memoria está dividida en 5 segmentos. Para aclarar conceptos vamos a ejecutar el siguiente programa (el código debería ser autoexplicativo).
#include <stdio.h> #include <stdlib.h> #include <string.h> int iglobal_1 = 1; int global_1; void fun(int arg_1, int arg_2, int arg_3){ int local_1 =1, local_2=2; // Stack por param printf("arg_1 @ 0x%08x con valor %d [STACK]\n",&arg_1,arg_1); printf("arg_2 @ 0x%08x con valor %d [STACK]\n",&arg_2,arg_2); printf("arg_3 @ 0x%08x con valor %d [STACK]\n\n",&arg_3,arg_3); // Stack local printf("local_1 @ 0x%08x con valor %d [STACK]\n",&local_1,local_1); printf("local_2 @ 0x%08x con valor %d [STACK]\n\n",&local_2,local_2); } int main(char* argv[], int argc){ int local_1; char *hvar = malloc(100*sizeof(char)); strcpy(hvar,"heap-var"); printf("local_1 @ 0x%08x con valor %d [STACK]\n",&local_1,local_1); printf("iglobal_1 @ 0x%08x con valor %d [DATA]\n",&iglobal_1,iglobal_1); printf("global_1 @ 0x%08x con valor %d [BSS]\n\n",&global_1,global_1); printf("hvar @ 0x%08x con valor %s [HEAP]\n\n",hvar,hvar); fun(31,32,33); return 0; }
Lo importante no es tanto el código como el resultado, que permite comprobar el lugar en el que han sido colocadas las variables del programa.
adrian@ubuntu:~/exploiting$ ./a.out local_1 @ 0xbf850adc con valor 3854324 [STACK] iglobal_1 @ 0x0804a01c con valor 21 [DATA] global_1 @ 0x0804a028 con valor 22 [BSS] hvar @ 0x09043008 con valor heap-var [HEAP] arg_1 @ 0xbf850ac0 con valor 31 [STACK] arg_2 @ 0xbf850ac4 con valor 32 [STACK] arg_3 @ 0xbf850ac8 con valor 33 [STACK] local_1 @ 0xbf850aac con valor 1 [STACK] local_2 @ 0xbf850aa8 con valor 2 [STACK]
Observad que el mapa de memoria es el que mostramos en la entrada anterior, los datos en las direcciones más bajas, luego el bss (hay padding de por medio), a continuación las variables del heap y finalmente el stack frame. Fijaos como el stackframe es tal y como comentábamos en la entrada anterior (para 32 bits, en 64 es diferente).
(gdb) x/16wx $sp 0xbffff430: 0x00000000 0x00000000 0xbffff458 0x0028ff80 0xbffff440: 0xbffff488 0x0028ff80 0x00000002 0x00000001 0xbffff450: 0xbffff464 0x00389ff4 0xbffff488 0x080485c5 0xbffff460: 0x0000001f 0x00000020 0x00000021 0xbffff488
Esta muestra de la pila se ha tomado a la entrada de la función fun, tras la inicialización de las variables locales. Fijaos en la dirección de retorno, la 0x080485c5, más arriba está el valor previo del EBP, el 0xbffff488, un poco de padding del compilador y las variables locales con valores 1 y 2. Justo debajo de la dirección de retorno se encuentran los valores que ha recibido como parámetros: 1f (31), 20 (32), 21 (33).
Visto esto, es importante resaltar el tipo de alineación en memoria. El x86 utiliza little endian, lo que quiere decir que el byte menos significativo se coloca en la parte más baja de la memoria. Si queréis podemos ver un ejemplo, usaremos la dirección de retorno, situada en la posición de memoria 0xbffff45c, y la mostraremos como una palabra de 32 bits y luego byte a byte. Como se ve, la palabra está invertida byte a byte.
(gdb) x/wx 0xbffff45c 0xbffff45c: 0x080485c5 (gdb) x/4bx 0xbffff45c 0xbffff45c: 0xc5 0x85 0x04 0x08
Esto quiere decir que si queremos cargar en memoria el valor 0x080485c5, debemos insertarlo como \xc5\x85\x04\x08. Fácil, ¿verdad?.
Otro tema importante cuando hablamos de explotación, es conocer el formato del ejecutable que estamos utilizando. Nos centraremos al principio en Linux, por lo que el formato que nos interesa es el ELF, en las siguientes entradas explicaremos dos clásicos, .plt y .dtors. Cuando hablemos de Windows, el formato que debemos conocer es PE.
Algo importante, tanto para depurar y mostrar direcciones de memoria y valores, como para entender la vulnerabilidad de format strings que explicaremos en futuras entradas (sí, otro clásico, pero esto es un repaso a lo básico, ¿no?), es comprender el funcionamiento de la familia de funciones printf. Veamos qué sucede cuando utilizamos printf de la siguiente manera:
printf("Esto es un número %d\n",20);
(gdb) disas main Dump of assembler code for function main: 0x080483e4 <main+0>: push %ebp 0x080483e5 <main+1>: mov %esp,%ebp 0x080483e7 <main+3>: and $0xfffffff0,%esp 0x080483ea <main+6>: sub $0x10,%esp 0x080483ed <main+9>: mov $0x80484d0,%eax 0x080483f2 <main+14>: movl $0x14,0x4(%esp) 0x080483fa <main+22>: mov %eax,(%esp) 0x080483fd <main+25>: call 0x804831c <printf@plt> 0x08048402 <main+30>: mov $0x0,%eax 0x08048407 <main+35>: leave 0x08048408 <main+36>: ret End of assembler dump. (gdb) x/s 0x80484d0 0x80484d0: "Esto es un número %d\n"
Si os fijáis desde main+9 hasta main+25, el compilador ha colocado en la pila la cadena “Esto es un número %d\n”, y también el valor 20 (0x14). La función printf recorre la cadena de texto y va mostrando por pantalla los caracteres, hasta que encuentra un especificador de formato (%d en este caso), y entonces busca en la pila el valor que debe colocar en esa posición. La siguiente tabla muestra los especificadores de formato soportados.
- especificadores de formato
Merecen especial atención %n, así como %x y -aunque no aparece en la tabla- la posibilidad de imprimir el n-ésimo caracter con el símbolo $.
printf("Este es el tercer valor %3$d\n", 1,2,3,4,5);
Imprimirá el siguiente mensaje “Este es el tercer valor 3”. En las siguientes entradas nos metermos ya en los desbordamientos clásicos de buffer, que seguro que es más interesante y entretenido. Como siempre, las dudas o aportaciones, en los comentarios.
Una cosa, si es little-endian, ¿por qué hemos de cargar 0x080485c5 como \x08\x04\x85\xc5? No sería precisamente al revés. Supongo que lo primero leído irá en la parte más baja de memoria, ¿no?
Otra cosa, 0x14 es 20, no 21 (perdón, comento conforme leo)
Typos corregidos, ¡gracias!