De camino al Buffer Overflow (I)

El Karma siempre estará de nuestro lado si conocemos las entrañas de nuestro sistema operativo y aún más, cuando queramos averiguar lo que le está sucediendo a un programa o conocer aquello a lo que está expuesto. En esta secuencia de posts se intentará dar un conocimiento avanzado sobre el entorno de ejecución. Sin más, espero que os guste y gracias por leerlo.

Cuando se inicia la ejecución de un programa, el sistema operativo carga el código ejecutable en una zona de memoria no ocupada y reserva dos espacios más de memoria que serán utilizados durante su ejecución, en concreto, como pila de ejecución y como área de datos dinámicos (en Inglés, heap).

La pila es encargada de contener los parámetros de las funciones, sus variables locales y sus valores de retorno, mientras que el heap almacena aquellos datos que se han solicitado mediante una instrucción de reserva de memoria, como por ejemplo, utilizando malloc en C o new en C++. Pues bien, una vez el código esté copiado a la memoria, sola será necesario que el sistema operativo indique a la CPU que tiene que apuntar con el registro puntero de instrucciones (conocido como EIP, del Inglés Extended Instruction Pointer) a la primera posición de éste.

Fuente: sevillasecandbeer.org
Fuente: sevillasecandbeer.org

Además, en aquellos lenguajes que permiten la creación de variables globales o de variables estáticas, como son C y C++, se reserva también una tercera zona que se llama zona de datos estáticos. Esta zona se subdivide en dos apartados, los cuales se diferencian en función de si los datos que contienen se encuentran inicializados o no antes del inicio de la ejecución de la función principal main. Las dos zonas ocupan un tamaño fijo de memoria durante toda la ejecución del programa, de la misma forma que lo hace la zona de código, debido a que el espacio requerido es posible determinarlo en tiempo de compilación y no variará en tiempo de ejecución.

Este hecho sucede porque el compilador, cuando termina el proceso de compilación, conoce todas las variables globales y estáticas y su tipo, y por lo tanto, sabe perfectamente la cantidad de memoria que hay que reservar. De esta forma, escribe en el ejecutable las instrucciones necesarias para reservar dicha memoria, que será exclusivamente la justa y necesaria, y también aquellas instrucciones para establecer los valores iniciales si es el caso.

Uno de los motivos por los cuales se define un área de datos estáticos es porque las variables globales pueden ser utilizadas en cualquier momento y por lo tanto, tienen que ser accesibles des del principio hasta el final de la ejecución, a diferencia de las variables que son definidas dentro de las funciones. Con el mismo criterio, algunos compiladores de lenguajes orientados a objetos almacenan también en esta zona las variables estáticas de clases, que son únicas para todas las instancias de una clase.

Bien, ¿qué lío, no? No os preocupéis, a continuación vamos a ver una imagen representativa de todo lo descrito hasta al momento y que nos ayudará a la comprensión del texto:

Estructura memoria

Un aspecto relevante, el cual se aprecia en la imagen, es que la pila crece hacia las posiciones más bajas de la memoria. Lo veremos en profundidad más adelante.

Como se ha indicado al inicio, el heap es el área de memoria que se utiliza para conceder asignaciones de bloques de bits. Cuando en una función o método encontramos una instrucción que realiza esta petición, el sistema operativo reserva la memoria que hemos solicitado en una zona libre del heap, la cual marcará como asignada y nos devolverá la dirección de memoria de la primera posición de los bytes reservados mediante un puntero (o una referencia, en función del lenguaje de programación).

En el momento que ya no necesitamos la memoria que hemos reservado dinámicamente, es conveniente informar al sistema operativo para que sea liberada y pueda ser asignada en una nueva petición. Este proceso se realiza con instrucciones como free en C o delete en C++, en el cual se utiliza el mismo puntero que el sistema operativo nos devolvió inicialmente en la petición de reserva.

