Skip to content

Instantly share code, notes, and snippets.

@mgarciaisaia
Created May 25, 2021 21:17
Show Gist options
  • Save mgarciaisaia/58ae74569deb5a5bc6d69c4141b148e4 to your computer and use it in GitHub Desktop.
Save mgarciaisaia/58ae74569deb5a5bc6d69c4141b148e4 to your computer and use it in GitHub Desktop.
Cómo inspectionar el stack con gdb

Cómo inspectionar el stack con gdb

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.

Nuestro programa de prueba

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.

Paso 0: Compilar el programa

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.

Paso 1: Arrancar gdb

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.

Paso 2: Mirar las direcciones de nuestras variables

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!

Concepto: el stack pointer (puntero del stack)

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.

Paso 3: Mirar nuestras variables en el stack al comienzo de main

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 en 0x7fffffffe27c
  • heap_string es azul y arranca en 0x7fffffffe280
  • stack_string es violeta y arranca en 0x7fffffffe28e

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.

Paso 3: Volver a mirar el stack en la línea 10

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 en 0x7fffffffe27c
  • heap_string es azul y arranca en 0x7fffffffe280
  • stack_string es violeta y arranca en 0x7fffffffe28e
(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.

Cómo se representa stack_string en la memoria

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"

Cómo heap_string y stack_string son diferentes

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 ""

Los bytes que representan el entero x

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;!

Paso 4: Leer de la entrada estándar

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.

Volvamos a mirar stack_string primero (¡hay un buffer overflow!)

(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.

Algo efectivamente detecta el buffer overflow

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.

Ahora miremos a heap_string

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).

¿Dónde están el stack y 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.

Jugar con gdb de esta manera es muy útil

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".

Quizá lldb sea más sencillo de usar

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.

Ideas para más ejercicios

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 de main. 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".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment