No haga ruido, quédese: cómo evitar la ejecución Fork&Run .NET con InlineExecute-Assembly

Hombre mirando la pantalla del ordenador mientras trabaja hasta tarde por la noche escribiendo código

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.

Fondo

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.

Las últimas novedades sobre tecnología, respaldadas por conocimientos de expertos

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.

¡Gracias! Se ha suscrito.

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.

¿Por qué InlineExecute-Assembly?

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.

Mixture of Experts | 12 de diciembre, episodio 85

Descifrar la IA: resumen semanal de noticias

Únase a nuestro panel de ingenieros, investigadores, responsables de producto y otros profesionales de talla mundial que se abren paso entre el bullicio de la IA para ofrecerle las últimas noticias y conocimientos al respecto.

Características clave

Carga de Common Language Runtime (CLR)

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.

Captura de pantalla: carga del CLR

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:

  1. Realiza una llamada a CLRCreateInstance, que se utilizará para recuperar nuestra interfaz ICLRMetaHost.
  2. A continuación, se utiliza ICLRMetaHost ->GetRuntime para obtener la información de tiempo de ejecución de la versión de .NET que solicitamos. Si su ensamblado se ha creado con la versión 3.5 o inferior de .NET, solicitaremos la v2.0.50727, y si su ensamblado se ha creado con la versión 4.0 o superior de .NET, solicitaremos la v4.0.30319. En realidad, hay una función en el BOF que nos ayudará a averiguar qué versión utiliza automáticamente nuestro ensamblado .NET, pero hablaremos de ello más adelante.
  3. Una vez que tenemos la información de nuestro tiempo de ejecución, utilizamos ICLRRuntimeInfo->IsLoadable para comprobar si nuestro tiempo de ejecución se puede cargar en el proceso. Esto también tendrá en cuenta si ya se han cargado otros tiempos de ejecución y establecerá nuestro valor BOOL fLoadable en 1 (verdadero) si nuestro tiempo de ejecución se puede cargar en el proceso.
  4. Si todo está bien, ejecutaremos ICLRRuntimeInfo->GetInterface para cargar el CLR en nuestro proceso y recuperar una interfaz para ICorRunTimeHost.
  5. Por último, llamaremos a ICorRuntimeHost->Start, que inicia el CLR.

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.

Captura de pantalla: creación del AppDomain y carga/ejecución del ensamblado

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:

  1. Utilizar ICorRuntimeHost->CreateDomain para crear nuestro AppDomain único
  2. Utilizar IUnknown->QueryInterface (pAppDomainThunk) para obtener un puntero a la interfaz AppDomain
  3. Crear nuestro SafeArray y copiar en él nuestros bytes del ensamblado .NET
  4. Cargar nuestro ensamblado a través de AppDomain->Load_3
  5. Obtener nuestro punto de entrada en el ensamblado mediante Assembly->EntryPoint
  6. Por último, invocar nuestro ensamblado mediante MethodInfo->Invoke_3

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.

Redireccionamiento de STDOUT de consola a pipe con nombre o ranura de correo: evitar la modificación de herramientas

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:

Captura de pantalla: redirección de la salida estándar de la consola al named pipe y posterior reversión

Redirección de la salida estándar de la consola a un named pipe y reversión

Determine la versión .NET del ensamblado

¿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:

Captura de pantalla: función que lee nuestro ensamblado .NET y ayuda a determinar qué versión de .NET se requiere al cargar el CLR

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:

Captura de pantalla: sentencia if/else para establecer la variable de la versión de .NET

Instrucción if/else para establecer la variable de versión de.NET

Aplicación de parches a la interfaz de escaneo antimalware (AMSI)

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:

Captura de pantalla: parcheo en memoria de AmsiScanBuffer

Parcheo en memoria de AmsiScanBuffer

Como puede ver en la captura de pantalla anterior, simplemente hacemos lo siguiente:

  1. Cargamos amsi.dll y obtenemos un puntero a AMSIScanBuffer
  2. Cambiamos la protección de la memoria
  3. Aplicamos el parche en nuestros bytes amsiPatch[]
  4. Restauramos la protección de la memoria a su estado original

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:

Captura de pantalla: ejemplo de elusión de AMSI en InlineExecute-Assembly

Ejemplo de elusión de AMSI en InlineExecute-Assemby

Parcheo de Event Tracing for Windows (ETW)

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:

Captura de pantalla: parcheo en memoria de EtwEventWrite

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:

Captura de pantalla: uso de Process Hacker para ver las propiedades de PowerShell.exe antes de ejecutar inlineExecute-Assembly con el indicador –etw

Uso de Process Hacker para ver las propiedades de PowerShell.exe antes de ejecutar inlineExecute-Assembly con el indicador –etw

Captura de pantalla: ejecución de inlineExecute-Assembly utilizando el indicador –etw

Ejecución de inline-Execute-Assembly utilizando el indicador –etw

