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的原理和代码实现

大致步骤

  1. 打开DLL文件,获得DLL的大小

  2. 创建本地堆空间,当然也可以用一个数组来当缓冲区

  3. 读取文件内容到本地堆空间

  4. 开目标进程,获得其句柄

  5. 申请一块保护属性为PAGE_EXECUTE_READWRITE的内存空间,将dll文件写入进去

  6. 获取 ReflectLoader 在目标进程内存中的地址

  7. 调用 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博客arrow-up-right

反射DLL注入 的实现实在太多了,我就挑选用几个比较知名的项目拼凑出一个属于我的反射dll注入的项目(bushi=。=),让我们一起去分析源码,来窥探 反射DLL注入 的奥秘吧

在具体介绍实现原理之前,我们首先来看看 ReflectiveLoader 实现大致实现思路,有一个比较明确的方向,且接下来我都会根据每一步详细展开:

  1. 暴力搜索DLL的基址

  2. 获取所需要的Windows API

  3. 加载 PE 文件节到内存

  4. 修复重定位表

  5. 修复导入表

  6. 获取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 转换为与位置无关的 shellcodearrow-up-right 中,通过 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 函数。该函数大致原理如下

  1. 获取PEB的地址

  2. 获取LDR的地址

  3. 遍历已加载模块列表,查找目标DLL。

  4. 解析目标DLL的PE结构,定位导出表。

  5. 遍历导出表,查找目标API名称。

  6. 返回找到的函数地址或NULL。

一句话总结就是遍历peb结构体中的ldr成员中的InMemoryOrderModuleList链表获取dll名称,遍历函数所在的dll导出表获得必要的函数的名称,如果匹配成功就返回目标函数的地址。

为了实现 GetApiAddressByName,还需要实现几个辅助函数,它们分布是my_towlowerMyCompareStringWMyCompareStringAExtractDllName

我简要的说一下它们的作用

  1. my_towlower:将宽字符从大写转换小写,是用于辅助 MyCompareStringW 函数的

  2. MyCompareStringW:不区分大小写的宽字符串比较。这主要查找目标DLL,因为我们的DLL名称是宽字符串表示的

  3. MyCompareStringA: ASCII字符串比较函数。这主要用于查找目标API名称,因为微软的API是ASCII字符串表示的

  4. 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 的四个字段,一个是 SizeOfRawDataPointerToRawDataVirtualAddressVirtualSize

  1. PointerToRawData:节区数据在 磁盘文件 中的偏移量

  2. SizeOfRawData:节区数据在 磁盘文件 中占用的实际大小

  3. VirtualAddress:节区加载到内存后的 相对虚拟地址(RVA)

  4. VirtualSize:节区在 内存 中占用的实际大小

我们就根据这四个字段将PE节一一映射到新内存中,具体看下面的代码

3.4 修复重定位表

大致步骤

  1. 计算基址偏移量

  2. 定位重定位表

  3. 遍历重定位块

  4. 处理重定位条目

  5. 地址修正逻辑

(一)重定位表

在反射式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 = 0x3IMAGE_REL_BASED_HIGHLOW

  • Offset = 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(条目偏移)= 实际需要修正的内存位置(即存储原始地址值的地址) 接下来我们分步来解释这一条语句

  1. 计算目标内存地址:BaseAddress + relocation->VirtualAddress + relocList->offset,三者相加得到需要修正的内存位置(即存储原始地址值的地址)

  2. 指针类型转换:转换为PULONG_PTR类型的指针,确保后续操作按机器字长处理数据,适应不同架构的地址修正需求

  3. 解引用:通过指针访问目标内存地址处的值,获取需要修正的原始地址值(如全局变量地址、函数指针等)

  4. 应用基址偏移修正:新地址 = 原地址 + (实际基址 - 预期基址)

内存位置变化,举一个全局变量的例子

  • baseOffset:实际加载地址 (BaseAddress) - 预期基地址 (ImageBase) = 0x20000000 - 0x10000000 = 0x10000000

  • 原内存地址: 0x10001234

  • 新内存地址:0x10001234 + baseOffset = 0x20001234

程序中某个全局变量地址原先指向 0x10001234 的地址,被动态修正为 0x20001234,使其指向实际加载后的正确位置。

完整代码如下

3.5 修复导入表

大致思路

  1. 获取导入表中的每一个导入描述符(PIMAGE_IMPORT_DESCRIPTOR),导入描述符中存放着需要导入的DLL的名称的RVA。

  2. 使用在 获取所需要的Windows API 获取的LoadLibraryA的函数指针加载相应的DLL

  3. IMAGE_IMPORT_DESCRIPTOROriginalFirstThunk 作为导入名称表(INT,Import Name Table),根据INT中存放信息,我们可以选择序号导入还是名称导入,无论哪一种导入,都需要使用之前获取到的GetProcAddress指针解析需要的函数地址。

  4. 将解析得到的函数地址填写到导入地址表(IAT,Import Address Table)

  5. 移动到下一个导入描述符,重复上述操作

既然都说到了INT和IAT,就简单的做个介绍

阶段
INT
IAT

编译时

由链接器生成

初始内容与INT相同

磁盘存储

保存函数名/序号

保存函数名/序号的副本

加载时

保持原样

被加载器替换为实际地址

运行时

保持原样

包含实际函数指针

协调工作

  1. 加载器遍历INT中的每个IMAGE_THUNK_DATA

  2. 根据每个thunk项解析函数地址:

    • 如果是序号导入:Ordinal & IMAGE_ORDINAL_FLAG

    • 如果是名称导入:AddressOfData指向IMAGE_IMPORT_BY_NAME

  3. 将解析得到的函数地址写入IAT对应位置

  4. 程序执行时通过IAT中的地址调用API

完整代码

3.6 获取dllmain的地址,执行dllmain

这一步比较简单,直接上代码

四、完整代码

4.1 Inject

4.2 ReflectiveLoader

测试

五、尾语

在末尾在说几句话吧

  1. Inject中你是可以先扩展到节区表,然后找到 ReflectiveLoader 的地址,再创建线程去调用它,与上文提到的思路大差不差,这样做的好处就是不用再从RVA转到文件偏移了

  2. CreateRemoteThread 是可以向被创建的线程传递一个参数的,如果不想在 ReflectiveLoader 中实现暴力搜索DLL的基址的话,可以向 ReflectiveLoader 传递DLL的基址

  3. 如果想实现类似CobaltStrike的有阶段beacon,你可以参考 oldboy21/RflDllOb: 反射式 DLL 注入制作 Bellaarrow-up-right 这个项目去修改 InjectReflectiveLoader,做到类型下图所示的功能

参考资料