All I Want for Christmas is Reflective DLL Injection
反射 DLL
在实现反射 DLL 及其加载器/注入器的过程中,我认为这是一个很好的开篇主题,适合作为一系列关于安全性的博文的开端,主要分享我在 C(++) 方面的探索与挑战。
首先,我在寻找能够全面阐述这一主题并解释每个步骤及其背后原因的资源时遇到了困难。虽然我可能不擅长搜索,但我意识到,只有在尝试解释一个主题时,才能真正理解它(并且在此过程中适当调侃,配上大量的表情包——说实话,我们真正理解某个事物,往往是因为我们知道下一个合适的表情包是什么)。
接下来,我们将讨论的内容包括:
-
反射 DLL 注入的技术原理 -
PE 结构简介 -
PE 在内存中的加载过程 -
PIC(位置无关代码)的要点 -
逐步实现反射加载器函数 -
逐步实现反射 DLL 注入器
希望你会喜欢。
Chee(e)rs
反射 DLL 注入
这项技术是如何运作的,为什么至今仍被广泛使用?简而言之,原因在于我们不希望将数据写入磁盘,这使得 EDR/AV 更容易扫描和分析我们的作品。此外,我们有时可能希望转移到另一个进程,并带上我们的能力(不仅仅是 PIC 外壳代码)。因此,反射 DLL 注入应运而生。当然,这也带来了一些挑战,因为 DLL 是 PE(可移植可执行文件),尽管我们希望这样做是可行的,但我们不能仅仅将其粘贴到内存中并运行入口点。这让我不禁想要深入探讨 PE 的概念及其工作原理,我们稍后会回到这一部分。
PE(可移植可执行文件)
可移植可执行文件的名称有些自解释,它可以在不同系统间移动,并仍然能够执行特定任务。我省略了列出所有 PE 类别扩展名的部分,告诉你 DLL(动态链接库)是其中之一。那么,是什么使 PE 成为 PE 呢?我们可以将 PE 视为由两大类信息组成:
-
头部 -
节
头部基本上是 PE 的身份证,包含一些关键信息,例如 PE 构建的架构、版本、签名等。
另一方面,节则包含了 PE 的核心,即实际编译的代码、变量、函数等。
PE 的真正可移植性在于所有运行时所需的信息都包含在 PE 对象内。
头部具有静态位置(不确定静态是否是准确的描述),在 PE 加载到内存时不需要调整,它们位于PE 的开头,其顺序和彼此之间的距离基于常量偏移(对于任何 PE 都是相同的,并且在头部中使用了一个相对地址,仍然指向原始数据)。此外,PE 编译的代码实际上不需要头部来运行,但这些头部是必要的,以便 Windows 加载器知道在哪里放置代码以便运行。我们将看到这对我们有多重要。
另一方面,节内部的内容完全依赖于编译器生成的 RVA(相对虚拟地址)。
当一个进程被启动时,操作系统(OS)会为该进程分配一组虚拟地址,以提供执行操作所需的内存。这组地址被称为进程的虚拟地址空间,并且不会提前分配。由于 PE 无法提前知道将要在内存中结束的地址,编译器必须生成相对虚拟地址(相对于 PE 在内存中的图像基址)来引用 PE 内的数据。为了更好地理解这个概念,我建议阅读一些博客这里和那里,但我们真正想要记住和关注的是,由于 PE 只会在加载到进程虚拟空间后执行,因此节中包含的所有信息都依赖于 RVA。
从另一个角度来看,当我们从文件中读取 PE 字节,或者仅仅是从互联网上下载文件时,我们正在处理原始数据。原始数据基本上是 PE 在磁盘上的样子,而在 PE 头部中,我们确实有关于如何将这些原始字节加载到内存中以及它们应该放置在哪里以确保 PE 的正确执行的信息。即使在处理原始数据时,我们也可以参考头部,它们在内存中的形状与在磁盘上是相同的。
最重要的是,在进程的虚拟空间内分配内存并写入原始字节并不意味着我们正在加载 PE。 PE 核心希望在虚拟内存中呈现特定的形状,而将节放置在正确的虚拟地址并赋予正确的内存保护(以及许多其他任务)的过程等同于加载。
我必须强调这一点
因为大多数时候,加载这个词可能会让人觉得读取 PE 的字节到字节数组中就足够了,使得 PE“加载到内存中”。事实并非如此,字节仍然看起来像原始数据,如果你想将它们映射以便 PE 能够执行,你必须查看头部,理解 PE 希望如何顺利运行,并最终在内存中创建它的_舒适_环境。
一个非常好的工具来了解 PE 结构是:
GitHub - hasherezade/pe-bear: 具有友好 GUI 的可移植可执行文件反向工程工具
让我们举个例子,看看头部和节:
我之前提到过,有两大类信息:节和头部
我们在左侧看到 PE 的结构,它以头部开始:
-
DOS 头 -
NT 头 -
文件头 -
可选头
在左侧,我们还可以看到 PE 文件的最开始部分(通过点击 DOS 头)。这些行以 16 字节(十六进制格式)表示,并从 0 开始。那个 0 是一个 RVA,相对于它在加载到内存后将获得的位置(虚拟地址)。所有这些头部的内容并不是这篇博文的主题(这篇博文已经变得太长了),但这里可能有你所有问题的答案:https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
在继续之前,正如我所说,所有这些地址都是 RVA,这意味着:
-
我们从文件中读取 PE → 好的字节[]现在持有 PE 的原始字节 -
我们_VirtualAlloc_一个新的地方来执行我们的 PE → 它的新地址由_VirtualAlloc_返回,例如0x00000249FCF30000
因此,我们在 PE-bear 中看到的所有地址将是:
PE-Bear RVA | 内存中 |
---|---|
0 | 0x00000249FCF30000+0 |
10 | 0x00000249FCF30000+10 |
依此类推 | … |
在头部下方,我们看到的节实际上是编译代码的节:
每个节在 PE 中有不同的目的,例如保存全局和静态变量、函数实现等。尽管这里不会涉及,但再次强调这个实际上是深入了解 PE 结构的绝佳资源。
还有几件值得一提的额外事项:
-
我们可以确认 PE 在磁盘上的外观与在内存中加载时的外观之间存在差异
-
可选头并不是真正的可选,它们包含了相当多的关于 PE 执行的有趣信息,其中包括入口点、图像大小以及指向导入/导出数据结构的指针
-
一个 PE 可能会使用其他库(DLL)中定义的函数,即使该 PE 本身就是一个 DLL。所有为 PE 的正确操作所需的DLL都在导入目录中定义:
-
如果 PE 实际上是一个 DLL,则导出目录包含该 DLL 导出/提供给加载它的进程的函数
以上列表仅代表 PE 结构及其在执行时行为的简要描述,但应该足以让我们进入这篇博文的真正热门话题。最后再总结一下😎
那么,以上所有内容的意义何在?
我之所以提到 RVA 和 Windows 加载器,是因为如果我们想在内存中执行 PE,我们必须像Windows 加载器一样行事。使这个话题更加有趣的是,反射 DLL 将成为它自己的加载器。
背景信息: 我们希望执行的 DLL 将在远程进程的内存空间中非常孤独,此外,DLL 不会自动加载,因此环境也不会那么_舒适_(仅仅是原始数据→如果我们想运行 PE,这可不够好)。
一些问题: 当然,我们可以构建自己的独立加载器并使其在远程进程中工作,但这将涉及从一个进程到另一个进程进行大量操作,而如果我们开始这一切是为了更好地进行 OPSEC 和隐蔽性,那真的是错误的道路(而且写起来非常痛苦,也毫无意义)。
一些解决方案: 正如我之前提到的,DLL 在导出函数方面是_有点_著名,DLL 可以实现提供给加载 DLL 的进程的函数。非常_好极了_,这如何帮助我们?任何由 DLL 导出的函数都在特定的 RVA(记得 PE-bear 吗?),因此理论上我们可能能够在 PE 加载到内存之前就知道这个函数在 PE 中的位置。该函数可以被调用以执行加载操作💡
确实,DLL 仍然需要加载到内存中才能执行(全局和静态变量、导入的函数……),但如果我们设法构建一个既位置无关又能够执行所有加载任务的函数呢?
这就是我们的解决方案。在我们希望加载的 DLL 中编写一个导出函数,该函数是位置无关的,并且能够执行加载操作。
让我们把这些美好的概念列成一个要点列表_请_:
-
我们将反射 DLL 注入到远程进程中 -
我们找到反射函数的原始地址 -
我们创建一个远程线程,在远程进程中执行该函数 -
DLL 在内存中加载自身并执行入口点😎
仍然有点令人困惑,我知道:
到目前为止,反射 DLL 注入的概念应该_或多或少_清晰。但由于我觉得我还没有充分解释位置无关代码的概念,让我们花几句话来谈谈它。那么,为什么 PE 是位置相关的,并且需要加载器才能执行?正如我们之前看到的,PE 依赖于许多 RVA,而这些 RVA 需要根据 PE 在内存中被分配的位置进行调整。然而,可以编写一种代码,该代码将被编译成一系列漂亮的字节,无论它们在内存中的位置如何都能运行,这就是因为例如不使用外部函数,仅将字符串推送到堆栈等。
位置相关代码示例: 你编写一个使用 Win32 MessageBoxA API 的 C++ 函数,以弹出一个“我在运行之前关心我在哪里”的消息框。通过 user32.dll 加载的 MessageBoxA API 的使用将在 DLL 未正确加载之前破坏代码。MessageBoxA 仍将在导入目录中,但地址错误→轰然倒塌。
位置无关代码示例: 你编写一个仍然使用 MessageBoxA API 的 C++ 函数,但不包括库并调用该函数,而是首先解析 user32.dll 在内存中的导出目录,并在不依赖 Windows 加载器的情况下检索 MessageBoxA 地址。最终它将弹出“我不关心我在哪里,只要让我运行”。
最后一些代码
最后,这篇博文将进入实操部分。我们将在这里开发两个不同的模块:
-
反射 DLL:导出一个位置无关的函数,该函数在调用 DLL 入口点之前执行所有加载操作 -
反射 DLL 注入器:DLL 加载自身,但在此之前需要注入到某个进程中,同时必须调用反射函数。此代码将提供帮助。
小免责声明: 此代码并未实现任何技术以绕过当前的安全保护状态。它只是一个用于学习/教学目的的博文。
反射 DLL
好的,首先,反射函数必须被导出:
我将跳过声明(大部分)变量的部分:代码将在我的Github上提供,这里我们将专注于加载器的逻辑。尽管有一些变量需要特别关注,但在这里重要的是要记住,“反射函数”中的所有内容必须是位置无关的。这意味着我们所有的变量必须是堆栈变量。 堆栈变量不会出现在编译代码段中(在那里它们需要被重定位),而是始终使用堆栈指针的相对偏移进行寻址。
像上面那样声明我们的字符串将使编译器在运行时将这些单个字符推送到堆栈。因此,区别在于初始化风格:显式定义单个字符与使用字符串字面量。前者导致在堆栈上分配数组,而后者则导致在可执行文件的初始化数据段中分配数组。
无论如何,在臭名昭著的加载过程中,我们将不得不处理内存、内存保护以及加载其他库。这听起来像 Win32 API,正如我之前提到的,我们不能在 PIC 的上下文中使用它,但我们仍然可以实现我们自己的GetModuleHandle和GetProcAddress来获取 Win32 API 函数地址。
简而言之,使用 GetModuleHandle 我们可以获取模块(DLL)的句柄,使用 GetProcAddress 我们可以检索该模块中函数的地址。我们如何实现这些函数?
如果上述实现正常工作,我们将获得我们所寻找的模块的句柄。如你所见,我正在使用“自定义”实现的 strcmp 等。这是因为该代码必须是位置无关的😊,我们唯一能处理的就是我们自己编写的仅处理堆栈变量的代码。
现在我们有了模块的句柄,我们可以用它来检索函数地址。但模块的句柄到底是什么?它是指向内存中模块开头的指针,因此我们可以将其作为起点来解析 PE 头部并找到我们想要的函数的 RVA:
太好了,如果一切顺利,我们就得到了我们想要的函数地址。重要的是要提到,有时模块在其导出目录中通过序号(整数)而不是名称引用函数(可以说这总是如此,有时名称不可用)。我们希望为此做好准备:
好吧,到目前为止,我们应该具备处理的能力:
-
Win32 API -
堆栈变量 -
自定义实现的其他库函数(请参考完整代码)
这就是实现我们反射代码的全部内容。
(从现在开始,我们将处理的代码位于“反射函数”中。)
首先,我们收集我们需要的函数指针:
现在,为了在内存中加载自身,DLL 必须寻找自己的首字节(因为作为优秀的加载器,我们需要读取头部),为此,它将从其当前位置向后走,直到满足某些条件。这里需要满足的条件是寻找与NT 和 DOS 签名字节(包含在 PE 头部中的字节序列,用于分类 PE)的匹配,这可能有效,但也可能产生误报。我为这篇博文以及未来的代码提出的解决方案是在远程进程中分配的内存中,在 DLL 之前写入一个头部(_有点像_如果你熟悉这个概念的 egghunter)。因此,在实际匹配 NT 和 DOS 签名之前,先匹配头部。反射函数将向后查找头部字节,只有在匹配后,才会尝试匹配 NT 和 DOS 签名。
在远程进程的内存中,它将看起来像这样:
现在,DLL 在内存中找到了自己,我们可以开始实际的加载过程:
-
在虚拟内存中映射节 -
修复导入地址表(IAT) -
调整基址重定位表 -
为任何虚拟节分配正确的内存保护 -
刷新 CPU 指令缓存 -
运行 DllMain 入口点💣
正如我之前提到的,我们在远程进程中写入的字节是原始字节。为了在内存中执行 DLL,其字节需要映射到特定的虚拟地址:
一旦节在正确的虚拟地址中,就所有 RVA 开始具有某种意义。 因此,在这里我们可以开始修复导入目录 = 遍历我们的反射 DLL 需要操作的 DLL 列表,导入它们并根据我们在内存中获得的位置调整每个函数的 RVA。 基本上将所有 RVA 转换为 VA(VA = 图像基址 + RVA)。
好的,此时 IAT 也已修复,这意味着如果 DLL 在该进程内存中执行,它将知道在哪里找到所需的函数。现在是应用基址重定位的时候了。我真的建议你在这里了解 PE 基址重定位的概述,但我们可以简要地说,编译程序时,编译器假设可执行文件的特定基址。然后,基于该基址计算并嵌入可执行文件中的各种地址。然而,可执行文件不太可能在这个确切的基址加载。相反,它可能会加载到不同的地址,从而使所有这些嵌入的地址无效。为了解决这个加载问题,包含所有这些需要调整的嵌入固定值的列表存储在一个专门的表中,称为PE 文件中的重定位表。该表位于.reloc 节中的数据目录内。重定位的过程由加载器(以及反射加载器函数😊)负责,负责修正这些值以反映基于可执行文件实际加载地址的正确地址。有关重定位的更多详细信息可以在这里找到。
让我们看看我们的待办事项清单,看看缺少什么:
-
在虚拟内存中映射节 -
修复导入地址表(IAT) -
调整基址重定位表 -
为任何虚拟节分配正确的内存保护 -
刷新 CPU 指令缓存 -
运行 DllMain 入口点
正如我们之前在 PE-bear 中看到的,所有原始数据必须映射到虚拟节中,每个节都有其自己的内存保护。我们希望确保为每个节分配正确的特性:
最后,为了完成加载过程:
反射 DLL 注入器
在“注入反射 DLL”的旅程中,我们仍然缺少_注入_部分。我们的 DLL 准备好加载自己并闪耀,然而它仍然需要被放置到远程进程中,并且必须执行反射函数。逐步来说,我们的注入器需要做什么?
-
下载/读取我们的 DLL 字节 -
找到反射函数的原始地址 -
在远程进程中分配内存 -
将原始字节写入远程内存位置 -
创建一个远程线程来运行“反射加载器”函数
首先,第一件事。让我们下载 DLL(跳过参数解析部分,你可以在这里找到完整代码):
可以使用不同的方法下载文件,我没有在 OPSEC 方面测试过任何,只是使用了我知道的那个。之后,我们想知道我们想要注入反射 DLL 的目标进程的 PID。
在这里,你可以在 OPSEC 方面进行一些工作。但由于绕过 EDR 的尖端技术并不是这篇博文的主题,我们继续打开并处理远程进程并注入我们心爱的 DLL:
好吧,如果我们没有遇到任何错误,这意味着我们的 DLL 已成功注入目标进程。我们现在需要找到反射加载器函数的原始字节的指针。这是因为我们想在远程进程中创建一个线程来运行反射加载器函数,而远程进程中的 DLL 仍处于“原始”状态。这里出现了一个小问题,在任何节中我们都没有指向相对原始地址的指针😮正如我们在 PE 结构中看到的,一旦节映射到虚拟内存(RVA),就会链接到函数/变量等,但不链接到原始数据。
尽管如此,我们可以找到我们想要的东西,基本上是一个 RRA(相对原始地址)。如果你想想,在节头中我们有指向原始数据开头的指针,此外我们还知道节开头与我们想要检索的函数之间的距离在原始数据和虚拟映射内存中是相同的。事实上,如果你记得,这就是我们如何将数据复制到虚拟地址的:
因此,一个函数可以为我们做数学运算:
回到我们的代码流程,现在我们可以找到反射函数的“RRA”:
太好了,现在我们有了反射函数的 RRA,因此为了在远程进程中调用它,我们需要创建一个远程线程,以该地址的代码作为起点。我们还知道远程进程中 DLL 的基地址,因为这正是_VirtualAllocEx_函数返回的地址!
如果你没有注意到代码中的某些内容,请记住,由于我们在 DLL 之前写入了一个头部,我们希望在远程分配的内存中引用的所有地址都偏移了 HEADER_SIZE😇
结果
参考文献
一些有用的资源:
-
当然是我的 Github页面 -
Stephen Fewer的代码 -
红队笔记
原文始发于微信公众号(securitainment):我希望的圣诞礼物是反射 DLL 注入
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论