La pila y las llamadas a funciones

Me sorprende la frecuencia con la que cada día aparecen nuevas vulnerabilidades en software hecho por buenos programadores. Conste que nos puede pasar a cualquiera (el que esté libre de pecado que tire la primera piedra), así que me gustaría dedicar un par -o tres, ya veremos- de artículos a tratar de concienciar (y concienciarme) sobre la programación de código seguro. Antes de entrar en harina voy a dedicar este artículo a aclarar cómo funciona la llamada a una función y el papel que juega la pila en todo esto. El ejemplo que voy a mostrar están desarrollados sobre arquitectura x86 y GNU/Linux.



Recordemos que la pila, en la arquitectura x86, crece hacia las direcciones bajas de la memoria, o lo que es lo mismo, cada vez que almacenamos un dato en la pila con push, el puntero de pila decrece y en cambio crece cuando sacamos un dato con pop.
La dirección base, que indica desde donde empieza a crecer, se almacena en el registro %ebp (extended base pointer) y el tope de la pila que crece y decrece cada vez que introducimos o sacamos un dato, se almacena en el registro %esp (extended stack pointer). En cada momento, la porción de memoria que se encuentra entre estos dos registros, más la dirección de retorno y la porción que se encuentra por encima (debajo en la imagen) de %ebp y que contiene los parámetros de llamada a la función, se llama stack frame.
Como veremos, este frame es dinámico y cambia de lugar y tamaño dentro de la pila según se va ejecutando la aplicación.




Cuando desde un programa en C se hace una llamada a una función, antes de ceder el control a ésta, debe asegurarse de dos cosas. Que la función a la que se llama recibe los parámetros de forma que sea capaz de recuperarlos e interpretarlos correctamente y, no menos importante, que cuando termine la ejecución de la función, se retorne la ejecución al punto desde donde se hizo la llamada.
Para analizar cómo se las apaña el programa para hacer esto partiremos de un sencillo e inocente código como el siguiente.


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

void saluda(char * texto, int edad) {
  char nombre[10];
  int dias;
  
  strcpy(nombre, texto);
  dias=edad*365;
  printf("Hola %s, tienes %d días\n", nombre, dias );  
}


int main(int argc, char *argv) {
  saluda("Alberto", 30);
  
  return 0;
}


Realizamos la compilación con los siguientes parámetros para que el ejecutable almacene información de depuración, y usamos -fno-stack-protector para que no incluya código extra para protección de la pila y el código ensamblador sea más claro.
Seguidamente, ejecutamos gdb para comenzar una sesión de depuración.


gcc -ggdb -fno-stack-protector -o saludo saludo.c
gdb -q saludo


Desde dentro de gdb podemos usar el comando disassemble para ver el código ensamblador de la función main() tal y como se ve en la siguiente captura.

Desensamblado función main

Vamos a fijarnos en las dos líneas que hay antes de la instrucción call:


0x080484d0 <+9>: movl   $0x1e,0x4(%esp)
   0x080484d8 <+17>: movl   $0x80485da,(%esp)


En %esp+4 se almacena el valor 0x1E (30 en decimal) que se corresponde con el segundo parámetro de la función. Seguidamente, se almacena en %esp+0 el puntero a la cadena de texto correspondiente al primer parámetro de la función (que como se ve en la captura corresponde a la dirección 0x80485da que contiene la cadena "Alberto"). Nótese como usa movl en lugar de push para guardar los valores en la pila.
Teniendo en cuenta que la pila crece hacia las direcciones bajas de memoria, concluimos que los parámetros se guardan en orden inverso al que tienen en la lista de parámetros de la función. Además, entre bambalinas, la instrucción call se encarga de guardar el registro %eip (puntero de instrucción que apunta a la siguiente instrucción que el microprocesador debe ejecutar) en la pila para que la función sepa a dónde ha de regresar cuando termine. Hecho esto, la instrucción call pone la dirección donde comienza la función a la que vamos a llamar en %eip para que el procesador la ejecute.
Seguidamente, vamos a analizar el código de la función saluda().

Desensamblado función saluda

En esta ocasión vamos a fijarnos en las tres primeras instrucciones.


0x08048464 <+0>: push   %ebp
   0x08048465 <+1>: mov    %esp,%ebp
   0x08048467 <+3>: sub    $0x28,%esp


Estas tres instrucciones son siempre las tres primeras en cualquier función, es por ello que reciben el nombre genérico de prólogo.
La primera instrucción guarda %ebp (puntero a la base de la pila) en la propia pila.Seguidamente guarda el registro %esp en %ebp para establecer un nuevo stack frame. Finalmente resta, en este caso, 0x28 a %esp para hacer espacio para las variables locales de la función.

¿Cómo se accede a los parámetros almacenados en la llamada y a las variables locales? Analicemos el siguiente fragmento de código encargado de hacer la llamada a strcpy().


0x0804841a <+6>: mov    0x8(%ebp),%eax
   0x0804841d <+9>: mov    %eax,0x4(%esp)
   0x08048421 <+13>: lea    -0x16(%ebp),%eax
   0x08048424 <+16>: mov    %eax,(%esp)
   0x08048427 <+19>: call   0x8048330 


La primera instrucción accede al primer parámetro de la función (char *texto) que se encuentra en %ebp+8. Es decir, fuera del stack frame actual. Se sabe que está 8 bytes por debajo de %ebp porque hay que sumar los 4 bytes del del registro %eip que guardó la instrucción call más los 4 bytes del propio parámetro que se almacenó en la pila.
Como era de esperar, según lo que ya hemos visto, este valor se vuelve a almacenar en %esp+4 ya que va a ser pasado como parámetro a la función strcpy().
En la tercera línea se accede a %ebp-0x16 para obtener la dirección efectiva de la variable local nombre. Es interesante ver cómo se usa un offset negativo respecto a %ebp, ya que la variable está justo por encima de %ebp (recordemos que se había hecho espacio para estas variables en la pila usando la instrucción sub $0x28,%esp).
De nuevo, este valor se almacena en la pila en %esp+0 para preparar la llamada a strcpy().

De este modo se continúa con la ejecución normal de la función hasta que llega al final, donde se encuentra las dos siguientes instrucciones:


0x080484c5 <+97>: leave  
   0x080484c6 <+98>: ret 


Toda función termina con estas dos instrucciones, por lo que reciben el nombre de epílogo. El comando leave limpia el stack frame incrementando %esp hasta %ebp. Finalmente ret saca %eip de la pila devolviendo la ejecución al código que realizó la llamada a la función.

Este es, grosso modo, el funcionamiento de la pila durante una llamada a una función. ¿Que por qué os cuento todo esto? Ahí va una pista:
¿Qué ocurriría si como primer parámetro de la función asigno una cadena con más de 10 caracteres? Seguramente muchos de vosotros pensaréis que bajo esta situación tenemos un problema que podría hacer que nuestra aplicación se colgara, pero os aseguro que lo mejor que nos puede pasar es que se cuelgue, porque... en fin, otro día os lo cuento.

No hay comentarios:

Publicar un comentario