Tutorial sobre Windbg [Parte V]

A continuación vamos a ver técnicas para examinar de manera útil y detallada una de las estructuras de datos más importantes: la pila.

¿Qué es una pila?

La pila es una de las estructuras de datos más sencillas que existen. Consta básicamente de dos operaciones: apilar, que introduce elementos en la pila, y desapilar, que quita el elemento que ocupa la primera posición (la cima) de la pila, siempre y cuando tenga alguno. Como ve, se asemeja a una pila de platos o una pila de libros. Los primeros elementos que llegan a la estructura son los últimos que la desocupan; es decir, se trata de una estructura de tipo LIFO (Last In, First Out).

El estudio de esta estructura de datos no es caprichoso: Se trata de la estructura de datos que sustenta, entre otras cosas, las cadenas de llamadas a funciones, por lo que proporciona información muy importante para cualquier persona que esté investigando un problema en Windows (y en general, en cualquier otro sistema operativo).

¿Qué es la pila de ejecución?

La pila de ejecución es una porción de memoria que se le asigna a un hilo de un proceso y que se maneja siguiendo la estructura de datos de una pila. La pila de ejecución se crea cuando nace un nuevo hilo (mediante una llamada a la función CreateThread). Cada hilo tiene una pila de ejecución en modo usuario y una pila de ejecución en modo núcleo (excepto los hilos de sistema o creados por un controlador, que solo disponen de una pila de ejecución en modo núcleo).

Estructura básica de la pila de ejecución

Una buena forma de comprender y ver de manera práctica cuál es la estructura de la pila de ejecución de un hilo es hacer uso de los comandos que desensamblan porciones de código en Windbg.

Abrimos Windbg e iniciamos en él una nueva sesión de depuración (se supone que la información simbólica está correctamente configurada). En la caja de comandos introducimos el siguiente comando para desensamblar, por ejemplo, la función SHCreateFileExtractIcon de Shell32:

0:000> uf SHELL32!SHCreateFileExtractIconW

El comando uf, como ya comenté en un anterior artículo, sirve para desensamblar completamente una función. La salida de este comando podemos dividirla en tres conjuntos de instrucciones bien diferenciados: el prólogo de la función, el contenido de la función y el epílogo de la función. Vamos a prestar especial atención al prólogo de la función, que es el encargado de preparar la pila de ejecución para que la función en curso se ejecute correctamente. Estas son las cuatro primeras instrucciones que aparecen en la salida del comando anterior:

SHELL32!SHCreateFileExtractIconW:
7601877c 8bff            mov     edi,edi
7601877e 55              push    ebp
7601877f 8bec            mov     ebp,esp
76018781 81ecc0020000    sub     esp,2C0h

La primera instrucción le resultará extraña a cualquier programador en ensamblador. Mov edi, edi es una instrucción que aparentemente mueve el contenido del registro edi en el registro edi, es decir, no hace absolutamente nada. Sin embargo, esa instrucción sirve para implementar el mecanismo de hotpaching en Windows. Hotpaching es la capacidad para instalar actualizaciones en Windows sin que sea necesario reiniciar el componente que está siendo actualizado. El secreto reside en que la instrucción mov edi, edi puede ser sustituida por una instrucción de salto (jmp) que apunte a la dirección de hotpaching. Como la instrucción mov edi, edi ocupa solamente 2 bytes, el único salto que se puede realizar es un salto pequeño (hasta 2^7 – 1 bytes hacia arriba o hacia abajo). Sin embargo, si desensamblamos unas cuantas instrucciones anteriores a esta función nos encontramos con algo bastante interesante:

0:000> u SHELL32!SHCreateFileExtractIconW-9
SHELL32!SHCreateLinks+0x1b:
76018773 5d              pop     ebp
76018774 c21400          ret     14h
76018777 90              nop
76018778 90              nop
76018779 90              nop
7601877a 90              nop
7601877b 90              nop
SHELL32!SHCreateFileExtractIconW:

Como ve, hay cinco instrucciones que no hacen nada (no-operaciones, nop), cada una de 1 byte de tamaño. En total tenemos 5 bytes que podemos utilizar para poder realizar, ahora sí, un salto grande a la dirección de hotpaching. En un próximo artículo de este blog explicaré con todo detalle cómo funciona hotpaching en Windows.

