Introducción al Reversing – 0xF Keylogger

Muy buenas a todos! En esta lección del curso de ingeniería inversa, realizaremos reversing a un keylogger. En esta lección aprenderemos:

  • Nociones de C.
  • Conocimiento de WinAPI y consultar su documentación.
  • Conocimiento sobre Windows Hooks.
  • Conocimiento de hilos.
  • Conocimientos de Sockets y HTTP.
  • Tools usadas:
    • Visual Studio 2019 (código fuente)
    • x64dbg (debugger)
    • Cheat Engine.
    • Wireshark.

Según la metodología que usamos a la hora de realizar ingeniería inversa de binarios, tendremos que tener en cuenta las API’s que usa el keylogger. Este binario ha sido desarrollado en C, y no tiene ningún packer. Del mismo modo el binario ha sido desarrollado y compilado usando Visual Studio 2019. También veremos como el malware no es detectable por el antivirus AVG, sin embargo en algunas ocasiones si es detectado por comportamiento. Al no existir ningún tipo de protección del binario, es muy factible su detección.

Existen buenos métodos de exfiltración de la información mucho más eficaces que las vistas aquí. Nosotros usaremos peticiones HTTP para enviar las pulsaciones de teclado de la victima recibidas en un VPS.

Metodología

Haremos uso de la metodología ya explicada en el curso. Pondremos breakpoints en las API’s de Windows.

Cualquier analista de malware viendo los símbolos ya crea cierta sospecha de que estamos lidiando con un keylogger, tales como SetWindowsHookExA, TranslateMessage, GetKeyNameTextA

Después de unos pasos ejecutados con el debugger, nos topamos con la llamada a CreateThread. En un principio en el curso no hemos visto que es lo que realiza esta función. Según la documentación de Microsoft:

Creates a thread to execute within the virtual address space of the calling process.

La función recibe 6 parámetros de las cuales la que más no interesa es el puntero correspondiente a la función que se ejecutará por el hilo (thread). Esta función contendrá toda la funcionalidad del keylogger. La función CreateThread crea un nuevo hilo para un proceso. El hilo de creación debe especificar la dirección de inicio del código que el nuevo hilo debe ejecutar, es decir, el puntero de la función. Por lo tanto, la dirección de inicio es el nombre de una función definida en el código del programa.

Breve explicación de CreateThread

Como no puede ser de otra manera, vamos a crear un sencillo código en C para entender bien el funcionamiento de los hilos.

#include<windows.h>
#include<stdio.h>
#include<conio.h>

void __stdcall MiThread()
{
    printf("Este es mi primer hilo.\n");
}
void main()
{
    printf("Entro en Main\n");
    HANDLE hThread;
    DWORD threadID;
    hThread = CreateThread(NULL, // security attributes ( default if NULL )
        0, // stack SIZE default if 0
        MiThread, // Start Address
        NULL, // input data
        0, // creational flag ( start if  0 )
        &threadID); // thread ID
    printf("Otras instrucciones que podrian darse en Main\n");
    printf("Saliendo de Main\n");
    CloseHandle(hThread);
    getchar(); //para no salirse de la consola y ver el output.
}

Compilamos en x86. Como sabemos del curso que en x86 los argumentos se pushean al stack, podríamos poner un breakpoint en la referencia de cadena Entro en Main.

Una vez ya tenemos controlado en el debugger el flujo de ejecución, podemos observar todos los argumentos que va a recibir CreateThread siendo pusheados al stack. Uno de ellos coincide con con el puntero correspondiente a la función que va a ejecutar el hilo, en este caso, se llama MiThread. Un hilo puede definirse como una ejecución separada que tiene lugar simultáneamente e independientemente de todo lo demás que pueda estar ejecutándose en main. Un hilo es como un programa que comienza en el punto A y se ejecuta hasta que llega al punto B. Se ejecuta independientemente de cualquier otra cosa que ocurra en el ordenador. Con los hilos, las tareas pueden continuar procesándose sin esperar a que otra tarea tenga problemas.

Por tanto en un proceso, puede haber múltiples hilos ejecutándose en función del tiempo. Si observamos los argumentos pasados a CreateThread, podemos darnos cuenta que uno de ellos, correspondiente al tercer argumento, es el puntero a la función que ejecutará hilo.

Los opcodes corresponden a un salto a MiThread.

En el volcado tenemos el opcode 0x55, que ya sabemos de sobra visto en el curso, que corresponde al primer valor del prólogo de cualquier función dada.

Una vez ejecute la función y volvamos al código de usuario (el main), abrimos Process Hacker y tenemos nuestro hilo creado y ejecutándose!.

