
Thread Stack Spoofing - PoC de una técnica avanzada de evasión en memoria que permite ocultar mejor la asignación de memoria del shellcode inyectado de los escáneres y analistas.
Una implementación PoC para una técnica avanzada de evasión en memoria que suplanta la pila de llamadas de un hilo. Esta técnica permite eludir reglas de examen de memoria basadas en hilos y ocultar mejor los shellcodes mientras están en memoria del proceso.
Esta es una implementación de ejemplo para la técnica Thread Stack Spoofing con el objetivo de evadir analistas de malware, AV y EDR que buscan referencias a los marcos del shellcode en la pila de llamadas de un hilo examinado. La idea es ocultar las referencias al shellcode en la pila de llamadas del hilo, disfrazando así las asignaciones que contienen el código del malware.
Esta implementación, junto con mi ShellcodeFluctuation, ofrece a la comunidad de Seguridad Ofensiva implementaciones de ejemplo para ponerse al día con lo que ofrecen los productos C2 comerciales, para que podamos hacerlo igual de bien en nuestras herramientas de Red Team. 💪
La implementación actual difiere mucho de lo publicado originalmente.
Esto se debe a que me di cuenta de que existe una forma mucho más simple de terminar el procesamiento de la pila de llamadas del hilo y ocultar los marcos relacionados con el shellcode simplemente escribiendo 0 en la dirección de retorno del primer marco que controlamos:
void WINAPI MySleep(DWORD _dwMilliseconds)
{
[...]
auto overwrite = (PULONG_PTR)_AddressOfReturnAddress();
const auto origReturnAddress = *overwrite;
*overwrite = 0;
[...]
*overwrite = origReturnAddress;
}
La implementación anterior, que utilizaba StackWalk64, se puede consultar en este commit c250724.
Esta implementación es mucho más estable y funciona bien tanto en Debug como en Release bajo las dos arquitecturas: x64 y x86.
Así es como se puede ver una pila de llamadas cuando NO está suplantada:

Y así, cuando la suplantación de la pila del hilo está activada:

Arriba podemos ver que el último marco en nuestra pila de llamadas es nuestro callback MySleep.
Cabe preguntarse si esto ofrece inmediatamente nuevas oportunidades de IOCs. Las reglas de caza pueden buscar hilos cuyas pilas de llamadas no se desenrollen hasta los siguientes puntos de entrada de hilo esperados que se encuentran en las bibliotecas del sistema:
kernel32!BaseThreadInitThunk+0x14
ntdll!RtlUserThreadStart+0x21
Sin embargo, la pila de llamadas del hilo suplantado puede parecer extraña al principio; un breve examen de mi sistema mostró que también hay otros hilos que no se desenrollan hasta los puntos de entrada anteriores:

La captura de pantalla anterior muestra un hilo de Total Commander x64 sin modificar. Como podemos ver, su pila de llamadas se asemeja bastante a la nuestra en términos de los marcos iniciales de la pila de llamadas.
¿Por qué deberíamos preocuparnos por falsificar cuidadosamente nuestra pila de llamadas cuando hay procesos que muestran características que simplemente podemos imitar?
El algoritmo aproximado es el siguiente:
dbghelp.dll, llamar a SymInitialize.kernel32!Sleep apuntando de vuelta a nuestro callback.VirtualAlloc + memcpy + CreateThread. El hilo debe comenzar desde nuestra función runShellcode para evitar que la StartAddress del hilo apunte a algún lugar inesperado y anómalo (como ntdll!RtlUserThreadStart+0x21).MySleep.0, lo que efectivamente debería finalizar la pila de llamadas.::SleepEx para permitir que Beacon duerma mientras espera más comunicaciones.Las direcciones de retorno de las funciones están dispersas por toda el área de memoria de la pila del hilo, apuntadas por el registro RBP/EBP.
Para encontrarlas en la pila, primero necesitamos recolectar los punteros de marco y luego desreferenciarlos para sobrescribirlos:

