Guía: Ingeniería reversa y análisis estático de malware

Publicado en Artículos, Seguridad,


Tweet about this on TwitterShare on Google+2Share on Facebook0Share on LinkedIn0

Cuando hablamos sobre el análisis estático de códigos maliciosos hacemos referencia el estudio de una amenaza sin tener que ejecutarla. De esta manera, es como si estuviéramos que realizar una autopsia para conocer qué es lo que hace o cuáles son las consecuencias que generará si llegase a infectar un sistema. Un primer acercamiento nos va a permitir conocer si el malware está empaquetado, en qué lenguaje de alto nivel fue desarrollado y otras tantas características más; Por ejemplo, podríamos ver qué librerías (DLLs) importa, las funciones que va a utilizar, el tamaño de sus secciones y otros datos de color.

Si quisiéramos ver cómo es que realiza tales acciones tendremos que adentrarnos aún más en lo que conocemos como Ingeniería Inversa y para ello, debemos entender sobre assembler y su utilidad en el desensamblado de los códigos maliciosos. En otras palabras, tendremos que aprender a hacerle una autopsia a un malware.

Introducción al desensamblado

Como mencionamos anteriormente, las técnicas básicas de análisis estático nos permiten conocer desde afuera, información acerca de un código malicioso. Dicho conjunto de técnicas es muy útil para darnos una idea inicial sobre la amenaza, pero para conocer todas sus funcionalidades debemos adentrarnos aún más. Listando las funciones que importa un malware, tomamos conocimiento de que la va a utilizar, pero no sabemos dónde ni cómo.

Por otro lado, al realizar un análisis dinámico de la amenaza, conocemos más información sobre su comportamiento, que información envía y recibe a través de la red, pero no como la utiliza internamente. Nuevamente, desensamblar la muestra nos permite conocer el detalle de dicha acción y responder a todas nuestras preguntas.

Aprender a desensamblar códigos maliciosos a través del uso de técnicas de Ingeniería Inversa es una habilidad que lleva tiempo desarrollar y puede resultar complicada. Sin embargo, no deja de ser una de las herramientas más útiles para combatir a los códigos y lograr proteger a los usuarios garantizando la seguridad y privacidad de su información.

Veamos los conceptos básicos para adentrarnos en el desensamblado y análisis de los códigos maliciosos.

Niveles de abstracción

Entender el funcionamiento de un sistema es un requerimiento necesario para saber cómo funcionan los códigos maliciosos. Además de ello también debemos darnos una idea de en qué nivel de abstracción o en qué capa vamos a estar trabajando.

A grandes rasgos existes tres niveles de abstracción en los cuáles nos vamos a manejar y son necesarios interpretar, según el caso que corresponda. El primero de ellos hace referencia al lenguaje de alto nivel que utilizó el creador de la amenaza al desarrollarla. El segundo involucra al código que se ejecuta directamente sobre el CPU de la máquina y que fue generado por el compilador y por último el lenguaje de bajo nivel con el cuál se trabaja durante el proceso de Ingeniería Inversa. En la siguiente imagen vemos la relación entre ellos:

Esta imagen nos da una idea simplificada de los diferentes niveles que existen dentro de todos los sistemas. Si arrancamos desde el hardware, microcódigo, código de máquina, los lenguajes de bajo nivel (Assembler), los lenguajes de alto nivel (C, C++, Delphi, etc) y los lenguajes interpretados (Java, Python, .NET, etc)

Según el caso en particular, las muestras de códigos maliciosos aparecen más frecuentemente en los últimos dos niveles, y partiendo desde esa base es el porqué de entender acerca de cómo desensamblar los códigos maliciosos para conocer sus actividades a través del uso de la Ingeniería Inversa.

Ingeniería Inversa