En los lenguajes más modernos, como Java, C# o C++11, existe un mecanismo llamado recolector de basura (en Inglés, garbage collector) que consiste en un proceso que se ejecuta en segundo plano y que va buscando bloques de memoria que no se encuentren referenciados en un instante dado, es decir, que no exista en el programa un puntero o referencia que apunte a la dirección del bloque. Si este es el caso, el bloque es marcado como libre y queda disponible para una nueva petición. Este mecanismo es muy cuestionado por los aspectos relacionados con el rendimiento.

Otro dato muy importante, es que en algunos casos el sistema operativo no prepara un área de heap por cada aplicación, sino que él mismo gestiona un heap global para que sea usado por todas las aplicaciones que se encuentren en ejecución.

Sin más, vamos al lío con la pila de ejecución. Como se ha comentado anteriormente, la pila de ejecución contiene los parámetros de las funciones, sus variables locales y sus valores de retorno, y también algunos valores de registros que serán detallados a continuación. De alguna forma, el contenido relacionado para cada función se encuentra claramente diferenciado gracias a una especie de contexto que se conoce con el nombre de stack frame. En otras palabras, para una función determinada su stack frame (conocido también como marco o contexto) es todo lo que se encuentra almacenado a la pila debido a su ejecución. Consideremos el siguiente código:

int main() { func1(10); func2(); return 0; }

void func1(int i) { func3(); }

 Seguimos con la representación gráfica de la evolución de los diferentes stack frames que encontraríamos a la pila durante la ejecución del código:

Viñetas Stack Frame

La ilustración anterior representa de la forma más esquemática e intuitiva el mecanismo que se utiliza dentro de la pila para tener el control de las diferentes funciones que se van ejecutando. Intuitiva en el sentido que la pila de la imagen crece en el sentido “deseado”, de la forma en que más estamos acostumbrados a verla, que es hacia arriba. En las ilustraciones posteriores, la pila se va a representar de la forma más calcada a la realidad, y esto significa que crecerá descendientemente, tal y como se mostraba en la primera imagen.

En referencia al contenido de la imagen, notamos que el stack frame correspondiente a la función func1() es ligeramente más grande que el resto debido a que tiene que contener el espacio para un valor entero que recibe por parámetro y que no tienen las demás funciones. Notamos también que a todas las viñetas hay un registro llamado ESP (del inglés, Extended Stack Pointer) que apunta siempre a la cima de la pila (la última posición de memoria ocupada por ésta). A la mayoría de registros se añade la característica extended porqué en las arquitecturas actuales tienen un tamaño de 32 bits en lugar de los 16 bits que tenían anteriormente. Luego concretaremos esto de las “arquitecturas actuales”.

Y aprovechando que estamos hablando de registros, vamos a hacer referencia a otro muy importante, el registro EBP (del Inglés, Extended Base Pointer). Èste, es un registro que, atención, apunta a una posición de la pila que contiene la dirección de memoria que apuntaba el mismo al stack frame anterior, es decir, al stack frame de la función que ha llamado a la función vigente. Su uso radica en que apunta a una posición que da una dirección relativa de los parámetros y las variables locales. Más concretamente, a la parte superior de donde apunta el registro EBP se encuentran las variables locales de la función actual y a la parte inferior se encuentra la dirección de retorno de ésta y los valores de los parámetros que ha recibido, y en segundo plano, por acumulación, los stack frames de las funciones “anteriores”.

El motivo por el cual se utiliza el registro EBP es porque el registro ESP puede incrementar o decrementar en la ejecución de la función, y en cambio, el registro EBP siempre permanecerá fijo. Este hecho es conveniente porqué de esta forma siempre podemos referirnos al contenido de la pila del mismo modo, es decir, con exactamente el mismo desplazamiento del registro en cualquier instante de la ejecución.

También es necesario destacar que en a la pila se pueden almacenar valores temporales de operaciones, es decir, que es posible utilizar la pila para que contenga valores intermedios de operaciones complejas. Estos valores temporales, de la misma forma que las variables globales, pueden ser referenciados por un desplazamiento “negativo” del registro EBP.

Bien, en este instante, que ya tenemos consolidado el concepto de stack frame, vamos a ver con exactitud de que está compuesto. Lo analizaremos en profundidad en la segunda entrega.

Ferran Verdés