
Una técnica de evasión basada en memoria que hace que el shellcode sea invisible desde el inicio hasta el final del proceso.
Una técnica de evasión basada en memoria que hace que el shellcode sea invisible desde el inicio hasta el final del proceso.
Quería compartir esta POC de autoinyección de shellcode para mostrar algunos conceptos de evasión de AV/EDR que pueden ser útiles para Red Teaming. Hace apenas unas semanas se me ocurrió una técnica personalizada de evasión en memoria a la que llamé ShellGhost. Esta técnica surge de la necesidad de tener un código que ejecute un shellcode 'invisible' desde el inicio hasta el final del proceso.
ShellGhost se basa en el manejo de excepciones vectorizadas (VEH) en combinación con breakpoints de software para detener cíclicamente la ejecución del hilo, reemplazar el breakpoint ejecutado con una instrucción de shellcode cifrada con RC4, descifrar la instrucción y reanudar la ejecución después de restaurar la protección de memoria a RX. Cuando se genera la siguiente EXCEPTION_BREAKPOINT, el manejador de excepciones reemplaza la instrucción de shellcode anterior con un nuevo breakpoint, de modo que la asignación nunca revelará el shellcode completo en un estado no cifrado. Esto ocurre dentro de una página de memoria privada que inicialmente está marcada como LECTURA/ESCRITURA. Tener una asignación PRV RW no se considerará un 'Indicador de Compromiso' por parte de escáneres de memoria como PE-Sieve y Moneta. Cuando la asignación se vuelve RX y se escanea la página, no se encontrará nada más que breakpoints. Esto sucede mientras el shellcode está realmente en ejecución. La siguiente imagen muestra que un reverse shell está en ejecución, pero Moneta no encuentra ningún IOC (aparte de que el binario no está firmado).

El intento de escanear el proceso con Pe-Sieve tiene un resultado aún mejor:

El mapeo de shellcode es la funcionalidad principal de ShellGhost. Esta táctica permite que el hilo ejecute instrucciones de forma intermitente sin exponer nunca el shellcode completo en memoria. Esto es posible porque la posición de cada instrucción individual del shellcode que ejecuta el hilo corresponde a la posición de un determinado breakpoint dentro de la página de memoria asignada. ShellGhost resuelve esta posición calculando la dirección virtual relativa (RVA) desde el RIP del hilo hasta la dirección base de la página de memoria asignada y la suma a la dirección base del shellcode cifrado / instrucciones cifradas. La cantidad de breakpoints que se reemplazarán no siempre es la misma, sino que varía según la cantidad de opcodes que cada instrucción necesita para generarse e interpretarse correctamente (CUOTA). Entonces, por ejemplo, la instrucción 'POP RBP' es igual a '5D', lo que significa que solo se reemplazará un breakpoint. Por el contrario, la instrucción 'JMP RAX' requiere los opcodes 'FF E0', por lo que se reemplazarán dos breakpoints. Por esta razón, creé la siguiente estructura de datos en C.
typedef struct CRYPT_BYTES_QUOTA {
DWORD RVA; // offset a la instrucción cifrada
DWORD quota; // número de opcodes que generan la instrucción
} CRYPT_BYTES_QUOTA, * PCRYPT_BYTES_QUOTA;
Los breakpoints no se reemplazan inmediatamente por sus instrucciones correspondientes. Esto se debe a que las instrucciones deben pasar por una rutina de descifrado antes de ejecutarse. Aquí es donde entra en juego el DWORD quota. ShellGhost se basa en la ahora popular 'SystemFunction032' para realizar el descifrado RC4. A diferencia de XOR, RC4 no es un esquema de cifrado de un solo byte. Esto significa que el shellcode no se puede cifrar y descifrar de una sola vez. Esta es también otra razón por la que cada instrucción se trata por separado. Después de reemplazar los breakpoints, la longitud del búfer que necesita SystemFunction032 será igual a la 'cuota de instrucción', que nuevamente representa el número de opcodes que componen la instrucción específica. Así que, por ejemplo, considere el siguiente fragmento.
CRYPT_BYTES_QUOTA instruccion[200];
instruccion[5].quota = 2
USTRING buf = { 0 }; // contendrá el búfer a descifrar y su longitud
USTRING key = { 0 }; // contendrá la clave RC4 y su longitud
buf.Length = 2 // longitud del búfer, o longitud de la instrucción a descifrar
Sabemos que la instrucción número 5 del shellcode se compone de 2 opcodes, por lo que se pasará una longitud de búfer de 2 a SystemFunction032. Esto es importante porque intentar descifrar todo el shellcode con una sola llamada a SystemFunction032 lo corromperá por completo.
El shellcode debe mapearse con ShellGhost_mapping.py antes de la compilación. El script extrae cada instrucción individual y la trata como un pequeño shellcode independiente. Las instrucciones se cifran una por una y se imprimen en formato C todas juntas como unsigned char. El resultado se puede codificar directamente en el código C. A continuación se muestra un ejemplo de cómo se ven las instrucciones de shellcode cifradas de MSF para calc.exe.

Este shellcode tiene 98 instrucciones, por lo que se declaran 98 estructuras CRYPT_BYTES_QUOTA. Cuando el código se ejecuta, estas estructuras deben poblarse con las RVA y CUOTAS de instrucciones adecuadas. El parámetro '-1' le indica al script de mapeo que imprima el fragmento de código que hace esto.

Los shellcodes x64 de Metasploit suelen tener parámetros de cadena de winapi almacenados entre instrucciones. Es decir, un shellcode x64 de MSF que llama a Winexec no empuja una serie de bytes con un byte nulo al final para tener el primer parámetro de cadena en la pila. Más bien, el registro RCX (primer parámetro) es un puntero dentro del propio shellcode, como se muestra en la siguiente imagen.