Cuando nos encontramos frente a una variante de algún código malicioso en un sistema y nos disponemos a analizarlo tenemos el archivo binario y debemos utilizar un desensamblador para generar el código en assembler con el objetivo de analizarlo. Ensamblador (assembler) es en realidad una clase de lenguaje de programación. Cada variante de ensamblador corresponde a una familia particular de microprocesadores tales como x86, x64, SPARC, PowerPC, MIPS o ARM. Dentro de todas estas familias la más habitual dentro las arquitecturas de procesadores es la x86, aunque con el pasar de los años vemos más y más procesadores x64.

Es a partir del desensamblado que debemos adentrarnos en el código de la amenaza para entender todas las acciones que realiza y cómo es que logra infectar un sistema para robar información, realizar ataques a otros sistemas o propagarse por la red. Para lograr una mejor comprensión de las acciones que queremos investigar debemos entender cómo se ejecuta la amenaza en el sistema, comprender qué son los registros del CPU, para qué se utilizan, cómo funciona la pila (stack), las instrucciones del CPU y muchas otras partes del sistema.

Arquitectura x86

Las arquitecturas de las computadoras más modernas siguen, hasta hoy en día, la arquitectura de Von Neumann que cuentan con tres componentes principales, que a pesar del paso de los años no han cambiado:

  • CPU: Es la unidad central de procesamiento, se encarga de ejecutar el código del programa o Sistema Operativo.
  • Memoria Principal (RAM): Almacena los datos y el código que se cargarán en la CPU para su ejecución.
  • Sistema de Entrada y Salida: Es la interfaz con los dispositivos como discos rígidos, teclados, monitores y demás.

A continuación podemos ver un diagrama que los representa:

A modo general, dentro de la estructura de esta arquitectura, la CPU contiene ciertos componentes para realizar tareas específicas. La unidad de control recibe desde la memoria RAM las instrucciones que debe ejecutar a través de un registro en particular, el Instruction Pointer (IP), que en pocas palabras apunta a la próxima instrucción a ejecutar. Los registros, son utilizados por la CPU para almacenar datos, valores o direcciones de memoria que acortan el tiempo que tardaría la CPU en ir a buscarlos directamente a la RAM. Existen registros que cumplen funciones específicas y otros de uso general.

Una vez que se cuentan con las instrucciones la ALU (del inglés, Aritmetic Logic Unit) es la encargada de ejecutarlas y almacenar el resultado directamente en la memoria RAM o en los registros. Este proceso de búsqueda y ejecución de una instrucción tras otras se repite a medida que se ejecuta un programa.

Memoria Principal

Ahora que mencionamos sobre cómo se ejecuta una instrucción en la CPU, tenemos que introducir cómo se divide las regiones de memoria de un Programa y para que se utiliza cada una. La memoria de un programa se puede dividir en 4 secciones principales:

Memoria principal
       |
     Stack
       |
     Heap
       |
     Code
       |
     Data
  • Data: La sección de datos de un programa hace referencia a una región específica de memoria. Contiene lo que se conoce como las variables estáticas que no cambian con la ejecución del programa. También en esta sección se encuentran las variables globales, que están disponibles desde cualquier parte del programa.
  • Code: En esta región de memoria se almacena el código que se ejecuta del programa donde se alojan todas las instrucciones que se van a ejecutar.
  • Heap: El heap es una región de memoria que se utiliza para alocar nuevos valores durante la ejecución del programa como así también para eliminarlos una vez que se dejaron de utilizar. El heap es una memoría dinámica y su contenido varía a medida que se ejecuta el programa
  • Stack (Pila): La pila se utiliza para alojar las variables locales, parámetros y valores de retorno de una función como así también contiene las direcciones de retorno entre una llamada a una función y otra, siendo muy útil para controlar el flujo de ejecución del programa.

Algo importante que deben recordar es que estas regiones de memoria no se encuentran en zonas contigüas de memoria y que su ubicación puede cambiar, estando en regiones más bajas o altas de memoria.

Instrucciones

Hasta acá, hablamos sobre instrucciones que se buscan desde la memoria RAM y son ejecutadas por la CPU, sin embargo no entramos en detalle de qué es una instrucción y cómo está compuesta. Las instrucciones en su conjunto son las que forman el programa en sí, y pueden contener cero o más operadores.

