A algunos les encanta y a otros les horroriza, pero a estas alturas no debería sorprender a nadie que las técnicas de .NET hayan llegado para quedarse más tiempo del previsto. El marco .NET es una parte integral del sistema operativo de Microsoft, siendo .NET Core su versión más reciente. Core es el sucesor multiplataforma de .NET Framework que lleva .NET también a Linux y macOS. Esto hace que .NET sea ahora más popular que nunca para las técnicas de posexplotación entre adversarios y equipos rojos. En este blog profundizaremos en un nuevo BOF (Beacon Object File) que permite a los operadores ejecutar ensamblados .NET en el propio proceso a través de Cobalt Strike, frente al módulo integrado tradicional de ejecución de ensamblados, que utiliza la técnica fork and run.
Cobalt Strike, un popular software de simulación de adversarios, detectó la tendencia de los equipos rojos a abandonar las herramientas de PowerShell en favor de C# debido al aumento de la capacidad de detección de PowerShell, y en 2018, con la versión 3.11 de Cobalt Strike, introdujo el módulo execute-assembly. Esto permitió a los operadores beneficiarse de la potencia de los ensamblados .NET de posexplotación mediante su ejecución en memoria, sin el riesgo añadido de tener que escribir esas herramientas en disco. Aunque la capacidad de cargar ensamblados .NET en memoria a través de código no gestionado no era nueva ni desconocida en el momento del lanzamiento, diría que Cobalt Strike llevó esa capacidad al gran público y contribuyó a seguir fomentando la popularidad de .NET para las técnicas de posexplotación.
El módulo execute-assembly de Cobalt Strike utiliza la técnica fork and run, que consiste en generar un nuevo proceso sacrificial, inyectar su código malicioso de posexplotación en ese nuevo proceso, ejecutarlo y, al terminar, finalizar el nuevo proceso. Esto tiene tanto beneficios como inconvenientes. El principal beneficio del método fork and run es que la ejecución se produce fuera del proceso de nuestro implante Beacon. Esto significa que, si algo sale mal en nuestra acción de posexplotación o si esta es detectada, existen muchas más probabilidades de que nuestro implante sobreviva. En pocas palabras, esto ayuda mucho a la estabilidad general del implante. Sin embargo, dado que los proveedores de seguridad han detectado este comportamiento de fork and run, ahora se ha convertido en lo que Cobalt Strike admite como un patrón costoso a nivel de OPSEC.
A partir de la versión 4.1, lanzada en junio de 2020, Cobalt Strike introdujo una nueva característica para intentar abordar este problema: los Beacon Object Files (BOF). Los BOF permiten a los operadores evitar los conocidos patrones de ejecución descritos anteriormente u otros fallos de OPSEC (como el uso de cmd.exe o powershell.exe) mediante la ejecución de archivos objeto en memoria, dentro del mismo proceso que nuestro implante beacon. Aunque no voy a entrar en los detalles del funcionamiento interno de los BOF, he aquí algunas entradas de blog que me han parecido interesantes:
Si ha leído los blogs anteriores, ahora debería ver que los BOF no han sido exactamente la solución definitiva que esperábamos y, si soñaba con reescribir todas esas increíbles herramientas .NET para convertirlas en BOF, me temo que esos sueños se han desvanecido. Lo siento. Sin embargo, no todo está perdido ya que, en mi opinión, los BOF pueden ofrecer grandes ventajas, y recientemente me he divertido mucho (y también me he frustrado un poco) llevando al límite lo que se puede hacer con ellos. Primero fue con la creación de CredBandit, que realiza un volcado completo en memoria de un proceso como LSASS y lo envía de vuelta a través de su canal de comunicación Beacon existente. Hoy presento InlineExecute-Assembly, que puede utilizarse para ejecutar ensamblados .NET dentro de su proceso beacon sin necesidad de modificar sus herramientas .NET favoritas. Veamos por qué escribí este BOF, algunas de sus características clave, sus limitaciones y cómo puede resultar útil a la hora de realizar simulaciones de adversarios o equipos rojos.
Boletín del sector
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.
Su suscripción se enviará en inglés. Encontrará un enlace para darse de baja en cada boletín. Puede gestionar sus suscripciones o darse de baja aquí. Consulte nuestra Declaración de privacidad de IBM para obtener más información.
El motivo detrás de la creación de InlineExecute-Assembly es bastante sencillo. Quería que nuestro equipo de simulación de adversarios tuviera una forma de ejecutar ensamblados .NET en el propio proceso, para evitar algunos de los inconvenientes de OPSEC mencionados anteriormente al utilizar Cobalt Strike en entornos maduros. También necesitaba que la herramienta no supusiera una carga adicional de tiempo de desarrollo para nuestro equipo al tener que modificar la mayoría de nuestras herramientas .NET actuales. Además, tenía que ser estable. Bueno, todo lo estable que puede ser un BOF complejo, ya que lo último que queremos es perder uno de nuestros pocos Beacons en el entorno. Básicamente, debería funcionar tan bien para el operador como el módulo execute-assembly de Cobalt Strike.
Lo sé, es bastante obvio. No llegaríamos muy lejos sin él, ¿verdad? Bromas aparte, las complejidades del funcionamiento del CLR y lo que sucede en profundidad podrían dar para una entrada de blog propia, por lo que vamos a revisar de forma muy general qué utiliza el BOF al cargar el CLR mediante código no gestionado.
Carga del CLR
Como se muestra en la captura de pantalla simplificada anterior, los pasos principales que seguirá el BOF para cargar el CLR son los siguientes:
Ahora el CLR está inicializado, pero aún queda algo más por hacer antes de poder ejecutar nuestros ensamblados .NET favoritos. Tenemos que crear nuestra instancia AppDomain, que es lo que Microsoft describe como “un entorno aislado donde se ejecutan las aplicaciones”. En otras palabras, se utilizará para cargar y ejecutar nuestros ensamblados .NET de posexplotación.
Se está creando AppDomain y se está cargando/ejecutando el ensamblado
Como se muestra en la captura de pantalla simplificada anterior, los pasos principales que seguirá el BOF para cargar e invocar nuestro ensamblado .NET son los siguientes:
Espero que ahora tenga una comprensión general de la ejecución de .NET mediante código no gestionado, pero esto todavía no nos acerca a tener una herramienta sólida desde el punto de vista operativo. Por ello, veremos algunas características que se implementaron en el BOF para mejorarlo.
Probablemente se pregunte por qué esto es importante. Bueno, si es como yo y valora su tiempo, no querrá dedicarlo a modificar prácticamente todos los ensamblados .NET que existen para que el punto de entrada devuelva una cadena con todos los datos que normalmente se enviarían a la salida estándar de la consola, ¿verdad? Me lo imaginaba. Para evitarlo, lo que tenemos que hacer es redirigir nuestra salida estándar a un named pipe o a un mail slot, leer la salida una vez escrita y, a continuación, revertirla a su estado original. De esta forma, podemos ejecutar nuestros ensamblados sin modificar, tal y como lo haríamos desde cmd.exe o powershell.exe. Ahora, antes de repasar el código, tengo que dar las gracias a @N4k3dTurtl3 y a su entrada de blog sobre la ejecución de ensamblados en el propio proceso y los mail slots. Esto es lo que me llevó a implementar esta técnica en mi propio implante C privado cuando se publicó por primera vez y, muchos meses después, porté esa misma funcionalidad a un BOF. Bien, una vez hechos los agradecimientos, veamos a continuación un ejemplo simplificado de cómo se lograría esto redirigiendo stdout a un named pipe:
Redirección de la salida estándar de la consola a un named pipe y reversión
¿Recuerda que al cargar el CLR a través de ICLRMetaHost ->GetRuntime teníamos que especificar qué versión del marco .NET necesitábamos? ¿Recuerda que eso dependía de la versión con la que se compiló nuestro ensamblado .NET? No sería muy divertido tener que especificar manualmente qué versión se necesita cada vez, ¿verdad? Por suerte para nosotros, @b4rtik implementó una función muy útil para gestionar esto en su módulo execute-assembly para el marco Metasploit, que podemos implementar fácilmente en nuestras propias herramientas, que se muestra a continuación:
Función que lee nuestro ensamblado .NET y ayuda a determinar qué versión de .NET necesitamos al cargar el CLR
Básicamente, lo que hace esta función es que, cuando se le pasan nuestros bytes de ensamblado, los lee y busca los valores hexadecimales de 76 34 2E 30 2E 33 30 33 31 39, que al convertirlos a ASCII dan como resultado v4.0.30319. Espero que le resulte familiar. Si se encuentra ese valor al leer el ensamblado, la función devuelve 1 o verdadero y, si no lo encuentra, devuelve 0 o falso. Podemos usar esto para determinar fácilmente qué versión cargar con 1/verdadero o 0/falso, como se muestra en ejemplo de código a continuación:
Instrucción if/else para establecer la variable de versión de.NET
No podemos hablar de técnicas ofensivas de .NET sin mencionar AMSI. Aunque no vamos a profundizar en qué es AMSI y todas las formas en las que se puede eludir, ya que esto se ha tratado en numerosas ocasiones, sí que hablaremos un poco sobre por qué puede ser necesario parchear AMSI en función de lo que decida ejecutar a través del BOF. Por ejemplo, si decide ejecutar Seatbelt sin ningún tipo de ofuscación, se dará cuenta rápidamente de que no ha obtenido ningún resultado y que su beacon está muerto. Sí, muerto y enterrado. Esto se debe a que AMSI ha interceptado el ensamblado, ha determinado que era malicioso y lo ha clausurado como una fiesta en casa que hace demasiado ruido. No es lo ideal, ¿verdad? Ahora tenemos dos buenas opciones en lo que respecta a AMSI: podemos ofuscar nuestras herramientas .NET mediante algo como ConfuserX o Invisibility Cloak, o podemos desactivar AMSI utilizando diversas técnicas. En nuestro caso, utilizaremos una de RastaMouse, que consiste en parchear el archivo amsi.dll en la memoria para que devuelva E_INVALIDARG y haga que el resultado del escaneo sea 0. Como se señala en su entrada de blog, esto suele interpretarse como AMSI_RESULT_CLEAN. Veamos una versión simplificada del código para un proceso x64 a continuación:
Parcheo en memoria de AmsiScanBuffer
Como puede ver en la captura de pantalla anterior, simplemente hacemos lo siguiente:
Al implementar esto en nuestra herramienta, ahora deberíamos poder ejecutar la versión predeterminada de Seatbelt.exe utilizando el indicador –amsi para eludir la detección de AMSI, como se muestra a continuación:
Ejemplo de elusión de AMSI en InlineExecute-Assemby
Afortunadamente para los responsables de la seguridad, no solo existe AMSI para ayudar a detectar técnicas maliciosas de .NET mediante ETW. Por desgracia, al igual que AMSI, también es bastante fácil de eludir para los adversarios, y @xpn realizó una investigación realmente impresionante sobre cómo se podía hacer. Veamos un ejemplo simplificado de cómo se podría parchear ETW para desactivarlo por completo:
Parcheo en memoria de EtwEventWrite
Como puede ver en la captura de pantalla anterior, los pasos son prácticamente idénticos a los que seguimos para parchear AMSI, por lo que no voy a repetirlos aquí. A continuación puede ver una captura de pantalla del antes y el después de ejecutar el indicador –etw:
Uso de Process Hacker para ver las propiedades de PowerShell.exe antes de ejecutar inlineExecute-Assembly con el indicador –etw
Ejecución de inline-Execute-Assembly utilizando el indicador –etw
Uso de Process Hacker para ver las mismas propiedades de PowerShell.exe tras ejecutar inlineExecute-Assembly
Por defecto, el AppDomain, named pipe o mail slot creado utiliza el valor predeterminado “totesLegit”. Estos valores se pueden cambiar para integrarse mejor en el entorno que esté realizando las pruebas, ya sea modificándolos en el aggressor script proporcionado o mediante indicadores de línea de comandos sobre la marcha. A continuación, se muestra un ejemplo de cómo cambiarlos a través de la línea de comandos:
Ejemplo de InlineExecute-Assembly utilizando un nombre de AppDomain y un nombre de named pipe únicos
Ejemplo de nombre de AppDomain único: ChangedMe
Ejemplo de nombre de named pipe único: LookAtMe
Ejemplo de eliminación de AppDomain tras finalizar con éxito la ejecución
Ejemplo de eliminación del named pipe tras finalizar con éxito la ejecución
Esta sección será prácticamente una repetición de lo que menciono en el repositorio de GitHub, pero me ha parecido importante reiterar algunas cosas que se deben tener en cuenta al utilizar esta herramienta:
A continuación, se presentan algunas consideraciones defensivas: