22-自举的代码幽灵——反射DLL注入(Reflective DLL Injection)
一、前言
⚠注意:最重要的技术总是压轴出场,本节所介绍的是高级的注入——反射性 DLL 加载器实现,难度略大,看不懂就可以先跳过本节,学习后面的知识。
反射注入揭示了计算机安全最深邃的哲学命题:防御者固守的"进程-文件-注册表"三维认知框架,便暴露了对抗高维攻击的致命缺陷,DLL始终以内存碎片的形态存在,杀毒软件的磁盘扫描如同在沙漠寻找特定沙粒,通过反射加载器构建的隧道,DLL的初始化如同超距作用般跳过标准生命周期。即使过去了十几年,反射DLL注入依旧是被攻击者广泛使用的高级注入技术。
反射DLL注入是让某个进程主动加载指定的dll的技术,而不依赖windows提供的loadlibraryA函数。常规的dll注入技术使用LoadLibraryA函数来使被注入进程加载指定的dll。这样使得常规dll注入技术在受害者主机上留下痕迹较大,很容易被edr等安全产品检测到。由于是自实现PE文件映射到内存,所以需要对PE文件结构和文件映射流程有比较深刻的理解。
反射DLL注入 特别特别的重要几乎是现代C2的标配,免杀效果良好,如果一个远控不能实现实现反射dll注入,就不能称之为c2。在分析sliver和cs源码时,我就看到一些敏感操作,如mimikatz是由反射dll注入的方式实现的,shellcode无文件落地,规避效果优秀。
由于 反射DLL注入 实在太过重要了,我将在下文花费大量笔墨来介绍 反射DLL注入 的原理和代码实现。
反射DLL注入 与常规的dll注入的大致步骤差不多,对于 Inject 其关键的差异是不通过LoadLibraryA+GetProcAddress来获得恶意DLL中的ReflectiveLoader函数。对于恶意DLL,自关键是实现DLL文件自加载,而我们文章也会重点围绕ReflectiveLoader的实现来展开。
二、Inject的原理和代码实现
大致步骤:
打开DLL文件,获得DLL的大小
创建本地堆空间,当然也可以用一个数组来当缓冲区
读取文件内容到本地堆空间
开目标进程,获得其句柄
申请一块保护属性为PAGE_EXECUTE_READWRITE的内存空间,将dll文件写入进去
获取 ReflectLoader 在目标进程内存中的地址
调用 ReflectLoader 函数
前5步相对简单,是很常规的利用Windows API将DLL文件先读到本地缓冲中,然后再写到目标进程的内存空间中。从我们的大致步骤中可以看到,我们并没有使用LoadLibraryA加载DLL,而是直接将DLL文件读取到内存中,此时的DLL文件还没有进行映射操作,还是保持着磁盘文件的形式。
直接将DLL读取到内存中会导致我们不能使用GetProcAddress获得ReflectLoader函数,所以 Inject 的核心是找到ReflectLoader的地址。
怎么找ReflectLoader的地址呢?首先明白一点,ReflectLoader是DLL的导出函数,其函数的相关信息存放在导出表中,但是这又会存在一个问题,因为此时DLL在内存是以磁盘文件的形式存在的,导出表的偏移地址是RVA,导出表里的函数的地址也是RVA,因此我们需要写一个函数将RVA->文件偏移地址。
RVA->文件偏移地址的公式:文件偏移 = 节区文件起始地址(PointerToRawData) + (RVA - 节区虚拟起始地址(VirtualAddress))
由这个公式可以编写出 RVAtoFileOffset 函数
有了 RVAtoFileOffset 函数我们就可以着手编写获取 ReflectLoader 在目标进程内存中的地址的 GetProcAddrByName 函数了
GetProcAddrByName 函数的核心就是遍历导出表,获得导出函数名,与指定函数名 (在本例中是 ReflectLoader) 进行比对,成功就结束循环,这个函数与下文的 GetApiAddressByName 逻辑类似,只是 GetProcAddrByName 适用以磁盘文件的形式的DLL。具体看下面的代码
三、ReflectiveLoader的原理和代码实现
VS中调试DLL工程的正确方法_vs debug dll-CSDN博客
反射DLL注入 的实现实在太多了,我就挑选用几个比较知名的项目拼凑出一个属于我的反射dll注入的项目(bushi=。=),让我们一起去分析源码,来窥探 反射DLL注入 的奥秘吧
在具体介绍实现原理之前,我们首先来看看 ReflectiveLoader 实现大致实现思路,有一个比较明确的方向,且接下来我都会根据每一步详细展开:
暴力搜索DLL的基址
获取所需要的Windows API
加载 PE 文件节到内存
修复重定位表
修复导入表
获取dllmain的地址,执行dllmain
关键步骤就这几步,其实还可以添加额外的几个步骤,比如说修复延迟导入表、修改节的保护属性、执行TLS回调函数等
补充:
TLS回调函数是在程序或DLL加载和卸载时自动调用的函数,通常用于初始化或清理线程本地存储的数据。
在反射DLL注入中,执行TLS回调函数的作用:遍历并执行所有注册的TLS回调函数,通知它们进程附加的事件,从而进行必要的初始化工作,这一步常见于SRDI中,即DLL转换为自加载的Shellcode。
3.1 暴力搜索DLL的基址
由于 Inject 将带有 ReflectiveLoader 的DLL加载于内存中的任意位置(ASLR防护),因此 ReflectiveLoader 将首先计算其自身Image在内存中的当前位置,即ImageBase,以便能够解析自己的PE头部,即DOS头、NT头,以供以后使用。
其实这一步也是看具体情况来决定是否要实现,比如说在 monoxgas/sRDI:反射 DLL 注入的 shellcode 实现。将 DLL 转换为与位置无关的 shellcode 中,通过 Inject 来传递DLL的基址也是一种比较常规的做法,而我将跟随Stephen Fewer的思想,自实现暴力搜索DLL的基址。
在Stephen Fewer的项目中,我们可以看到他为了使项目可以在多平台多架构上通用,定义了一些的宏定义

