0%
毅种循环

模块 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 大小#

获取本地 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_SUSPENDEDDEBUG_PROCESS 标志来创建进程。在下面的代码中,我们将使用 DEBUG_PROCESS 标志。

进程创建后,使用 ReadProcessMemory 读取其 ntdll.dll 映像。读取完成后,使用 DebugActiveProcessStop WinAPI 分离调试器,然后使用 TerminateProcess WinAPI 终止该进程。注意,如果不先分离,进程可能无法正常终止。

如果是使用的 CREATE_SUSPENDED 标志,则应使用 ResumeThread 来恢复(通常也是为了清理/终止它)。

上述逻辑在下面的 ReadNtdllFromASuspendedProcess 函数中以编程方式实现。

整合代码#

一旦成功获取了干净的 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 那样特殊的对齐转换逻辑。

改进实现#

目前的实现使用的是标准 WinAPI 来取消 ntdll.dll 挂钩。为了实现更隐蔽的操作,应该使用 Direct Syscalls (直接系统调用)Indirect Syscalls (间接系统调用) 来执行取消挂钩的操作(例如 NtReadVirtualMemory 替代 ReadProcessMemoryNtProtectVirtualMemory 替代 VirtualProtect)。这留给读者作为练习。

演示#

  1. 一个 PID 为 6412 的挂起子进程。

图片
图片

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

图片
图片

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

图片
图片

  1. 成功替换文本节。

图片
图片