El segmento stack (pila)

El segmento stack, traducido normalmente como 'pila', contiene una estructura sencilla de datos, un área de memoria dinámica implementada como una cola last-in-first-out (último en entrar, primero en salir). Esto quiere decir que los elementos son introducidos en orden inverso al que son recuperados. Se suele poner una analogía con una pila de platos donde los vamos apilando uno encima del otro y luego los recuperamos en orden inverso, empezando desde arriba con el último que pusimos (conviene tener en cuenta que es posible acceder o modificar valores en otras posiciones del stack).

El stack tiene tres funciones principales, que tienen que ver con la llamada a funciones y con la evaluación de expresiones:

  1. El stack proporciona el lugar de almacenamiento para las variables locales declaradas dentro de las funciones (conocidas en C como 'variables automáticas')
  2. El stack almacena la información de estado y retorno cuando se llama a una función, denominado stack frame (marco de la pila de una función)
  3. El stack también se utiliza por la aplicación como zona temporal de almacenamiento, para evaluar algunas expresiones, almacenar resultados parciales, etc. También cuando almacenamos espacio con la función alloca() la memoria proviene del stack

La implementación concreta de la pila varia con el sistema y con la arquitectura del procesador. En este ejemplo voy a tomar como referencia una arquitectura x64 (64 bit) en un sistema GNU/Linux desde la perspectiva de una aplicación en C. En este tipo de entorno (como en la mayoría) la pila crece hacia direcciones de memoria más bajas.

El diseño típico de memoria de un proceso en GNU/Linux x64 sería algo así:

Memory layout 64b

El proceso comienza en la dirección 0x400000. Concretamente la sección Text, que corresponde al código del proceso. Desde la dirección 0x0 a la dirección 0x400000 es un espacio reservado. Esto es simplemente una decisión de implementación: para los ejecutables ELF de 64 bits el punto de entrada de un ejecutable (que no sea PIE) es 0x400000, mientras que para los ejecutables ELF de 32 bits, el punto de entrada es 0x08048000.

Actualmente los ejecutables de 64 bits se compilan por defecto como PIE 'Position Independent Executables' (ELF ET_DYN en lugar de ET_EXEC) que son programas ejecutables binarios hechos enteramente de código independiente de posición. Esto hace que tengan una dirección base de 0x0 y por tanto se puede reubicar aleatoriamente en una dirección base diferente cada vez que se ejecutan (ver: Protección de ejecutables: PIE)

Como podemos ver en la imagen, el stack se encuentra en las direcciones altas de memoria (0x7FFFFFFFFFFF) y tiene un crecimiento descendente, es decir, hacia direcciones de memoria más bajas, al contrario que el heap (recordemos siempre que se trata de direcciones virtuales de memoria).

La dirección de memoria del stack también es aleatoria siempre que tengamos activado ASLR 'Address Space Layout Randomization' al menos con valor 1 (ver Protección de ejecutables: ASLR)

El stack frame o marco de la pila

Vamos a ver un poco más en detalle como funciona el stack cuando utilizamos las llamadas a funciones dentro de nuestra aplicación.

El stack frame o marco de la pila, es la parte del stack dedicada a una función. Este almacena los valores de las variables locales, variables temporales y registros guardados.

stack frames

Para seguir el funcionamiento del stack son importantes algunos registros de la CPU. El primero de ellos es stack pointer rsp (puntero a la pila) que vemos en el esquema anterior. Este registro siempre apunta al final del stack, se dice que apunta a la parte superior del stack, pero tenemos que tener en cuenta que como vimos el stack crece hacia abajo, es decir hacia direcciones de memoria más bajas.

La dirección almacenada en rsp cambia constantemente a medida que los elementos del stack entran y salen, de modo que siempre apunta al último elemento. Muchas instrucciones de CPU actualizan automáticamente el rsp durante su ejecución.

El siguiente registro que vamos a tener en cuenta es rbp, base pointer o frame pointer (puntero base o puntero al marco). Este registro apunta a una ubicación fija dentro del marco de la pila de la función que se está ejecutando actualmente, y proporciona un punto de referencia estable (base) para acceder a argumentos y variables locales. rbp cambia solo cuando comienza o finaliza una llamada de función. Por lo tanto, podemos acceder fácilmente a cada elemento en la pila como un desplazamiento de rbp.

