Crackeando el cracker – Una visión práctica del reversing en Windows

Gran parte de los estudiantes y practicantes de seguridad ofensiva no tienen en cuenta el mundo que se ubica un poco más abajo de los scripts y los temidos permisos mal configurados. Probablemente ya estés familiarizado con los famosos Buffer Overflows -u Overflows en general- y estés cansado de mirar frustrado al tamaño de un buffer desde GDB. Aún así, en el mundo real, no toda la explotación consiste en hacer BoFs en un Ubuntu ya preparado, sobre todo cuando la mayoría del mercado de los sistemas operativos está regida por Microsoft.

En este post voy a tratar algunos aspectos generales y prácticos del reversing, la explotación y manipulación de binarios en Windows. Si bien yo he utilizado Windows 11 para hacerlo, un Windows 10 funcionará exactamente igual.

Requisitos

Como regla general, para trabajar con Windows recomiendo enormemente las siguientes herramientas (aunque no las voy a usar todas):

Además, vamos a necesitar un poco de experiencia con C, C# y ensamblador x86 y x64.

Objetivo

Voy a estar “atacando” una herramienta de fuerza bruta que he adquirido. Voy a tapar algunas partes para evitar la posibilidad de conflictos con el autor de dicha herramienta. Aclaro además que no me hago responsable ni apoyo el mal uso de dicha herramienta.

Esta herramienta prueba combinaciones tipo usuario:contraseña en una web con el objetivo de verificar qué credenciales son válidas. Para evitar problemas, utiliza una lista con proxies que se pueden ir alternando. Por razones éticas he creado mi propia lista de proxies y configurado un laboratorio, evitando así atacar a una web real.

Cargamos una lista de cuentas y la lista de proxies. Además he ajustado el número de hilos a 300. Pero a la hora de pulsar en ese ominoso botón que dice “Start”, nos devuelve el siguiente error:

Reversing y explotación

Para ponernos una meta, vamos a analizar cómo y por qué salta esta ventana. Antes de proceder mucho más, podemos echar un vistazo usando ILSpy, ya que dnSpy ya no está mantenido.

Se pueden distinguir 4 archivos DLL distintos dentro del mismo ejecutable, pero sólo necesitamos indagar en el primero, ya que las demás librerías son estéticas. Además, se ve que el programa está escrito en C#, lo que significa que el archivo no es exactamente ensamblador de 32 ni de 64 bits, sino IL de Microsoft, archiconocido como MSCIL (Microsoft Common Intermediate Language) o simplemente CIL, que se asemeja en cierto modo al bytecode de Java. Al compilar un programa de .NET, éste se devuelve en CIL, que a pesar de considerarse un lenguaje ensamblador sigue siendo de bastante alto nivel, lo que facilita enormemente su decompilación, pero descarta el uso de desensambladores estáticos convencionales como Ghidra o Cutter. Desde ILSpy podemos buscar algunas palabras clave que salieron en el error de antes para entender por dónde empezar:

using System.Windows;

internal static class VerifyVersion
{
	internal static bool IsPaidVersion()
	{
		if (0 == 0)
		{
			Application.Current.Dispatcher.Invoke(() => MessageBox.Show("To access this feature, you need to purchase a paid version here: https://ejemplo.com \n\nPaid features:\n - Maximum of 999 threads (instead of 201);\n - All unlocked features;"));
			return false;
		}
		return true;
	}
}

Pero sabemos que esta pieza de código se ejecuta, así que podemos buscar las referencias a esta función y ver qué la utiliza.

Esta función se llama en las clases sxesac.Classes.CheckerHandler.VerifyUserOptions y sxesac.Classes.Helpers.ProxyDownloader+d__2.MoveNext. Ya por los nombres entendemos que la que nos interesa es la primera, y no la segunda. Decompilamos la función VerifyUserOptions() para ver cómo funciona:

using System.Windows;
using sxesac.Classes.Helpers;
using sxesac.Classes.Internal;
using sxesac.Helpers;

