Nota: Este post es mi traducción de "How to look at the stack with gdb", de Julia Evans. Todos los créditos a ella por el tremendo laburo que se manda todo el tiempo.
Ayer, mientras hablaba con una persona, me mencionó que no entendía cómo funcionaba realmente el stack (la pila) o cómo inspeccionarlo.
Así que acá hay un paso a paso sencillo sobre cómo usar gdb
para mirar el stack de un programa C. Creo que sería similar para un programa Rust, pero voy a usar C porque me resulta un poco más sencillo para un ejemplo de juguete, y porque además es más fácil hacer Cosas Terribles™ en C.
Acá hay un programa sencillo en C que declara algunas variables y lee dos strings de la entrada estándar. Uno de esos strings está en el heap, y el otro en el stack.
#include <stdio.h>
#include <stdlib.h>
int main() {
char stack_string[10] = "stack";
int x = 10;
char *heap_string;
heap_string = malloc(50);
printf("Enter a string for the stack: ");
gets(stack_string);
printf("Enter a string for the heap: ");
gets(heap_string);
printf("Stack string is: %s\n", stack_string);
printf("Heap string is: %s\n", heap_string);
printf("x is: %d\n", x);
}
Este programa usa la tremendamente insegura función gets
, que nunca jamás deberías usar, pero eso es a propósito - aprendemos más cuando las cosas fallan.
Podemos compilarlo con gcc -g -O0 test.c -o test
.
El flag -g
compila el programa con los símbolos de debug, que van a ayudar a inspeccionar nuestras variables.
-O0
le dice a gcc que apague todas las optimizaciones, cosa que hice para asegurarme de que no elimine a nuestra variable x
al optimizar.
Podemos arrancar gdb así:
$ gdb ./test
Imprime algunas cosas sobre la GPL, y después nos da un prompt. Creemos un breakpoint en la función main
.
(gdb) b main
Breakpoint 1 at 0x1171: file test.c, line 4.
Ahora podemos ejecutar el programa:
(gdb) run
Starting program: /home/bork/work/homepage/test
Breakpoint 1, main () at test.c:4
4 int main() {
¡Genial! El programa está ejecutando, y podemos empezar a inspeccionar su stack.
Empecemos aprendiendo sobre nuestras variables. Cada una de ellas tiene una dirección en la memoria, que podemos imprimir así:
(gdb) p &x
$3 = (int *) 0x7fffffffe27c
(gdb) p &heap_string
$2 = (char **) 0x7fffffffe280
(gdb) p &stack_string
$4 = (char (*)[10]) 0x7fffffffe28e
Así que si miramos el stack en esas direcciones, ¡deberíamos podemos ver todas esas variables!
Vamos a necesitar usar el puntero del stack, así que voy a tratar de explicarlo bien rápido.
En x86 hay un registro llamado ESP al que llamamos "stack pointer". Básicamente, es la dirección del inicio del stack de la función actual. En gdb lo podés acceder con $sp
. Cuando llamás a una nueva función o retornás de una función, el valor del stack pointer cambia.
Primero, miremos al stack al comienzo de la función main
. Este es el valor de nuestro stack pointer en este momento:
(gdb) p $sp
$7 = (void *) 0x7fffffffe270
Así que el stack de nuestra función actual comienza en 0x7fffffffe270
. Piola.
Ahora usemos gdb para imprimir las primeras 40 palabras (es decir, 160 bytes) de memoria después del comienzo del stack de nuestra función actual. Es posible que parte de esta memoria no sea parte del stack porque no estoy totalmente segura de cuán grande sea el stack acá. Pero al menos el principio es parte de nuestro stack.
(gdb) x/40x $sp 0x7fffffffe270: 0x00000000 0x00000000 0x55555250 0x00005555 0x7fffffffe280: 0x00000000 0x00000000 0x55555070 0x00005555 0x7fffffffe290: 0xffffe390 0x00007fff 0x00000000 0x00000000 0x7fffffffe2a0: 0x00000000 0x00000000 0xf7df4b25 0x00007fff 0x7fffffffe2b0: 0xffffe398 0x00007fff 0xf7fca000 0x00000001 0x7fffffffe2c0: 0x55555169 0x00005555 0xffffe6f9 0x00007fff 0x7fffffffe2d0: 0x55555250 0x00005555 0x3cae816d 0x8acc2837 0x7fffffffe2e0: 0x55555070 0x00005555 0x00000000 0x00000000 0x7fffffffe2f0: 0x00000000 0x00000000 0x00000000 0x00000000 0x7fffffffe300: 0xf9ce816d 0x7533d7c8 0xa91a816d 0x7533c789
Remarqué aproximadamente dónde están las variables stack_string
, heap_string
y x
, y las pinté de colores:
x
es roja y arranca en0x7fffffffe27c
heap_string
es azul y arranca en0x7fffffffe280
stack_string
es violeta y arranca en0x7fffffffe28e
Puede que le haya errado un poquito en algunas de las marcas de esas variables, pero están más o menos por ahí.
Una cosa extraña que podés notar acá es que x
es el número 0x5555
, ¡pero en nuestro código asignamos x
a 10! Esto es porque la asignación de x
no se hace realmente hasta después de que arranquemos nuestra función main
, y ahora estamos recién al comienzo de main
.
Salteemos algunas líneas y esperemos a haber inicializado nuestras variables con los valores que les asignamos. Para cuando estemos en la línea 10, x
ya debería valer 10
.
Primero, necesitamos poner otro breakpoint:
(gdb) b test.c:10
Breakpoint 2 at 0x5555555551a9: file test.c, line 11.
Y continuar la ejecución del programa:
(gdb) continue
Continuing.
Breakpoint 2, main () at test.c:11
11 printf("Enter a string for the stack: ");
¡Bien! Volvamos a mirar esas mismas cosas. gdb
formatea los bytes de manera ligeramente distinta acá, aunque no sé muy bien por qué. Acá va un recordatorio de dónde estaban nuestras variables en el stack:
x
es roja y arranca en0x7fffffffe27c
heap_string
es azul y arranca en0x7fffffffe280
stack_string
es violeta y arranca en0x7fffffffe28e
(gdb) x/80x $sp 0x7fffffffe270: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe278: 0x50 0x52 0x55 0x55 0x0a 0x00 0x00 0x00 0x7fffffffe280: 0xa0 0x92 0x55 0x55 0x55 0x55 0x00 0x00 0x7fffffffe288: 0x70 0x50 0x55 0x55 0x55 0x55 0x73 0x74 0x7fffffffe290: 0x61 0x63 0x6b 0x00 0x00 0x00 0x00 0x00 0x7fffffffe298: 0x00 0x80 0xf7 0x8a 0x8a 0xbb 0x58 0xb6 0x7fffffffe2a0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe2a8: 0x25 0x4b 0xdf 0xf7 0xff 0x7f 0x00 0x00 0x7fffffffe2b0: 0x98 0xe3 0xff 0xff 0xff 0x7f 0x00 0x00 0x7fffffffe2b8: 0x00 0xa0 0xfc 0xf7 0x01 0x00 0x00 0x00
Acá hay un par de cosas interesantes para charlar antes de seguir con el programa.
Ahora mismo (en la línea 10) stack_string
tiene asignado "stack". Miremos cómo se representa eso en la memoria.
Podemos imprimir los bytes en el string de esta manera:
(gdb) x/10x stack_string
0x7fffffffe28e: 0x73 0x74 0x61 0x63 0x6b 0x00 0x00 0x00
0x7fffffffe296: 0x00 0x00
El string "stack" son 5 caracteres, que corresponde a los 5 bytes ASCII - 0x73
, 0x74
, 0x61
, 0x63
y 0x6b
. 0x73
es s
en ASCII, 0x74
es t
, etc.
También podemos pedirle a gdb que nos muestre el string con x/1s
:
(gdb) x/1s stack_string
0x7fffffffe28e: "stack"
Notarás que stack_string
y heap_string
se representan de maneras muy distintas en el stack:
stack_string
tiene el contenido del string ("stack")heap_string
es un puntero a una dirección en algún otro lado de la memoria
Acá están los bytes del stack para la variable heap_string
:
0xa0 0x92 0x55 0x55 0x55 0x55 0x00 0x00
Esos bytes en realidad hay que leerlos de atrás para adelante, porque x86 es little-endian, por lo que la dirección de memoria de heap_string
es 0x5555555592a0
.
Otra manera de ver la dirección de heap_string
en gdb es simplemente imprimirla con p
:
(gdb) p heap_string
$6 = 0x5555555592a0 ""
x
es un entero de 32 bits, y los bytes que la representan son 0x0a 0x00 0x00 0x00
.
Otra vez necesitamos leer esos bytes al revés (por el mismo motivo que leímos los bytes de heap_string
en orden inverso), así que esto corresponde al número 0x000000000a
, o 0xa
, que es 10.
¡Tiene sentido! ¡Asignamos int x = 10;
!
Bueno, ya inicializamos las variables, ahora veamos cómo cambia el stack cuando ejecuta esta parte del programa C:
printf("Enter a string for the stack: ");
gets(stack_string);
printf("Enter a string for the heap: ");
gets(heap_string);
Necesitamos poner otro breakpoint:
(gdb) b test.c:16
Breakpoint 3 at 0x555555555205: file test.c, line 16.
Y continuar la ejecución del programa:
(gdb) continue
Continuing.
Pedimos 2 strings, y yo ingresé 123456789012
para el string del stack, y bananas
para el del heap.
(gdb) x/1s stack_string
0x7fffffffe28e: "123456789012"
Parece bastante normal, ¿no? Ingresamos 123456789012
y ahora vale 123456789012
.
Pero hay algo raro con esto. Así es como se ven esos bytes en el stack. Otra vez, están remarcados en violeta.
0x7fffffffe270: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe278: 0x50 0x52 0x55 0x55 0x0a 0x00 0x00 0x00 0x7fffffffe280: 0xa0 0x92 0x55 0x55 0x55 0x55 0x00 0x00 0x7fffffffe288: 0x70 0x50 0x55 0x55 0x55 0x55 0x31 0x32 0x7fffffffe290: 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x30 0x7fffffffe298: 0x31 0x32 0x00 0x8a 0x8a 0xbb 0x58 0xb6 0x7fffffffe2a0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe2a8: 0x25 0x4b 0xdf 0xf7 0xff 0x7f 0x00 0x00 0x7fffffffe2b0: 0x98 0xe3 0xff 0xff 0xff 0x7f 0x00 0x00 0x7fffffffe2b8: 0x00 0xa0 0xfc 0xf7 0x01 0x00 0x00 0x00
Lo raro de esto es que se supone que stack_string sólo tenía 10 bytes. ¿Pero ahora le metimos 13 bytes de repente? ¿Qué está pasando?
Esto es un buffer overflow (desborde de buffer) clásico, y lo que está ocurriendo es que stack_string
escribió sobre otros datos del programa. Esto aún no causó ningún problema en nuestro caso, pero puede crashear tu programa, o, pero, exponerte a Problemas de Seguridad Muy Malos™.
Por ejemplo, si stack_string
hubiera estado justo antes que heap_string
en la memoria, podríamos haber sobreescrito la dirección a la que apunta heap_string
. No sé muy bien qué hay en la memoria después de stack_string
en este caso, pero podríamos usar esto para generar problemas.
Cuando genero este buffer overflow, así:
./test
Enter a string for the stack: 01234567891324143
Enter a string for the heap: adsf
Stack string is: 01234567891324143
Heap string is: adsf
x is: 10
*** stack smashing detected ***: terminated
fish: Job 1, './test' terminated by signal SIGABRT (Abort)
Estimo que lo que está sucediendo es que la variable stack_string
está al final del stack de esta función, y entonces esos bytes van a otra región de memoria diferente.
Cuando hacés esto intencionalmente como un ataque de vulnerabilidades, se lo llama "stack smashing" (romper el stack o algo así), y de algún modo algo está detectando que esto está ocurriendo. Al principio no tenía idea de cómo estaba siendo detectado, pero algunas personas me escribieron para decirme que es un feature del compilador llamado "stack protection". Básicamente, agrega un valor "fusible" al final del stack, y, cuando una función retorna, chequea que ese valor no haya cambiado. Acá hay un artículo sobre el stack smashing protector en la wiki de OSDev (en inglés).
Eso es todo lo que tengo para decir de los buffer overflows.
También leímos un valor (bananas
) a la variable heap_string
. Miremos cómo se ve eso en la memoria.
Así es como se ve heap_string
en el stack después de haber leído en esa variable.
(gdb) x/40x $sp 0x7fffffffe270: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe278: 0x50 0x52 0x55 0x55 0x0a 0x00 0x00 0x00 0x7fffffffe280: 0xa0 0x92 0x55 0x55 0x55 0x55 0x00 0x00 0x7fffffffe288: 0x70 0x50 0x55 0x55 0x55 0x55 0x31 0x32 0x7fffffffe290: 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x30
Lo interesante acá es que ¡sigue viéndose exactamente igual! Es una dirección, y esa dirección no cambió. Pero miremos qué hay en esa dirección.
(gdb) x/10x 0x5555555592a0
0x5555555592a0: 0x62 0x61 0x6e 0x61 0x6e 0x61 0x73 0x00
0x5555555592a8: 0x00 0x00
¡Esos son los bytes de bananas
! Esos bytes no están en el stack, si no en otra parte de la memoria (en el heap).
Hablamos un montón sobre cómo el stack y el heap son regiones de memoria diferentes, pero ¿cómo saber en qué parte de la memoria están?
Hay un archivo para cada porceso llamado /proc/$PID/maps
que te muestra los mapeos de memoria para cada proceso. Acá es donde podés encontrar al stack y al heap.
$ cat /proc/24963/maps
... lots of stuff omitted ...
555555559000-55555557a000 rw-p 00000000 00:00 0 [heap]
... lots of stuff omitted ...
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
Algo a notar es que acá la dirección del heap comienza con 0x5555
y las direcciones del stack comienzan con 0x7fffff
. Así que es bastante sencillo distinguir entre una dirección en el stack y una en el heap.
Esto fue una introducción fugaz, y no expliqué todo, pero con algo de suerte ver cómo se ven los datos en memoria aclara un poco qué es realmente el stack.
Realmente recomiendo jugar con gdb de esta manera - incluso si no entendés cada cosa que ves en la memoria, ver los datos reales en la memoria de mi programa hace que me cueste muchísimo menos entender estos conceptos abstractos como "el stack", "el heap" o "los punteros".
Algunas personas me sugirieron que lldb es más sencillo de usar que gdb. Aún no lo probé, pero le pegué un vistazo y, sí, parece que puede ser más sencillo. Por lo que ví rápidamente, todo lo que hicimos en esta guía también funciona en lldb, excepto que tenés que hacer p/s
en lugar de p/1s
.
Algunas ideas (sin ningún orden en particular) de ejercicios para seguir trabajando sobre el stack:
-
Intentá agregar otra función a
test.c
y poné un breakpoint al inicio de esa función a ver si podés encontrar el stack demain
. Dicen que "el stack crece hacia abajo" cuando llamás a una función, ¿podés ver eso con gdb? -
Hacé una función que devuelva un puntero a un string en el stack a ver qué problemas trae. ¿Por qué está mal devolver un puntero a un string en el stack?
-
Tratá de generar un stack overflow en C y tratá de entender, mirando con gdb, qué es lo que está pasando exactamente cuando se desborda el stack.
-
¡Mirá el stack de un programa Rust y tratá de encontrar las variables!
-
Probá algunos de los desafíos de buffer overflow del nightmare course. El README de cada desafío tiene la solución, así que evitá leerlo si querés evitarte spoilers. La idea con todos esos desafíos es que te dan un binario y tenés que encontrar cómo causarle un buffer overflow para que imprima el string "flag".