Making The Perfect Injector: Abusing Windows Address Sanitization And CoW
我的目标是在这篇博文结尾的时候,制作出一个与众不同的注入器:让你的dll无法被用户模式的调试器调试,让你的内存页对NtQueryVirtualMemory
与NtReadVirtualMemory
不可见,最后,你的执行代码在目标进程甚至不会有一个句柄。在实现这些操作的同时,我希望它不会触发 patchguard, 并且在目标进程运行时没有驱动运行。
这个目标看起来似乎很蠢,但其实它非常简单,因为windows会帮助我们。
(源码可以在博文底部找到)
0x1:利用Windows地址过滤
当我们把ntoskrnl.exe拖入IDA的时候,我们可以注意一个地址检查
1 | __int64 __usercall MiReadWriteVirtualMemory@<rax>(ULONG_PTR BugCheckParameter1@<rcx>, unsigned __int64 a2@<rdx>, unsigned __int64 a3@<r8>, __int64 a4@<r9>, __int64 a5, int a6) |
有趣的是,操作系统使用硬编码的方式来确保内核内存不会泄露,而这个方式并非CPU用来检查地址是否可以被用户层访问的方式。
1 |
|
如上所示,这是CPU翻译逻辑地址时会用到的结构,而这些众多的标志位则表明着这个逻辑地址的一些属性,在这些属性中,user/supervisor
来决定这片内存是否能够被用户层访问。所以与人们想的不一样的是,cpu并非使用
1 | Va >= 0xFFFFFFFF80000000 |
来检查内存访问,而是使用
1 | Pte->user & Pde->user & Pdpte->user & Pml4e->user |
利用操作系统与cpu检测机制不一样这点,我们可以实现内存对所有用户层api不可见,但它又能够执行在用户模式上。操作非常简单:
1 | BOOL ExposeKernelMemoryToProcess( MemoryController& Mc, PVOID Memory, SIZE_T Size, uint64_t EProcess ) |
看吧,现在我们有了一个秘密的内存页。
0x2:利用Copy-on-Write
现在我们已经有了一片隐藏的内存了,最后我们需要做的只是让它跑起来。
CoW 是操作系统为了节省内存,让进程共享某些物理内存,直到它的内存数据被改变的时候才重新映射的一种技术。
我们知道ntdll.dll是每个进程都会加载的一个链接库,而它的代码段(.text)几乎不会改变,所以为什么要一次又一次地为成百上千个进程分配用于储存它的物理内存呢?
实现非常简单
- 如果一个PE文件在其他进程中被映射,并且它的虚拟地址在本进程中并未被使用,那么当本进程映射这个PE文件时,操作系统只会拷贝它的PFN到本进程,并设置这片内存属性为只读。
- 如果有指令试图写入这片内存,将会产生一个PageFualt,这个时候操作系统将重新为这片内存分配物理地址空间,并移除只读属性。
这意味着,当我们通过物理内存HOOK一个dll的时候,我们实际上是创建了一个系统范围有效的HOOK。
我们选择HOOK一个常用的API:TlsGetValue
。
因为PML4E在各个进程间是不同的,因此我们不能直接跳转到我们的隐藏内存,我们可以在KERNEL32.DLL找到一片区域用于执行检查PID的代码:
1 | std::vector<BYTE> PidBasedHook = |
因为PE始终是0x1000对齐的,所以找到一片35字节的可用区域非常简单。
最后,添加上收尾代码,确保我们的HOOK只执行一次,并且在执行前恢复HOOK。
1 | std::vector<BYTE> Prologue = |
至此,所有的工作都完成了。我们成功注入了并隐藏了dll。通过一个系统范围有效的HOOK来确保在目标进程执行前驱动程序就可以卸载。
省略了一些细枝末节,此文核心是PTE欺骗,至于后面通过COW劫持线程,个人认为实用性不大。