跳至主要內容

71-反调试 - 多种技术

Maldevacademy大约 11 分钟安全开发

简介

安全研究人员和恶意软件分析人员会使用调试来增强对恶意软件样本的了解。这使他们能够针对这些样本编写更好的检测规则。作为一个恶意软件开发人员,您应该始终采用反调试技术来让分析人员花费更多的时间在分析上。

本篇模块将讨论几种反调试技术。

使用 IsDebuggerPresent 检测调试器

最简单的反调试技术之一是使用 WinAPI IsDebuggerPresentopen in new window。此函数如果检测到调试器附加到调用进程,则返回 TRUE;如果未检出调试器,则返回 FALSE。以下代码片段展示了检测调试器的函数。

if (IsDebuggerPresent()) {
  printf("[i] IsDebuggerPresent 检测到调试器 \n");
  // 运行无害代码。
}

IsDebuggerPresent 替代方法 (1)

调用 IsDebuggerPresent WinAPI 可疑,即使它通过 API 哈希进行了良好的隐藏。该 WinAPI 被认为是检测调试程序的一种非常基础的方法,可以使用 ScyllaHideopen in new window 等工具来绕过它,这是一个适用于 xdbg 的反反调试插件。

更好的方法是创建 IsDebuggerPresent WinAPI 的自定义版本。回想一下 Windows 进程 - 初学者模块 展示了具有 BeingDebugged 成员的 PEB 结构,当进程正在被调试时将该成员设置为 1。一个简单的 IsDebuggerPresent WinAPI 替换涉及检查 BeingDebugged 值,如下面的自定义函数所示。

如果 BeingDebugged 元素被设置为 1,则 IsDebuggerPresent2 函数返回 TRUE

BOOL IsDebuggerPresent2() {

  // 获取 PEB 结构
#ifdef _WIN64
	PPEB					pPeb = (PEB*)(__readgsqword(0x60));
#elif _WIN32
	PPEB					pPeb = (PEB*)(__readfsdword(0x30));
#endif

  // 检查 'BeingDebugged' 元素
  if (pPeb->BeingDebugged == 1) 
    return TRUE;
	
   return FALSE;
}

替代 IsDebuggerPresent 方法(2)

另一种自定义 IsDebuggerPresent WinAPI 版本的方法是利用未记录的 NtGlobalFlagopen in new window 标志,它也位于 PEB 结构中。如果进程正在被调试,则 NtGlobalFlag 成员被设为十六进制 0x70,否则为 0。需要注意的是,只有当进程由调试器创建时,NtGlobalFlag 元素才被设为 0x70。因此,如果在执行之后附加了调试器,此方法将无法检测到调试器。

0x70 来源于以下标志的组合:

  • FLG_HEAP_ENABLE_TAIL_CHECK - 0x10
  • FLG_HEAP_ENABLE_FREE_CHECK - 0x20
  • FLG_HEAP_VALIDATE_PARAMETERS - 0x40

如果 NtGlobalFlag 元素被设为 0x70,则 IsDebuggerPresent3 函数返回 TRUE

#define FLG_HEAP_ENABLE_TAIL_CHECK   0x10
#define FLG_HEAP_ENABLE_FREE_CHECK   0x20
#define FLG_HEAP_VALIDATE_PARAMETERS 0x40

BOOL IsDebuggerPresent3() {

  // 获取 PEB 结构
#ifdef _WIN64
  PPEB pPeb = (PEB*)(__readgsqword(0x60));
#elif _WIN32
  PPEB pPeb = (PEB*)(__readfsdword(0x30));
#endif

  // 检查 'NtGlobalFlag' 元素
  if (pPeb->NtGlobalFlag == (FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK | FLG_HEAP_VALIDATE_PARAMETERS))
    return TRUE;

  return FALSE;
}

通过 NtQueryInformationProcess 检测调试器

NtQueryInformationProcess 系统调用将使用两个标志位 ProcessDebugPortProcessDebugObjectHandle 来检测调试器。

回想一下 NtQueryInformationProcess 类似于以下内容:

NTSTATUS NtQueryInformationProcess(
  IN    HANDLE           ProcessHandle,               // 要检索信息的进程句柄。
  IN    PROCESSINFOCLASS ProcessInformationClass,     // 要检索的进程信息类型
  OUT   PVOID            ProcessInformation,          // 函数将请求的信息写入的缓冲区的指针
  IN    ULONG            ProcessInformationLength,    // 'ProcessInformation' 参数所指缓冲区的大小
  OUT   PULONG           ReturnLength                 // 函数返回请求的信息大小的变量指针
);

ProcessDebugPort 标志

