有人对它青睐有加,也有人对其避之不及,但时至今日,.NET 相关攻击手法的存续时间远超预期已是公认的事实。.NET 框架是 Microsoft 操作系统不可或缺的核心组件,其最新版本为.NET Core。作为.NET Framework 的跨平台继任者,.NET Core 将.NET 技术体系拓展至 Linux 和 macOS 系统。这使得.NET 如今成为攻击者与红队在后渗透阶段最常用的攻击手段之一。本文将深入解析一款全新的 Beacon Object File (BOF) 工具:它支持渗透测试人员通过 Cobalt Strike 在进程内执行.NET 程序集,彻底摒弃了传统内置 execute-assembly 模块所采用的 fork and run 技术。
Cobalt Strike 作为一款主流的模拟攻击软件,敏锐捕捉到红队的技术转型趋势:由于 PowerShell 的检测防御能力不断增强,红队正逐步弃用 PowerShell 工具,转而采用 C# 技术栈。基于这一趋势,Cobalt Strike 在 2018 年发布的 3.11 版本中正式推出了execute-assembly模块。该模块支持渗透测试人员在内存中直接执行后渗透阶段的.NET 程序集,无需将工具落地磁盘,从而降低了被检测的风险。尽管在当时,通过非托管代码在内存中加载.NET 程序集的技术并非全新概念,但可以说,正是 Cobalt Strike 推动了这项技术的普及,并进一步助推了.NET 技术在后渗透攻击手法中的应用热度。
Cobalt Strike 的 execute-assembly 模块采用 fork and run 技术,其原理为:创建一个新的傀儡进程,将后渗透阶段的恶意代码注入该进程空间,待恶意代码执行完毕后,再终止这个新进程。这既有优点也有缺点。fork and run 方法的优势在于,代码执行过程是在 Beacon 植入程序所在进程之外完成的。这意味着,若后渗透操作中出现意外或被检测拦截,Beacon 植入程序的存活概率会大幅提升。简而言之,这种方式能有效保障植入程序的整体稳定性。但随着安全供应商逐渐识别出这种 fork and run 的行为模式,正如 Cobalt Strike 官方所承认的,该方法现已成为一种 OPSEC 敏感的操作模式。
在 2020 年 6 月发布的 4.1 版本中,Cobalt Strike 推出了一项全新功能 ——Beacon Object Files (BOF),旨在解决上述问题。BOF 支持渗透测试人员在 Beacon 植入程序的同进程内存空间中执行目标文件,从而规避上文提及的特征明显的执行模式,以及其他易引发反侦察风险的操作(例如调用 cmd.exe 或 powershell.exe)。关于 BOF 的内部工作机制,本文暂不展开赘述,以下是几篇我认为颇具参考价值的博客文章:
如果你读过上述博客就会明白,BOF 并非我们理想中的“救命稻草”。要是你还曾幻想把那些好用的.NET 工具全部重写为 BOF 格式,那恐怕要失望了。很抱歉打破这个期待。不过希望并未完全破灭,在我看来,BOF 仍有不少实用价值。最近我就一直在尝试探索 BOF 的能力边界,这个过程充满乐趣,也夹杂着不少挫败感。我的第一个尝试是开发了CredBandit 这款工具,它能够在内存中完整转储 LSASS 等进程的数据,并通过已有的 Beacon 通信信道回传数据。而今天,我要正式发布 InlineExecute-Assembly:这款工具可以直接在 Beacon 进程内执行.NET 程序集,且无需对你常用的.NET 工具做任何修改。接下来,就让我们深入聊聊我开发这款 BOF 的初衷、它的核心功能、使用注意事项,以及它在模拟攻击/红队演练中能够发挥的作用。
开发 InlineExecute-Assembly 的初衷其实很简单。我希望为我们的模拟攻击团队提供一种进程内执行.NET 程序集的方式,以规避前文提到的那些 OPSEC 风险,尤其是在利用 Cobalt Strike 针对防护成熟的环境开展操作时,这类风险尤为突出。同时,我还要求这款工具不能增加团队的开发负担:无需对我们现有的绝大多数.NET 工具做任何修改,就能直接使用。此外,工具还必须保证稳定性,至少要达到复杂 BOF 所能企及的稳定水平,毕竟我们最不愿看到的,就是丢失好不容易植入目标环境的 Beacon 会话。简单来说,这款工具要尽可能让渗透测试人员用得像 Cobalt Strike 原生的 execute-assembly 模块一样流畅。
我知道,这一点其实显而易见。毕竟没有它,后续操作根本无从谈起,对吧!言归正传,CLR 的工作机制及其底层运行逻辑本身就足够单独写一篇博客详解,因此本文仅从极高层面概述这款 BOF 在通过非托管代码加载 CLR 时所用到的核心逻辑。
加载 CLR
如上方简化屏幕截图所示,BOF 加载 CLR 的核心步骤如下:
至此,CLR 已完成初始化,但要真正执行我们常用的.NET 程序集,还需完成后续几步操作。首先要创建 AppDomain 实例,Microsoft 将其定义为“应用程序运行的隔离环境”。换句话说,我们将借助这个隔离环境来加载并执行后渗透阶段的.NET 程序集。
AppDomain 的创建以及程序集的加载与执行
如上方简化屏幕截图所示,BOF 加载并调用.NET 程序集的核心步骤如下:
希望现在你已对通过非托管代码执行.NET 程序有了整体认知,但仅靠这些还远远达不到实战工具的要求。因此,我们接下来会聊聊这款 BOF 中实现的几项关键功能,正是这些功能让它从“勉强能用”变得“完全适配实战”。
你可能会疑惑这一功能的重要性何在。其实道理很简单:如果你和我一样珍惜时间,就绝不会想把精力耗费在修改几乎所有.NET 程序集上,只为了让这些程序集的入口点返回一个包含所有数据的字符串(而这些数据原本只需直接输出到控制台标准输出流即可),对吧?我猜也是。为了避免这种情况,我们需要将标准输出流重定向至命名管道或邮槽,待输出内容写入后读取数据,再将输出流恢复至原始状态。通过这种方式,我们就能直接运行未经修改的.NET 程序集,操作体验与在 cmd.exe 或 powershell.exe 中执行完全一致。不过在展开代码讲解前,我必须先感谢 @N4k3dTurtl3,以及其发布的一篇关于进程内执行程序集与邮槽的博客文章。早在该技术刚问世时,我正是受这篇文章启发,将此方法集成到了自己的私有 C 语言植入程序中;数月后,我又把这一功能移植到了 BOF 中。好了,感谢的话就说到这里,下面我们来看一个简化示例,说明如何通过将标准输出重定向至命名管道实现这一功能:
将控制台标准输出重定向至命名管道并恢复原始状态
还记得吗?通过 ICLRMetaHost -> GetRuntime 加载 CLR 时,我们必须指定所需的.NET Framework 版本。而这个版本又取决于.NET 程序集的编译版本。如果每次都要手动指定所需版本,那也太麻烦了,对吧?好在 @b4rtik 在其为 Metasploit 框架开发的 execute-assembly 模块中实现了一个实用的函数来处理这个问题,我们可以轻松地将该函数集成到自己的工具中,具体实现如下:
读取.NET 程序集并辅助判定加载 CLR 所需.NET 版本的函数
该函数的核心逻辑是:当传入.NET 程序集的字节数据后,它会遍历这些字节,查找十六进制值 76 34 2E 30 2E 33 30 33 31 39,这串值转换为 ASCII 码后即为 v4.0.30319。希望这看起来很眼熟。若遍历程序集字节时找到该十六进制值,函数会返回 1(即 true);若未找到,则返回 0(即 false)。我们可根据返回值(1/true 或 0/false)快速判定应加载的.NET 版本,具体如以下代码示例所示:
设置 .NET 版本变量所用的 If/else 语句
聊到.NET 攻击技巧,就绝对绕不开 AMSI。虽然我们不会深入讲解 AMSI 的定义以及各类绕过方法,相关内容早已被大量剖析过,但仍需简单说明:为何根据 BOF 中要执行的内容,修补 AMSI 可能是必要的操作。比如,若你尝试运行未做任何混淆处理的 Seatbelt,很快会发现不仅没有任何输出,就连信标也彻底失效了。没错,是彻底、完全失效。究其原因,是 AMSI 检测到了你的恶意程序集,判定其具有恶意特征,然后像取缔噪音过大的家庭派对一样,直接终止了你的操作。这显然不是我们想要的结果,对吧?针对 AMSI 检测,我们有两种可行方案:一是借助 ConfuserX 或 Invisibility Cloak 这类工具对.NET 攻击工具进行混淆处理;二是通过多种技术手段禁用 AMSI。在本工具的实现中,我们采用了 RastaMouse 提出的一种方法——在内存中修补 amsi.dll,使其返回 E_INVALIDARG 错误码,并将扫描结果置为 0。正如其博客中所指出的,该值通常会被系统解读为AMSI_RESULT_CLEAN。以下是适用于 x64 进程的简化版实现代码:
AmsiScanBuffer 的内存补丁
从上方屏幕截图中可清晰看到,我们只需执行以下操作:
将该逻辑集成到工具后,我们即可通过添加 --amsi 参数绕过 AMSI 检测,运行未经修改的 Seatbelt.exe 原版程序,具体效果如下所示:
InlineExecute-Assembly AMSI 绕过示例
对于防御方而言,幸运的是,除了 AMSI 之外,还可借助 ETW 来检测恶意.NET 攻击行为。但遗憾的是,与 AMSI 类似,攻击者也能相当轻松地绕过 ETW 监控,@xpn 就针对 ETW 的绕过方法开展了极具价值的研究。以下是通过修补 ETW 实现其完全禁用的简化示例:
EtwEventWrite 内存修补
从上方屏幕截图中能清楚看到,这些步骤与我们修补 AMSI 的方式几乎完全一致,因此这里就不再赘述。你可参考下方屏幕截图,查看添加 --etw 标志前后的运行效果对比:
在使用 --etw 标志运行 inlineExecute-Assembly 之前,通过 Process Hacker 查看 PowerShell.exe 的属性
使用 –etw 标志运行 inline-Execute-Assembly
在运行 inlineExecute-Assembly 之后,通过 Process Hacker 查看同一个 PowerShell.exe 进程的属性
默认情况下,创建 AppDomain、命名管道或邮槽会使用默认值 “totesLegit”。你可根据测试环境的实际情况修改这些值,以更好地隐藏踪迹,既可以在提供的 aggressor 脚本中修改,也能通过命令行参数实时调整。以下是通过命令行修改这些值的示例:
InlineExecute-Assembly 使用示例:配置独立的 AppDomain 名称与自定义命名管道名称
独立的 AppDomain 名称 ChangedMe 示例
独立的命名管道 LookAtMe 示例
AppDomain 在执行成功后被移除的示例
命名管道在执行成功后被移除的示例
本部分内容基本与我在 GitHub 代码仓库中的说明一致,但我认为有必要重申使用本工具时需要注意的几点事项:
以下是一些防御性注意事项: