定义 Cobalt Strike 反射式加载器

一名身着黑色连帽衫的男性在街道上使用笔记本电脑,勾勒出黑客的典型形象

尽管安全解决方案的新一代 AI 与机器学习组件持续强化基于行为的检测能力,但许多方案的核心仍依赖于基于特征码的检测。Cobalt Strike 自问世以来便是威胁参与者和红队广泛使用的热门命令与控制 (C2) 框架,因此安全解决方案对其建立了大量特征码识别。

为延续 Cobalt Strike 过去的实战应用,IBM® X-Force Red 对抗模拟团队投入大量研发资源,通过内部工具对 Cobalt Strike 进行定制化改造。我们部分针对 Cobalt Strike 的内部工具已推出公开版本,例如 “InlineExecute-Assembly”、“CredBandit” 和 “BokuLoader“。过去两年间,鉴于 Cobalt Strike 特征码被过度识别,我们仅将其用于模拟技术能力较低的威胁参与者;在执行更高级的红队演练时,则转向使用其他第三方及自研 C2 框架。

通过研发实践,我们在高级红队演练中取得了更好的实战成效,主要得益于以下方向:

  • 定制化内部工具。
  • 定制化内部加载器。
  • 定制化内部 C2 框架。
  • 持续投入资源扩展第三方 C2 框架的功能与隐蔽性。

然而,目前仍有大量威胁参与者使用盗版 Cobalt Strike,因此模拟这类威胁参与者仍具有重要意义。对于愿意投入研发资源的红队而言,在模拟此类对手时仍可能通过 Cobalt Strike 取得实战成果。此外,Cobalt Strike 作为优秀的学习工具,可通过红队培训课程帮助新手通过实战操作掌握 C2 框架的应用。

随着我们持续扩展 C2 框架能力,特此分享过去在 Cobalt Strike 框架基础上的开发经验,特别是通过开发定制反射式加载器的实践。本系列内容也旨在帮助防御者理解 Cobalt Strike 的工作原理,以构建更强大的检测方案。

基于反射式加载器的框架扩展

本篇博客是系列教程的开篇指南,涵盖开发 Cobalt Strike 反射式加载器的基础知识。随着系列教程的推进,我们将在此基础上展开深入探讨并持续引用本文内容。

通过本系列学习,最终目标是创建一个能与 Cobalt Strike 现有规避功能集成,并通过当前工具尚未涵盖的高级技术进一步增强的反射式加载器。后续文章将深入探讨特定规避功能的开发方法,以及如何将其集成到我们的 Cobalt Strike 反射式加载器中。

作为系列开篇,本文将涵盖以下内容:

  • 通过 Windows DLL 加载器从磁盘加载 C2 植入程序存在的问题。
  • Cobalt Strike 反射式加载流程的概念与机制。
  • 构建高效反射式加载器的设计要求。
  • 反射式加载过程涉及的各阶段。

当我们以攻击性安全工具开发者的视角审视 Cobalt Strike 的反射式加载机制时,将重点关注检测与规避的潜在节点。部分开发细节将被省略或简化,建议通过调试现有反射式加载器项目、从头重建项目或参加专业培训来补充知识。

加载信标 DLL

Cobalt Strike 的 C2 植入程序(称为“信标”)是一个 Windows 动态链接库 (DLL),而在 Cobalt Strike 中使用自定义 DLL 加载器的模块化功能被称为用户自定义反射式加载器 (UDRL)。

内置 Windows DLL 加载器

通常情况下,内置的 Windows DLL 加载器负责将 DLL 加载到进程的虚拟内存空间。Windows DLL 加载器主要存在于用户空间,但在从磁盘映射 DLL 时也会跨越到内核空间。

在对抗模拟中使用 Windows DLL 加载器存在以下缺陷:

  • 原始 DLL 必须存在于文件系统中。
  • 原始 DLL 必须未经混淆处理。
  • Windows DLL 加载器会触发内核镜像加载事件。

因此,使用 Windows DLL 加载器加载信标 DLL 并非理想解决方案。为克服这些挑战,我们采用反射式加载器从内存加载信标 DLL。

反射式加载主要规避的三大检测点包括:

  1. 避免文件系统中存在带特征码的恶意软件。
  2. 避免触发可被安全解决方案监控的内核镜像加载事件。
  3. 避免 C2 植入 DLL 出现在进程环境块 (PEB) 列表中。

反射式加载器与 Windows DLL 加载器对比

反射式加载可理解为直接从内存加载原始 DLL,而非从文件系统加载。

虽然反射式加载与内置 Windows DLL 加载器均实现将 DLL 从原始文件格式加载至进程虚拟内存空间的功能。但与 Windows DLL 加载器相比,反射式加载具备关键优势——无需 DLL 文件存在于文件系统。这种内存加载方式支持无限级联加载阶段,因为 C2 植入 DLL 可隐藏在进程内存的多层加密与编码之中。

原始文件格式与虚拟地址格式对比