Una instrucción se identifica por palabras especiales como mov, push, pop, call, add, etc, y corresponden a un opcode (código de operación) específico.

Operadores en x86

Los operadores son utilizados para identificar los datos utilizados por una instrucción. Entre los diferentes tipos de operadores existen podemos diferenciar tres:

  • Operadores Inmediatos: Son valores fijos, que no cambian durante la ejecución del programa y hacen referencia a un número, cadena de texto valor booleano, etc.
  • Operadores de registro: En este caso el operador que se adjunta con una instrucción uopcode hace referencia a uno de los registros específicos del CPU como son el caso de EAX, ECX y demás.
  • Direcciones de memoria: El tercer tipo de operador que podemos tener dentro de un sistema hace referencia a una dirección de memoria y el contenido que se aloja en él. Para saber que estamos halando de una dirección de memoria y no de un valor, dentro de los desensambladores, las direcciones de memoria suelen ir cerradas entre corchetes []

Registros

Un registro es un espacio de almacenamiento disponible para el CPU. Una de las principales características de estos, es que pueden ser accedidos más rápido que cualquier otro dispositivo de almacenamiento de una computadora. Los procesadores x86 cuentan con una serie de registros disponibles para utilizar como almacenamiento temporal para variables, valores y demás información que utilizan durante la ejecución de instrucciones como así también punteros a secciones de memoria como la pila. Podemos mencionar 4 categorías diferentes:

  • Registros generales
  • Segmentos de registros
  • Flags (banderas de estado)
  • Instruction Pointer (IP), puntero a la próxima instrucción a ejecutar.
 
Registros generales Segmentos de registros Registros de estado IP
EAX (AX,AH,AL) CS EFLAGS EIP
EBX (BX,BH,BL) SS
ECX (CX,CH,CL) DS
EBP (BP) FS
ESP (SP) GS
ESI (SI)

En una arquitectura x86, todos los registros tienen un tamaño de 32 bits, sin embargo se pueden referenciar tanto los de 32 como de 16 bits. Por ejemplo, ECX hace referencia a los 32 bits del registro, sin embargo, CX solo referencia a los 16 bits de la parte baja.

Registros generales

Normalmente, los registros generales almacenan datos o direcciones de memoria y son utilizados de manera intercambiable para lograr que se ejecuten las instrucciones del programa. Algunos de estos registros generales son utilizados para funciones específicas. Por ejemplo, para realizar multiplicaciones o divisiones se utilizan los registros EAX y EBX.

Flags

El registro que nombramos como EFLAG es un registro de estado. En esta arquitectura, tiene una longitud de 32 bits y cada uno de sus bits es una bandera. Según el valor de 0 o 1 de cada bit serán utilizados para controlar las operaciones de la CPU luego de la ejecución de una instrucción. Entre los registros más importantes para remarcar en relación al análisis de malware podemos enumerar:

  • ZF (Zero Flag): Este bit se activa cuando el resultado de una operación es igual a cero.
  • CF (Carry Flag): Este bit se activa cuando el resultado de una operación es muy grande o muy pequeño para el operador de destino.
  • SF (Sign Flag): Según si el resultado de una operación es un valor positivo o negativo. Si el valor es positivos se queda en cero y es uno en caso contrario.
  • TF (Trap Flag): Este flag se utiliza para depurar (debugging) un programa. En caso de que esté activo el procesador ejecutará una instrucción a la vez.

Un ejemplo práctico, la instrucción mov

De todas las instrucciones que se pueden ejecutar dentro de un procesador existen algunas de ellas que son las más comunes y habituales como así también las menos complejas. Una instrucción muy común es la instrucción mov cuya función es mover los datos desde una ubicación a otra. Esta instrucción utiliza dos operadores para realizar su trabajo. El primero es el destino al cual se quieren mover los datos y el segundo está relacionado con la dirección u ubicación de destino.

