简介
我们之前都是直接通过使用直接系统调用的方式来绕过用户态钩子,通过在项目文件中创建并调用系统调用来实现此目标。还有另外一种方法也可以绕过用户态的钩子,那么这种方法是将已经加载到进程中的钩子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.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; // 如果打开文件失败,跳转到结束部分
}
// 获取文件大小
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; // 如果没有成功读取文件内容,返回 FALSE
else
return 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;否则返回 TRUE
if (*ppNtdllBuf == NULL)
return FALSE;
else
return 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 _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60); // 读取 GS 寄存器 0x60 偏移
#elif _WIN32
PPEB 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 _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60); // 获取 PEB 地址
#elif _WIN32
PPEB 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通过磁盘解除挂钩的学习思路。
标签:ntdll,PVOID,Windows,text,Ntdll,dll,内核,NULL From: https://www.cnblogs.com/o-O-oO/p/18662773原创 relaysec Relay学安全