(la imagen anterior fue tomada del artículo de Eli Bendersky titulado Stack frame layout on x86-64)
*(PULONG_PTR)(frameAddr + sizeof(void*)) = Fake_Return_Address;
La implementación inicial de ThreadStackSpoofer hacía esto en las funciones walkCallStack y spoofCallStack, pero la implementación actual muestra que estos esfuerzos no son necesarios para mantener una pila de llamadas sigilosa.
Uso:
C:\> ThreadStackSpoofer.exe <shellcode> <spoof>
Donde:
<shellcode> es una ruta al archivo shellcode<spoof> cuando sea 1 o true habilitará la suplantación de la pila del hilo, cualquier otra cosa la deshabilita.Ejemplo de ejecución que suplanta la pila de llamadas del hilo de beacon:
PS D:\dev2\ThreadStackSpoofer> .\x64\Release\ThreadStackSpoofer.exe .\tests\beacon64.bin 1
[.] Leyendo bytes del shellcode...
[.] Enganchando kernel32!Sleep...
[.] Inyectando shellcode...
[+] El shellcode ahora se está ejecutando.
[>] Dirección de retorno original: 0x1926747bd51. Finalizando la pila de llamadas...
===> MySleep(5000)
[<] Restaurando dirección de retorno original...
[>] Dirección de retorno original: 0x1926747bd51. Finalizando la pila de llamadas...
===> MySleep(5000)
[<] Restaurando dirección de retorno original...
[>] Dirección de retorno original: 0x1926747bd51. Finalizando la pila de llamadas...
Mira el código y su implementación, comprende el concepto y vuelve a implementar el concepto dentro de tus propios cargadores de shellcode que utilizas para llevar a cabo tus compromisos de Red Team. Esta es otra técnica para la evasión avanzada en memoria que aumenta las posibilidades de tu equipo de no ser detectado por antivirus, EDR y analistas de malware que examinan tus implantes.
Al desarrollar tu cargador de shellcode avanzado, también podrías querer implementar:
BeaconEyeRW (desde RX/RWX) y cifrar su contenido - utilizando la técnica de Shellcode Fluctuation - justo antes de dormir (lo que podría evadir escáneres como Moneta o pe-sieve)Como se me ha señalado, la técnica aquí no es aún verdaderamente fiel a su nombre de ser un suplantador de pila. Ya que simplemente sobrescribimos direcciones de retorno en la pila del hilo, no estamos suplantando las áreas restantes de la pila en sí. Además, estamos dejando nuestra pila de llamadas no desenrollable, lo que la hace parecer anómala, ya que el sistema no podrá recorrer correctamente toda la cadena de marcos de la pila de llamadas.
Sin embargo, soy consciente de estas limitaciones; por ahora lo he dejado como está, ya que me preocupaba principalmente evadir escáneres automatizados que pudieran iterar sobre procesos, enumerar sus hilos, recorrer las pilas de esos hilos y detectar cualquier dirección de retorno que apunte a una memoria que no sea de imagen (como SEC_PRIVATE - la asignada dinámicamente por VirtualAlloc y similares). Un analista de malware concentrado detectaría de inmediato la rareza y consideraría el hilo bastante inusual, dando caza a nuestro implante. Estoy más que seguro de ello. Sin embargo, no creo que los escáneres automatizados actuales como AV/EDR tengan implementadas heurísticas que realmente recorran la pila de cada hilo para verificar si es desenrollable ¯\_(ツ)_/¯.
Seguramente este proyecto (y la implementación comercial que se encuentra en los frameworks C2) da argumentos a los proveedores de AV y EDR para considerar la implementación de heurísticas adecuadas que cubran esta novedosa técnica de evasión.
Para mejorar esta técnica, se podría apuntar a un verdadero Suplantador de Pila de Hilos insertando marcos de pila falsos cuidadosamente elaborados, establecidos en un proceso de desenrollado inverso. Lee más sobre esta idea a continuación.
Una conversación de horas con namazso me enseñó que, para apuntar a un suplantador de pila de hilos adecuado, necesitaríamos revertir el proceso de desenrollado de la pila de llamadas en x64. En primer lugar, hay que conocer cuidadosamente el proceso de desenrollado de la pila explicado en (a) más abajo. El sistema, al recorrer la pila de llamadas de un hilo en arquitectura x64, no se basará simplemente en las direcciones de retorno dispersas por la pila del hilo, sino que:
RUNTIME_FUNCTION, UNWIND_INFO y UNWIND_CODE. Estas estructuras describen dónde están la dirección de inicio y fin de la función, y dónde están todas las secuencias de código que modifican RBP o RSP.UNWIND_CODEs que presenta la función examinada para calcular con precisión la ubicación de la dirección de retorno de ese marco y el valor del puntero de pila.Para interferir con este proceso necesitaríamos revertirlo teniendo nuestra forma revertida de RtlVirtualUnwind. Necesitaríamos iterar sobre las funciones definidas en un módulo (digamos kernel32), escanear los códigos UNWIND_CODE de cada función y emularlos de cerca hacia atrás (en comparación con RtlVirtualUnwind y precisamente RtlpUnwindPrologue) para encontrar ubicaciones en la pila donde poner nuestras direcciones de retorno falsas.
namazso menciona la necesidad de introducir 3 marcos de pila falsos para unir correctamente la pila de llamadas:
MySleep (que tiene un código UWOP - Unwind Operation Code - diferente). Hacemos esto revisando todas las funciones de un módulo, analizando sus UWOPs, calculando cuán grande debe ser el marco falso. Este marco debe tener UWOPs diferentes a los del llamador de nuestro MySleep.pop en RBP desde la pila - básicamente a través del código UWOP_PUSH_NONVOL.RSP desde RBP mediante el código UWOP_SET_FPREG.El RSP restaurado debe establecerse con el RSP tomado desde donde el flujo de control entró en nuestro MySleep para que todos nuestros marcos queden ocultos, como resultado del desenrollado del tercer gadget allí.
Para comenzar el proceso, se puede iterar sobre el .pdata del ejecutable desreferenciando la entrada del directorio de datos IMAGE_DIRECTORY_ENTRY_EXCEPTION.
Considera el siguiente ejemplo:
ULONG_PTR imageBase = (ULONG_PTR)GetModuleHandleA("kernel32");
PIMAGE_NT_HEADERS64 pNthdrs = PIMAGE_NT_HEADERS64(imageBase + PIMAGE_DOS_HEADER(imageBase)->e_lfanew);
auto excdir = pNthdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];
if (excdir.Size == 0 || excdir.VirtualAddress == 0)
return;
auto begin = PRUNTIME_FUNCTION(excdir.VirtualAddress + imageBase);
auto end = PRUNTIME_FUNCTION(excdir.VirtualAddress + imageBase + excdir.Size);
UNWIND_HISTORY_TABLE mshist = { 0 };
DWORD64 imageBase2 = 0;
PRUNTIME_FUNCTION currFrame = RtlLookupFunctionEntry(
(DWORD64)llamador,
&imageBase2,
&mshist
);
UNWIND_INFO *mySleep = (UNWIND_INFO*)(currFrame->UnwindData + imageBase);
UNWIND_CODE myFrameUwop = (UNWIND_CODE)(mySleep->UnwindCodes[0]);
log("1. MySleep RIP UWOP: ", myFrameUwop.UnwindOpcode);
for (PRUNTIME_FUNCTION it = begin; it < end; ++it)
{
UNWIND_INFO* unwindData = (UNWIND_INFO*)(it->UnwindData + imageBase);
UNWIND_CODE frameUwop = (UNWIND_CODE)(unwindData->UnwindCodes[0]);
if (frameUwop.UnwindOpcode != myFrameUwop.UnwindOpcode)
{
// Se encontró una función candidata para un marco gadget de desincronización
}
}
El proceso es un poco enrevesado, pero se reduce a revertir el proceso de desenrollado de la pila de llamadas del hilo sustituyendo marcos de pila arbitrarios por otros cuidadosamente seleccionados, en un enfoque similar a ROP.
Este PoC no sigue ni replica este algoritmo, porque mi comprensión actual me permite aceptar que la pila de llamadas termine en un marco de pila basado en EXE y no quiero complicar en exceso ni mis cargadores de shellcode ni este PoC. Dejo el ejercicio de implementar esto y compartirlo públicamente a un lector interesado. O tal vez me siente y lo intente yo mismo si tengo más tiempo libre :)
Más información:
RtlpUnwindPrologue and RtlVirtualUnwind.pdata sectionRtlpUnwindPrologueSi planeas agregar esta funcionalidad a tus propios cargadores de shellcode / herramientas, asegúrate de EVITAR desenganchar kernel32.dll.
Un intento de desenganchar kernel32 restaurará la funcionalidad original de Sleep, impidiendo que se llame a nuestro callback.
Si no se llama a nuestro callback, el hilo no podrá suplantar su propia pila de llamadas por sí mismo.
Si eso es lo que deseas, entonces podrías necesitar ejecutar otro hilo, un hilo vigilante, que se asegure de que la pila del hilo de Beacon se suplante cada vez que duerma.
Si usas Cobalt Strike y un BOF unhook-bof de Raphael Mudge, asegúrate de revisar mi Pull Request que agrega un parámetro opcional al BOF para especificar las bibliotecas que no deben desengancharse.
De esta manera puedes mantener tus enganches en kernel32:
beacon> unhook kernel32
[*] Ejecutando unhook.
Se omitirán estos módulos: wmp.dll, kernel32.dll
[+] host called home, sent: 9475 bytes
[+] received output:
ntdll.dll <.text>
Unhook completado.
unhook-bof modificado con opción para ignorar módulos especificados
Este PoC fue diseñado para funcionar con shellcodes de Beacon de Cobalt Strike. Se sabe que Beacon llama a kernel32!Sleep para esperar más instrucciones de su C2.
Este cargador aprovecha ese hecho enganchando Sleep para realizar su mantenimiento.
Esta implementación podría no funcionar con otros shellcodes del mercado (como Meterpreter) si no usan Sleep para enfriarse.
Dado que esto es meramente una Prueba de Concepto que muestra la técnica, no tengo intención de agregar soporte para ningún otro framework C2.
Cuando entiendas el concepto, seguramente podrás traducirlo a los requisitos de tu shellcode y adaptar la solución en tu beneficio.
Por favor, no abras issues en Github relacionadas con "este código no funciona con el shellcode XYZ", se cerrarán de inmediato.
Este y otros proyectos son el resultado de noches sin dormir y mucho trabajo duro. Si te gusta lo que hago y aprecias que siempre devuelvo algo a la comunidad, Considera invitarme un café (o mejor una cerveza) solo para agradecer. 💪
Mariusz Banach / mgeeky, 21
<mb [at] binary-offensive.com>
(https://github.com/mgeeky)