Donut: generador de shellcodes capaces de cargar assembly .NET en memoria (1 de 2)

En 2015, el AMSI (Antimalware Scan Interface) fue integrado en varios componentes de Windows usados para ejecutar scripts (VBScript, JScript, PowerShell). Casi al mismo tiempo, se agregó a PowerShell Script Block Logging permitiendo capturar el contenido completo de los scripts que se ejecutan, eliminando así cualquier ofuscación utilizada. De esta manera los Red Teams tuvieron que dar otro pasito y empezaron a usar ensamblados o assemblies (suena mejor en inglés) generalmente escritos en C#. Ahora lo veremos en más detalle que son, pero adelantaros que los assemblies brindan toda la funcionalidad de PowerShell pero con la clara ventaja de cargar y ejecutar completamente desde la memoria y además hoy en día casi todas las máquinas con microsoft Windows tienen el framework .NET instalado que pueden usarlo así que ¡buena combinación!

Ya sabéis que .NET fue creado para rivalizar y reemplazar a Java por lo que tiene ciertas similitudes. Es "compilado" a CIL, un lenguaje intermedio y usa código managed compilado just-in-time por el CLR (además de poder operar con código unmanaged o nativo). Soporta C#, F#, C++/CLI, PowerShell, JScript, VBScript, IronPython, IronRuby y permite operar entre ellos. Antes de comenzar, debemos comprender algunos componentes importantes de .NET.

- CLR o Common Language Runtime: al igual que Java, .NET utiliza un entorno (o "máquina virtual") para interpretar el código en tiempo de ejecución. Todo el código .NET se compila en un lenguaje intermedio al código nativo "Just-In-Time" antes de la ejecución.

- CIL o Common Intermediate Language: hablando de un lenguaje intermedio, .NET usa CIL (también conocido como MSIL). Todos los lenguajes .NET (de los cuales hay muchos) están en "assemblies" en este lenguaje intermedio. CIL es un lenguaje assembly genérico orientado a objetos que se puede interpretar en código máquina para cualquier arquitectura de hardware. Como tal, los diseñadores de lenguajes .NET no necesitan diseñar sus compiladores en torno a las arquitecturas en las que se ejecutarán. En cambio, simplemente necesitan diseñarlo para compilarlo en un idioma: CIL.

- Assemblies .NET: las aplicaciones .NET se empaquetan en .NET Assemblies. Se llaman así porque el código de su lenguaje elegido se ha "ensamblado" en CIL pero no se ha compilado realmente. Los assemblies usan una extensión del formato PE y se representan como un EXE o una DLL que contiene CIL en lugar de código de máquina nativo.

- Dominios de aplicación: los assemblies se ejecutan dentro de una "caja" segura conocida como dominio de aplicación. Pueden existir varios assemblies dentro de un dominio de aplicación, y pueden existir múltiples dominios de aplicación dentro de un proceso. Los AppDomains están destinados a proporcionar el mismo nivel de aislamiento entre los ensamblajes en ejecución que normalmente se proporciona para los procesos. Los subprocesos pueden moverse entre AppDomains y pueden compartir objetos a través de la organización y los delegados.

Estado del arte en .NET

En Windows el framework .NET permite operar en memoria para no tocar el disco pero sin embargo está bastante restringido. Hasta hace poco se usaban dos formas principales:

- Assembly.Load(): la librería estándar del framework .NET incluye una API para la reflexión de código. Esta API incluye System.Reflection.Assembly.Load, que se puede usar para cargar programas .NET desde la memoria. En menos de cinco líneas de código, puede cargar una DLL .NET o EXE desde la memoria y ejecutarlo. Si bien la API de Reflection es muy versátil y puede ser útil de muchas maneras diferentes, solo puede ejecutar código en el proceso actual. No se proporciona soporte para ejecutar payloads en procesos remotos.

- execute-assembly: Raphael Mudge introdujo en Cobalt Strike 3.11 un comando llamado "execute-assembly" que ejecutaba assemblies .NET desde la memoria como si se ejecutaran desde el disco. El principal problema con execute-assembly es que se ejecuta de la misma manera cada vez. Esa previsibilidad asegura que sea confiable, pero también permite a los defensores crear análisis. Tampoco permite inyectar en un proceso remoto que esté en ejecución, ni especificar cómo se produce esa inyección. Además tiene una limitación de 1 MB  de tamaño para sus payloads, lo que limita su flexibilidad en el diseño de herramientas posteriores a la explotación.

Avanzando

Para superar estas limitaciones, necesitamos una técnica que cumpla con los siguientes requisitos:

- permita ejecutar código .NET desde la memoria.
- pueda funcionar con cualquier proceso de Windows, independientemente de su arquitectura y de si tiene cargado el CLR.
- permita inyectar ese código en un proceso remoto (diferente) o en el proceso local (actual).
- permita determinar de qué manera se produce esa inyección.
- funcione con múltiples tipos de procesos de inyección.

El tipo de payload más flexible que cumpla esos requisitos es un shellcode pero no se puede convertir un assembly .NET a shellcode porque, como hemos dicho, se ejecutan a través de un entorno de tiempo de ejecución, no directamente en el hardware. ¿No sería genial si pudiéramos inyectar assemblies .NET como shellcodes? ¿Te apetece un donut?

Introduciendo Donut

Donut, creado por Odzhan y TheWover, es una herramienta que permite crear un PIC (position-independent code) o shellcode x86 o x64 que puede cargar un assembly .NET desde la memoria. Es decir, a partir de un Assembly .NET, parámetros y un punto de entrada (como Program.Main), produce un shellcode capaz de cargarse en memoria. El assembly .NET se puede cargar desde una url o directamente embeberlo en el shellcode y, en cualquier caso, estará cifrado con Chaskey y una clave generada aleatoriamente de 128 bits. Después de cargar el assembly a través del CLR, la referencia original se borra de la memoria para disuadir a los escáneres de memoria. El assembly se carga en un nuevo dominio de aplicación para permitir la ejecución de assemblies en AppDomains desechables.