Si debuggeamos hasta alcanzar getchar(), se ejecuta antes las instrucciones contenidas en main y luego ejecuta el hilo, según el output obtenido.

Pero esto no es del todo cierto, simplemente ha dado la coincidencia de que ha terminado antes main que ejecutarse la función printf del hilo. Por regla general dependerá de lo que venga después de CreateThread en main, y se ejecutarán ambas cosas a la vez, al final al cabo es la funcionalidad de los hilos.

Función Keylogger

Continuamos con el keylogger después de esta breve explicación sobre CreateThread. Una vez ejecuta esta función nos encontramos con WaitForSingleObject. Uno de los parámetros que recibe es el valor de retorno (almacenado en el registro EAX) de CreateThread. Se usa por ser la manera propia de esperar a que el hilo termine.

Como ya sabemos, si localizamos la dirección de memoria de la función y ponemos un breakpoint, podriamos empezar a reversear la función keylogger. ¿Cómo lo sabríamos? Ya se dijo anteriormente, localizamos el tercer argumento que se pasa a CreateThread y colocamos un breakpoint allí. La manera sencilla es irse al Stack donde esta el puntero, y botón derecho Mostrar DWORD en el desensamblador.

Ejecutamos con F9 y ya estaríamos en el inicio del codigo del thread.

Si observamos tenemos la llamada a la API GetModuleHandle. Según la documentación de Microsoft:

If this parameter is NULL, GetModuleHandle returns a handle to the file used to create the calling process.

Efectivamente, una vez se ejecuta el valor de retorno en EAX corresponde al binario .exe, localizándose la cabecera MZ en el volcado.

Y así podemos corroborarlo en el mapa de memoria.

A continuación explicaremos que son los hooks en Windows al usarse la API SetWindowsHookEx

Windows Hooks

Según la documentación de Microsoft:

hook is a mechanism by which an application can intercept events, such as messages, mouse actions, and keystrokes. A function that intercepts a particular type of event is known as a hook procedure. A hook procedure can act on each event it receives, and then modify or discard the event.

En nuestro caso vamos a monitorizar los eventos de entrada de teclado (Keylogging) de bajo nivel. Para ello usaremos LowLevelKeyboardProc. Los argumentos que se pasarán a SetWindowsHookEx son los siguientes:

HHOOK SetWindowsHookExA(
  int       idHook,
  HOOKPROC  lpfn,
  HINSTANCE hmod,
  DWORD     dwThreadId
);

Antes de ver en el stack los argumentos pusheados, vamos a estudiar uno a uno que significa cada parámetro.

  • idHook: Corresponde al tipo de hook que se va a instalar. En nuestro caso es 0xd, que es 13 en decimal, por tanto es este valor:
  • lpfn: Corresponde al puntero del procedimiento. En este caso será la función Keylogging donde se realizará las pulsaciones de teclado en bajo nivel. Se puede identificar en el volcado el opcode 0x55, siendo el inicio del prologo de la función Keylogging. Por tanto como no puede ser de otra manera ponemos un breakpoint ahí, para así poder estudiar esta funcionalidad.
  • hmod: Corresponde al handle que obtuvimos con GetModuleHandle. Es decir, el puntero donde esta localizado en el mapa de memoria el .exe.
  • dwThreadId: El hilo con el que el hook se asocia. En este caso es NULL, significando que el hook esta asociado a todos los threads.

Para instalar el procedimiento hook, nos valdría el código de a continuación, lo cual coincide en gran manera con lo que tenemos en el debugger:

LRESULT CALLBACK KeyEvent(int nCode, WPARAM wParam, LPARAM lParam)
{
  // ...
  return CallNextHookEx(NULL, nCode, wParam, lParam);
}