El código de cada función está generalmente encerrado dentro de un prólogo y un epílogo similares en cada función. El Prologo ayuda a inicializar el stack frame de esa función y el epilogo lo finaliza.

Durante la ejecución de la función, el registro rbp (base pointer o frame pointer) permanece sin cambios y apunta al principio del stack frame para esa función. De esta forma es posible apuntar a las variables locales y los argumentos del stack usuando como referencia rbp.

El prologo de una función sería algo como:

push %rbp
mov %rsp, %rbp
sub $0x10,%rsp ; en este caso 0x10 es el total del tamaño de las variables locales para esa función y por tanto para ese stack frame

Se guarda el antiguo rbp en el stack, que luego se recuperará en el epilogo. Entonces se selecciona un nuevo rbp con la posición actual de la cima del stack (al que apunta siempre rsp). Se reserva entonces la memoria para las variables locales restando el valor total de estas a rsp.

En el epilogo de la función tendríamos:

mov %rbp, %rsp
pop %rbp
ret

Al mover la posición actual del stack (rsp) a la dirección inicial del marco del stack (rsp), podemos estar seguros de que toda la memoria asignada en la pila se desasigna. Luego se restaura el anterior registro rbp desde el stack, apuntando rbp al stack frame anterior. Finalmente se extrae la dirección de retorno del stack en rip para regresar a la siguiente línea de código desde donde se llamó a la función.

Vamos a ver como funcionan estos registros con un sencillo programa que se utiliza frecuentemente para mostrar los desbordamientos de buffer en el stack:

#include <string.h> 
#include <stdio.h> 
 
void fun_copy(char *str) { 
    char buffer[16]; 
    strcpy(buffer, str); 
    printf("%s\n", buffer); 
} 
 
int main (int argc, char *argv[]) { 
    if(argc == 2) 
        fun_copy(argv[1]); 
    return 0; 
} 

Para evitar que el compilador utilice el protector de stack conocido como canaries, que añade datos de control en el stack para monitorizar los desbordamientos de buffer, tenemos que compilar el programa con la opción -fno-stack-protector (ver más en: Protección de ejecutables: Canaries)

Ejecutamos la aplicación con el debuger GDB. Le pasamos como parametros 123456:

gdb ./t3fstack
(gdb) set args 123456

Lo primero que tendremos en el stack es el marco de __lib_star_main que es la función que llama a main. También tendremos el marco de main que se crea en las primeras líneas del código:

(gdb) disassemble main
Dump of assembler code for function main:
0x00005555555546b8 <+0>: push %rbp
0x00005555555546b9 <+1>: mov %rsp,%rbp
0x00005555555546bc <+4>: sub $0x10,%rsp
0x00005555555546c0 <+8>: mov %edi,-0x4(%rbp)
0x00005555555546c3 <+11>: mov %rsi,-0x10(%rbp)
0x00005555555546c7 <+15>: cmpl $0x2,-0x4(%rbp)
0x00005555555546cb <+19>: jne 0x5555555546e0 <main+40>
0x00005555555546cd <+21>: mov -0x10(%rbp),%rax
0x00005555555546d1 <+25>: add $0x8,%rax
0x00005555555546d5 <+29>: mov (%rax),%rax
0x00005555555546d8 <+32>: mov %rax,%rdi
=> 0x00005555555546db <+35>: callq 0x55555555468a <fun_copy>
0x00005555555546e0 <+40>: mov $0x0,%eax
0x00005555555546e5 <+45>: leaveq
0x00005555555546e6 <+46>: retq
End of assembler dump.

Vamos revisar el stack cuando llamamos a la función fun_copy() en 'callq 0x55555555468a <fun_copy>'. Vamos a ver el puntero del stack justo después de llamar a la función:

(gdb) p $rsp
$1 = (void *) 0x7fffffffdd48

y ahora el contenido de esa dirección:

(gdb) x/1xg 0x7fffffffdd48
0x7fffffffdd48: 0x00005555555546e0