La siguiente instrucción del prólogo de la función, push ebp, guarda en la pila de ejecución el contenido del registro ebp. El registro ebp contiene el valor del puntero base, que sirve para poder enlazar los marcos de funciones que se introducen en la pila. Concretamente, el puntero base apunta siempre al comienzo del marco de ejecución de una función (de ahí que también reciba el nombre de puntero al registro de activación) y sirve para poder utilizarlo como referencia cada vez que se quieran hacer desplazamientos relativos. Por este motivo, el contenido de este registro debe poder ser restaurado cada vez que finaliza la ejecución de una función (momento en el cual desaparece su marco de la pila). El registro ebp siempre se guarda en la pila antes de llamar a una función. No solamente debe almacenarse ese registro, cuando se realiza una llamada a una nueva función (en ensamblador, call <NombreFunción>), también debe almacenarse la dirección de retorno de la función, para que el procesador sepa qué instrucción debe ejecutar cuando finalice la función a la que se está llamando.

La instrucción mov ebp, esp almacena en el registro ebp el contenido del registro esp, que como ya comenté en un artículo anterior se trata del puntero de pila. El efecto de esta instrucción es que tanto el registro ebp como el puntero de pila (esp) apunten a la misma dirección de memoria, el comienzo del marco de ejecución de la función en curso.

La última instrucción del prólogo, sub esp, 2C0h, resta 2C0 (704 en decimal) bytes al puntero de pila. Esto se hace para reservar espacio para las variables locales de la función. Se usa la instrucción resta (sub) y no suma (add) porque la pila va creciendo hacia direcciones decrecientes de memoria. Esto es un punto que siempre debe tener presente cuando depure código en Windows.

El epílogo de la función es similar al prólogo, pero básicamente restaurando los cambios realizados en la pila de ejecución. Es decir, se suma al puntero de pila la cantidad de bytes que se restara al comienzo de la ejecución de la función (y por consiguiente, las variables locales desaparecen), se restaura el contenido del valor del puntero de pila con el contenido del valor del registro ebp, se desapila (pop) el valor del registro ebp que se guardó al comienzo de la ejecución de la función, y se pasa a ejecutar a partir de la dirección de retorno, que también fue debidamente guardada antes de llamar a la función que en estos momentos está a punto de finalizar.

Vemos un resumen gráfico de este aparentemente complejo proceso:

Pila

Vemos que en el marco de pila del diagrama, antes de proceder a la llamada de MiFunc2 desde MiFunc se almacenan también en la pila los parámetros que recibe esta función, justo antes de la dirección de retorno. De la manera en la que se almacena información en la pila podemos deducir estas dos reglas bastante útiles:

  • Si queremos acceder a los parámetros de una función, debemos partir del registro ebp y sumar la cantidad de bytes necesaria (@ebp + desp).
  • Si quieremos acceder a las variables locales de una función, debemos partir del registro ebp y restar la cantidad de bytes necesaria (@ebp – desp).

Estas dos reglas no son reglas de oro, pues en ocasiones las optimizaciones que realiza el compilador a la hora de almacenar variables dificulta este tipo de análisis.

La pila de ejecución de un hilo es un tema que podría ocupar buena parte de un libro, así que aquí solo se han resaltado los conceptos principales sobre el puntero de pila, el paso de parámetros, la salvaguarda de la dirección de retorno y la reserva de espacio para variables locales. Estos son los temas principales que debe manejar con soltura cuando se disponga a examinar la pila de ejecución de algún hilo. Vamos a ver qué comandos tiene Windbg para mostrar trazas de pila.

La familia de comandos k*

La familia de comandos k* de Windbg nos permite sacar por pantalla una traza de pila del contexto activo de depuración. ¿Qué es el contexto activo de depuración? Cuando se está depurando código en modo usuario, el contexto activo de depuración es el hilo actual. Para ver qué hilo es el hilo actual, puede usar el comando ~. (nótese que hay un punto al final). Para cambiar el hilo actual, para así poder mostrar la traza de pila de otro de los hilos, puede usar el comando ~<Hilo>s. Por ejemplo, para cambiar el hilo actual por el hilo número 2, podría usar la sintaxis ~2s. También es posible cambiar el hilo actual desde la interfaz gráfica de Windbg. Para ello vaya al menú View y haga clic sobre Processes and Threads (o pulse Alt+9). Haga doble clic sobre algún hilo para hacerlo el hilo actual, que además quedará marcado en negrita.