DWORD WINAPI KeyLogger(LPVOID lpParameter)
{
  HINSTANCE hExe = GetModuleHandle(NULL);
  hKeyHook = SetWindowsHookEx(WH_KEYBOARD_LL,(HOOKPROC)KeyEvent, hExe,NULL);
  MSG msg;
  while (GetMessage(&msg, NULL, 0, 0))
	{
		TranslateMessage(&msg);
		DispatchMessage(&msg);
	}

Por lo tanto, para SetWindowsHookEx, requerimos que el parámetro idHook sea WH_KEYBOARD_LL ya que tiene un alcance global. Para nuestro parámetro dwThreadId, lo ponemos a 0 ya que esto permitirá que nuestro procedimiento hook se asocie a todos los hilos existentes que se ejecutan en el mismo escritorio de nuestra aplicación. Hasta ahora, el keylogger es inútil para nosotros ya que no esta implementado ningún método para almacenar las pulsaciones de teclado, sin embargo como analistas de malware y usando la ingeniería inversa, estamos reconstruyendo el código.

Como no puede ser de otra manera pusimos el breakpoint en el callback KeyEvent, ya que tenemos que estudiar el método de captura de pulsaciones de teclado.

Capturando las pulsaciones de teclado con LowLevelKeyboardProc

Primero, necesitamos saber sobre los argumentos que se pasan a la función LowLevelKeyboardProc. Según la documentación de Microsoft.

An application-defined or library-defined callback function used with the SetWindowsHookEx function. The system calls this function every time a new keyboard input event is about to be posted into a thread input queue.

Una vez se ejecuta la API explicada en el apartado anterior, el binario se queda ejecutándose a la espera de una pulsación de teclado. Cuando la victima pulsa una tecla, el debugger se para justo en el inicio de la función callback KeyEvent (debido a que pusimos un breakpoint).

LRESULT CALLBACK LowLevelKeyboardProc(
  _In_ int    nCode,
  _In_ WPARAM wParam,
  _In_ LPARAM lParam
);

El primer parámetro corresponde a nCode. Según la documentación de Microsoft:

A code the hook procedure uses to determine how to process the message. If nCode is less than zero, the hook procedure must pass the message to the CallNextHookEx function without further processing and should return the value returned by CallNextHookEx. This parameter can be one of the following values.

Si el valor es 0, significa que en el código fuente estaría escrito HC_ACTION. En el debugger se aprecia que se compara dicho valor con 0, por tanto si es 0, los parámetros wParam y lParam contienen información sobre un mensaje de teclado.

El segundo parámetro corresponde a wParam. Es el identificador del mensaje del teclado. Este parámetro puede ser uno de los siguientes mensajes: WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN o WM_SYSKEYUP. Con hacer un poco de reversing nos damos cuenta que estamos en una estructura de control que comprueba este parametro.

Compara con 0x104 y con 0x100. Según la documentación de windows corresponde a:

#define WM_SYSKEYDOWN                   0x0104
#define WM_KEYDOWN                      0x0100

Finalmente el último parámetro corresponde al puntero de la estructura KBDLLHOOKSTRUCT. Contiene información sobre un evento de entrada de teclado de bajo nivel.

typedef struct tagKBDLLHOOKSTRUCT {
  DWORD     vkCode;
  DWORD     scanCode;
  DWORD     flags;
  DWORD     time;
  ULONG_PTR dwExtraInfo;
} KBDLLHOOKSTRUCT, *LPKBDLLHOOKSTRUCT, *PKBDLLHOOKSTRUCT;

Llegamos a la parte más importante. Antes de conocer la API GetKeyNameTextA, no monitorizamos el valor de los registros justo en el prologo de la función callback KeyEvent. El registro EDI, contiene un puntero cuyo contenido es un byte correspondiente a la pulsación de teclado.

Perfecto! Pues ya tenemos localizado donde se almacena dicho valor en memoria. Se me ocurre que se podría hacer una aplicación para cada vez que se ejecute este keylogger pueda detectarlo, o incluso modifique en tiempo real en memoria dicho valor! Una PoC (Proof of Concept) rápida podemos hacerla con Cheat Engine y buscar el carácter y modificarlo.

Podéis probar en hacerlo a la vez, y se replica al mismo tiempo lo que modifiquéis en Cheat Engine, se verá reflejado en x64dbg. No os preocupéis si no sabéis usar este software, haré tutoriales usando Cheat Engine en este curso!.

Si continuamos hasta la llamada de GetKeyNameTextA uno de los argumentos interesante es el Buffer donde se almacenará la key.

int GetKeyNameTextA(
  LONG  lParam,
  LPSTR lpString,
  int   cchSize
);

Una vez se ejecuta la función tenemos en nuestro buffer el byte correspondiente a la pulsación de teclado! En este caso pulsamos «A». Si la función tiene éxito el valor de retorno es 1. Por lo tanto se controlará dependiendo de ese valor de retorno que hará la aplicación. En este caso es sencillo, es enviar dicho carácter a un servidor remoto usando sockets y el protocolo HTTP.

If the function succeeds, a null-terminated string is copied into the specified buffer, and the return value is the length of the string, in characters, not counting the terminating null character.

También tenemos la llamada a printf con una string de «Reintentando Conexión», simplemente indicando que por cada pulsación al usuario le da un output por salida estándar de esta string. Quizás sea por motivos de ingeniería social, y engaño a la victima de no cerrar la terminal 😅

Petición HTTP a través de sockets

Como es un curso de ingeniería inversa, esta muy interesante conocer el funcionamiento a bajo nivel de las aplicaciones. En este caso ya que sabemos como obtener una pulsación de teclado usando la WinApi, ahora veremos como se realizan peticiones HTTP a través de sockets. Muy probablemente y con total seguridad, se obtendrá información del servidor remoto donde se envía esta pulsación de teclado. ¿Cómo se enviará? Pues de momento no sabemos el como, pero esta pregunta la responderá la ingeniería inversa del binario.

Como podéis apreciar justo antes de entrar en la función responsable de la petición HTTP, el argumento que se le pasa es el buffer donde esta nuestra pulsación de teclado. Otro indicio más que obvio del comportamiento del malware

Con un simple vistazo apreciamos como se crea la petición HTTP. Incluso ya sabríamos que se realiza una petición POST. También tenemos la IP del servidor.

Ponemos un breakpoint en la primera función y ejecutamos. Si analizamos los argumentos pasados, podemos deducir que es un sprintf y almacenará dicha información en el puntero que nosotros llamamos como header. Como no puede ser de otra manera uno de los argumentos pasado a sprintf, corresponde a la pulsación de teclado.

Oh my God! Resulta que envía peticiones POST usando la pulsación de teclado como un recurso web. Obviamente la petición es válida pero como el recurso no existe no devolverá response, pero esta claro que en el servidor web remoto del atacante, en los logs vendrá dicha petición.

Otra manera más de muchas otras, de enviar información de manera «legítima», aunque por comportamiento, los AV’s detectarán este keylogger.

En la siguiente función es la responsable de realizar la conexión por sockets al servidor.

La primera función que nos encontramos es gethostbyname. Según la documentación de Windows:

The gethostbyname function retrieves host information corresponding to a host name

Por tanto será la IP del servidor. Seguidamente tenemos el socket.

The socket function creates a socket that is bound to a specific transport service provider.

SOCKET WSAAPI socket(
  int af,
  int type,
  int protocol
);

Recibe 3 parámetros. Estos 3 argumentos se pushean al stack tal como vemos a continuación:

  • af: La especificación de la familia address. En este caso es AF_INET que tiene como valor 0x2.
  • type: El tipo de socket. En este caso es SOCK_STREAM.
  • protocol: El protocolo usado. En este caso es TCP.

Ahora tendremos que declarar la estructura sockaddr para poder pasarle esta información en connect.

struct sockaddr_in {
        short   sin_family;
        u_short sin_port;
        struct  in_addr sin_addr;
        char    sin_zero[8];
};

Simplemente es la familia IPv4, el puerto y la dirección IP. La función connect establece una conexión con el socket específico.

int WSAAPI connect(
  SOCKET         s,
  const sockaddr *name,
  int            namelen
);

Los parámetros que recibe son por tanto 3. Os dejo la información que ofrece la documentación de Windows:

El primero es el socket, el segundo es la estructura con la información de conexión y el último el size. Una vez se ejecute esta función connect, si capturamos la petición con Wireshark tenemos los 3 paquetes correspondientes a una conexión TCP. (Three-way handshake).

Si el valor de retorno es cero, la conexión se ha realizado con éxito.

If no error occurs, connect returns zero. Otherwise, it returns SOCKET_ERROR, and a specific error code can be retrieved by calling WSAGetLastError.

Y ya para finalizar se realizará send y closesocket. Como no puede ser de otra manera la función send envía datos en un socket conectado.

int WSAAPI send(
  SOCKET     s,
  const char *buf,
  int        len,
  int        flags
);

Los parámetros recogidos en la documentación de Windows:

El primer parámetro corresponde al valor de retorno en la conexión del socket visto anteriormente, el segundo argumento es el puntero al buffer donde se almaceno la petición POST, y será el data que se enviará; y por último el size en bytes del data. Si analizamos con Wireshark el envió, se puede observar lo siguiente. Se envía la petición POST y el response que obtenemos es una Bad Request pero el objetivo se ha cumplido, que es realizar la petición con la pulsación de teclado.

Y en el VPS del atacante recibimos la petición visto en los logs:

El resultado de ejecutar el binario y capturar unas pulsaciones de teclado y recibirlo en nuestro VPS remoto:

En las siguientes entradas veremos más conceptos y realizaremos más ingeniería inversa!! Espero que os haya gustado la entrada nueva del curso, hasta la próxima, @naivenom.