Dentro de los operadores con los que se puede utilizar la instrucción podemos incluir registros o direcciones de memoria directamente. A continuación podemos observar algunas de los usos más comunes de esta instrucción:

 
Instrucción Descripción
mov ebx, eax Mueve el contenido de EBX a EAX
mov eax, 0x72 Mueve el valor 0x72 al registro EAX
mov eax, [0x40100] Copia los 4 bytes alojados en la dirección 0x40100 al registro EAX
mov eax, [ebx] Copia los 4 bytes alojados en la dirección de memoria a la que apunta EBX al registro EAX

Para probar estas instrucciones que estamos demostrando aquí es bastante sencillo, tomando cualquier máquina con un debugger como el OllyDbg o Immunity Debugger podemos sobrescribir una instrucción y comenzar a jugar moviendo los datos de un lado a otro del sistema:

En la imagen anterior podemos ver cómo luego de que se ejecutó la instrucción “mov ebp, 0×42” el valor del registro EBP se ha actualizado al nuevo valor y queda resaltado en celeste. Este caso práctico y sencillo, que ustedes pueden probar en cualquier máquina utilizando un debugger, de igual manera que se podemos probar cómo funciona esta instrucción también podemos jugar con muchas instrucciones más como add, sub, mul, call, etc.

Reconociendo estructuras comunes en ingeniería reversa

Cuando realizamos reversing sobre una posible amenaza tenemos que acostumbrarnos a la idea de que vamos a revisar cientos de líneas de código en un lenguaje de bajo nivel, tan solo para comprender una parte del funcionamiento. Esto puede sonar fatal y desanimar a aquellos que se están iniciando; después de todo, a mí me ocurrió cuando empecé a aprenderlo. Sin embargo, uno no tarda en darse cuenta que con un poco de práctica y las herramientas adecuadas, la ingeniería reversa no tiene por qué ser una experiencia desagradable. Por ello, en este artículo compartiremos una introducción a ciertos patrones que suelen verse al realizar reversing en arquitecturas x86, lo cual nos permitirá ubicarnos rápidamente y comprender mejor el código.

Si bien en algún momento de nuestro análisis seguramente tendremos que inspeccionar una porción del código que nos interesa línea por línea, también es cierto que existen muchas herramientas que nos facilitan enormemente la tarea de encontrar aquello que queremos en un momento determinado. Después de todo, ¡divide y vencerás!

En la imagen anterior vemos cómo un desensamblador (IDA, en este caso) no solo nos muestra el código, sino que además nos estructura el flujo de ejecución de forma gráfica. De esta forma, resulta mucho más sencillo seguir los saltos condicionales y los posibles escenarios de ejecución dentro de una subrutina. Adicionalmente, puede accederse a un diagrama con la interconexión y jerarquía de las diversas funciones o subrutinas en el ejecutable. Esto se observa en la imagen a continuación:

Pero más allá de todas las facilidades que brindan las herramientas, hay ciertas situaciones que se repiten en la ingeniería reversa y que vale la pena destacar. Para comenzar, analizaremos el siguiente programa:

#include <stdlib>
int sub (int x, int y){
  return x+y;
}

int main (int argc, char ** argv) {
  int a;
  a = atoi (argv[1]);
  return sub (argc,a);
}

Puede observarse que es muy sencillo: el programa recibe un argumento que se almacena como número entero en una variable local y se llama a la subrutina sub con esa variable local y el conteo de argumentos en argc. Esta subrutina simplemente retorna la suma de esos dos valores. Al desensamblar el ejecutable producido por estas líneas de código, se obtiene lo siguiente:

