简介
我们之前都是直接通过使用直接系统调用的方式来绕过用户态钩子,通过在项目文件中创建并调用系统调用来实现此目标。还有另外一种方法也可以绕过用户态的钩子,那么这种方法是将已经加载到进程中的钩子DLL替换为一个未经修改且未被钩主的版本来达到相同的目标。
将勾住的DLL替换为一个未被勾住的版本需要手动设置导入地址表,修复重定位表以及其他繁琐的过程。为了避免这一复杂的过程。我们可以直接替换DLL文件的一部分,特别是包含钩子的.text区域。.text区域中包含了DLL导出函数的代码。一般钩子都会安装在这个区域。
那么要替换.text区域是非常简单的,只需要获取到其基址和大小,这些信息都是位于IMAGE_OPTIONAL_HEADER头部中的BaseOfCode字段和SizeOfCode字段。
另外一种方法是获取到.text区域基址和大小的方法是通过IMAGE_SECTION_HEADER头部,搜索.text字符串在IMAGE_SECTION_HEADER.Name数组中的位置。
那么为了替换.text区域的内容,就需要去更改该区段的内存权限。通常情况下,.text段被标记为可读可执行的权限。为了能够替换为新的.text区域,那么就必须修改内存权限以允许写入数据,可以通过VirtualProtect Win API来修改内存权限。我们必须将.text区域的权限设置为PAGE_EXECUTE_READWRITE权限。
对于大多数的DLL文件来说,.text区域在磁盘上的偏移量为0x400,也就是1024。我们可以使用Pe-Bear来查看。
那么我们在想为什么偏移是400?
在Windows PE文件格式中,.text区域存放的是程序的代码,例如DLL中的导出函数,PE文件的结构通常要求 .text 区段从特定的内存位置开始,以确保内存的对齐和访问效率。
那么当DLL被加载到内存中时,文件中的偏移量会发生变化,对于大多数的DLL文件,.text区域的偏移通常会被设置为0x1000。这是因为在内存中,Windows通常采用4KB,作为默认的内存页大小。其实也是为了对其。
磁盘上的偏移与内存上的偏移
DLL的.text段在磁盘上的偏移和加载到内存中的偏移是存在差异的。在磁盘上的偏移DLL的.text段通常会以1kb(1024字节)为对其单位。而在内存中,当DLL被加载到进程的内存空间中时,操作系统会将它映射到虚拟内存,并且会使用4KB的页面对其。这意味着DLL的.text段在内存中的偏移会被对齐到4KB的边界。
接下来我们将从磁盘上来获取ntdll.dll。从磁盘上获取到的ntdll.dll文件是从未被篡改的版本。在Windows操作系统中,Ntdll.dll通常位于C:\Windows\System32\目录中,通过这种方式,可以从原始的磁盘文件中获取一个干净,未被修改的ntdll.dll。并将其加载到内存中,替换到目标进程中已经被篡改的版本。
Ntdll解除挂钩-磁盘
首先我们肯定是需要从磁盘上读取Ntdll.dll文件的。那么我们可以通过GetWindowsDirectoryA函数来获取当前操作系统的Windows安装目录的路径。
函数原型如下:
UINT GetWindowsDirectoryA(LPSTR lpBuffer,UINT nSize
);
通过CreateFileA函数来读取ntdll.dll文件返回文件句柄。
获取该ntdll.dll文件的大小,再去申请一块内存用于将Ntdll.dll读取到内存中。
如下代码:
#include <Windows.h>
#define NTDLL "NTDLL.DLL" // 定义 ntdll.dll 的文件名// 从磁盘读取 ntdll.dll 文件到缓冲区
BOOL ReadNtdllFromDisk(OUT PVOID* ppNtdllBuf) {CHAR cWinPath[MAX_PATH / 2] = { 0 }; // 存储 Windows 目录的路径CHAR cNtdllPath[MAX_PATH] = { 0 }; // 存储 ntdll.dll 的完整路径HANDLE hFile = NULL; // 用于存储文件句柄DWORD dwNumberOfBytesRead = NULL, // 读取的字节数dwFileLen = NULL; // 文件的总字节长度PVOID pNtdllBuffer = NULL; // 用于存储 ntdll.dll 内容的缓冲区// 获取 Windows 目录路径(例如 C:\Windows)if (GetWindowsDirectoryA(cWinPath, sizeof(cWinPath)) == 0) {printf("[!] GetWindowsDirectoryA 失败,错误代码:%d \n", GetLastError());goto _EndOfFunc; // 如果失败,跳转到结束部分}// 使用 Windows 目录路径构建 ntdll.dll 的完整路径// 示例路径:C:\Windows\System32\ntdll.dllsprintf_s(cNtdllPath, sizeof(cNtdllPath), "%s\\System32\\%s", cWinPath, NTDLL);// 打开 ntdll.dll 文件,获取文件句柄hFile = CreateFileA(cNtdllPath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);if (hFile == INVALID_HANDLE_VALUE) {printf("[!] CreateFileA 失败,错误代码:%d \n", GetLastError());goto _EndOfFunc; // 如果打开文件失败,跳转到结束部分}// 获取文件大小dwFileLen = GetFileSize(hFile, NULL);// 为文件内容分配足够的内存pNtdllBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dwFileLen);// 读取 ntdll.dll 文件内容if (!ReadFile(hFile, pNtdllBuffer, dwFileLen, &dwNumberOfBytesRead, NULL) || dwFileLen != dwNumberOfBytesRead) {printf("[!] ReadFile 失败,错误代码:%d \n", GetLastError());printf("[i] 读取了 %d 字节,预期读取 %d 字节 \n", dwNumberOfBytesRead, dwFileLen);goto _EndOfFunc; // 如果读取文件失败,跳转到结束部分}// 将读取到的文件内容传递给调用者*ppNtdllBuf = pNtdllBuffer;_EndOfFunc:// 清理资源if (hFile)CloseHandle(hFile); // 关闭文件句柄if (*ppNtdllBuf == NULL)return FALSE; // 如果没有成功读取文件内容,返回 FALSEelsereturn TRUE; // 成功读取文件,返回 TRUE
}int main() {PVOID ntdllbuffer = NULL;ReadNtdllFromDisk(&ntdllbuffer);
}
接下来需要使用CreateFileMappingA和MapViewOfFile函数来映射Ntdll了。
如果我们要使用CreateFileMappingA和MapViewOfFile函数来从C:\Windows\System32读取并映射ntdll.dll。你可以利用Windows加载DLL并处理内存对齐的方式。使用 CreateFileMappingA 和 MapViewOfFile 时,内存中的.text段偏移将为4096 字节。
这是Windows默认的页面大小。如果你希望将文件映射到内存,但是避免触发安全回调(PsSetLoadImageNotifyRoutine),可以使用SEC_IMAGE_NO_EXECUTE标记。该标记确保文件映射时不会赋予执行权限。从而避免EDR等工具检测到。
这里的安全回调PsSetLoadImageNotifyRoutine例程会注册一个驱动程序提供的回调。虽然每当无论是EXE还是DLL被加载的时候,都会接收到通知。
如下代码:
#define NTDLL "NTDLL.DLL"BOOL MapNtdllFromDisk(OUT PVOID* ppNtdllBuf) {HANDLE hFile = NULL, // 文件句柄,用于打开 ntdll.dll 文件hSection = NULL; // 映射文件的句柄CHAR cWinPath [MAX_PATH / 2] = { 0 }; // 存储 Windows 系统目录的路径CHAR cNtdllPath [MAX_PATH] = { 0 }; // 存储 ntdll.dll 的完整路径PBYTE pNtdllBuffer = NULL; // 存储映射到内存中的 ntdll.dll 文件数据// 获取 Windows 系统目录路径(如 C:\Windows)if (GetWindowsDirectoryA(cWinPath, sizeof(cWinPath)) == 0) {printf("[!] GetWindowsDirectoryA 获取系统目录失败. 错误: %d \n", GetLastError());goto _EndOfFunc;}// 使用更安全的 sprintf_s 函数,拼接出 ntdll.dll 的完整路径sprintf_s(cNtdllPath, sizeof(cNtdllPath), "%s\\System32\\%s", cWinPath, NTDLL);// 打开 ntdll.dll 文件,获取文件句柄hFile = CreateFileA(cNtdllPath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);if (hFile == INVALID_HANDLE_VALUE) {printf("[!] CreateFileA 打开文件失败. 错误: %d \n", GetLastError());goto _EndOfFunc;}// 创建文件映射对象,使用 'SEC_IMAGE_NO_EXECUTE' 标志,禁止执行映射区域hSection = CreateFileMappingA(hFile, NULL, PAGE_READONLY | SEC_IMAGE_NO_EXECUTE, NULL, NULL, NULL);if (hSection == NULL) {printf("[!] CreateFileMappingA 创建文件映射失败. 错误: %d \n", GetLastError());goto _EndOfFunc;}// 将文件映射到内存中,创建视图(只读)pNtdllBuffer = MapViewOfFile(hSection, FILE_MAP_READ, NULL, NULL, NULL);if (pNtdllBuffer == NULL) {printf("[!] MapViewOfFile 映射文件失败. 错误: %d \n", GetLastError());goto _EndOfFunc;}// 返回映射后的 ntdll.dll 内存基址*ppNtdllBuf = pNtdllBuffer;_EndOfFunc:// 关闭文件句柄和文件映射句柄if (hFile)CloseHandle(hFile);if (hSection)CloseHandle(hSection);// 如果映射失败,返回 FALSE;否则返回 TRUEif (*ppNtdllBuf == NULL)return FALSE;elsereturn TRUE;
}
如上无论是从磁盘中读取Ntdll还是以文件映射的方式,其实都是将Ntdll到内存中。需要注意的是Ntdll文件如果是从磁盘上读取而不是映射到内存时,其.text段的偏移量可能是4096,而不是1024,所以将文件映射到内存时比较可靠的。
因为.text偏移量始终等于IMAGE_SECTION_HEADER.VirtualAddressDLL 文件的偏移量。
为了解除Ntdll.dll的挂钩Hook,需要执行一系列的操作。为了替换本地被Hook的ntdll.dll的.text段,必须首先获取基地址和大小。这可以通过多种方式完成。但是首先需要获取到本地ntdll.dll模块的句柄。
我们可以通过GetModuleHandle来获取到ntdll.dll模块的句柄。但是这种方式依赖于Windows API的实现。
我们都知道在x64系统上,PEB的地址存储在GS寄存器的偏移0x60处。在x86系统上,PEB的地址存储在FS寄存器的偏移0x30处。
我们可以通过内联汇编指令来获取PEB.
#ifdef _WIN64PPEB pPeb = (PPEB)__readgsqword(0x60); // 读取 GS 寄存器 0x60 偏移
#elif _WIN32PPEB pPeb = (PPEB)__readfsdword(0x30); // 读取 FS 寄存器 0x30 偏移
#endif
那么获取到PEB的地址之后就可以通过遍历LDR链表。首先通过pPeb⏩Ldr⏩InMemoryOrderModuleList获取到双向链表。该双向链表中包含了已加载模块的信息。
Flink指向链表中的下一个节点,第一次Flink指向当前模块,这通常是EXE文件,第二次Flink指向Ntdll模块,减去0x10
的偏移及为模块的PLDR_DATA_TABLE_ENTRY
结构。
PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pPeb->Ldr->InMemoryOrderModuleList.Flink->Flink - 0x10);
最后返回该模块的基地址。
return pLdr->DllBase;
如下测试代码:
#include <Windows.h>
#define NTDLL "NTDLL.DLL" // 定义 ntdll.dll 的文件名
// 定义泛型的 PEB 和 TEB 类型
typedef struct _PEB_LDR_DATA {ULONG Length;UCHAR Initialized;PVOID SsHandle;LIST_ENTRY InLoadOrderModuleList;LIST_ENTRY InMemoryOrderModuleList;LIST_ENTRY InInitializationOrderModuleList;
} PEB_LDR_DATA, * PPEB_LDR_DATA;typedef struct _UNICODE_STRING {USHORT Length;USHORT MaximumLength;PWSTR Buffer;
} UNICODE_STRING, * PUNICODE_STRING;typedef struct _LDR_DATA_TABLE_ENTRY {LIST_ENTRY InLoadOrderLinks;LIST_ENTRY InMemoryOrderLinks;LIST_ENTRY InInitializationOrderLinks;PVOID DllBase;PVOID EntryPoint;ULONG SizeOfImage;UNICODE_STRING FullDllName;UNICODE_STRING BaseDllName;ULONG Flags;SHORT LoadCount;SHORT TlsIndex;LIST_ENTRY HashLinks;PVOID TimeDateStamp;
} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;typedef struct _PEB {BOOLEAN InheritedAddressSpace;BOOLEAN ReadImageFileExecOptions;BOOLEAN BeingDebugged;BOOLEAN BitField;PVOID Mutant;PVOID ImageBaseAddress;PPEB_LDR_DATA Ldr;PVOID ProcessParameters;PVOID SubSystemData;PVOID ProcessHeap;PVOID FastPebLock;PVOID AtlThunkSListPtr;PVOID IFEOKey;ULONG CrossProcessFlags;PVOID KernelCallbackTable;ULONG SystemReserved[1];ULONG AtlThunkSListPtr32;PVOID ApiSetMap;
} PEB, * PPEB;
PVOID FetchLocalNtdllBaseAddress() {#ifdef _WIN64PPEB pPeb = (PPEB)__readgsqword(0x60); // 获取 PEB 地址
#elif _WIN32PPEB pPeb = (PPEB)__readfsdword(0x30); // 获取 PEB 地址
#endif// 获取 ntdll.dll 模块(LDR 链表中的第二个条目)PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pPeb->Ldr->InMemoryOrderModuleList.Flink->Flink - 0x10);// 返回 ntdll.dll 的基址return pLdr->DllBase;
}
int main() {PVOID ntdllbuffer = NULL;ReadNtdllFromDisk(&ntdllbuffer);MapNtdllFromDisk(&ntdllbuffer);PVOID ntdllbase = NULL;ntdllbase = FetchLocalNtdllBaseAddress();
}
那么现在就可以通过可选PE头来获取.text段信息了。在可选PE头中提供了.text段的基地址。
那么首先的话肯定是需要解析DOS头。
PIMAGE_DOS_HEADER pLocalDosHdr = (PIMAGE_DOS_HEADER)ntdllbase;
那么下来就是获取Nt头。
PIMAGE_NT_HEADERS pLocalNtHdrs = (PIMAGE_NT_HEADERS)((PBYTE)ntdllbase + pLocalDosHdr->e_lfanew);
获取Nt头之后,通过Nt头定位到可选PE头的BaseCode字段。通过BaseCode字段的值加上ntdll的基地址就可以获取到.text段的地址了。
PVOID pLocalNtdllTxt = (PVOID)(pLocalNtHdrs->OptionalHeader.BaseOfCode + (ULONG_PTR)ntdllbase);
也可以通过可选PE头中的SizeOfCode字段来获取到.text段的大小。
获取到.text段的基地址以及大小之后。
接下来,我们需要获取到未挂钩的ntdll.dll文件的.text段的基地址。为此我们可以使用ReadNtdllFromDisk函数或MapNtdllFromDisk函数。需要注意的是如果使用ReadNtdllFromDisk函数,则.text段的偏移量为1024个字节。这是因为我们从磁盘读取文件时。那么如果使用MapNtdllFromDisk,.text段的偏移量等于ntdll.dll在映射后的IMAGE_SECTION_HEADER.VirtualAddress。
那么其实说白了如果你通过映射文件的方式,.text 段的基地址通常会通过 IMAGE_SECTION_HEADER.VirtualAddress 来确定,所以我们需要通过Ntdll模块的基地址加上IMAGE_SECTION_HEADER.VirtualAddress。那么如果你是通过文件读取的方式,.text段的偏移固定为1024,所以通过Ntdll模块的基地址加上1024即可。
下一步我们将替换本地已经挂钩的ntdll.dll模块的.text段,并使用未挂钩的.text段来替换。所以我们首先肯定是需要更改目标.text段的内存权限,因为我们需要写入,所以需要通过VirtualProtect函数将其.text段设置为PAGE_EXECUTE_READWRITE。然后使用memcpy函数来进行替换,最后再将权限更改回去。
这里定义了一个ReplaceNtdllTxtSection函数,该函数的目标是将本地Hook的Ntdll.dll的.text部分替换为未Hook的版本。该函数使用预处理指令根据ntdll.dll的方式来调整.text部分的偏移量。
如下代码:
该函数需要接受一个参数,它需要接受未Hook的Ntdll.dll的基地址。
BOOL ReplaceNtdllTxtSection(IN PVOID pUnhookedNtdll) {PVOID pLocalNtdll = (PVOID)FetchLocalNtdllBaseAddress(); // 获取本地已钩的 Ntdll.dll 基地址// 打印本地和未钩住的 Ntdll 基地址printf("\t[i] 'Hooked' Ntdll Base Address : 0x%p \n\t[i] 'Unhooked' Ntdll Base Address : 0x%p \n", pLocalNtdll, pUnhookedNtdll);printf("[#] Press <Enter> To Continue ... ");getchar();// 获取 DOS 头PIMAGE_DOS_HEADER pLocalDosHdr = (PIMAGE_DOS_HEADER)pLocalNtdll;if (pLocalDosHdr && pLocalDosHdr->e_magic != IMAGE_DOS_SIGNATURE) // 检查 DOS 头签名是否正确return FALSE;// 获取 NT 头PIMAGE_NT_HEADERS pLocalNtHdrs = (PIMAGE_NT_HEADERS)((PBYTE)pLocalNtdll + pLocalDosHdr->e_lfanew);if (pLocalNtHdrs->Signature != IMAGE_NT_SIGNATURE) // 检查 NT 头签名是否正确return FALSE;PVOID pLocalNtdllTxt = NULL, pRemoteNtdllTxt = NULL; // 本地已钩住的文本段基地址,未钩住的文本段基地址SIZE_T sNtdllTxtSize = NULL; // 文本段的大小// 获取文本段PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pLocalNtHdrs);for (int i = 0; i < pLocalNtHdrs->FileHeader.NumberOfSections; i++) {// 判断该节是否为文本段if ((*(ULONG*)pSectionHeader[i].Name | 0x20202020) == 'xet.') {// 计算本地文本段基地址pLocalNtdllTxt = (PVOID)((ULONG_PTR)pLocalNtdll + pSectionHeader[i].VirtualAddress);#ifdef MAP_NTDLL// 如果定义了 MAP_NTDLL,使用映射方法获取未钩住的文本段基地址pRemoteNtdllTxt = (PVOID)((ULONG_PTR)pUnhookedNtdll + pSectionHeader[i].VirtualAddress);
#endif // MAP_NTDLL#ifdef READ_NTDLL// 如果定义了 READ_NTDLL,使用读取方法获取未钩住的文本段基地址pRemoteNtdllTxt = (PVOID)((ULONG_PTR)pUnhookedNtdll + 1024);
#endif // READ_NTDLL// 获取文本段的大小sNtdllTxtSize = pSectionHeader[i].Misc.VirtualSize;break;}}// 打印本地和未钩住的文本段地址及其大小printf("\t[i] 'Hooked' Ntdll Text Section Address : 0x%p \n\t[i] 'Unhooked' Ntdll Text Section Address : 0x%p \n\t[i] Text Section Size : %d \n", pLocalNtdllTxt, pRemoteNtdllTxt, sNtdllTxtSize);printf("[#] Press <Enter> To Continue ... ");getchar();//---------------------------------------------------------------------------------------------------------------------------// 检查是否获取到了所有必需的信息if (!pLocalNtdllTxt || !pRemoteNtdllTxt || !sNtdllTxtSize)return FALSE;#ifdef READ_NTDLL// 检查 'pRemoteNtdllTxt' 是否为文本段的基地址if (*(ULONG*)pLocalNtdllTxt != *(ULONG*)pRemoteNtdllTxt) {printf("\t[i] Text section is of offset 4096, updating base address ... \n");// 如果不是,说明读取的文本段的偏移量为 4096,所以我们需要加上 3072(因为已经加过 1024)(ULONG_PTR)pRemoteNtdllTxt += 3072;// 再次检查if (*(ULONG*)pLocalNtdllTxt != *(ULONG*)pRemoteNtdllTxt)return FALSE;printf("\t[+] New Address : 0x%p \n", pRemoteNtdllTxt);printf("[#] Press <Enter> To Continue ... ");getchar();}
#endif // READ_NTDLL//---------------------------------------------------------------------------------------------------------------------------// 打印替换文本段的提示printf("[i] Replacing The Text Section ... ");DWORD dwOldProtection = NULL;// 修改文本段的内存权限,使其可写且可执行if (!VirtualProtect(pLocalNtdllTxt, sNtdllTxtSize, PAGE_EXECUTE_WRITECOPY, &dwOldProtection)) {printf("[!] VirtualProtect [1] Failed With Error : %d \n", GetLastError());return FALSE;}// 复制新的文本段内容memcpy(pLocalNtdllTxt, pRemoteNtdllTxt, sNtdllTxtSize);// 恢复原先的内存保护权限if (!VirtualProtect(pLocalNtdllTxt, sNtdllTxtSize, dwOldProtection, &dwOldProtection)) {printf("[!] VirtualProtect [2] Failed With Error : %d \n", GetLastError());return FALSE;}// 打印完成提示printf("[+] DONE !\n");return TRUE;
}
如上代码的本质其实就是获取到本地已经Hook的Ntdll的.text段和未Hook的Ntdll的.text段。通过VirtualProtect函数将其.text段的内存保护权限更改为PAGE_EXECUTE_WRITECOPY。然后通过mempcy函数复制新的未Hook得.text段到已经Hook的.text段。最后将权限修改回来。
这里唯一需要解释的是为何要 检查pRemoteNtdllTxt是否为文本段的基地址。
这里需要注意的是在某些情况下,Ntdll.dll的前四个字节可能是会被修改的,如果这些字节不是0xCC 0xCC 0xCC 0xCC
,那么说明ntdll.dll文件可能被修改过。
假设我们是通过磁盘读取的方式来读取Ntdll.dll的,判断如果前四个字节是 0xCC 0xCC 0xCC 0xCC,我们认为文件没有被修改过,此时可以直接使用 1024 字节作为文本段的偏移量。如果前面四个字节不是 0xCC 0xCC 0xCC 0xCC,则表示文件已经被篡改或勾住。此时我们需要使用真实的偏移量4096。
if (*(ULONG*)pLocalNtdllTxt != *(ULONG*)pRemoteNtdllTxt)
这里的判断很简单。
pLocalNtdllTxt和pRemoteNtdllTxt分别指向勾住和未勾住的Ntdll.dll的指针。
*(ULONG*)pLocalNtdllTxt
是强制转换为 ULONG*(无符号长整型指针)然后解引用它们。
最后取出它们所指向的内存地址中的4字节数据。其实就是0xCC 0xCC 0xCC 0xCC
。
如上就是Ntdll通过磁盘解除挂钩的学习思路。
原创 relaysec Relay学安全