Microsoft 关于 ProcessDebugPort 标志的文档指出以下内容:

获取一个 DWORD_PTR 值,该值是进程调试器的端口号。非零值表示该进程正在环 3 调试器的控制下运行

换句话说,如果 NtQueryInformationProcess 返回 ProcessInformation 参数接收到的非零值,则该进程正在被主动调试。

ProcessDebugObjectHandle 标志

未公开的标志 ProcessDebugObjectHandle 的工作原理类似于之前的 ProcessDebugPort 标志,用于获取当前进程的调试对象句柄,当进程正在进行调试时会创建该句柄。通过 NtQueryInformationProcess 获得的 ProcessInformation 参数中的非零值表示对进程进行了主动调试。

如果 NtQueryInformationProcess 无法检索调试对象句柄,这意味着它未检测到调试器,并将返回错误代码 0xC0000353。根据 Microsoft 在 NTSTATUS 值open in new window 上的文档,该错误代码等效于 STATUS_PORT_NOT_SET

利用 NtQueryInformationProcess 检测调试器

NtQIPDebuggerCheck 函数同时使用ProcessInformationProcessDebugObjectHandle来检测调试器。如果 NtQueryInformationProcess 使用 ProcessDebugPortProcessDebugObjectHandle标志返回一个有效句柄,则该函数返回 TRUE


BOOL NtQIPDebuggerCheck() {
  NTSTATUS STATUS = NULL;
  auto pNtQueryInformationProcess = (fnNtQueryInformationProcess)GetProcAddress(
      GetModuleHandle(TEXT("NTDLL.DLL")), "NtQueryInformationProcess");
  if (pNtQueryInformationProcess == NULL) {
    printf("\t[!] GetProcAddress Failed With Error : %d \n", GetLastError());
    return FALSE;
  }

  DWORD64 dwIsDebuggerPresent = NULL;
  DWORD64 hProcessDebugObject = NULL;

  // 使用'ProcessDebugPort'标志调用NtQueryInformationProcess
  STATUS = pNtQueryInformationProcess(
      GetCurrentProcess(), ProcessDebugPort, &dwIsDebuggerPresent,
      sizeof(DWORD64), NULL);

  if (STATUS != 0x0) {
    printf("\t[!] NtQueryInformationProcess [1] Failed With Status : 0x%0.8X \n",
           STATUS);
    return FALSE;
  }

  // 如果NtQueryInformationProcess返回非零值,则该句柄是有效的,这意味着我们正在被调试
  if (dwIsDebuggerPresent != NULL) {
    // 检测到调试器
    return TRUE;
  }

  //使用'ProcessDebugObjectHandle'标志调用NtQueryInformationProcess
  STATUS = pNtQueryInformationProcess(
      GetCurrentProcess(), ProcessDebugObjectHandle, &hProcessDebugObject,
      sizeof(DWORD64), NULL);

  // 如果STATUS不为0且不为0xC0000353(即'STATUS_PORT_NOT_SET')
  if (STATUS != 0x0 && STATUS != 0xC0000353) {
    printf("\t[!] NtQueryInformationProcess [2] Failed With Status : 0x%0.8X \n",
           STATUS);
    return FALSE;
  }

  // 如果NtQueryInformationProcess返回非零值,则该句柄是有效的,这意味着我们正在被调试
  if (hProcessDebugObject != NULL) {
    // 检测到调试器
    return TRUE;
  }

  return FALSE;
}

通过硬件断点检测调试器

此方法仅在调试期间设置硬件断点时有效。硬件断点(也称为硬件调试寄存器)是现代微处理器的特性,它在触发特定内存地址或事件时暂停进程的执行。硬件断点在处理器中实现,因此比普通软件断点更快、更高效。普通软件断点依赖于操作系统或调试器定期检查程序执行。

设置硬件断点后,特定寄存器的值会发生改变。这些寄存器的值可用于确定是否将调试器附加到进程。如果寄存器 Dr0Dr1Dr2Dr3 包含非零值,则设置了硬件断点。以下示例使用 xdbg 调试器对 NtAllocateVirtualMemory 系统调用放置了一个硬件断点。请注意,Dr0 的值已从零更改为 NtAllocateVirtualMemory 的地址。

图片
图片
图片
图片
图片
图片

获取寄存器值

要获取 Dr 寄存器值,可以使用 GetThreadContext WinAPI。在_线程劫持_模块中,GetThreadContext 用于检索指定线程的上下文。上下文以 CONTEXT 结构返回。此结构还包括 Dr0Dr1Dr2Dr3 寄存器值。

HardwareBpCheck 函数通过检查上述寄存器值来检测调试器的存在。如果检测到调试器,该函数返回 TRUE