private bool VerifyUserOptions(int taskNumber)
{
    if (taskNumber == 0)
    {
        System.Windows.Application.Current.Dispatcher.Invoke(() => MessageBox.Show("How do you want to get work done if you don't have any employees? Gotta hire some, no?\nYou can't have 0 threads.\n\nTo continue, specify at least 1 thread.", "No threads", MessageBoxButton.OK, MessageBoxImage.Exclamation));
        return false;
    }
    if (LoadedAccountsCollection.Count == 0)
    {
        MessageBox.Show("Your accounts list is empty.\n\nTo continue, add an accounts list.", "Accounts list is empty", MessageBoxButton.OK, MessageBoxImage.Exclamation);
        return false;
    }
    if (taskNumber > LoadedAccountsCollection.Count)
    {
        int count = LoadedAccountsCollection.Count;
        MessageBox.Show($"XXXX lowered the threads number you specified ({taskNumber}) because you loaded {LoadedAccountsCollection.Count} accounts.\n\nXXXXX will continue checking accounts with {count} threads.", "You can't have more threads than accounts", MessageBoxButton.OK, MessageBoxImage.Exclamation);
        taskNumber = count;
        return true;
    }
    if (ProxyHelper.LoadedProxiesCollection.Count == 0)
    {
        MessageBox.Show("Your proxies list is empty.\n\nTo continue, add a proxy list.", "Proxy list is empty", MessageBoxButton.OK, MessageBoxImage.Exclamation);
        return false;
    }
    if (taskNumber > ProxyHelper.LoadedProxiesCollection.Count && ProxyModifier.proxyMethod != 0)
    {
        MessageBox.Show("Your thread number is bigger than the proxies you loaded.\n\nTo continue, lower your threads list or load more proxies using the Stack-Add feature.", "Threads are bigger than proxy number", MessageBoxButton.OK, MessageBoxImage.Exclamation);
        return false;
    }
    if (taskNumber > 201 && !VerifyVersion.IsPaidVersion())
    {
        return false;
    }
    Cleanup();
    isChecking = true;
    return true;
}

En la parte que he resaltado en negrita se pueden encontrar el número máximo de hilos que admite la versión gratis del programa junto a una llamada a la función sxesac.Classes.Internal.VerifyVersion.IsPaidVersion(), que es la que encontramos más arriba. Parece un indicio de que vamos por buen camino. Aún así, si nos fijamos con atención se puede ver que todos los condicionales acaban en return false, salvo uno:

if (taskNumber > LoadedAccountsCollection.Count)
    {
        int count = LoadedAccountsCollection.Count;
        MessageBox.Show($"lowered the threads number you specified ({taskNumber}) because you loaded {LoadedAccountsCollection.Count} accounts.\n\nXXXXX will continue checking accounts with {count} threads.", "You can't have more threads than accounts", MessageBoxButton.OK, MessageBoxImage.Exclamation);
        taskNumber = count;
        return true;
    } 

Parece que es algo interesante. Todos los condicionales comprueban si algo fuera de lugar, y devuelven false en caso de que haya algo mal. En esta parte eso no se cumple, y podemos salir antes de tiempo de la función devolviendo true de manera arbitraria. Finalmente hemos encontrado un bug.

Ahora vamos a abrir una de mis herramientas favoritas para mirar con algo más de detalle: Cheat Engine. Seleccionamos el proceso y lo abrimos con el debugger:

Después de abrir el proceso y buscar el valor 201, que es el número de hilos, nos salen bastantes resultados. Para filtrarlos bastará con alterar el valor y volver a escanear con el nuevo valor.

Y ya sabemos dónde se ubica el número de hilos en memoria pero, gracias al ASLR (Address Space Layout Randomization), no nos servirá de gran cosa la próxima vez que lo abramos con Cheat Engine. Vamos a buscar una manera de que esto no ocurra, utilizando una opción que trae CE para encontrar qué partes del código acceden a esa variable.

Nótese con especial atención que ya que el MSCIL se compila en tiempo de ejecución o en runtime, lo que vamos a analizar con CE a partir de ahora va a ser ensamblador hecho y derecho.

Ahora solo haría falta hacer que algo interactúe con esa variable, así que vamos a darle a “Start” en el programa y vamos a ver que accede.

Después de hacer clic un par de veces para asegurarnos de que es esa parte la que queremos, la seleccionamos y ya podemos ver algo más interesante, que la variable se almacena en [rax+98] (en numeración hexadecimal). Con CE podemos inyectar nuestro propio código, pero es recomendable tener experiencia con C y ensamblador de 32 y 64 bits. Ahora vamos a abrir esa parte del código:

Con CTRL+A abrimos el módulo Auto Assemble de CE y ahí podremos escribir nuestro código. Escribir lo que queremos hacer ahora desde 0 es extremadamente tedioso, así que vamos ir a Template>AOB Injection. Lo que hace esta plantilla es seleccionar un AOB con los opcodes de la porción de código en la que queremos inyectar. Un AOB (Array Of Bytes) es, como su nombre indica, una agrupación de bytes específicos.

CE nos va a pedir ahora la dirección en la que queremos inyectar y un nombre que darle al script. Para la dirección, la dejaremos como está. Para el nombre, recomiendo poner un nombre descriptivo y SIN espacios. Yo personalmente lo voy a dejar como INJECT.

