在分析koadic渗透利器时,发现它有一个注入模块,其DLL注入实现方式和一般的注入方式不一样。搜索了一下发现是由HarmanySecurity的Stephen Fewer提出的ReflectiveDLL Injection. 由于目前互联网上有关这个反射式DLL注入的分析并不多,也没有人分析其核心的ReflectiveLoader具体是怎么实现的,因此我就在这抛砖引玉了。
0×00 引言
常规的DLL注入方式相信大家都很熟悉了,利用CreateRemoteThread这一函数在目标进程中开始一个新的线程,这个线程执行系统的API函数LoadLibrary,之后DLL就被装载到目标进程中了。然而,由于这一技术被大量的恶意软件利用,各种安全对DLL注入这一块自然是严加看守,而常规的注入方式太过于套路化(CreateRemoteThread+ LoadLibrary),导致它十分容易被检测出来。同时,常规的DLL注入方式还需要目标DLL必须存在磁盘上,而文件一旦“落地”就也存在着被杀毒软件查杀的风险。
因此我在这里介绍一种新的DLL注入方式,它不需要在文件系统存放目标DLL,减少了文件“落地”被删的风险。同时它不需要像常规的DLL注入方式那么套路,因此更容易通过杀软的行为检测。由于反射式注入方式并没有通过LoadLibrary等API来完成DLL的装载,DLL并没有在操作系统中”注册”自己的存在,因此用ProcessExplorer等软件也无法检测出进程加载了该DLL。
0×01 核心思路
我们不想让DLL文件“落地”, 那我们可以在磁盘上存放一份DLL的加密后的版本,然后将其解密之后储存在内存里。我们然后可以用VirtualAlloc和WriteProcessMemory将DLL文件写入目标进程的虚拟空间中。然而,要”加载”一个DLL,我们使用的LoadLibrary函数要求该DLL必须存在于文件系统中。这可怎么办呢。
没错,我们需要抛弃LoadLibrary,自己来实现整个装载过程!我们可以为待注入的DLL添加一个导出函数,ReflectiveLoader,这个函数实现的功能就是装载它自身。那么我们只需要将这个DLL文件写入目标进程的虚拟空间中,然后通过DLL的导出表找到这个ReflectiveLoader并调用它,我们的任务就完成了。
于是,我们的任务就转到了编写这个ReflectiveLoader上。由于ReflectiveLoader运行时所在的DLL还没有被装载,它在运行时会受到诸多的限制,例如无法正常使用全局变量等。而且,由于我们无法确认我们究竟将DLL文件写到目标进程哪一处虚拟空间上,所以我们编写的ReflectiveLoader必须是地址无关的。也就是说,ReflectiveLoader中的代码无论处于虚拟空间的哪个位置,它都必须能正确运行。这样的代码被我们称为“地址无关代码”(position-independent code, PIC)。
0×02 注射器实现
要实现反射式注入DLL我们需要两个部分,注射器和被注入的DLL。其中,被注入的DLL除了需要导出一个函数ReflectiveLoader来实现对自身的加载之外,其余部分可以正常编写源代码以及编译。而注射器部分只需要将被注入的DLL文件写入到目标进程,然后将控制权转交给这个ReflectiveLoader即可。因此,注射器的执行流程如下:
1. 将待注入DLL读入自身内存(利用解密磁盘上加密的文件、网络传输等方式避免文件落地)
2. 利用VirtualAlloc和WriteProcessMemory在目标进程中写入待注入的DLL文件
3. 利用CreateRemoteThread等函数启动位于目标进程中的ReflectiveLoader
至此,我们注射器的任务就已经完成了。下一步就是ReflectiveLoader的实现了。
0×03 ReflectiveLoader的实现
ReflectiveLoader要完成的任务是对自身的装载。所谓的“装载”具体而言是什么意义呢?
所谓“装载”,最重要的一点就是要将自身合适地展开到虚拟空间中。我们都知道在PE文件包含了许多节,而为了节省存储空间,这些节在PE文件中比较紧密地凑在一起的。而在广阔虚拟空间中,这些节就可以映射到更大的空间中去。更不用说还存在着.bss这样的在PE文件中不占空间,而要在虚拟空间中占据位置的节了。ReflectiveLoader需要做的一件很重要的事就是按照规则去将这些节映射到对应的地址去。
同时,由于DLL中可能会用到其他DLL的函数,装载一个DLL还需要将这个DLL依赖的其他动态库装入内存,并修改DLL的IAT指向到合适的位置,这样对其他DLL函数的引用才能正确运作。
虽然我们上文提到,ReflectiveLoader的代码是地址无关的,但是该DLL的其他部分的代码却并不是这样的。在一份源代码编译、链接成为DLL时,编译器都是假设该DLL会加载到一个固定的位置,生成的代码也是基于这一个假设。在反射式注入DLL的时候,我们不太可能申请到这个预先设定好的地址,所以我们需要面对一个重定位(Rebasing)的问题。
以上就是ReflectiveLoader所面对的问题。接下来我们看看它是如何解决这些问题的。
1) 定位DLL文件在内存中的基址
ReflectiveLoader做的第一件事就是查找自身所在的DLL具体被写入了哪个位置。
ReflectiveLoader首先利用一个重定位技巧找到自身所在的大致位置:
ULONG_PTR caller( VOID ) { return(ULONG_PTR)_ReturnAddress(); }
其中函数_ReturnAddress()返回的是当前调用函数的返回地址,也就是caller()的下一条指令的地址。这个地址位于ReflectiveLoader的内部,而ReflectiveLoader位于被注入的DLL文件内部,因此这个地址离DLL文件的头部不远了。
借助上文找到的地址,我们逐字节的向上遍历,当查找到符合PE格式的文件头之后,就可以认为找到了DLL文件在内存中的地址了。
2)获取所需的系统API。
ReflectiveLoader启动时,目标进程已在正常的运行状态中了,此时目标进程已经装载了一些核心的DLL文件。我们可以搜索这些DLL文件,查找需要的API函数,为后续操作提供方便。具体地,我们需要的函数是kernel32.dll中的LoadLibraryA(), GetProcAddress(), VirtualAlloc()以及ntdll.dll中的NtFlushInstructionCache()函数。
ReflectiveLoader借助PEB (ProcessEnvironment Block)来查找kernel32.dll和ntdll.dll在内存中的位置。这一部分需要对TEB (ThreadEnvironment Block)和PEB (Process Environment Block)有一个基本的了解,我在此简略介绍一下。
每一个线程都具有一个TEB结构,其中记录了相关线程的一些基本信息。线程运行时,其FS段寄存器记录了其TEB的位置。而在TEB结构的0×30偏移处记录了PEB结构的指针,因此可以通过如下代码访问PEB:
mov EAX, FS:[0x30] //EAX指向了PEB结构。
PEB结构包含有65个成员,大小达到0×210个字节,在此就不细致介绍了。需要注意的是,在PEB结构的0x0C偏移处,是一个指向PEB_LDR_DATA结构体的指针,其结构如下:
其中的三个LIST_ENTRY是三个链表,按照不同的顺序规则将当前进程加载的所有模块链接起来。通过遍历其中的任意一个LIST_ENTRY,我们就可以获得所有模块的基地址,具体方法就不细致阐述了。
在获取了模块基地址之后,通过对PE文件的解析,找到DLL文件的导出表,再根据导出表就可以找到任一导出函数的地址了。对PE文件的解析有太多文章,这里也不细致阐述了。
在此,我们得到了函数LoadLibraryA(), GetProcAddress(), VirtualAlloc()以及NtFlushInstructionCache()。它们将在之后被用到。
3) 分配一片用来装载DLL的空间。
虽然在ReflectiveLoader运行时,DLL文件已经在进程内存中了,但是要装载这个DLL,我们还需要更大的空间。借助在第2)步得到的函数VirtualAlloc(),我们可以分配一片更大的内存空间用于加载DLL。在PE头中的IMAGE_OPTIONAL_HEADER结构体中的SizeOfImage成员记载DLL被装载后的大小,我们按照这个大小分配内存即可。
uiBaseAddress = (ULONG_PTR)pVirtualAlloc( NULL, ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfImage, MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE );
uiBaseAddress记录了VirtualAlloc的返回值,也就是分配内存空间的起始地址。于是uiBaseAddress就成为了DLL被装载后的基地址。
4) 复制PE文件头和各个节
分配了用于装载的空间后,ReflectiveLoader将DLL文件的头部(也就是DOS文件头、DOS插桩代码和PE文件头)复制到新的空间的首部。再根据PE文件的节表将各个节复制到相应的位置中.
5) 处理DLL的引入表
被注入的DLL可能还依赖于其他的DLL,因此我们还需要装载这些被依赖的DLL,并修改本DLL的引入表,使这些被引入的函数能正常运行。
PE文件的引入表是一个元素为IMAGE_IMPORT_DESCRIPTOR的数组。每一个被依赖的DLL都对应着数组中的一个元素。下图表示了IMAGE_IMPORT_DESCRIPTOR结构以及我们需要进行的处理。
我们要做的就是根据IMAGE_IMPORT_DESCRIPTOR中的NAME成员找到DLL的名称,根据名称装载这些被依赖的DLL。 IMAGE_IMPORT_DESCRIPTOR中的OriginalFirstThunk指示了要从该DLL中引入哪些函数。有的函数是由名称导入的,此时IMAGE_THUNK_DATA会指向这个函数名;有的函数是由函数序号导入,此时分析IMAGE_THUNK_DATA我们会得到这个序号。无论是以什么方式导入,我们都要需要找到对应的函数,然后将其地址填入FirstThunk指向的IMAGE_THUNK_DATA数组中。装载这些被依赖的DLL就不需要我们手工操作了,我们直接利用步骤2)中获得的LoadLibraryA()来装载它们。对于那些通过函数名导入的函数来说,我们可以直接用GetProcAddress()来得到它们的地址;而对于通过序数导入的函数来说,则需要我们再次手工分析PE文件的导出表来找到它们的位置。
在得到所需的函数的地址后,将它们填入上图的相应位置,这样我们就完成了对引入表的处理了。
6) 对DLL进行重定位
被注入的DLL只有其ReflectiveLoader中的代码是故意写成地址无关、不需要重定位的,其他部分的代码则需要经过重定位才能正确运行。幸运的是DLL文件提供了我们进行重定位所需的所有信息,这是因为每一个DLL都具有加载不到预定基地址的可能性,所以每一个DLL都对自身的重定位做好了准备。
PE可选印象头的DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]就指向了重定位表。重定位表的数据结构如下:
从定义上看,IMAGE_BASE_RELOCATION只包含了两个DWORD,其实在内存中它之后还跟了若干个大小为两个字节的元素,就是定义中被注释掉的“WORD Typeoffset[1]“。IMAGE_BASE_RELOCATION结构和后面紧跟的若干个Typeoffset组成了一个块,其大小为结构体中的SizeOfBlock。因此,Typeoffset的数量可以根据SizeofBlock算出。当一个块结束时,后面紧跟的就是下一个块。若SizeofBlock为0则标志着重定位表结束了。
Typeoffset的高4位代表重定位类型,一般为3,低12位则表示重定位地址。这个地址和IMAGE_BASE_RELOCATION中的VirtualAddress加起来则指向一个需要重定位的指令。
找到需要重定位的地点之后,怎么重定位呢?前文说到Typeoffset指示了多种重定位类型,其中最常见的为3,在此我只介绍这种情况。其他重定位类型的主体思想基本是相似的,只有细微的不同。
我们首先计算得到基地址的偏移量,也就是实际的DLL加载地址减去DLL的推荐加载地址。DLL推荐加载地址保存在NT可选印象头中的ImageBase成员中,而实际DLL加载地址则是我们在第3)步中函数VirtualAlloc()的返回值。然后我们将VirtualAddress和Typeoffset合力组成的地址所指向的双字加上这个偏移量,重定位就完成了。
*(DWORD*)(VirtualAddress + Typeoffset的低12位) += (实际DLL加载地址 – 推荐DLL加载地址)
在完成所有的重定位后,我们最后调用第2)步得到的NtFlushInstructionCache()清除指令缓存以避免问题。
7) 调用DLL入口点
至此,ReflectiveLoader的任务全部完成,最后它将控制权转交给DLL文件的入口点,这个入口点可以通过NT可选印象头中的AddressOfEntryPoint找到。一般地,它会完成C运行库的初始化,执行一系列安全检查并调用dllmain。
0×04 总结
反射式DLL注入是一种新型的DLL注入方式,它不需要像传统的注入方式一样需要DLL落地存储,避免了注入DLL被安全软件删除的危险。由于它没有通过系统API对DLL进行装载,操作系统无从得知被注入进程装载了该DLL,所以检测软件也无法检测它。同时,由于操作流程和一般的注入方式不同,反射式DLL注入被安全软件拦截的概率也会比一般的注入方式低。
反射式DLL注入的实现中运用了大量对PE文件结构的解析。了解,以及动手实践这个注入方式会让您对PE文件格式,PE文件加载的理解更加深刻。