BOOL HardwareBpCheck() {

    CONTEXT Ctx = { .ContextFlags = CONTEXT_DEBUG_REGISTERS };

    if (!GetThreadContext(GetCurrentThread(), &Ctx)) {
        printf("\t[!] GetThreadContext 失败。错误代码:%d \n", GetLastError());
        return FALSE;
    }

    if (Ctx.Dr0 != NULL || Ctx.Dr1 != NULL || Ctx.Dr2 != NULL || Ctx.Dr3 != NULL)
        return TRUE; // 检测到调试器

    return FALSE;
}

通过黑名单数组检测调试器

检测调试进程的另一种方法是检查当前运行进程的名称,并与已知的调试器名称列表进行对比。此名称的“黑名单”存储在硬编码的数组中。如果进程名称与黑名单匹配,则表明系统上正在运行一个调试器应用程序。

可以从前面讨论过的任何技术中枚举计算机上正在运行的进程。对于此方案,将使用 CreateToolhelp32Snapshot 进程枚举技术。

黑名单数组表示如下:

#define BLACKLISTARRAY_SIZE 5 // 数组中元素的数量

WCHAR* g_BlackListedDebuggers[BLACKLISTARRAY_SIZE] = {
    L"x64dbg.exe",                 // xdbg 调试器
    L"ida.exe",                    // IDA 反汇编器
    L"ida64.exe",                  // IDA 反汇编器
    L"VsDebugConsole.exe",         // Visual Studio 调试器
    L"msvsmon.exe"                 // Visual Studio 调试器
};

黑名单数组应尽可能包含更多调试器名称,以便检测更多种类的调试器。此外,应使用字符串哈希处理字符串,因为二进制文件中的调试器名称可用作 IOC(入侵指标)。

BlackListedProcessesCheck 函数使用 g_BlackListedDebuggers 数组作为黑名单进程数组。如果进程名称与 g_BlackListedDebuggers 的元素匹配,它将返回 TRUE

BOOL BlackListedProcessesCheck() {

    HANDLE hSnapShot = NULL;
    PROCESSENTRY32W ProcEntry = { .dwSize = sizeof(PROCESSENTRY32W) };
    BOOL bSTATE = FALSE;


    hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
    if (hSnapShot == INVALID_HANDLE_VALUE) {
        printf("\t[!] CreateToolhelp32Snapshot 失败,错误代码: %d \n", GetLastError());
        goto _EndOfFunction;
    }

    if (!Process32FirstW(hSnapShot, &ProcEntry)) {
        printf("\t[!] Process32FirstW 失败,错误代码: %d \n", GetLastError());
        goto _EndOfFunction;
    }

    do {
        // 遍历 `g_BlackListedDebuggers` 数组并将其每个元素与
        // 从快照中获取的当前进程名称进行比较
        for (int i = 0; i < BLACKLISTARRAY_SIZE; i++) {
            if (wcscmp(ProcEntry.szExeFile, g_BlackListedDebuggers[i]) == 0) {
                // 检测到调试器
                wprintf(L"\t[i] 找到了 Pid 为 %d 的 \"%s\" \n", ProcEntry.szExeFile, ProcEntry.th32ProcessID);
                bSTATE = TRUE;
                break;
            }
        }

    } while (Process32Next(hSnapShot, &ProcEntry));


_EndOfFunction:
    if (hSnapShot != NULL)
        CloseHandle(hSnapShot);
    return bSTATE;
}

通过 GetTickCount64 检测断点

断点用于在程序运行的特定点暂停执行,以供分析内存、寄存器状态、变量等。

可以通过使用 GetTickCount64open in new window WinAPI 检测执行暂停。此函数会检索自系统启动以来经过的毫秒数。通过分析处理器在两次 GetTickCount64 调用之间花费的时间,可以指示恶意软件是否正在调试。如果花费的时间超出了预期,则可以安全地假定恶意软件正在被调试。

image
image

识别延迟

可以通过计算 T1 - T0 的平均值并将其存储为硬编码值来检测断点。当 T1 - T0 的输出超过此值时,延迟可能是由于断点造成的。例如,如果 T1 - T0 主机上的输出为 20 秒,但在运行时输出大于该值,则 T1 - T0 之间的延迟很可能是由于断点引起的。应略微增加原始值以考虑可能会更慢的处理器。

GetTickCount64反调试代码

TimeTickCheck1函数使用相应方法检测断点。如果dwTime2 - dwTime1超过中间执行代码的平均值(即50),则该函数将返回TRUE