Lo que queremos hacer ahora es guardar la dirección de memoria en una variable que controlemos nosotros, así que vamos a crear un poco de espacio en memoria usando la función alloc().

[ENABLE]

aobscan(INJECT,8B 80 98 00 00 00 5D) // should be unique
alloc(newmem,$1000,INJECT)
globalalloc(numero_de_hilos,4)

label(code)
label(return)

numero_de_hilos:
  dd 0

newmem:

code:
  mov [numero_de_hilos],rax
  mov eax,[rax+00000098]
  jmp return

INJECT:
  jmp newmem
  nop
return:
registersymbol(INJECT)

[DISABLE]

INJECT:
  db 8B 80 98 00 00 00

unregistersymbol(INJECT)
dealloc(newmem)
dealloc(numero_de_hilos)

He resaltado el código que he añadido yo. La función globalalloc() reserva un espacio en la memoria para nuestra variable y lo hace accesible de manera global. Toma los argumentos numero_de_hilos (cómo llamar a la variable) y 4 (el tamaño en bytes). Después inicio la variable con numero_de_hilos: y le doy el valor 0 con dd 0. Más tarde guardo el valor de rax en numero_de_hilos. Después seleccionamos File>Assign to current cheat table para poder usar el script.

Pero aún no tenemos claro qué hace este script, ¿verdad? Lo que hace es guardar la dirección del rax (sin sumarle los 98 bytes de diferencia) en la dirección a la que apunta numero_de_hilos, es decir, en nuestra variable. Aún así, si no accedemos a ella, de poco nos va a servir. Es muy importante el uso de globalalloc() en vez de alloc() si vamos a acceder a esa variable de manera manual, ya que la vuelve global, y por lo tanto accesible para el resto del programa y de scripts que añadamos.

Tras asignar el script a nuestro cheat table nos convendría ponerle una descripción y un nombre descriptivos.

A la izquierda podemos ver unas casillas bajo el nombre de “Active”. Si marcamos la casilla sobre un valor o una variable, la congelaremos, impidiendo que cambie su valor. Si la marcamos sobre un script, se ejecutará el código en la sección bajo el nombre de [ENABLE] y si lo desmarcamos se ejecutará la sección bajo el nombre de [DISABLE] que se encuentra en dicho script.

[ENABLE]

aobscan(INJECT,8B 80 98 00 00 00 5D) // should be unique
alloc(newmem,$1000,INJECT)
globalalloc(numero_de_hilos,4)

label(code)
label(return)

...

[DISABLE]

INJECT:
  db 8B 80 98 00 00 00

unregistersymbol(INJECT)
dealloc(newmem)
dealloc(numero_de_hilos)

...

Ahora, vamos a ver que valor tiene nuestra variable. Para ello, vamos a seleccionar la opción que pone “Add address manually”. Es de vital importancia tener en cuenta los siguientes conceptos:

  1. El valor que hay almacenado en numero_de_hilos es un puntero.
  2. Ese puntero es el del rax, no el de rax+98, que es el que nos interesa.
  3. Para que se actualice el valor, vamos a tener que ejecutar el script.

¿Cómo vamos a proceder entonces? Donde nos pone “Address” escribiremos [numero_de_hilos]+98. Al ponerlo entre corchetes lo estamos teniendo en cuenta como un puntero y no como un valor numérico. El +98 que le sigue es el offset hasta el valor que queremos ver. En este punto, esto sería lo equivalente a ese [rax+00000098], pero con variables que controlamos.

Con eso y una descripción relativamente decente ya estaría listo, pese a que nos marca que el Cheat Engine actualmente no tiene ni la más remota idea de qué dirección le estamos diciendo. Eso es porque todavía no hemos ejecutado el script y no se ha reservado ni iniciado en memoria, así que vamos a hacerlo.

Ahora ya está inyectado, pero aún no se ha ejecutado. La razón es porque la parte del código en la que lo hemos inyectado aún no se ha ejecutado. Para hacer que se ejecute en este caso, valdría simplemente con darle a “Start” y ya está.

Esta técnica es extremadamente útil para depurar programas de manera eficiente, y a diferencia de la creencia popular, Cheat Engine no sólo sirve para juegos. Ahora que ya tenemos lo que queremos, podemos eliminar la primera celda ya que guarda una dirección estática, es decir, la próxima vez que arranquemos el programa va a mirar en la dirección que tiene ahora asignada, lo que se traduce en que no va a darnos el valor que buscamos gracias al ya mencionado ASLR. En cambio la tercera celda es dinámica, o sea, va a cambiar y adaptarse a la próxima vez gracias al script que hemos creado.