Lo que hace callq es guardar en la pila la dirección de retorno de main donde debe continuar la aplicación cuando finalice la rutina fun_copy(). Como vemos la dirección que ha guardado es 0x00005555555546e0 que es justo la dirección siguiente a la llamada callq.

Vamos a ver ahora el código de la función fun_copy():

Dump of assembler code for function fun_copy:
=> 0x000055555555468a <+0>: push %rbp
0x000055555555468b <+1>: mov %rsp,%rbp
0x000055555555468e <+4>: sub $0x20,%rsp
0x0000555555554692 <+8>: mov %rdi,-0x18(%rbp)
0x0000555555554696 <+12>: mov -0x18(%rbp),%rdx
0x000055555555469a <+16>: lea -0x10(%rbp),%rax
0x000055555555469e <+20>: mov %rdx,%rsi
0x00005555555546a1 <+23>: mov %rax,%rdi
0x00005555555546a4 <+26>: callq 0x555555554550 <strcpy@plt>
0x00005555555546a9 <+31>: lea -0x10(%rbp),%rax
0x00005555555546ad <+35>: mov %rax,%rdi
0x00005555555546b0 <+38>: callq 0x555555554560 <puts@plt>
0x00005555555546b5 <+43>: nop
0x00005555555546b6 <+44>: leaveq
0x00005555555546b7 <+45>: retq
End of assembler dump.

push %rbp : Lo primero guarda en el stack el registro del anterior rbp, es decir el comienzo del marco de main en este caso:

1: $rsp = (void *) 0x7fffffffdd40
2: $rbp = (void *) 0x7fffffffdd60

mov %rsp,%rbp : Copia rsp a rbp:

1: $rsp = (void *) 0x7fffffffdd40
2: $rbp = (void *) 0x7fffffffdd40

sub $0x20,%rsp : añade espacio en el stack para las variables locales: 8 bytes para el registro rbp que ya hemos guardado, 8 bytes para el puntero str y 16 bytes para el buffer = 32 bytes (0x20 en hexadecimal)

1: $rsp = (void *) 0x7fffffffdd20
2: $rbp = (void *) 0x7fffffffdd40

mov %rdi,-0x18(%rbp) : Guardamos el valor que pasamos a la función, el puntero str, en el stack (el primer argumento de una función reside en el registro %rdi). Como vemos la dirección donde copiamos es rbp (que usamos como puntero base) menos 0x18.

Ahora la aplicación se prepara para llamar a strcpy. Esta función necesita dos argumentos: el origen y el destino de la copia en los registros %rdi y %rsi respectivamente. Primero copia el origen que es el puntero str a rdx: mov -0x18(%rbp),%rdx. Luego el destino que es buffer a rax: lea -0x10(%rbp),%rax. En ambos casos utiliza nuevamente el puntero rbp como base. Finalmente pasa estos valors a rdi y rsi: mov %rdx,%rsi y mov %rax,%rdi antes de llamar a la función: callq 0x555555554550 <strcpy@plt>.

Al regreso de strcpy, si todo fue correctamente tendremos copiado el valor de str '123456' en buffer ( rbp - 0x10 ).

Luego se realiza la llamada a printf de forma similar, poniendo la dirección del buffer en %rdi como primer argumento y llamando a la función.

La función esta preparada para regresar, así que utiliza leaveq para restaurar los valores de main para %rsp y %rbp. Esto es exactamente equivalente a primero copiar rbp a rsp (mov %rbp, %rsp):

1: $rsp = (void *) 0x7fffffffdd40
2: $rbp = (void *) 0x7fffffffdd40

Luego extraer del stack el anterior rbp (pop %rbp):

1: $rsp = (void *) 0x7fffffffdd48
2: $rbp = (void *) 0x7fffffffdd60

Para regresar, con retq saca la dirección de retorno del stack haciendo pop en el registro rip (instruction pointer) y salta a ella para volver a main.

Modificado por última vez enViernes, 14 Agosto 2020 19:31
(1 Voto)
Etiquetado como :

1 comentario

Deja un comentario

Asegúrese de introducir toda la información requerida, indicada por un asterisco (*). No se permite código HTML.