BOOL TimeTickCheck1() {

	DWORD	dwTime1		= NULL,
		    dwTime2		= NULL;

	dwTime1 = GetTickCount64();

/*
		其它代码			
*/

	dwTime2 = GetTickCount64();
	
	printf("\t[i] (dwTime2 - dwTime1) : %d \n", (dwTime2 - dwTime1));

	if ((dwTime2 - dwTime1) > 50) {
		return TRUE;
	}

	return FALSE;
}

通过 QueryPerformanceCounter 检测断点

QueryPerformanceCounteropen in new window WinAPI 与先前显示的 GetTickCount64 WinAPI 相同。区别在于,QueryPerformanceCounter 使用硬件提供的具有高解析度性能计数器,该计数器可以以纳秒为增量测量时间,而 GetTickCount64 使用每毫秒增量的时间计数器。请注意,QueryPerformanceCounter 以计数形式而不是毫秒形式检索性能计数器值。

TimeTickCheck2 函数使用 QueryPerformanceCounter WinAPI 检测断点。如果 Time2.QuadPart - Time1.QuadPart 超过中间执行代码的平均值(100000 个计数),则返回 TRUE。

BOOL TimeTickCheck2() {

    LARGE_INTEGER Time1 = { 0 },
            Time2 = { 0 };

    if (!QueryPerformanceCounter(&Time1)) {
        printf("\t[!] QueryPerformanceCounter [1] Failed With Error : %d \n", GetLastError());
        return FALSE;
    }

/*
        其他代码
*/

    if (!QueryPerformanceCounter(&Time2)) {
        printf("\t[!] QueryPerformanceCounter [2] Failed With Error : %d \n", GetLastError());
        return FALSE;
    }

    printf("\t[i] (Time2.QuadPart - Time1.QuadPart) : %d \n", (Time2.QuadPart - Time1.QuadPart));

    if ((Time2.QuadPart - Time1.QuadPart) > 100000) {
        return TRUE;
    }

    return FALSE;
}

通过 DebugBreak 检测调试器

DebugBreakopen in new window 会导致在当前进程中发生断点异常 EXCEPTION_BREAKPOINT。如果调试器已附加到当前进程,则该异常应由调试器处理。该技术的原理是触发异常并查看调试器是否尝试处理此异常。

我们将使用 __try__except 代码块来处理来自 DebugBreak 调用的异常,并使用 GetExceptionCodeopen in new window 调用来获取在这种情况下生成的异常代码,这有两种可能的情况:

  1. 如果获取的异常为 EXCEPTION_BREAKPOINT,则执行 EXCEPTION_EXECUTE_HANDLER,这表示该异常未被调试器处理。

  2. 如果异常不是 EXCEPTION_BREAKPOINT,则表示调试器处理了引发的异常(而不是我们的 try-except 代码块),然后执行 EXCEPTION_CONTINUE_SEARCH,这会强制调试器负责处理引发的异常。

以下 DebugBreakCheck 函数如果 WinAPI DebugBreak 成功执行且异常未被调试器捕获/处理,而是由我们的 try-except 代码块处理,则返回 FALSE,表示当前进程未附加调试器。

BOOL DebugBreakCheck() {

	__try {
		DebugBreak();
	}
	__except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
		// 如果异常等于 EXCEPTION_BREAKPOINT,则执行 EXCEPTION_EXECUTE_HANDLER 并且函数返回 FALSE
		return FALSE;
	}
	
	// 如果异常不等于 EXCEPTION_BREAKPOINT,则执行 EXCEPTION_CONTINUE_SEARCH 并且函数返回 TRUE
	return TRUE;
}

通过 OutputDebugString 检测调试器

另一种可用于检测调试器的 WinAPI 是 OutputDebugStringopen in new window。此函数用于向调试器发送要显示的字符串。如果存在调试器,则 OutputDebugString 将成功执行其任务。

可以运行 OutputDebugString 并使用 GetLastError 检查其是否失败;如果失败,则 GetLastError 将返回一个非零错误代码。在这种情况下,非零错误代码等同于没有调试器正在运行。如果 GetLastError 返回零,则 OutputDebugString 成功向调试器发送了一条字符串。

OutputDebugStringCheck 函数使用上述逻辑,如果 OutputDebugStringW 成功,则返回 TRUE。此外,它使用 SetLastErroropen in new window 将最后一个错误值设置为 1。这仅仅是为了确保在调用 OutputDebugString 之前它是一个非零值,以减少误报。

BOOL OutputDebugStringCheck() {

	SetLastError(1);
	OutputDebugStringW(L"MalDev Academy");

	// 如果 GetLastError 为 0,则 OutputDebugStringW 成功了
	if (GetLastError() == 0) {
		return TRUE;
	}

	return FALSE;
}