
Motor de hooking por puntos de interrupción de hardware para Windows que utiliza registros de depuración para hookear funciones, evadir ETW/AMSI y eludir la supervisión de EDR en el espacio de usuario.
Este artículo fue originalmente para VX-Underground Black Mass Halloween Edition 2022.
Motores de hooking:
Técnica genérica de evasión en user-land x64 utilizando registros de depuración:
Ejemplos de hooks ETW/AMSI disponibles
Nuestra tarea es hookear funciones de forma trivial y desviar el flujo de código según sea necesario, y finalmente eliminar el hook una vez que ya no se necesite.
No podemos recurrir a hooks de IAT, ya que no siempre se invocan y por lo tanto no son fiables. El hooking inline es una técnica potente; sin embargo, requiere que parcheemos la memoria donde se encuentra el código. Esta es una técnica poderosa, pero herramientas como PE-Sieve y Moneta pueden distinguir la diferencia entre la copia en memoria y la copia en disco de un módulo y señalar esto. Esto nos deja con la herramienta perfecta para el trabajo: los registros de depuración, aunque están bastante infravalorados por los autores de malware.
En Windows, como visión general de alto nivel, un proceso es esencialmente una encapsulación de hilos, y cada uno de estos hilos mantiene un contexto que es el estado del hilo: los registros y la pila, etc. Los registros de depuración son un recurso privilegiado, y también lo es establecerlos; sin embargo, Windows expone varias syscalls que nos permiten solicitar que el kernel realice una acción privilegiada en nuestro nombre; esto incluye establecer los registros de depuración, lo cual es perfecto para nosotros. NtSetThreadContext y NtGetThreadContext exponen funcionalidad para modificar cualquier contexto de hilo al que podamos abrir un manejador con el privilegio necesario. Podemos ver cómo establecer los registros de depuración con la API Win32.```c CONTEXT context = { .ContextFlags = CONTEXT_DEBUG_REGISTERS }; GetThreadContext(thd, &context);
// set our debug information in the Dr registers
SetThreadContext(thd, &context);
Hay 8 registros de depuración, de Dr0 a Dr7. Los que nos interesan son solo
Dr0-3, en los que almacenamos las direcciones en las que queremos detenernos, y Dr6 es solo el estado de depuración.
Lo más importante es Dr7, que describe las condiciones de los puntos de interrupción bajo las cuales el
procesador lanzará una excepción. Hay varias limitaciones al usar registros de depuración,
como un número limitado (4) y no aplicarse a todos los hilos/hilos recién creados.
¡Veremos cómo abordar algunas de estas limitaciones!
Cuando se lanza la excepción, buscará un manejador de excepciones que podemos definir
y registrar en nuestro programa [1]. En nuestro manejador de excepciones definido, queremos que nuestro
código asociado (diferentes flujos de código) se ejecute cuando se active el punto de interrupción correspondiente.```c
LONG WINAPI ExceptionHandler(PEXCEPTION_POINTERS ExceptionInfo)
{
if (ExceptionInfo->ExceptionRecord->ExceptionCode == STATUS_SINGLE_STEP)
{
// Look for our associated code flow relative to our RIP
if (HWBP_ADDRESS_MAP.contains(ExceptionInfo->ContextRecord->Rip)) {
HWBP_ADDRESS_MAP.at(ExceptionInfo->ContextRecord->Rip).func(ExceptionInfo);
return EXCEPTION_CONTINUE_EXECUTION;
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
Esto se logra mediante una función constructora que establece la asignación entre una función lambda de "callback" y una dirección. using EXCEPTION_FUNC = std::function <void(PEXCEPTION_POINTERS)>;```c typedef struct { UINT pos; EXCEPTION_FUNC func; } HWBP_CALLBACK;
// Global std::unordered_map<uintptr_t, HWBP_CALLBACK> HWBP_ADDRESS_MAP{ 0 };
// Create our mapping HWBP_ADDRESS_MAP[address].func = function; HWBP_ADDRESS_MAP[address].pos = pos;
Debemos iterar a través de todos los hilos de nuestro proceso y establecer los ajustes correspondientes al contexto para ellos. Esto se puede lograr utilizando las funciones auxiliares de ToolHelp32: CreateToolhelp32Snapshot y Thread32Next. Esto no es nada sofisticado, pero aborda una de nuestras limitaciones de no adjuntarnos a todos los hilos.```c
VOID SetHWBPS(const uintptr_t address, const UINT pos, const bool init = true)
{
DWORD pid{ GetCurrentProcessId() };
HANDLE h{ CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
if (h != INVALID_HANDLE_VALUE) {
THREADENTRY32 te{ .dwSize = sizeof(THREADENTRY32) };
if (Thread32First(h, &te)) {
do {
if ((te.dwSize >= FIELD_OFFSET(THREADENTRY32, th32OwnerProcessID) +
sizeof(te.th32OwnerProcessID)) && te.th32OwnerProcessID == pid) {
HANDLE thd = OpenThread(THREAD_ALL_ACCESS, FALSE, te.th32ThreadID);
if (thd != INVALID_HANDLE_VALUE) {
SetHWBP(thd, address, pos, init);
CloseHandle(thd);
}
}
te.dwSize = sizeof(te);
} while (Thread32Next(h, &te));
}
CloseHandle(h);
}
}
Tener puntos de interrupción de hardware establecidos es sin duda sospechoso, ya que pueden indicar actividad maliciosa (aunque, que yo sepa, ningún EDR los busca activamente). Pueden usarse en nuestra contra como un posible IoC, por lo que debemos eliminar sus rastros una vez que hayamos terminado de usarlos.
¡Podemos implementar esto en nuestra función deconstructor!! Esto iterará a través de todos los hilos y comprobará si el registro (&context.Dr0)[pos] apunta a la dirección en la que inicialmente establecimos el punto de interrupción de hardware (pos es solo un índice % 4 que nos da acceso a context.Dr0-Dr3). También podemos eliminar las condiciones necesarias en el registro Dr7. También debemos recordar eliminar nuestra entrada de mapeo. Por lo tanto, nuestro punto de interrupción de hardware solo estará presente durante el tiempo requerido!```c SetHWBPS(address, pos, false); HWBP_ADDRESS_MAP.erase(address);
Un ejemplo de punto de interrupción de hardware sería Sleep, donde simplemente reemplazamos la duración del sleep por 0.```c
HWBP HWBPSleep{ (uintptr_t)&Sleep, 0, // Set Dr 0
([&](PEXCEPTION_POINTERS ExceptionInfo) {
ExceptionInfo->ContextRecord->Rcx = 0;
ExceptionInfo->ContextRecord->EFlags |= (1 << 16); // continue execution
}) };
Sabemos que hay que establecer RCX debido a la convención de llamada rápida de cuatro registros de Windows x64[1]. El primer argumento del constructor es la dirección en la que se debe detener (break), el segundo es en qué registro Dr0- 3 se almacena (nota: solo podemos tener 4 direcciones de detención a la vez), y el tercero es una función lambda que capturará por referencia PEXCEPTION_POINTERS, que es la información que recibirá un manejador de excepciones. Esto nos permitirá en última instancia controlar el flujo de un programa de manera diferente según qué punto de interrupción se haya disparado.
Cuando se crea un nuevo hilo, este no hereda el conjunto de Debug Registers asociado a menos que de algún modo logremos interceptar la creación de un nuevo hilo! Un truco ingenioso que podemos usar sería capturar la dirección de inicio real y desviar el nuevo hilo para que cree nuestro propio hilo. La mayoría de los hilos nuevos terminan llamando a NtCreateThreadEx.```c // Global Variable PVOID START_THREAD{ 0 };