我不想实现这样的宏定义,然后参考了几篇文章之后了解到,使用这样的方式
可以获取 ReflectiveLoader 函数运行时在DLL中的地址。大致了解过进程的内存布局的人都会知道,ReflectiveLoader 函数通常位于代码段(.text),而DOS头位于 ReflectiveLoader 的后面(低地址区域),所以我们可以通过 ReflectiveLoader 的地址从前往后逐地址去验证DOS头部和NT头部,直到找到 DLL的基址,即DOS头部地址。

怎么验证DOS头和NT头呢?DOS头的签名是 0x5A4D(小端序),即 “MZ” 字符串;NT头的签名是 0x00004550(小端序),即 “PE00”,可以用010 editor随便看一个pe文件

知道原理之后,我们尝试实现第一步的代码
3.2 获取所需要的Windows API
因为我们的DLL不是通过系统加载到内存中的,所以DLL的导入表是未修复的状态,我们就不可以使用API,但是在 复制PE头和节到新内存区域 中需要用到VirtualAlloc ,在 修复导入表 中需要用到LoadLibraryA+GetProcAddress,所以需要解析主机进程kernel32.dll导出表,获取所需要API的地址。
为了实现这一目标,我就自实现了一个可以根据DLL的名称和API名称获取API函数地址的 GetApiAddressByName 函数。该函数大致原理如下
获取PEB的地址
获取LDR的地址
遍历已加载模块列表,查找目标DLL。
解析目标DLL的PE结构,定位导出表。
遍历导出表,查找目标API名称。
返回找到的函数地址或NULL。
一句话总结就是遍历peb结构体中的ldr成员中的InMemoryOrderModuleList链表获取dll名称,遍历函数所在的dll导出表获得必要的函数的名称,如果匹配成功就返回目标函数的地址。
为了实现 GetApiAddressByName,还需要实现几个辅助函数,它们分布是my_towlower、MyCompareStringW、MyCompareStringA、ExtractDllName
我简要的说一下它们的作用
my_towlower:将宽字符从大写转换小写,是用于辅助MyCompareStringW函数的MyCompareStringW:不区分大小写的宽字符串比较。这主要查找目标DLL,因为我们的DLL名称是宽字符串表示的MyCompareStringA: ASCII字符串比较函数。这主要用于查找目标API名称,因为微软的API是ASCII字符串表示的ExtractDllName:因为LDR_DATA_TABLE_ENTRY这个结构体的FullDllName.Buffer字段表示的是完整的DLL路径,我们需要从DLL路径中提取出DLL名称自定义宽字符转小写my_towlower` 函数
不区分大小写的宽字符串比较函数MyCompareStringW
ASCII字符串比较函数 MyCompareStringA
提取 DLL 名称的函数ExtractDllName
GetApiAddressByName的实现如下
现在,获取所需要的Windows API 这一步骤的所有函数都准备好了,还有一点需要明确,就是我们的节区并没有映射到内存中,如果我们使用类似 CHAR VirtualAlloc[] = "VirtualAlloc"; 的常量字符串,这些字符串是保存在 .rdata 中的,在未完成映射时,我们是无法访问到的,所以我们需要将常量字符串改成栈字符串,以将字符串保存到 .text 中,将字符串改成函数内数组就会以栈保存了。
CHAR getProcAddress[] = { 'G','e','t','P','r','o','c','A','d','d','r','e','s','s','\0' }; 是这样存放的

而 CHAR VirtualAlloc[] = "VirtualAlloc"; 这样存放的

我们开始正式的获取所需要的API
3.3 加载 PE 文件节到内存
这一步骤相对简单,需要用VirtualAlloc申请一块RWX的保护属性、SizeOfImage 大小的内存区域,然后逐字节将所有头部信息复制到新内存区域,大小为 SizeOfHeaders。
补充
SizeOfHeaders:这是 PE 文件头(IMAGE_OPTIONAL_HEADER结构)中的一个字段,表示 所有头部结构的总大小。 的SizeOfImage:这是IMAGE_OPTIONAL_HEADER中的另一个字段,表示 整个 PE 映像(Image)加载到内存后的总大小
复制完所有头部信息后,我们就要开始将PE文件映射到内存里。回想一下,我们在 Inject 中是将DLL以文件的形式读取到内存中的,并没有进行映射,所以我们模拟系统的加载器进行映射就需要用到 IMAGE_SECTION_HEADER 的四个字段,一个是 SizeOfRawData、PointerToRawData、VirtualAddress、VirtualSize
PointerToRawData:节区数据在 磁盘文件 中的偏移量SizeOfRawData:节区数据在 磁盘文件 中占用的实际大小VirtualAddress:节区加载到内存后的 相对虚拟地址(RVA)VirtualSize:节区在 内存 中占用的实际大小
我们就根据这四个字段将PE节一一映射到新内存中,具体看下面的代码
3.4 修复重定位表
大致步骤
计算基址偏移量
定位重定位表
遍历重定位块
处理重定位条目
地址修正逻辑
(一)重定位表
在反射式DLL注入中,当DLL未加载到其预设基址(ImageBase)时,需通过重定位表修正所有硬编码地址,也就是绝对地址,确保代码正确执行。此过程是绕过ASLR(地址空间布局随机化)的关键步骤
重定位表是一个可变长度的数据结构,它会被单独存放在 .reloc 命名的节中,重定位表的位置和大小可以从数据目录中的第6个(索引值为5) IMAGE_DATA_DIRECTORY 结构中获取到,它的数据结构如下
重定位表由多个重定位块(Relocation Block) 组成,每个块对应一个内存页(4KB),其中 VirtualAddress 字段记录了第一个重定位块的位置
(二)重定位块
每个重定位块以一个 IMAGE_BASE_RELOCATION 结构开头,后面跟着在本页中使用的所有重定位项,每个重定位项占用16位,最后一个块是一个使用全0填充的 _IMAGE_BASE_RELOCATION 全零结束块。IMAGE_BASE_RELOCATION 数据结构如下所示
(三)重定位条目(TypeOffset)
TypeOffset的每个元素都是一个自定义类型结构,每个重定位条目为 16位(WORD),其组成如下:
Type
高4位
定义地址修正类型(如DIR64)
Offset
低12位
相对于块头VirtualAddress的偏移
数据结构定义
若条目值为0x3012(十六进制),则:
Type = 0x3→IMAGE_REL_BASED_HIGHLOWOffset = 0x012→ 偏移18字节
TypeOffset的元素个数 = (SizeOfBlock - 8 )/ 2 ,SizeOfBlock 表示块的总字节数,SizeOfBlock - 8 表示减去 IMAGE_BASE_RELOCATION 结构体所占的字节数得到一个块内所有重定位条目所占的字节数,一个重定位条目占2个字节,(SizeOfBlock - 8 )/ 2 就得到重定位条目的数量了。
在下面的代码中有这样的一条语句 (PBYTE)relocList != (PBYTE)relocation + relocation->SizeOfBlock。这里的relocation指向当前块的起始位置,而SizeOfBlock是整个块的大小,包括块头(IMAGE_BASE_RELOCATION结构)和后续的重定位项,当 relocList 的地址达到当前块的末尾(即 relocation 的起始地址加上 SizeOfBlock 的值)时停止循环。
当然你也可以计算出一个重定位块的重定位条目数,然后用for循环遍历也是可以的,感兴趣的读取可以自己去实现。
(四)地址修正
简单举一个例子
公式: BaseAddress(DLL实际基址) + VirtualAddress(块起始RVA) + offset(条目偏移)= 实际需要修正的内存位置(即存储原始地址值的地址) 接下来我们分步来解释这一条语句
计算目标内存地址:BaseAddress + relocation->VirtualAddress + relocList->offset,三者相加得到需要修正的内存位置(即存储原始地址值的地址)
指针类型转换:转换为PULONG_PTR类型的指针,确保后续操作按机器字长处理数据,适应不同架构的地址修正需求
解引用:通过指针访问目标内存地址处的值,获取需要修正的原始地址值(如全局变量地址、函数指针等)
应用基址偏移修正:新地址 = 原地址 + (实际基址 - 预期基址)
内存位置变化,举一个全局变量的例子:
baseOffset:
实际加载地址 (BaseAddress) - 预期基地址 (ImageBase) = 0x20000000 - 0x10000000 = 0x10000000原内存地址:
0x10001234新内存地址:
0x10001234 + baseOffset = 0x20001234
程序中某个全局变量地址原先指向 0x10001234 的地址,被动态修正为 0x20001234,使其指向实际加载后的正确位置。

完整代码如下
3.5 修复导入表
大致思路
获取导入表中的每一个导入描述符(
PIMAGE_IMPORT_DESCRIPTOR),导入描述符中存放着需要导入的DLL的名称的RVA。使用在
获取所需要的Windows API获取的LoadLibraryA的函数指针加载相应的DLL以
IMAGE_IMPORT_DESCRIPTOR的OriginalFirstThunk作为导入名称表(INT,Import Name Table),根据INT中存放信息,我们可以选择序号导入还是名称导入,无论哪一种导入,都需要使用之前获取到的GetProcAddress指针解析需要的函数地址。将解析得到的函数地址填写到导入地址表(IAT,Import Address Table)
移动到下一个导入描述符,重复上述操作
既然都说到了INT和IAT,就简单的做个介绍
编译时
由链接器生成
初始内容与INT相同
磁盘存储
保存函数名/序号
保存函数名/序号的副本
加载时
保持原样
被加载器替换为实际地址
运行时
保持原样
包含实际函数指针
协调工作
加载器遍历INT中的每个IMAGE_THUNK_DATA
根据每个thunk项解析函数地址:
如果是序号导入:
Ordinal & IMAGE_ORDINAL_FLAG如果是名称导入:
AddressOfData指向IMAGE_IMPORT_BY_NAME
将解析得到的函数地址写入IAT对应位置
程序执行时通过IAT中的地址调用API
完整代码
3.6 获取dllmain的地址,执行dllmain
这一步比较简单,直接上代码
四、完整代码
4.1 Inject
4.2 ReflectiveLoader
测试

五、尾语
在末尾在说几句话吧
在
Inject中你是可以先扩展到节区表,然后找到ReflectiveLoader的地址,再创建线程去调用它,与上文提到的思路大差不差,这样做的好处就是不用再从RVA转到文件偏移了CreateRemoteThread是可以向被创建的线程传递一个参数的,如果不想在ReflectiveLoader中实现暴力搜索DLL的基址的话,可以向ReflectiveLoader传递DLL的基址如果想实现类似CobaltStrike的有阶段beacon,你可以参考 oldboy21/RflDllOb: 反射式 DLL 注入制作 Bella 这个项目去修改
Inject和ReflectiveLoader,做到类型下图所示的功能

参考资料
最知名的Reflective DLL Injection项目,也算是技术起源了: GitHub - stephenfewer/ReflectiveDLLInjection:反射式DLL注入是一种库注入技术,其中采用反射式编程的概念来执行将库从内存加载到主机进程中。