Ayer hablábamos sobre la CPU y los registros de propósito general para entender como procesa a bajo nivel el hardware de nuestro sistema el código compilado al interpretar los opcodes que generan los compiladores.
Hoy vamos a ver muy brevemente como funciona la memoria y más específicamente la pila en la arquitectura x86. De esta forma todos estos conceptos no nos serán arcanos cuando entremos en detalles de ¿cómo funciona un depurador de C/C++?.
La pila es una estructura que es necesario e importante comprender cuando se desarrolla un depurador. La pila almacena información acerca de como una función es invocada, los parámetros que recibe (aunque en la arquitectura x86_64 esto no es del todo cierto) y como y a donde debe volver cuando finaliza la ejecución de la misma.
Estructura de la pila
La pila es una estructura FILO (First In, Last Out) donde los argumentos son apilados en la cima de la misma cuando se invoca una función y retirados cuando la función finaliza. El registro ESP se usa para seguir la pista a la parte más alta del marco de la pila y el registro EBP para seguirle la pista a la parte baja (aunque ya vimos que ciertos compiladores pueden decidir no usar ebp para eso).
La pila crece desde direcciones altas de memoria hacia direcciones más bajas. Por ejemplo esta llamada en C:
int metodo_sin_importancia(no_importa_1, no_importa_2, no_importa_3);
Se traduciría al siguiente código ensamblador:
push no_importa_3 push no_importa_2 push no_importa_1 call metodo_sin_importancia
Y la pila tendría este aspecto:
Esta es una estructura de datos sencilla y es la base para todas las llamadas a funciones en x86 de 32 bits dentro de un binario. Para x86_64 esto no es exactamente así ya que se utilizan varios registros para almacenar los primeros cuatro (o seis depende de si estamos en POSIX o Microsoft) siempre que sean enteros o punteros o en otros cuatro (o seis) en caso de que sean parámetros de punto flotante, a partir de ese número de parámetros, los siguientes pasarían a la pila de esta misma manera.
Pero como dijimos en el primer artículo, vamos a mantener el asunto lo más simple posible y vamos a dar por hecho que estamos en una arquitectura de 32 bits.
Cuando la función metodo_sin_importancia
retorna, saca todos los parámetros de la pila y salta a la dirección de retorno para continuar con la ejecución de la función que la invocó y continuar de esta manera con la ejecución de la aplicación.
Otra cosa a tener en cuenta de la pila es que las variables locales se almacenan en ella durante la ejecución de la aplicación y son válidas solo en el contexto de las funciones a las que pertenecen. Las variables locales se introducen en la parte alta de la pila encima de la dirección de retorno.
Esto último da pie a la exposición a una vulnerabilidad muy común en las aplicaciones programadas en C y C++ conocida como desbordamiento de buffer que consiste en aprovechar que la aplicación no comprueba el tamaño de datos introducidos por el usuario antes de empujarlos a la pila y al desbordar una variable somos capaces de sobreescribir la dirección de retorno de una función y hacer que apunte a otra parte y así ejecutar código arbitrario.
Conclusión
La habilidad de capturar el marco de pila en un depurador es extremadamente útil para depurar funciones, capturar la pila en un crash y seguir la pista de desbordamientos de buffer. Comprender su funcionamiento es importante para abordar el siguiente artículo que tratará sobre los eventos de depurado.
Más información Pila (estructura de datos) (Inglés)