Estructuras de control de flujo en código Assembly | WeLiveSecurity

Estructuras de control de flujo en código Assembly

Una guía que explica de manera general como identificar algunas estructuras de control de flujo al desensamblar un programa compilado.

Una guía que explica de manera general como identificar algunas estructuras de control de flujo al desensamblar un programa compilado.

Al comenzar a visualizar el código desensamblado de un programa es usual sentir que se está frente a un conjunto de instrucciones sin estructura o sentido aparente. Sin embargo, si se analiza en mayor profundidad, dichas estructuras comienzan a emerger y el código comienza a cobrar sentido.  Por lo tanto, conocerlas y saber cómo identificarlas puede ser de gran ayuda para acelerar y facilitar nuestras tareas de ingeniería inversa sobre programas compilados para una determinada arquitectura.

En este post nos enfocaremos en mostrar cómo suelen observarse en lenguaje ensamblador algunas estructuras de control de flujo presentes en la mayoría de los lenguajes de alto nivel. En los ejemplos se utilizará sintaxis de C++, instrucciones de Assembly x86-64 y compilador GCC.

If

Los condicionales están fuertemente basados en instrucciones de comparación (CMP) y de salto (Jxx). La idea detrás de esto será aplicar una comparación entre dos elementos y, según el resultado de dicha comparación, saltar a otro punto del código Assembly o seguir a la siguiente instrucción. Luego, al depender del resultado de una comparación, las instrucciones de salto utilizadas serán las de salto condicional y la instrucción utilizada dependerá del tipo de comparación presente en la condición del if.

Imagen 1. Ejemplo de comparación entre un If en C++ y en Assembly. A la derecha puede observarse la comparación CMP, el salto condicional JNE (Jump Not Equal) y los posibles cursos de ejecución

While/for

Los ciclos son similares a los ifs, ya que contienen una condición que será la que determine durante cuánto tiempo se estará dentro del ciclo. Mientras esta condición no se cumpla (negación de la guarda), no se tomará el salto condicional y simplemente se continuará la ejecución en la siguiente instrucción. Si la condición se cumple, se activará el salto condicional que apuntará a una instrucción por fuera del ciclo. Lo único que falta será la instrucción que permita que la ejecución efectivamente sea un ciclo. Esto se logrará colocando al final del código del cuerpo del ciclo una instrucción de salto no condicional JMP que apunte a la primera instrucción del ciclo, es decir, a la condición inicial.

Imagen 2. Ejemplo de comparación entre un While en C++ y en Assembly. A la derecha puede observarse la comparación CMP, el salto condicional JG (Jump Greater) para salir del ciclo si la guarda no vale y el salto no condicional JMP para volver a la instrucción de comparación

Switch

Al igual que los ciclos y los ifs, los switchs también utilizarán la combinación de instrucciones CMP y un salto condicional Jxx; sin embargo, éste hará un uso mucho más intensivo, ya que intuitivamente un switch puede ser visto como una serie de ifs anidados. De esta manera, habrá muchas comparaciones y saltos condicionales seguidos donde cada instrucción de salto llevará a su correspondiente etiqueta, donde estará el código a ejecutar si se cumple la condición. Luego, al final del código de cada caso habrá un salto no condicional JMP que apuntará a la siguiente instrucción luego del switch para retomar el flujo de ejecución.

Imagen 3. Ejemplo de comparación entre un Switch en C++ y en Assembly. A la derecha pueden observarse las comparaciones CMP para cada caso del switch, el salto condicional correspondiente a cada una de ellas y los saltos no condicionales que llevan al final del switch para salir del mismo

Funciones

Si bien esto podría variar según el lenguaje y el compilador, las funciones generalmente tienen una estructura particular bien definida. Estas suelen comienzar con dos instrucciones cuya finalidad es armar el Stack Frame para la función. Posteriormente, podría haber instrucciones encargadas de reservar espacio en el stack para ubicar allí el valor de las variables locales de la función. A continuación, estarán presentes las instrucciones correspondientes a las tareas que realiza la función y, finalmente, la misma terminará con una instrucción RET. Teniendo en cuenta esta estructura es muy sencillo identificar el comienzo y el fin de una función.

Imagen 4. Ejemplo de comparación entre una función en C++ y en Assembly. A la derecha puede observarse el armado y desarmado del stack frame, la inicialización de las variables en el stack y el return.

Observaciones generales

Es importante tener en cuenta que, si bien los compiladores suelen respetar estructuras similares a las aquí mostradas, estas podrían llegar a variar o a tener modificaciones. Esto se debe a que cada compilador puede funcionar de manera diferente o aplicar distintos tipos de optimizaciones sobre el código con el fin de que este tenga mejor performance (por ejemplo, loop unrolling de ciclos). Por lo tanto, si se quisiera conocer la forma puntual en la que un compilador específico compila cierta estructura, pueden utilizarse herramientas como Compiler Explorer o incluso realizar pruebas uno mismo compilando y desensamblando código hasta encontrarla. De todas maneras, aunque puedan variar las instrucciones utilizadas o el orden de las mismas, la idea detrás de cada estructura suele mantenerse.

Newsletter

Discusión