En color rojo se marcan unas líneas que aparecen al inicio de cada rutina (main y sub) y que en general deberíamos encontrar al inicio de cualquier rutina, ya que realizan tareas de inicialización de la sección de la pila o stack correspondiente. Cuando main invoca a sub, sub debe realizar el cambio de la sección del stack de main a la sección propia de sub, guardando el valor base de main para poder restablecerlo luego; este hecho se traduce en push ebp. Luego, mov ebp, esp realiza el cambio a la sección del stack de sub. También con color rojo se marcan las líneas que invierten ese proceso una vez que la rutina ha terminado de ejecutarse: mov esp, ebp retrocede el stack pointer hasta la base, limpiando así las variables locales o cualquier otro dato innecesario que haya quedado en la pila; mientras que pop ebp restablece el puntero base a la rutina previa. Se observa que sub sólo incluye pop ebp dado que no cuenta con variables locales u otros datos que limpiar del stack. En definitiva, si vemos estas instrucciones ahora, podemos saber dónde empieza y termina una rutina, y nos resultará particularmente útil si en algún momento nos perdemos en el código.

Cabe destacar que en la imagen se han resaltado distintos patrones con otros colores, que están relacionados con limpieza del stack, convenciones de llamadas a subrutinas, asignación de espacio para variables locales y manipulación de parámetros y variables.

Ya analizamos el significado de las líneas resaltadas en rojo. Pero también hemos marcado unas líneas con color naranja: si prestamos atención, veremos que estas líneas se encuentran después de una llamada a subrutina call. Antes de seguir, debe mencionarse que existen diversas convenciones para la llamada a subrutinas. Es necesario que el código que llama y el que es llamado sepan dónde colocar y buscar los datos, respectivamente. En este sentido, podemos mencionar dos de las convenciones más importantes:

  • cdecl: esta es la forma más común de manejar el pasaje de parámetros a una subrutina. Se caracteriza por hacer el pasaje a través del stack, introduciendo los parámetros según aparecen en la llamada, de derecha a izquierda (en nuestro ejemplo, primero argv y luego argc). Además, el resultado devuelto por la subrutina es colocado en eax (o edx:eax, de ser necesario). Pero lo más importante a mencionar es que la rutina que llama (no la que es llamada) es la que debe encargarse de limpiar lo introducido en el stack una vez que se ha finalizado la llamada.
  • stdcall: esta convención se utiliza en menor medida; mayormente la vemos en funciones de la API Win32. Es exactamente igual a cdecl, excepto por la limpieza del stack: en este caso es la subrutina invocada quien debe llevar a cabo la tarea.

Entonces, ¿cómo se traduce esto en el código? Si se utiliza cdecl, inmediatamente después de la llamada a la subrutina se observan instrucciones para realizar la limpieza del stack. Esto es justamente lo que se marca con color naranja en nuestro ejemplo: luego de la llamada a atoi, se limpia el espacio que se reservó en el stack para pasar el parámetro (4 bytes); luego de la llamada a sub, se hace lo mismo, pero con los dos parámetros pasados (8 bytes). Si tenemos en cuenta que el registro esp apunta al tope del stack, y que éste crece de direcciones de memoria más grandes a direcciones más chicas, la suma de un valor positivo a esp hará que los valores de la cima queden fuera del stack, logrando el efecto de limpieza o borrado. Pero… ¿qué pasa si se utiliza stdcall?

Al forzar la convención stdcall para la llamada a sub, se observan dos cosas. En primer lugar, que en el cuerpo de main, y después de la llamada a sub, no hay operaciones para limpiar los parámetros introducidos en el stack; si comparamos este código con el anterior, vemos que la línea add esp,8 ya no está. En segundo lugar, al observar el código de sub vemos que la instrucción ret ha cambiado. Ahora es responsable sub de limpiar su propio stack frame, para lo cual ejecuta ret con la cantidad de bytes a limpiar, tomando 4 bytes por parámetro.