Ahora que ya tenemos preparado nuestro entorno, vamos a ver si podemos hacer algo. Antes de nada vamos a guardar la cheat table para abrirla después, y así verificar que nuestro script funciona sin fallos.

Ahora, podemos cerrar CE y la aplicación en cuestión y volver a abrir ILSpy por la parte del código con un bug.

using System.Windows;
using sxesac.Classes.Helpers;
using sxesac.Classes.Internal;
using sxesac.Helpers;

private bool VerifyUserOptions(int taskNumber)
{
	if (taskNumber == 0)
	{
		System.Windows.Application.Current.Dispatcher.Invoke(() => MessageBox.Show("How do you want to get work done if you don't have any employees? Gotta hire some, no?\nYou can't have 0 threads.\n\nTo continue, specify at least 1 thread.", "No threads", MessageBoxButton.OK, MessageBoxImage.Exclamation));
		return false;
	}
	if (LoadedAccountsCollection.Count == 0)
	{
		MessageBox.Show("Your accounts list is empty.\n\nTo continue, add an accounts list.", "Accounts list is empty", MessageBoxButton.OK, MessageBoxImage.Exclamation);
		return false;
	}
	if (taskNumber > LoadedAccountsCollection.Count)
	{
		int count = LoadedAccountsCollection.Count;
		MessageBox.Show($"XXXXX lowered the threads number you specified ({taskNumber}) because you loaded {LoadedAccountsCollection.Count} accounts.\n\nXXXX will continue checking accounts with {count} threads.", "You can't have more threads than accounts", MessageBoxButton.OK, MessageBoxImage.Exclamation);
		taskNumber = count;
		return true;
	}
	if (ProxyHelper.LoadedProxiesCollection.Count == 0)
	{
		MessageBox.Show("Your proxies list is empty.\n\nTo continue, add a proxy list.", "Proxy list is empty", MessageBoxButton.OK, MessageBoxImage.Exclamation);
		return false;
	}
	if (taskNumber > ProxyHelper.LoadedProxiesCollection.Count && ProxyModifier.proxyMethod != 0)
	{
		MessageBox.Show("Your thread number is bigger than the proxies you loaded.\n\nTo continue, lower your threads list or load more proxies using the Stack-Add feature.", "Threads are bigger than proxy number", MessageBoxButton.OK, MessageBoxImage.Exclamation);
		return false;
	}
	if (taskNumber > 201 && !VerifyVersion.IsPaidVersion())
	{
		return false;
	}
	Cleanup();
	isChecking = true;
	return true;
} 

He resaltado el bug en negrita. Este bug ocurre cuando el número de hilos es mayor que el número de cuentas cargadas. Si esto ocurre, el programa reduce el número de hilos para adaptarse al número de cuentas cargadas y sale antes de tiempo de la función devolviendo un true. El código de después no se llegaría a ejecutar, por lo que sería algo parecido a un control de acceso erróneo o Broken Access Control. Así que vamos a ponerlo en práctica a ver si funciona. Para ello vamos a abrir el programa junto a Cheat Engine para ver si es verdad que ese número de hilos se reduce.

Para tenerlo todo listo sólo tendríamos que activar el script y darle a “Start” en el programa. Después de eso podemos cargar nuestra lista de combos y mis proxies de pega. Luego sólo quedaría poner un número de hilos mayor al de las cuentas cargadas.

Ahora pulsamos “Start”. Lo que esperamos ahora es que el número de hilos cambie en CE y que sea mayor que el límite de 201 hilos.

Ahí ya podemos apreciar que nos sale el aviso que esperábamos. Yo le he dado a aceptar y lo he dejado corriendo por unos segundos para verificar que el programa no crashea o se corrompe y después le he dado al botón de “Stop” para pararlo. En la segunda imagen podemos ver que el número ha cambiado exactamente al número de combos cargados, y además es mayor que el límite de 201 hilos. Finalmente hemos analizado código, hecho algunas pruebas, configurado un entorno de análisis sencillo y, además, encontrado y explotado una vulnerabilidad de tipo Broken Access Control.

Conclusión

Si bien la investigación de programas y el desarrollo de ciertos exploits puede ser extremadamente intimidante, con las herramientas apropiadas, paciencia y ganas de aprender se puede conseguir todo. Antes de descubrir este programa tenía muy poco conocimiento sobre la explotación y el trabajo en entornos Windows, ya que me he centrado más en aquellos basados en Linux/Unix. He aprendido mucho más de lo que pensé y he comprendido como funciona gran parte del .NET Core, lo que demuestra que la ambición de aprender lo es todo en éste campo.

Beltrán Rivera Arias