Llevo tres horas pegándome cabezazos contra el teclado por un puto Segmentation Fault en un programa en C que, en teoría, debería ser trivial. He abierto el GDB, he hecho un backtrace para ver dónde se había estampado, y ahí estaba, riéndose en mi cara: había machacado la pila de llamadas (Call Stack) al usar la función strcpy copiando una cadena de texto mucho más larga de lo que el buffer podía tragar.
Picar código en C o en Ensamblador tiene estas cosas. Te da las llaves del Ferrari, todo el poder y la velocidad, pero a cambio, te deja estrellarte contra un muro a 200 km/h si no prestas atención a la carretera. Así que he decidido que, ya que estoy desvelado, voy a escribir esto. Para mí mismo y para cualquiera que esté trasteando con Ensamblador x86 y no termine de pillar cómo demonios funciona la dichosa pila de llamadas a nivel de silicio.
El barro: Cómo funciona la pila de llamadas
Para entender el Call Stack o la pila, hay que olvidarse de las comodidades de lenguajes de alto nivel como Java o PHP. Aquí no hay recolector de basura ni red de seguridad. A nivel de procesador (un Pentium IV HT en mi caso, que suena como un avión de combate cuando compilo un proyecto grande), la pila no es más que un segmento continuo de memoria RAM que usamos como si fuera una pila de platos. El último plato que pones en la pila es obligatoriamente el primero que quitas. Es lo que se llama una estructura LIFO (Last In, First Out).
En la arquitectura x86 de 32 bits, hay una peculiaridad que suele liar bastante al principio: la pila crece "hacia abajo". Es decir, empieza en una dirección de memoria alta (por ejemplo, 0xBFFFF000) y, a medida que metemos datos, va ocupando direcciones más bajas (0xBFFFEFFC, etc.).
Para manejar todo este cotarro, tenemos dos registros principales en la CPU que son los verdaderos capataces de la obra:
ESP(Extended Stack Pointer): Es el puntero de la pila. Siempre, sin excepción, apunta a la cima de la pila (el último byte que hemos metido).EBP(Extended Base Pointer): Nos sirve de punto de anclaje. Una referencia constante dentro de nuestra función para poder acceder a las variables locales y a los parámetros que nos han pasado, sin importar si elESPsube o baja.
El ritual del Stack Frame
Cuando llamamos a una función (usando una instrucción call en ensamblador), la máquina hace dos cosas mágicas por debajo:
1. Mete (push) en la pila la dirección de la siguiente instrucción. Esta es la sagrada "dirección de retorno", para saber adónde tiene que volver el programa cuando acabe la función.
2. Salta a la dirección de memoria donde empieza el código de la función.
Una vez que aterrizamos dentro de la función, lo primero es montar lo que se llama el Stack Frame (el marco de la pila). Es un ritual, casi un rezo, que verás en absolutamente cualquier código desensamblado si le metes el OllyDbg o lo miras con GDB:
mi_funcion:
; --- Prólogo de la función ---
push ebp ; Guardamos el EBP de la función anterior para no perderlo
mov ebp, esp ; El ESP de ahora pasa a ser nuestra nueva base (EBP)
sub esp, 24 ; Hacemos hueco: reservamos 24 bytes para variables locales
; --- Aquí va la chicha, el código real ---
; Los parámetros que recibimos están "arriba" del EBP: [ebp+8], [ebp+12]...
; Nuestras variables locales están "abajo": [ebp-4], [ebp-8]...
; --- Epílogo de la función ---
mov esp, ebp ; Restauramos el ESP. Limpiamos las locales de un plumazo.
pop ebp ; Restauramos el EBP original del que nos llamó
ret ; Pop de la dirección de retorno y saltamos de vuelta
El problema exacto que me ha amargado esta noche es que, al meter datos a lo bestia en una variable local (imagina que escribo 40 bytes en un espacio de 24), el código ha seguido escribiendo memoria hacia "arriba". He sobrescrito mi propio EBP guardado y, lo que es infinitamente peor, la dirección de retorno.
Cuando mi función ha terminado y ha ejecutado el ret, el procesador ha cogido la basura que yo había escrito, se ha pensado que era una dirección de memoria válida y ha intentado ejecutar código allí. ¡Violación de segmento al canto! El kernel de mi fiel Linux ha detectado la jugarreta y ha matado el proceso en el acto para evitar un desastre mayor.
Reflexiones de madrugada
Cacharrear a este nivel de bajo nivel es duro, para qué nos vamos a engañar. A veces, cuando el compilador de C me escupe errores incomprensibles, desearía dedicarme a hacer foros en PHP o pelearme con CSS, y olvidarme para siempre de registros, desplazamientos de memoria y volcados de núcleo.
Pero hay algo increíblemente satisfactorio en entender exactamente qué hace la máquina debajo de las sábanas de tus programas en C. Saber cómo se empujan los parámetros con un push, cómo se lee la memoria y cómo el ret nos devuelve a casa te hace infinitamente mejor programador. Te da un control absoluto sobre el hardware.
Viendo cómo avanza la informática en este 2005, con los nuevos procesadores de 64 bits de AMD asomando la cabeza y los sistemas operativos intentando proteger más la memoria (me consta que se está trabajando en protecciones a nivel de hardware, como el bit NX, para que no puedas ejecutar código inyectado en la pila), la forma de explotar estos buffer overflows cambiará. Será más difícil para los hackers colar shellcodes de forma trivial.
Aun así, la arquitectura fundamental, el concepto del Stack Frame, seguirá siendo el mismo. Es el corazón de cómo interactúan las funciones y los programas. Si dominas la pila, dominas la máquina.
Me voy a la cama antes de que salga el sol y los pájaros empiecen a cantar. Mañana será otro día.