Captura de pantalla: uso de Process Hacker para ver las mismas propiedades de PowerShell.exe después de ejecutar inlineExecute-Assembly

Uso de Process Hacker para ver las mismas propiedades de PowerShell.exe tras ejecutar inlineExecute-Assembly

AppDomains únicos, pipelines con nombre, ranuras de correo

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:

Captura de pantalla del terminal que muestra la ejecución del comando inlineExecute-Assembly --dotnetassembly /root/Desktop/MessageBoxCS.exe en el beacon. La salida incluye mensajes de estado: ejecutando inlineExecute-Assembly, el host se comunicó con el servidor (enviados 16319 bytes), se recibió la salida ‘Hello From .NET!’ y el mensaje de finalización ‘inlineExecute-Assembly Finished’.

Ejemplo de InlineExecute-Assembly utilizando un nombre de AppDomain y un nombre de named pipe únicos

Captura de pantalla: ejemplo del nombre de AppDomain único ChangedMe

Ejemplo de nombre de AppDomain único: ChangedMe

Captura de pantalla: ejemplo del nombre de named pipe único LookAtMe

Ejemplo de nombre de named pipe único: LookAtMe

Captura de pantalla: ejemplo de la eliminación del AppDomain tras finalizar con éxito la ejecución

Ejemplo de eliminación de AppDomain tras finalizar con éxito la ejecución

Captura de pantalla: ejemplo de la eliminación del named pipe tras finalizar la ejecución correctamente

Ejemplo de eliminación del named pipe tras finalizar con éxito la ejecución

Advertencias

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:

  1. Aunque he intentado que esto sea lo más estable posible, no hay garantías de que no se produzcan fallos o de que los beacons no mueran. No contamos con el lujo adicional del fork and run, donde si algo sale mal, nuestro beacon sobrevive. Esta es la contrapartida de los BOF. Dicho esto, no puedo enfatizar lo importante que es que pruebe los ensamblados de antemano para asegurarse de que funcionan correctamente.
  2. Dado que los BOF se ejecutan en el proceso y toman el control de su beacon mientras están en funcionamiento, esto debe tenerse en cuenta antes de utilizarlos para ensamblados de larga duración. Si decide ejecutar algo que tardará mucho tiempo en devolver resultados, su beacon no estará activo para ejecutar más comandos hasta que se reciban los resultados y su ensamblado termine de ejecutarse. Esto tampoco respeta la configuración de sleep. Por ejemplo, si su sleep está configurado en 10 minutos y ejecuta el BOF, recibirá los resultados en cuanto este termine de ejecutarse.
  3. A menos que se realicen modificaciones en las herramientas que cargan archivos PE en memoria (p. ej., SafetyKatz), lo más probable es que estas terminen con su beacon. Muchas de estas herramientas funcionan bien con execute-assembly porque pueden enviar la salida de su consola desde el proceso sacrificable antes de finalizar. Cuando finalizan a través de nuestro BOF en proceso, terminan nuestro proceso, lo que mata nuestro beacon. Estas herramientas se pueden modificar para que funcionen, pero yo aconsejaría ejecutar este tipo de ensamblados a través de execute-assembly, ya que podrían cargarse en su proceso otros elementos no compatibles con la OPSEC que no se eliminan.
  4. Si su ensamblado utiliza Environment.Exit, será necesario eliminarlo, ya que terminará con el proceso y el beacon.
  5. Los named pipes y los mail slots deben ser únicos. Si no recibe datos de vuelta y su beacon sigue activo, lo más probable es que el problema sea que necesita seleccionar un nombre de named pipe o mail slot diferente.

Consideraciones defensivas

A continuación, se presentan algunas consideraciones defensivas:

  1. Se utiliza PAGE_EXECUTE_READWRITE al realizar el parcheo de memoria de AMSI y ETW. Esto se hizo a propósito y debería ser una señal de alerta, ya que muy pocos programas tienen rangos de memoria con la protección de memoria PAGE_EXECUTE_READWRITE.
  2. El nombre por defecto del named pipe creado es “totesLegit”. Esto se hizo a propósito y podrían utilizarse detecciones por firma para señalarlo.
  3. El nombre por defecto del mal slot creado es “totesLegit”. Esto se hizo a propósito y podrían utilizarse detecciones por firma para señalarlo.
  4. El nombre por defecto del AppDomain cargado es “totesLegit”. Esto se hizo a propósito y podrían utilizarse detecciones por firma para señalarlo.
  5. Puede encontrar buenos consejos para detectar el uso malicioso de .NET (por @bohops) aquí, (por F-Secure) aquí y aquí
  6. Búsqueda de carga de .NET CLR en procesos sospechosos, como procesos no gestionados que nunca deberían tener cargado el CLR.
  7. Más información sobre el seguimiento de eventos.
  8. Búsqueda de otros IOC conocidos de beacon de Cobalt Strike o IOC de salida/comunicación de C2.