Para cerrar este análisis, haremos referencia a las operaciones realizadas sobre el registro ebp y la diferencia entre variables locales y argumentos de llamada a subrutina. En nuestra primer imagen del código desensamblado, hemos marcado en amarillo la operación push ecx. Sin embargo, podemos notar que ecx no está inicializado. Esto nos indica que el valor que está siendo introducido en el stack es indeterminado, lo que significa que se está guardando espacio en el stack. Si además observamos que esta línea no está presente en sub, entendemos que el espacio que se guarda es para variables locales (en el caso de main, una sola variable, a). Entonces, de manera general, podemos decir que si una rutina cuenta con variables locales, luego del código de inicialización encontraremos las líneas que guardan este espacio para las variables en el stack.

Si ahora nos detenemos en el resto de las operaciones realizadas que hacen referencia al registro ebp, notaremos que algunas le suman un valor, y otras se lo restan. En general, nos resultará muy útil en la práctica considerar lo siguiente: cuando a ebp le sumamos un valor para referenciar una posición dentro del stack, estaremos accediendo a uno de los argumentos del código que está siendo ejecutado. Por su parte, si le sustraemos un valor, estamos dentro del stack frame local, con lo cual se hace referencia a variables locales. En otras palabras, si vemos que a ebp se le suma algo, se está tratando de recuperar un parámetro; si se le resta algo, se quiere acceder a una variable local. Esto se observa en la otra parte del código que se ha marcado con color amarillo: se guarda en la pila el valor de la variable a, para la cual antes se había reservado espacio con push ecx.

Dado que, al realizar reversing, la cantidad de instrucciones de un programa puede ascender a los cientos de miles, resulta muy poco práctico realizar un análisis instrucción por instrucción. Por eso, se hace necesario lograr cierto nivel de abstracción en el proceso; con un poco de práctica podemos acostumbrarnos a ver un amplio panorama de lo que hace el código, mediante el análisis de grupos de instrucciones en lugar de instrucciones individuales. Asimismo, si pensamos en lenguajes de alto nivel, nos daremos cuenta de que existen estructuras o construcciones que, al ser combinadas, le dan funcionalidad a un programa. Entre esas construcciones encontramos aquellas que rigen el flujo de ejecución, como los condicionales, bucles y demás. Si reconocemos estas estructuras entre el código en ensamblador, estaremos más cerca del lenguaje de alto nivel, con todas las ventajas que esto ofrece.

Para poder reconocer bucles entre las líneas de código en lenguaje ensamblador, primero debemos entender la estructura de un bucle en un lenguaje de alto nivel. Por ello, en la siguiente imagen observamos un programa sencillo con un bucle en C:

#include <stdlib>
int main () {
  int i;
  for  (i = 1; i<10; i++ )
    printf ("Contando %d\n", i);
  return 0;
}

El código mostrado imprime por pantalla 9 líneas, desde “Contando 1” hasta “Contando 9”. Ahora bien, si nos centramos en el funcionamiento del bucle, podemos bosquejar el siguiente pseudocódigo:

  1. Asignar a la variable i el valor 1
  2. Si el valor de i es mayor o igual a 10, ir al paso 6. Si es menor que 10, continuar con el paso 3
  3. Imprimir por pantalla “Contando i”, con el valor actual de i
  4. Sumar 1 a la variable i
  5. Ir al paso 2
  6. Salir del bucle

Si ahora trasladamos este ejemplo particular al caso general, notaremos que los bucles contarán con las siguientes secciones:

  • La encargada de inicializar las variables
  • La que realiza una comparación, en la cual se basa la repetición
  • La que contiene el código a ejecutar repetidamente
  • Aquella que incrementa la variable de comparación

Por lo tanto, cuando estemos realizando reversing sobre un ejecutable, debemos observar aquellos grupos de instrucciones que se correspondan con estas secciones, prestando especial atención a las instrucciones de salto, el pegamento que une las secciones.


Por Pablo Ramos y Matías Porolli vía ESET

Califica esta entrada

Etiquetas: , ,


Deja un comentario

Cuanto es 18 + 14 ?
Please leave these two fields as-is:
IMPORTANTE! Necesitas resolver la operación matemática para poder continuar.

Newsletter

Redes sociales

Centro de soporte

Centro de recursos