El equipo rojo moderno se define por su capacidad para comprometer los endpoints y tomar medidas para completar los objetivos. Para lograr lo primero, muchos equipos implementan su propio comando y control personalizado (C2) o utilizan una opción de código abierto. Para esto último, hay un flujo constante de herramientas posteriores a la explotación que se benefician de varias características en Windows, Active Directory y aplicaciones. Durante los últimos años, el mecanismo de ejecución de estas herramientas se ha basado en gran medida en la ejecución de ensamblados .NET en memoria.
A pesar de ser una parte muy importante del arsenal moderno del equipo rojo, la técnica para ejecutar ensamblados de .NET en un endpoint comprometido se ha mantenido prácticamente estancado. En esta entrada de blog, hablaremos de cómo los equipos rojos pueden incorporar sus arneses de ejecución en .NET en esta década.
Manténgase al día sobre las tendencias más importantes e intrigantes del sector en materia de IA, automatización, datos y mucho más con el boletín Think. Consulte la Declaración de privacidad de IBM.
No hace mucho tiempo, muchos equipos rojos confiaban en PowerShell para las herramientas posteriores a la explotación. Cobalt Strike dio un paso para cambiar eso en 2018 al implementar el módulo execute-assembly. La ejecución del ensamblado generaría un proceso de sacrificio e inyectaría una DLL reflexiva en la que cargaría el entorno en tiempo de ejecución de lenguaje común (CLR) y ejecutaría un ensamblado .NET proporcionado por el operador.
Esto provocó que muchas de las herramientas posteriores a la explotación se pasaran a los ensamblados de .NET. Después de un tiempo, los defensores comenzaron a crear detecciones para el comportamiento de "bifurcar y ejecutar" de la ejecución del ensamblado, es decir, la inyección reflexiva de DLL. Para solucionar este problema, Shawn Jones, también del equipo de IBM® Adversary Simulation, desarrolló el archivo de objeto de baliza (BOF) InlineExecute-Assembly. Esto permitió a los operadores alejarse del comportamiento de “bifurcar y ejecutar” de la ejecución del ensamblado y permanecer dentro del proceso de implantación. Desde entonces, muchos marcos C2 han adoptado este comportamiento de forma nativa.
Si aún no está familiarizado con cómo alojar el CLR y ejecutar ensamblados .NET, le recomendaría que lea la entrada de blog de Shawn enlazada arriba.
La técnica de ejecución del ensamblado se beneficia de una característica de Windows conocida como “Alojamiento CLR no administrado”. El tiempo de ejecución de lenguaje común, o CLR, es el tiempo de ejecución para .NET. Los usuarios pueden escribir ensamblados .NET en una variedad de lenguajes (C#, F#, etc.) que se compilan en un lenguaje intermedio (IL). El CLR es responsable de tomar un ensamblado que contiene IL y ejecutarlo.
Tradicionalmente, la técnica de ejecución y ensamblaje se ha basado en el uso de la interfaz obsoleta. Al cargar desde un array de bytes, evitamos el problema de tener que colocar cualquier código en el sistema de archivos, que podría escanearse con soluciones defensivas.
Desde entonces, ha sido reemplazado por la interfaz .
Figura 1: página de MSDN para la interfaz ICLRRuntimeHost
Los documentos de MSDN para la interfaz
Nota: Aunque no podemos usar
El método nos permite proporcionar nuestra propia implementación de la interfaz COM , que es como podemos indicar al CLR que use varias características. Las personalizaciones de CLR son una característica de la que se habla poco y que permite a los desarrolladores tomar el control de aspectos del CLR. Las personalizaciones funcionan mediante el uso de varias interfaces de “gestor” que nosotros, como desarrolladores, podemos implementar, y nuestra implementación indicará al CLR qué gestores nos gustaría que utilizara. Todo lo que no implementemos simplemente lo gestionará el CLR como normalmente lo haría. A continuación se muestra una lista de los gestores compatibles.
Figura 2: algunas de las interfaces compatibles con las personalizaciones de CLR
He utilizado recuadros rojos para resaltar los dos gestores que se tratarán en esta entrada de blog:
Escribí mi prueba de concepto inicial para las personalizaciones CLR en C++, pero finalmente decidí reimplementarlo todo en C puro, que es lo que veremos en esta publicación. Prefiero los implantes escritos en C para evitar la sobrecarga de C++, por lo que quería que este arnés de ejecución de ensamblaje también estuviera en C. Implementar interfaces COM en C es una tarea enorme, pero espero que la siguiente información lo haga más fácil en el futuro. A continuación se muestra cómo debe definir la interfaz , que he denominado “MyHostControl”.
Figura 3: archivo de encabezado que implementa la interfaz IHostControl
Para implementar nuestra interfaz COM, debemos tener los siguientes componentes (mostrados arriba en este orden):
La implementación de los métodos actuales es un poco más sencilla, como se muestra a continuación:
Figura 4: implementación de los métodos QueryInterface, AddRef y Release
Como he mencionado antes, los métodos
Figura 5: implementación del método GetHostManager
Aquí no necesitamos implementar el método
Ahora que hemos implementado nuestra interfaz , podemos llamar y poner en marcha el CLR. A continuación hay un fragmento de código para hacer las tareas normales de alojamiento CLR (, , ), y luego llamar usando nuestra interfaz personalizada de control de host. Luego iniciamos el CLR.
Figura 6: llamada a SetHostControl e inicio del CLR
Ahora que sabemos cómo implementar interfaces COM en C, implementar administradores específicos es más sencillo. La interfaz nos permite tomar el control de la gestión de memoria del CLR. A continuación se muestra una lista de todas las funciones que necesitamos implementar para .
Figura 7: lista de métodos para la interfaz IHostMemoryManager
Probablemente note que algunos métodos permiten un comportamiento interesante, como los métodos virtuales* (VirtualAlloc, VirtualProtect, VirtualQuery, VirtualFree) que se utilizan para realizar la mayor parte de la gestión de la memoria en Windows. A continuación se muestra una implementación muy básica de estos métodos.
Figura 8: implementación de VirtualAlloc, VirtualFree, VirtualQuery y VirtualProtect
Tomar el control sobre las asignaciones de memoria permite al operador ser tan creativo como desee. Por ejemplo, podría realizar las llamadas a la API de asignación mediante syscalls indirectas. También podría realizar un seguimiento de todas las asignaciones realizadas por el CLR y cifrarlas cuando su implante entre en modo de suspensión. Tenga en cuenta que el cifrado de las asignaciones de CLR no es muy estable. Además de los métodos virtuales*, también existe el método
Anteriormente mencioné que intentar cifrar las asignaciones de memoria CLR pasa de ser “algo inestable” a “muy inestable”, dependiendo exactamente de cómo lo haga. Esto se debe a que si cifra o libera un fragmento de memoria que el CLR intenta consultar más adelante, el CLR lanzará un error y hará que su proceso se bloquee. Sin embargo, hay una excepción que he encontrado: las asignaciones hechas durante las cargas iniciales de ensamblado.
Cuando carga un ensamblador, ya sea en memoria o desde disco, el CLR asignará espacio y mapeará el ensamblador en memoria. Por lo que yo sé, no existía una buena forma públicamente conocida de identificar esta región de memoria y borrarla, aparte de buscar en la memoria del proceso patrones de bytes o asignaciones del tamaño esperado. Las personalizaciones de CLR proporcionan un mecanismo sencillo para realizar un seguimiento de estas asignaciones en forma del método . Este método es una devolución de llamada de notificación que se activa cada vez que CLR carga un ensamblado en el proceso, y la devolución de llamada incluye la dirección y el tamaño de la asignación como argumentos. Según mis pruebas, esta llamada solo se activa cuando se carga un ensamblador en el proceso, lo que nos proporciona una buena forma de llevar un control de las asignaciones de carga del ensamblaje. Para mayor solidez, puede comprobar el tamaño o analizar la memoria para asegurarse de que es el ensamblado que espera. A continuación se muestra un ejemplo de implementación de este método. Dado que es solo una devolución de llamada de notificación, puede hacer lo que quiera y luego simplemente devolver S_OK.
Figura 9: implementación del método AcquiredVirtualAddressSpace
A diferencia de las otras asignaciones realizadas por CLR, no he tenido ningún problema para cifrar o borrar esta región de memoria después de que el ensamblado haya terminado de ejecutarse. Puede tener problemas si intenta ejecutar el mismo ensamblado en el mismo dominio de aplicación de nuevo, ya que el CLR podría intentar utilizar el ensamblado almacenado en caché que ahora no es válido. La mayoría de las implementaciones de ejecución del ensamblado crearán un nuevo dominio de aplicación y lo destruirán después de la ejecución, así que asegúrese de probar su implementación.
Esta funcionalidad de notificación también tiene una aplicación defensiva menor. Por lo general, Defensive Products utiliza el Event Tracing for Windows (ETW) para realizar un seguimiento de las cargas de ensamblado en la CLR, pero esto proporciona otra forma de recibir una notificación si se ha cargado un ensamblado en el proceso. Como la dirección y el tamaño de la memoria están incluidos, sería trivial que un producto defensivo realizara un análisis de memoria en esa región.
Los otros gestores que examinaremos son y . es responsable de dos cosas: dar al CLR una lista de ensamblados que debe manejar la carga (en lugar de nosotros, como host del CLR) y devolver una interfaz con el CLR. tiene dos métodos: y .
se llamará a cada vez que se pida al CLR que cargue un ensamblado que no esté en la lista de ensamblados que el CLR es responsable de cargar (devuelto por ). El CLR llama a y le pasa la cadena de identidad de un ensamblaje, y se encarga de devolver los bytes del ensamblaje. Probablemente haya visto una cadena de identidad antes, se parece a: “”.
Una vez que ha resuelto el ensamblado, el contenido del ensamblado se devuelve estableciendo un puntero que se proporciona como argumento. He destacado el argumento pertinente, , en la captura de pantalla de abajo.
Figura 10: argumentos a favor del método ProvideAssembly de la interfaz IHostAssemblyStore
El ensamblado se devuelve estableciendo el puntero en la dirección de un IStream en memoria. Normalmente, el CLR intentaría localizar el ensamblado siguiendo su orden de búsqueda de directorios en el disco, de forma similar a cargar una DLL en un proceso normal de Windows. Pero como podemos ofrecer nuestra propia implementación, podemos aceptar una solicitud de ensamblado que normalmente se cargaría desde el disco y, en su lugar, proporcionar un ensamblado que tengamos en memoria. Los bytes del ensamblado sí necesitan estar en un IStream, lo que puede lograrse utilizando la funció SHCreateMemStream que toma una matriz de bytes y devuelve un IStream.
Quizás te preguntes por qué esto es importante si es simplemente otra forma de cargar ensamblados en la memoria. ¿Qué ocurre con la Anti-Malware Scan Interface (AMSI)?
AMSI es responsable de escanear cualquier ensamblado que se cargue de manera reflexiva en busca de contenido malicioso. Windows Defender utiliza AMSI, y AMSI también permite que otros EDR se conecten y analicen el contenido de los ensamblados cargados en la memoria. Algunos tienden a despreciar AMSI, ya que se puede eludir, pero creo que para algo que se instala en Windows de forma predeterminada, AMSI es una característica de seguridad bastante eficaz. Como mínimo, detectará una gran cantidad de herramientas maliciosas .NET (como Seatbelt) que se ejecutan en memoria. Hay un amplio historial de jugar al gato y al ratón eludiendo AMSI entre los equipos rojos, pero muchas de las elusiones de AMSI se basan en parchear los bytes de las funciones clave (como AMSIScanBuffer) para que no se ejecuten o devuelvan un valor “bueno”. Las elusiones de AMSI tradicionales son engorrosos ya que dejan bytes Copy-on-Write en la sección .text de la memoria de AMSI.dll, que son un indicio inequívoco para cualquier defensor que observe un proceso sospechoso. Las elusiones de AMSI más sofisticadas, como los ganchos de punto de interrupción de hardware, también tienen otros IOC asociados, como inspeccionar el contexto del hilo para buscar el uso del registro de depuración.
Implementando nuestra propia versión del método , podemos eludir completamente el AMSI. Tradicionalmente, el método se utiliza para cargar conjuntos desde un array de bytes y es instrumentado por AMSI. Pero, ¿sabía que hay otras funciones Load_* que rara vez se utilizan?
Figura 11: la familia de métodos Load
Ahora la parte importante: porque estamos llamando a
A continuación se muestra un ejemplo de ejecución de Seatbelt a través de una llamada a
Figura 12: ejecución del cinturón de seguridad y elusiones de AMSI
Figura 13: una lista de módulos de proceso sin AMSI.dll cargada
Según un artículo de CLR Inside Out publicado en la edición de agosto de 2006 de la revista MSDN, Microsoft mismo ha utilizado una técnica similar para que SQL Server 2005 cargue ensamblados .NET desde la base de datos en lugar de desde el disco. La capacidad de almacenar y ejecutar ensamblados .NET desde una SQL database es una de mis técnicas de movimiento lateral favoritas y se puede hacer fácilmente usando SQLRecon, otra herramienta X-Force Red escrita por Sanjiv Kawa.
Estoy publicando una prueba de concepto de esta técnica que puede encontrar en GitHub aquí. Esta prueba de concepto muestra cómo implementar las interfaces COM , , , y . Llama a e inicia el CLR utilizando la interfaz .
La implementación del administrador de memoria simplemente llama a las API de Windows correctas (por ejemplo, VirtualAlloc), pero incluye un ejemplo de seguimiento de todas las asignaciones de memoria realizadas por el CLR. También incluye un ejemplo de cómo borrar los artefactos de carga ensambladora proporcionados por la devolución de llamada mencionada anteriormente.
También hay una prueba de concepto completa para el bypass AMSI. Hay una advertencia con esta omisión si intenta implementarla en sus propias herramientas: la identidad del ensamblado que intente cargar utilizando el método debe coincidir con la identidad del ensamblado que finalmente devuelva al CLR. Por ejemplo, si llama a con un argumento de “Seatbelt, Version=0.0.0.0, PublicKeyToken=null, Culture=neutral”, entonces el ensamblado que finalmente intenta ejecutar también debe tener esta identidad. No puede intentar cargar mscorlib, sino devolver Seatbelt, ya que el CLR lo comprobará y arrojará un error. Tenga en cuenta qué nombres de ensamblado está intentando cargar, pero si todavía está intentando cargar reflexivamente un ensamblado llamado Seatbelt en cualquier año en el que esté leyendo esto, le sugiero que cierre esta entrada de blog y realice una actividad más satisfactoria.
En la prueba de concepto, utilizo el metodo de la interfaz para obtener la cadena de identidad del conjunto que se va a ejecutar. También puede separar esto del implante y obtener la identidad del conjunto en el cliente o el servidor del equipo, y luego pasar la cadena de identidad como argumento a su implante.
Aunque se trata de una novedosa elusión de AMSI, en última instancia es solo eso: una elusión de AMSI. Los productos defensivos deberían utilizar estrategias de defensa profundas y no depender simplemente de AMSI para detectar ensamblados maliciosos. Cualquier ensamblado cargado usando esta técnica también generará los mismos eventos de Event Tracing for Windows (ETW) que cualquier otro ensamblado en memoria. Los ensamblados maliciosos se pueden detectar mediante el análisis de memoria, como hemos visto en varias plataformas de EDR (detección y respuesta de endpoints) más avanzadas. Muchas herramientas post-explotación también tienen sus propios IOCs únicos.
Como se ha mencionado anteriormente, también hay algunas aplicaciones defensivas de esta investigación. La devolución de llamada proporciona otro método para recibir notificaciones cuando se cargan ensamblados en el proceso. Si un defensor implementara la interfaz , se insertaría en la cadena de carga del ensamblado y tendría la capacidad de bloquear por completo las cargas del ensamblado o modificar los bytes de un ensamblado antes de que se cargue en el proceso. Voy a arriesgarme y decir que creo que es muy probable que haya avances futuros en este ámbito.
Me gustaría referirme a nuestra línea de tiempo de esta investigación y por qué la publicamos ahora. Realicé toda esta investigación en junio de 2023 y la hemos mantenido privada desde entonces, aunque se ha presentado en varias conferencias CFP. En el momento en que se realizó esta investigación, busqué en motores de búsqueda algo similar, inicialmente buscando material de referencia y luego buscando confirmar si fui el primero en identificar este comportamiento. Desde entonces, he realizado búsquedas periódicas para ver si alguien había publicado algún trabajo similar. A principios de enero de 2025 encontré esta pieza: Using CLR Hosting to Evade AMSI, de Marcos González Hermida en la revista “NTT Data”.
Este artículo reveló este mismo desvío y, según la revista, se publicó inicialmente en junio de 2024 (el enlace complementario anterior es de julio de 2024). Es un texto excelentemente escrito y le recomendaría que lo lea. Las únicas observaciones que haría son que el autor concluye que los ensamblados deben estar firmados para poder ejecutarse y que, en su prueba de concepto, utilizan el método para obtener manualmente el método Main del ensamblado que cargan (Rubeus, concretamente). No he tenido problemas para cargar ensamblados sin firmar con esta técnica, y puede usar el mismo método para obtener el punto de entrada del ensamblado cargado que se utiliza en muchas implementaciones de execute-assembly, sin tener que conocer los nombres del espacio de nombres o de las clases.
Con la publicación del artículo de Marcos y la prueba de concepto, esta elusión de AMSI ahora podría considerarse pública, por lo que decidimos que era hora de publicar nuestra investigación junto con una prueba de concepto de lo que descubrimos.
En esta publicación, analizamos cómo pueds usar las personalizaciones de CLR para mejorar su OPSEC mientras ejecuta herramientas .NET en memoria. Además, demostramos una elusión completa de AMSI utilizando estas características CLR menos conocidas. El uso de las herramientas de.NET seguirá siendo eficaz para los profesionales de la ofensiva y los actores de amenazas. Por este motivo, es fundamental que los defensores comprendan cómo funciona el CLR y construyan estrategias de defensa en profundidad para detectar herramientas posteriores a la explotación.
¡Gracias Brett y Valentina por revisar esta investigación!
Dealing with Failure: Failure Escalation Policy in CLR Hosts – Este es el único ejemplo real que pude encontrar de espionaje ofensivo utilizando personalizaciones de CLR cuando inicialmente estaba haciendo esta investigación.
Hosted Pumpkin : un repositorio de GitHub que contiene una prueba de concepto para implementar varias personalizaciones de CLR.
Shellcode: Loading .NET Assemblies From Memory – Donut fue de gran ayuda para resolver todas las estructuras de datos y definiciones relevantes en C.
Customizing the Microsoft .NET Framework Common Language Runtime por Steven Pratschner – Este es el texto definitivo sobre las personalizaciones de CLR. Simplemente una lectura obligada si tienes algún interés en esta área.