加载 DLL 时需要理解的一个关键概念是,DLL 在磁盘上和内存中的格式化方式不同。原始文件格式与虚拟地址格式下 DLL 的主要区别在于:

原始文件格式:

  • 存在于文件系统的 DLL 格式。
  • DLL 各节紧密排列。
  • 偏移量基于磁盘原始 DLL 文件起始位置计算。
  • 该格式占用较少内存空间。

虚拟地址格式:

  • 存在于进程虚拟内存空间的 DLL 格式。
  • 各节间存在间隔。
  • 偏移量采用相对虚拟地址 (RVA)。
  • 在进程运行时,DLL 及其他模块通过 RVA 定位资源位置。
  • 该格式占用更多内存空间。

原始信标与虚拟信标对比

通过  Aleksandra Doniec 开发的  PE-Bear  工具分析 HTTP 信标 DLL,可观察到 DLL 各节在原始格式与虚拟地址格式下的差异:

表格展示了信标 DLL 各节的原始地址与虚拟地址对比。

表格展示了信标 DLL 各节的原始地址与虚拟地址对比。

此 HTTP/S 信标 DLL 在加载到进程的虚拟内存空间时大小为 0x52000  字节327KB 相比之下,其在文件系统中的大小为 0x44000  个字节(272KB 字节。这种大小差异源于虚拟地址格式中各节间存在间隔,而原始文件格式中各节紧密排列。

PE-Bear  工具以可视化形式呈现信标 DLL 在原始文件格式与虚拟地址空间格式下的对比:

原始格式与虚拟格式下信标 DLL 的可视化呈现

原始格式与虚拟格式下信标 DLL 的可视化呈现(左侧为原始格式,右侧为虚拟格式)

使用 Windows DLL 加载器加载信标

虽然在对抗模拟中并非明智之举,但将未经混淆的原始信标 DLL 释放至磁盘并使用 Windows DLL 加载器加载,是解密信标与 DLL 加载机制的有效方式。本质上,信标仅是 DLL 文件。Windows DLL 加载器与反射式加载器都只是将 DLL 加载至进程的过程。

使用 Windows DLL 加载器加载信标 DLL 需执行以下步骤:

  1. 生成未经混淆的原始信标 DLL。
  2. 创建程序:
    1. 使用LoadLibrary API 从磁盘加载信标 DLL。
    2. 通过调用虚拟信标 DLL 入口点执行信标。
  3. 将可执行程序与信标 DLL 置于同一文件夹。
  4. 运行程序。

生成未经混淆的原始信标 DLL

首先需禁用所有 可延展 PE 选项 ,这些选项会导致 Windows DLL 加载器无法加载信标 DLL。为此我们修改 可延展 C2 配置文件 ,在  stage  块中禁用可延展 PE 规避选项:

已修改的可延展 C2 配置文件 stage 块显示禁用了 Cobalt Strike 规避功能。

已修改的可延展 C2 配置文件 stage 块显示禁用了 Cobalt Strike 规避功能。

修改配置文件后,我们重启 Cobalt Strike Team Server,并将我们的 no_evasion.profile  配置文件作为参数传入。

为此博客文章制作的屏幕截图

通过 Cobalt Strike 客户端连接团队服务器。然后创建一个Windows Stageless Payload 输出选项设置为  Raw 、监听器设置为 的 https 。将载荷保存为 beacon.dll 。

通过 Cobalt Strike 客户端创建“原始无阶段”信标 DLL 过程的屏幕截图

通过 Cobalt Strike 客户端创建“原始无阶段”信标 DLL 过程的屏幕截图

创建信标 DLL 加载器程序

使用以下代码创建名为 的 C 程序loadBeaconDLL.c  并编译:

通过 Windows C 代码使用 Windows DLL 加载器从磁盘加载信标 DLL。

通过 Windows C 代码使用 Windows DLL 加载器从磁盘加载信标 DLL。

我们使用 Kernel32.LoadLibraryA API 从磁盘加载原始信标 DLL。此 API 将调用内置 Windows DLL 加载器,将信标 DLL 从磁盘加载至宿主进程的虚拟内存空间。

在加载过程中,Windows DLL 加载器将通过调用其入口点并传入 DLL_PROCESS_ATTACH (1)  作为参数来初始化我们的信标 DLL。

当 Windows DLL 加载器将信标 DLL 加载并初始化到进程的虚拟内存空间后,我们需要再次调用虚拟信标 DLL 的入口点 0x4.

程序必须知晓虚拟信标 DLL 的入口点才能执行虚拟信标 DLL。这可通过在程序中动态解析虚拟信标 DLL 头部获取入口点相对虚拟地址 (RVA) 实现,也可通过快速查看后硬编码该值。

为进行概念验证,将手动发现信标 DLL 入口点 RVA 并硬编码至程序中。使用 PE-Bear 工具发现信标入口点的 RVA 为 0x1D840 :

使用 PE-Bear 查找信标 DL 入口点 RVA 的屏幕截图

使用 PE-Bear 查找信标 DL 入口点 RVA 的屏幕截图

 LoadLibraryA  API 返回虚拟信标 DLL 的基地址。只需将其与入口点 RVA 相加即可确定入口点位置。

代码准备就绪后,将 C 程序编译为 Windows 可执行文件:

编译程序使用的命令。

编译程序使用的命令。

在文件系统中定位程序与信标 DLL

通过将信标 DLL 与可执行信标加载器程序置于同一目录,Windows DLL 加载器能在执行加载例程时发现 DLL。

我们将两个 beacon.dll 以及loadBeaconDLL.exe  都放置在文件系统的同一目录下:

信标 DLL 与加载器程序存放于同一目录。

信标 DLL 与加载器程序存放于同一目录。

执行程序

从 Windows 桌面双击  loadBeaconDLL.exe  程序,建立与团队服务器的活跃信标连接。

通过 Windows DLL 加载器加载的信标 DLL 成功连接 C2 团队服务器。

通过 Windows DLL 加载器加载的信标 DLL 成功连接 C2 团队服务器。

Cobalt Strike 反射式加载

Cobalt Strike 使用的是  Stephen Fewer  反射式加载器 项目的修改版本。这款具有传奇色彩的内存 DLL 加载器已有十余年历史,曾被 Metasploit 及其他知名攻击性安全工具使用。

UDRL 使用注意事项

多年来,Cobalt Strike 反射式加载器已增强至能处理 Cobalt Strike 提供的所有可延展 PE 规避功能。使用自定义用户定义反射式加载器 (UDRL) 的主要劣势在于,可延展 PE 规避功能可能无法开箱即用。

使用 UDRL 时,部分规避功能已完全实现——这些功能会在生成信标载荷时由 Cobalt Strike 的可延展 PE 引擎通过补丁方式集成至信标 DLL。然而,目前像这类功能obfuscate 必须由 UDRL 处理,而其他如等功能sleepmask 和 cleanup 在正确集成 UDRL 的情况下,则可由信标处理。

反射式加载方法

原始反射式加载器方法

原始反射式加载器项目要求将 ReflectiveLoader  编译到我们的 DLL 项目中,并在我们的 C2 植入式 DLL 中将其导出。

随后另一项目需负责:

  1. 发现导出函数的虚拟地址 ReflectiveLoader  。
  2. 执行 ReflectiveLoader 导出函数(返回已加载 DLL 的入口点)。
  3. 调用反射式加载 DLL 的入口点。
原始反射式加载器将 DLL 加载至虚拟内存的图示。

原始反射式加载器将 DLL 加载至虚拟内存的图示。

预置反射加载器方法

另一种方法是将反射式加载器预置到 DLL 前端。这种方式支持加载任何非托管 DLL,且无需从源代码编译 DLL。这是一种强大的反射加载方法,能够加载任何可移植可执行文件(EXE 或 DLL)。

预置反射式加载器的 DLL 加载至虚拟内存的图示。

预置反射式加载器的 DLL 加载至虚拟内存的图示。

Cobalt Strike 反射式加载器方法

Cobalt Strike 的反射式加载实现融合了上述两种方法。了解 Metasploit 的 Meterpreter 反射式加载机制的用户会对这种方法感到熟悉。

与原始反射式加载器方法类似, ReflectiveLoader  函数在原始信标 DLL 内部被编译并导出。当操作员从 Cobalt Strike 客户端生成信标载荷时,Cobalt Strike 的可延展 PE 引擎会对原始信标 DLL 打补丁,向反射式加载器指示需要使用的可延展 PE 选项。信标的 DOS 标头会被修补,使其通过硬编码偏移量调用 ReflectiveLoader 导出函数。信标 DOS 标头中的初始补丁字节(用于调用 ReflectiveLoader  导出函数的部分),将在本博客中称为“调用反射式加载器桩”。

当 UDRL 被载入 Cobalt Strike,且操作员从客户端生成信标载荷时,Cobalt Strike 的可延展 PE 引擎会在导出函数的原始文件偏移位置注入反射式加载器 shellcode ReflectiveLoader  。

当可延展 PE 引擎完成对原始信标 DLL 的补丁处理后,原始信标 DLL 将以可执行 shellcode 的形式交付给操作者。

Cobalt Strike反射式加载器将信标DLL加载至虚拟内存的图示。

Cobalt Strike反射式加载器将信标DLL加载至虚拟内存的图示。

信标的调用反射式加载器桩

通过 PE-Bear 反汇编器查看初始字节,可见信标 DLL 本身具备可执行性:

调用反射式加载器桩显示为可执行的汇编操作码。

调用反射式加载器桩显示为可执行的汇编操作码。

这些初始字节 MZAR  可通过 Cobalt Strike C2 配置文件中的可延展 PE 选项进行自定义。这些字节必须为可执行代码且最终执行空操作 (nop )。

在执行可选的预置 nops  及魔数字节后,调用反射式加载器桩会执行以下操作:

  • 创建栈帧。
  • 使用 RIP 相对寻址确定原始信标 DLL 的基地址。
  • 在已知的原始文件偏移位置调用 ReflectiveLoader  导出函数 0x16E3C  。
  • 调用已加载信标 DLL 的入口点。

通过查看信标 DLL 的导出函数目录,我们确认 ReflectiveLoader  导出函数为 0x16E3C  :

使用 PE-Bear 确定 ReflectiveLoader 导出函数原始文件偏移的屏幕截图。

使用 PE-Bear 确定 ReflectiveLoader 导出函数原始文件偏移的屏幕截图。

由于存在于导出目录中,导出函数的地址 ReflectiveLoader 采用 RVA 格式,指向虚拟状态下的信标 DLL。由于 ReflectiveLoader  导出函数具备可执行性,可知其位于.text 信标 DLL 的节内。

要发现 ReflectiveLoader  导出函数的原始文件偏移,首先需要了解 .text  节的虚拟地址与原始地址之间的差值。获知该差值后,只需从 ReflectiveLoader  导出函数的 RVA 中减去该值,即可计算出 ReflectiveLoader  导出函数的原始文件偏移。

节的虚拟地址与原始地址 .text  列于信标 DLL 的节标头:

信标 DLL.text 节的信标 DLL 的节内。

信标 DLL.text 节的信标 DLL 的节内。

两者差值为 0xC00  字节。通过从 ReflectiveLoader  导出函数的 RVA 值中减去 0x17A3C  该差值,可计算出原始文件偏移为 0x16E3C 。

我们可以在 PE-Bear 中通过右键点击 ReflectiveLoader  导出函数的“Function RVA”来确认这一点,并点击 Follow RVA:17A3C 。上方小工具中的十六进制查看器将跳转至 ReflectiveLoader  导出函数的原始文件偏移位置进行显示。

综上所述,Cobalt Strike 反射式加载流程如下:

  • 线程执行原始信标 DLL。
  • 调用反射式加载器桩 ReflectiveLoader  在已知原始文件偏移处调用导出函数。
  • 反射式加载器将原始信标 DLL 加载至宿主进程的虚拟内存。
  • 加载完成后,反射式加载器将虚拟信标 DLL 入口点返回给调用反射式加载器桩。
  • 调用反射式加载器桩调用虚拟信标 DLL 的入口点。
图示展示 Cobalt Strike 执行信标 DLL 反射式加载的主要阶段。

图示展示 Cobalt Strike 执行信标 DLL 反射式加载的主要阶段。

反射式加载器设计要求

位置无关代码

由于反射式加载器在信标 DLL 加载之前执行,其代码需为纯 shellcode。

编写复杂 shellcode 最简单的方法是使用纯 C 语言(无外部依赖)。随后将 C 文件编译为目标文件。所有内容必须包含在text 目标文件的节中。最终提取.text 该节,获得反射式加载器 shellcode。

Cobalt Strike 如何插入 UDRL

Cobalt Strike 的可延展 PE 引擎负责从反射式加载器目标文件中提取 shellcode,并将其注入原始信标 DLL 在导出函数的原始文件偏移位置 ReflectiveLoader  。该过程通过以下 UDRL 的 Aggressor 脚本实现:

利用 Cobalt Strike 将反射式加载器 shellcode 写入原始信标 DLL 的 Aggressor 脚本。

利用 Cobalt Strike 将反射式加载器 shellcode 写入原始信标 DLL 的 Aggressor 脚本。

我们的 UDRL Aggressor 脚本通过以下步骤指引 Cobalt Strike 写入反射式加载器 shellcode:

  • 打开$handle 指向 UDRL 目标文件openf 的关注和投资。
  • 通过文件$handle 读取字节流并保存至$data 字节数组变量中。
  • 随后使用关闭文件$handle 通过closef 的关注和投资。
  • 内置的
    <a href="https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics_aggressor-scripts/as-resources_functions.htm#extract_reflective_loader">extract_reflective_loader</a>
    
    函数会从字节数组变量中解析 UDRL $data 目标文件,定位.text UDRL 目标文件的节,提取该 .text 节并保存至$loader 字节数组变量中。
  • 内置的
    <a href="https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics_aggressor-scripts/as-resources_functions.htm#setup_reflective_loader">setup_reflective_loader</a>
    
    Cobalt Strike Aggressor 函数将利用可延展 PE 引擎发现导出函数的原始文件ReflectiveLoader 偏移,并提取 UDRL shellcode 进行注入$loader 字节数组变量中。
  • 最后,将修改后的信标 DLL 返回给 Cobalt Strike,并通过客户端保存文件。

反射式加载阶段

Cobalt Strike 已为我们完成了从反射式加载器目标文件提取 .text 节、注入反射式加载器 shellcode,以及通过信标 DLL 标头调用反射式加载器桩来触发加载器的工作。

以下是实现信标反射式加载必须开发的阶段:

  1. 查找原始信标 DLL
  2. 解析信标 DLL 标头
  3. 为虚拟信标 DLL 分配内存
  4. 将节加载至虚拟内存空间
  5. 加载 DLL 依赖项
  6. 解析导入地址表
  7. 处理重定位
  8. 执行信标

第一阶段:定位原始信标 DLL 基地址

我们可采用多种方法在内存中发现原始信标 DLL 的地址。一些方法包括:

  • 回溯搜索 MZ 与 PE 标头
  • 回溯搜索特征标记 (Egg)
  • 从反射式加载器调用桩获取原始信标 DLL 基地址

定位内存中的当前位置

使用回溯搜索方法时,首先需要获取当前线程指令指针的地址(RIP )。我们可通过以下简单技巧实现 getRip :

  1. 在 UDRL 中创建函数 getRip 。
  2. 调用 getRip  将指令后的地址推入call getRip 栈顶。这会作为返回地址。
  3. 随后在 getRip  函数,直接从栈顶复制调用者的返回地址。
  4. 在 x64 Windows C 编程中,函数可通过返回值传递数据。该返回值通过寄存器返回给调用者 RAX  。通过将调用者的返回地址移入 RAX  寄存器,即可实现向调用者返回其返回地址。
英特尔 x64 汇编代码:通过 RDI 寄存器获取原始信标 DLL 基地址。

英特尔 x64 汇编代码:通过 RDI 寄存器获取原始信标 DLL 基地址。

回溯搜索 MZ 与 PE 标头

原始反射式加载器项目采用回溯搜索 MZ 与 PE 标头的方法。但这些标头特征已成为检测点。为应对此问题,Cobalt Strike 增加了 magic_mz 以及magic_pe  可延展 PE 规避功能。

Cobalt Strike  文档 说明magic_mz  选项的作用:

  • “覆盖信标反射式 DLL 的前几个字节(包括 MZ 标头)。需使用有效指令。修改 CPU 状态的指令后必须跟随恢复状态的指令。”

当配置 MZ--  原始文件偏移的字节 0x00 ,以及PE00  原始文件偏移的字节 0x80  是反射加载器已知的。这些字节由可延展 PE 引擎注入信标 DLL。

这些字节必须具有特定唯一性,否则反射式加载器将无法定位。同时  MZ  标头对应的字节必须是可执行的空操作指令。不能使用类似的值 0x00  否则可能导致信标崩溃。此特性可能成为潜在检测点。

回溯搜索特征标记 (Egg)

发现这一潜在检测点后,我开发了一种不同但原理相似的方法来定位原始信标 DLL 基地址。该方法采用特征标记搜索技术,从当前地址向后回溯 RIP ,在已知的原始文件偏移处搜索两次重复出现的唯一 64 位特征标记 beacon.dll+0x50  。

选择地址 beacon.dll+0x50  作为搜索起点,是因为该位置存放着“此程序无法在 DOS 模式下运行”的标识文本,而反射式加载信标时无需此内容。

由于无法直接访问 Java 可延展 PE 引擎, BokuLoader.cna  可通过 UDRL 的 Aggressor 脚本将 0xB0C0ACDC  特征标记写入信标。以下代码演示如何修改原始信标 DLL 以嵌入特征标记:

用于向原始信标 DLL 写入特征标记并在 Cobalt Strike 脚本控制台显示修改的 Aggressor 脚本。

用于向原始信标 DLL 写入特征标记并在 Cobalt Strike 脚本控制台显示修改的 Aggressor 脚本。

UDRL 代码必须知晓由脚本写入原始信标 DLL 的特征标记值。获取特征标记值后,特征标记搜索器将向后搜索两个重复的特征标记实例,如下方代码所示:

用于向后搜索两个 64 位特征标记实例的英特尔 x64 汇编代码。

用于向后搜索两个 64 位特征标记实例的英特尔 x64 汇编代码。

  • UDRL 的 Aggressor 脚本和 C 代码均可修改以使用不同的特征标记。

由于不再使用  MZ  和  PE  标头,我们可以在 UDRL 的 Aggressor 脚本中将其 置为 空操作指令:

用于掩蔽原始信标 DLL 标头中 MZ、PE 及未使用 DOS 标识字节的 Aggressor 脚本。

用于掩蔽原始信标 DLL 标头中 MZ、PE 及未使用 DOS 标识字节的 Aggressor 脚本。

通过调用反射式加载器桩获取原始信标 DLL 基地址

此外,还有一种专属于 Cobalt Strike 的方法可用于发现原始信标 DLL 的基地址。如上文所述,调用反射式加载器桩的初始字节会在调用反射式加载器之前,将原始信标 DLL 的基地址存储在 RDI 寄存器中。因此,我们无需 RIP 从当前地址向后搜索特征标记,只需在反射式加载器代码开始时直接从 RDI  寄存器获取该值。

为了在调试器中进一步验证,生成一个信标载荷,在其前端插入断点(0xCC ),并在  x64dbg 中打开。由于断点已预置,原始信标的基地址位于 +1  分配内存的起始处。如前所述,调用反射式加载器桩使用RIP 相对寻址来获取原始信标 DLL 的基地址:

x64dbg 单步调试调用反射式加载器桩的屏幕截图显示,在调用反射式加载器之前,原始信标 DLL 基地址已保存在 RDI 寄存器中。

x64dbg 单步调试调用反射式加载器桩的屏幕截图显示,在调用反射式加载器之前,原始信标 DLL 基地址已保存在 RDI 寄存器中。

以下是从调用反射式加载器桩获取原始信标 DLL 基地址的可行示例:

通过内联汇编 C 代码从 RDI 寄存器获取原始信标 DLL 基地址。

通过内联汇编 C 代码从 RDI 寄存器获取原始信标 DLL 基地址。

第二阶段:解析信标 DLL 标头

获得原始信标 DLL 的基地址后,我们现在可以获取将信标加载到进程虚拟地址空间所需的值。

下表列出了需要从原始信标 DLL 标头获取的值、它们的位置及其类型:。

表格列举了原始信标 DLL 标头中对加载信标 DLL 有用的值。

表格列举了原始信标 DLL 标头中对加载信标 DLL 有用的值。

规避技术

并非所有标头信息的内容都是加载信标 DLL 所必需的。必要值可重新打包或混淆处理。非必要值可删除或随机化。

第三阶段:为虚拟信标分配内存

一旦我们了解SizeOfImagef 从原始信标 DLL 的标头信息中,就需要分配相应大小的内存空间。该内存空间将用于存放我们的虚拟信标 DLL。

为虚拟信标 DLL 分配内存可采用多种方法。不同方法会使用不同类型的内存。Cobalt Strike 默认反射式加载器支持的方法包括:

此处为 Cobalt Strike 虚拟信标 DLL 内存分配选项表格。

此处为 Cobalt Strike 虚拟信标 DLL 内存分配选项表格。

规避技术

通过 UDRL 还可进一步扩展此过程。可改用这些函数的  NTAPI  版本。更进一步,还可通过直接或间接系统调用来调用  NTAPI  函数——这可能有助于增强规避功能,也可能并无帮助。

当分配器方法设置为 VirtualAlloc  在 Cobalt Strike 可定制 C2 配置文件中为虚拟信标 DLL 分配内存时,目前  BokuLoader  项目 NtAllocateVirtualMemory  将采用直接系统调用方式:

BokuLoader 项目中的代码示例显示,通过直接系统调用来为虚拟信标 DLL 分配内存。

BokuLoader 项目中的代码示例显示,通过直接系统调用来为虚拟信标 DLL 分配内存。

  • 系统调用号码是通过  HellsGate  方法获取。
  • 若系统调用存根处存在用户态钩子,则改用  HalosGate  方法。

下图展示了使用 HellsGate 和 HalosGate 方法确定系统调用号的代码示例:

BokuLoader 项目中展示如何从进程中获取系统调用的代码示例。

BokuLoader 项目中展示如何从进程中获取系统调用的代码示例。

第四阶段:将节加载到虚拟内存空间

既然我们已经为虚拟信标 DLL 分配了内存,现在需要将信标的各个段从其原始文件偏移位置(即它们在原始信标 DLL 中的位置)复制到已分配内存中对应的相对虚拟偏移位置。

如果我们分配内存READWRITE 需要记录地址.text 节及其大小。在调用虚拟信标 DLL 的入口点之前,我们需要更改内存保护属性.text 节为可执行。

分配内存READWRITE_EXECUTE 会使反射式加载过程更简单,但也会增加被安全解决方案检测到的可能性。

以下是 BokuLoader 项目中的一个简化代码示例,演示了这一过程:。

BokuLoader 项目中展示将段从原始信标 DLL 复制到虚拟信标 DLL 的代码示例。

BokuLoader 项目中展示将段从原始信标 DLL 复制到虚拟信标 DLL 的代码示例。

规避技术

关于加载节的一些规避检测特性包括:

  • 不将信标的标头复制到虚拟信标 DLL 中。
  • 释放虚拟信标 DLL 中本应存放标头的内存空间。

在公开的 BokuLoader 项目中,信标 DLL 的标头不会从原始信标 DLL 复制到虚拟信标 DLL。目前第一个 0x1000  虚拟信标 DLL 的字节均为空值(0x00‘s )。根据我的测试,信标在正确加载到虚拟内存后并不依赖其标头信息。避免复制标头可能有助于规避内存扫描器检测,但这些空字节也可能成为潜在的检测点。

另一个可能的规避机会是让 UDRL Aggressor 脚本加密各节。这些节可以在内存中由 UDRL 使用 UDRL 与 UDRL Aggressor 脚本之间共享的密钥进行解密。

第五阶段:加载 DLL 依赖项

x64 HTTP/S 信标的正常运行依赖于四个 DLL。如果这些 DLL 当前未加载到进程中,我们的反射式加载器将需要加载它们。

这四个 DLL 列在 HTTP/S 信标 DLL 的导入目录中:

PE-Bear 中列出的信标 DLL 导入目录中的 DLL 屏幕截图。

PE-Bear 中列出的信标 DLL 导入目录中的 DLL 屏幕截图。

内置的 Cobalt Strike 反射式加载器使用  kernel32.LoadLibraryA  API 进行 DLL 加载。

规避技术

DLL 加载可以通过多种不同的方式实现,每种方式都有不同的操作安全考量。一些方法包括:

如果 DLL 已存在于进程中,上述 Windows API 仍可用于获取 DLL 基址,但这可能会触发不必要的检测告警。

或者,PEB 中保存有指向 

<a title="https://learn.microsoft.com/cn-zh/windows/win32/api/winternl/ns-winternl-peb_ldr_data" href="https://learn.microsoft.com/cn-zh/windows/win32/api/winternl/ns-winternl-peb_ldr_data">_PEB_LDR_DATA</a>

struct 结构的指针。在该结构内部,有一个包含进程中所有已加载 DLL 及其相关信息的链表(

InMemoryOrderModuleList

)。BokuLoader 利用此链表来发现 DLL 信息,从而避免不必要的 API 调用。

如果 DLL 不存在于 InMemoryOrderModuleList 当前 BokuLoader 会使用 NTDLL.LdrLoadDll  API,借助内置的 Windows DLL 加载器将 DLL 依赖项加载到内存中。

反射式嵌套加载不易用于加载 DLL 依赖项,因为反射式加载器通常不会将 DLL 注册到进程。DLL 外部的代码无法正确使用反射式加载的 DLL。 DarkLoadLibrary  项目或许能够在不触发内核镜像加载事件的情况下,正确地将 DLL 加载到内存中。

BokuLoader 项目中展示如何通过遍历 InMemoryOrderModuleList 来解析已加载 DLL 基址的代码示例。

BokuLoader 项目中展示如何通过遍历 InMemoryOrderModuleList 来解析已加载 DLL 基址的代码示例。

第六阶段:解析导入地址表

在所需的 DLL 加载到进程中后,必须解析导入目录中列出的 API。随后,需要将 API 地址写入虚拟信标 DLL 的导入地址表 (IAT) 中。这样,信标在需要调用 API 时,就知道该跳转到哪个地址 WININET.HttpSendRequest

导入条目需要通过序号或名称字符串进行解析。

在下图中,我们可以看到 Cobalt Strike 信标 DLL 的导入条目同时使用了序号和名称字符串:

PE-Bear 屏幕截图显示信标 DLL 的部分导入条目必须通过序号解析。

PE-Bear 屏幕截图显示信标 DLL 的部分导入条目必须通过序号解析。

内置的 Cobalt Strike 反射式加载器使用 Kernel32.GetProcAddress  API 来解析导入条目的虚拟地址。

规避技术

解析 API 地址的一些规避检测方法包括:

  • 自定义代码实现GetProcAddress
  • NTDLL.LdrGetProcedureAddress

BokuLoader 使用自定义代码实现GetProcAddress 解析导入条目的地址,同时支持名称字符串和序号处理。

该NTDLL.LdrGetProcedureAddress 也能支持名称字符串和序号处理。如果导入条目返回的地址是转发到另一个 DLL 的转发器,BokuLoader 默认使用NTDLL.LdrGetProcedureAddress 来解析该转发器。

在写入 IAT 时,可以通过写入我们已实现的挂钩函数的虚拟地址(而非目标 API 的虚拟地址)来实现挂钩。只要在调用 IAT 中的地址时,将预期输出返回给信标,我们就可以在返回到信标之前执行额外代码。未来的文章及公开的 BokuLoader 版本将展示如何利用 IAT 挂钩实现高级规避功能。

通过近期发布的版本,公开的 BokuLoader 项目通过自定义实现,支持了obfuscate Cobalt Strike C2 配置文件中的可延展 PE 功能。通过修改 UDRL Aggressor 脚本中的BokuLoader.cna 掩码密钥,可以选择自定义的单字节 XOR 密钥以提升混淆效果。

关于操作安全,需要注意的是,模式匹配引擎能够暴力破解单字节 XOR 掩码。未来的文章将展示如何利用 Cobalt Strike 的 Aggressor 脚本功能创建我们自己的可延展 PE 引擎,以混淆信标从而应对模式匹配检测。

第七阶段:解析重定位

信标 DLL 具有许多重定位项,必须在执行前解析并写入虚拟信标 DLL 的基址重定位表。

在 PE-Bear 中,我们可以看到信标 DLL 默认的映像基址 0x180000000 :

PE-Bear 屏幕截图显示信标 DLL 的映像基址。

PE-Bear 屏幕截图显示信标 DLL 的映像基址。

在开始写入重定位项之前,我们需要计算虚拟信标 DLL 的基址与硬编码基址之间的差值。

例如,假设虚拟信标 DLL 的基址为 0x7FFC44FE0000 。从虚拟信标 DLL 的基址中减去硬编码基址,得到基址差值:

获取基址差值的屏幕截图

接下来,为确定基址重定位表中每个重定位条目的虚拟地址,我们将基址差值加上硬编码的重定位条目地址,以确定其在虚拟信标 DLL 内的重定位位置。

在下图中,我们可以看到信标的重定位条目以小端序格式反向写入:

PE-Bear 屏幕截图显示部分重定位条目以小端序格式存在。

PE-Bear 屏幕截图显示部分重定位条目以小端序格式存在。

该重定位条目的硬编码地址为 0x1800341C8 。

我们将此地址与基址差值相加,得到该重定位在虚拟信标 DLL 中的虚拟地址:

将地址与基址差值相加以获取重定位在虚拟信标 DLL 中虚拟地址的屏幕截图:

对于每个重定位条目,我们需要检查其类型是否为

<a title="https://learn.microsoft.com/cn-zh/windows/win32/debug/pe-format" href="https://learn.microsoft.com/cn-zh/windows/win32/debug/pe-format">IMAGE_REL_BASED_DIR64 (0xA)</a>

。如果该条件不成立,我们将跳过该重定位项的写入。

一旦确定了重定位项在虚拟信标 DLL 内的虚拟地址,我们就将其写入存放硬编码重定位项地址的内存空间。

如果您有兴趣进一步了解如何进行 PE 重定位,请查看公开 BokuLoader 项目中的 doRelocations 函数代码。在发布此博客文章之前,我已将重定位代码从汇编语言改写成(希望是)易于理解的 C 代码,以帮助其他想了解此过程技术细节的人。

第八阶段:执行信标

执行信标可分解为三个步骤:

  • 确保虚拟信标 DLL 的各段具有正确的内存权限。
  • 初始化虚拟信标 DLL。
  • 调用虚拟信标 DLL 的入口点。

使虚拟信标可执行

如果我们为虚拟信标 DLL 分配的内存是 READWRITE_EXECUTE ,则无需更改内存保护属性即可使信标正常运行而不会崩溃。

如果我们将虚拟信标内存分配为不可执行 (READWRITE ),则需要将 .text  虚拟信标 DLL 的节更改为可执行。节的位置和虚拟大小 .text  应事先作为变量保存在 UDRL 主函数中。

在公开的 BokuLoader 项目中,内存保护属性的更改是通过直接系统调用 NTProtectVirtualMemory 来完成的,如下方代码示例所示:

BokuLoader 项目中演示将虚拟信标 DLL 的 .text 节更改为可执行的代码示例。

BokuLoader 项目中演示将虚拟信标 DLL 的 .text 节更改为可执行的代码示例。

来自 .data  虚拟信标 DLL 的节应具有 权限READWRITE 。如果该节不可写,我们的信标 DLL 可能在执行时崩溃。

初始化虚拟信标 DLL

为了使虚拟信标 DLL 正常运行,必须首先通过调用虚拟信标 DLL 的入口点来对其进行初始化。第一个参数是虚拟信标 DLL 的基址。第二个参数是 fwdReason  应将其设置为 DLL_PROCESS_ATTACH (1) 。

BokuLoader 项目中初始化虚拟信标 DLL 的代码示例。

BokuLoader 项目中初始化虚拟信标 DLL 的代码示例。

执行我们的虚拟信标 DLL

初始化虚拟信标 DLL 后,我们可以选择将虚拟信标的入口点返回给反射加载器存根调用,也可以在 UDRL 中调用虚拟信标 DLL 的入口点 fwdReason  设置为 0x4 。

与典型 DLL 不同(其第一个参数 hinstDLL  到 的 SQL 链接

<a href="https://learn.microsoft.com/cn-zh/windows/win32/dlls/dllmain">DLLMAIN</a>

应为虚拟 DLL 的基址),信标期望传入原始信标 DLL 的基址。若未提供此参数,部分可延展 PE 规避功能可能会失效。

BokuLoader 项目中展示两种不同执行虚拟信标 DLL 方式的代码示例。

BokuLoader 项目中展示两种不同执行虚拟信标 DLL 方式的代码示例。

结语

希望这篇博文能帮助红队和蓝队更好地理解 Cobalt Strike 及其反射加载过程。通过反射加载仍可实现的规避手段还有很多。深入了解这些概念后,组织能更好地为成功防御网络威胁做好准备。

本系列的后续文章将聚焦于将 UDRL 与现有 Cobalt Strike 规避功能相集成,深入探讨已公开 BokuLoader 中存在的未公开规避功能,以及尚未公开发布的高级功能。敬请关注更多深度信息与技术,学习如何通过 UDRL 开发将您的 Cobalt Strike 运用能力提升到新水平!

Mixture of Experts | 12 月 12 日,第 85 集

解码 AI:每周新闻摘要

加入我们世界级的专家小组——工程师、研究人员、产品负责人等将为您甄别 AI 领域的真知灼见,带来最新的 AI 资讯与深度解析。