Una vez que el hilo actual esté establecido en el hilo que desea examinar, ya se puede proceder a mostrar su traza de pila. El comando más básico para conseguir esto es el comando k. Este es un ejemplo de la salida de este comando:

0:005> k
ChildEBP RetAddr 
05b6fb30 774f5e7c ntdll!KiFastSystemCallRet
05b6fb34 774e067b ntdll!NtWaitForWorkViaWorkerFactory+0xc
05b6fc94 77421194 ntdll!TppWorkerThread+0x216
05b6fca0 7750b3f5 kernel32!BaseThreadInitThunk+0xe
05b6fce0 7750b3c8 ntdll!__RtlUserThreadStart+0x70
05b6fcf8 00000000 ntdll!_RtlUserThreadStart+0x1b

En este ejemplo puede ver que el hilo actual es el hilo 5 (0:005). Cada una de las filas de esta tabla de información representa un marco de pila, una ejecución de función en un momento determinado. Se debe leer de abajo hacia arriba, es decir, la función _RtlUserThreadStart de Ntdll llamó a la función __RtlUserThreadStart, la cual llamó a BaseThreadInitThunk de Kernel32, y así sucesivamente. La columna ChildEBP nos dice, para cada marco de pila, cuál es la dirección del registro ebp (recuerde que dicha información queda almacenada en la pila para poder enlazar dinámicamente los marcos de pila). La columna RetAddr nos dice qué dirección de memoria pasará a ejecutarse cuando desaparezca ese marco de pila.

Si disponemos de información simbólica completa (algo que es muy raro si estamos depurando un programa Windows de terceros), podemos aprovecharnos de dicha información para que Windbg nos muestre información detallada sobre todos los parámetros de cada función. Para ello usamos el comando kp (similarmente, kP, pero este muestra los parámetros en una nueva línea).

Si no disponemos de información simbólica completa (lo habitual), podemos decirle a Windbg que nos muestre los 3 primeros parámetros de cada función. El comando que consigue esto , es kb:

0:005> kb
ChildEBP RetAddr  Args to Child             
05b6fb30 774f5e7c 774e067b 00000128 05b6fbe8 ntdll!KiFastSystemCallRet
05b6fb34 774e067b 00000128 05b6fbe8 73e952b4 ntdll!NtWaitForWorkViaWorkerFactory+0xc
05b6fc94 77421194 02efe378 05b6fce0 7750b3f5 ntdll!TppWorkerThread+0x216
05b6fca0 7750b3f5 02efe378 73e952c0 00000000 kernel32!BaseThreadInitThunk+0xe
05b6fce0 7750b3c8 774dd63e 02efe378 00000000 ntdll!__RtlUserThreadStart+0x70
05b6fcf8 00000000 774dd63e 02efe378 00000000 ntdll!_RtlUserThreadStart+0x1b

Las tres columnas correspondientes a Args to Child nos proporciona los tres primeros parámetros de cada función. Estos parámetros pueden ser tipos de datos simples (enteros, etc.), o bien punteros a direcciones de memoria que alberguen tipos más complejos (como estructuras de datos). Dado que el espacio de pila es un bien bastante preciado (y en modo núcleo lo es aún más), difícilmente va a ver estructuras de datos completas pasadas como parámetros a funciones. Tiene pues que utilizar los conceptos sobre interpretación de direcciones de memorias que aprendió en un anterior artículo para poder examinar detalladamente dichos parámetros.

En un siguiente artículo seguiremos viendo los entresijos de la familia de comandos k* y aprenderemos a reconstruir una traza de pila en aquellos casos en los que por diversos motivos Windbg no puede hacer el trabajo por nosotros (es decir, casos del mundo real). Veremos también en qué consisten las distintas convenciones de pasos de parámetros que hay en programación, pues esto tiene influencia en la pila de ejecución resultante.

Leave a Reply

Your email address will not be published. Required fields are marked *