Repo: https://github.com/TheWover/donut

Cómo funciona

Unmanaged Hosting API

Microsoft proporciona una API conocida como la API de alojamiento CLR no administrado, en inglés Unmanaged Hosting API. Esta API permite al código no administrado (como C o C++) alojar, inspeccionar, configurar y usar Common Language Runtimes. Es una API legítima que se puede usar para muchos propósitos. Microsoft lo usa para varios de sus productos, y otras compañías lo usan para diseñar cargadores personalizados para sus programas. Se puede utilizar para mejorar el rendimiento de las aplicaciones .NET, crear sandboxes o simplemente hacer cosas extrañas...

Una de las cosas que puede hacer es cargar manualmente assemblies .NET en dominios de aplicación arbitrarios. Puede hacer esto desde el disco o desde la memoria. Donut usa esta capacidad para cargar desde la memoria, es decir, para cargar el payload sin tocar el disco.

Inyección CLR

La primera acción que realiza el shellcode de donut es cargar el CLR. Una vez que se carga el CLR, el shellcode crea un nuevo dominio de aplicación. En este punto, se debe obtener el payload del assembly .NET. Si el usuario proporcionó una URL se descargará de ahí el assembly. De lo contrario, se obtendrá de la memoria. De cualquier manera, se cargará en el nuevo "AppDomain". Después de cargar el assembly pero antes de ejecutarlo, la copia descifrada se liberará y luego se liberará de la memoria con VirtualFree para evadir los escáneres de memoria. Finalmente, el punto de entrada especificado por el usuario se invocará junto con cualquier parámetro proporcionado.

Si el CLR ya está cargado en el proceso del host, el shellcode de donut seguirá funcionando. El assembly .NET se cargará en un nuevo dominio de aplicación dentro del proceso administrado. .NET está diseñado para permitir que los assemblies .NET creados para múltiples versiones de .NET se ejecuten simultáneamente en el mismo proceso. Como tal, su payload siempre debe ejecutarse sin importar el estado del proceso antes de la inyección.

Generación de Shellcode

La lógica anterior describe cómo funciona el shellcode generado por donut. Esa lógica se define en payload.exe. Para obtener el shellcode, exe2h extrae el código de máquina compilado del segmento .text en payload.exe y lo guarda como un array en C en un archivo header en C. Donut combina el shellcode con una instancia de Donut (una configuración para el shellcode) y un módulo Donut (una estructura que contiene el assembly .NET, el nombre de la clase, el nombre del método y cualquier parámetro).

Para generar el shellcode con donut se debe especificar un assembly .NET, un punto de entrada y cualquier parámetro que deseemos usar. Por ejemplo, si nuestro assembly usa el espacio de nombres Test e incluye la clase Program con método Main, entonces usaríamos las siguientes opciones:

donut.exe -f Test.exe -c Test.Program -m Main

Para generar el mismo shellcode en procesadores de 32-bit, usaremos la opción ‘-a’:

donut.exe -a 1 -f Test.exe -c Test.Program -m Main

También se pueden proporcionar parámetros a cualquier punto de entrada que especifiquemos. La longitud máxima de cada parámetro actualmente es de 32 caracteres. Para demostrar esta funcionalidad, podemos usar las siguientes opciones y nuestro assembly de ejemplo para crear un shellcode que generará un proceso de Bloc de notas y un proceso de Calc:

.\donut.exe -f .\DemoCreateProcess\bin\Release\DemoCreateProcess.dll -c TestClass -m RunProcess -p notepad.exe,calc.exe

Al generar un shellcode para ejecutarse en una máquina Windows más antigua, es posible que necesitemos usar la v2 del CLR, en lugar de la v4. v2 funciona para versiones de .NET Framework <= 3.5, mientras que v4 funciona para versiones> = 4.0. Por defecto, donut usa la versión 4 del CLR. Podemos pedirle que use v2 con la opción "r" y especificando "v2.0.50727" como parámetro.

.\donut.exe -r v2.0.50727 -f .\DemoCreateProcess\bin\Release\DemoCreateProcess.dll -c TestClass -m RunProcess -p notepad.exe,calc.exe

El nombre del dominio de aplicación para el payload .NET también se puede especificar manualmente usando la opción "-d". Por defecto, se generará aleatoriamente. Podemos especificar un nombre así:

.\donut.exe -d ResourceDomain -r v2.0.50727 -f .\DemoCreateProcess\bin\Release\DemoCreateProcess.dll -c TestClass -m RunProcess -p notepad.exe,calc.exe

Para reducir el tamaño del shellcode (o por muchas otras razones) se puede especificar una URL donde se alojará el payload. Donut producirá un Módulo Donut cifrado con un nombre aleatorio que deberemos colocar en la URI que se especificó. El nombre y la ubicación donde debemos colocarlo se imprimirá en su pantalla cuando generemos el shellcode.

.\donut.exe -u http://remote_server.com/modules/ -d ResourceDomain -r v2.0.50727 -f .\DemoCreateProcess\bin\Release\DemoCreateProcess.dll -c TestClass -m RunProcess -p notepad.exe,calc.exe

Y hasta aquí la primera parte de este post, como veis teórica pero necesaria para entender como funciona la herramienta. En la siguiente entrada veremos ya un caso práctico de uso de Donut :):

Segunda entrada - uso práctico


Referencias:

- https://thewover.github.io/Introducing-Donut/
- https://modexp.wordpress.com/2019/05/10/dotnet-loader-shellcode/

Comentarios