优雅持久:借助 InlineExecute-Assembly 规避 Fork&Run 模式的.NET 执行

一名男子深夜盯着电脑屏幕编写代码

有人对它青睐有加,也有人对其避之不及,但时至今日,.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 的初衷、它的核心功能、使用注意事项,以及它在模拟攻击/红队演练中能够发挥的作用。

辅以专家洞察分析的最新科技新闻

通过 Think 时事通讯,了解有关 AI、自动化、数据等方面最重要且最有趣的行业趋势。请参阅 IBM 隐私声明

谢谢!您已订阅。

您的订阅将以英语提供。每份时事通讯都包含取消订阅链接。您可以在此管理您的订阅或取消订阅。更多相关信息,请参阅我们的 IBM 隐私声明

为什么开发 InlineExecute-Assembly?

开发 InlineExecute-Assembly 的初衷其实很简单。我希望为我们的模拟攻击团队提供一种进程内执行.NET 程序集的方式,以规避前文提到的那些 OPSEC 风险,尤其是在利用 Cobalt Strike 针对防护成熟的环境开展操作时,这类风险尤为突出。同时,我还要求这款工具不能增加团队的开发负担:无需对我们现有的绝大多数.NET 工具做任何修改,就能直接使用。此外,工具还必须保证稳定性,至少要达到复杂 BOF 所能企及的稳定水平,毕竟我们最不愿看到的,就是丢失好不容易植入目标环境的 Beacon 会话。简单来说,这款工具要尽可能让渗透测试人员用得像 Cobalt Strike 原生的 execute-assembly 模块一样流畅。

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

解码 AI:每周新闻摘要

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

主要功能

加载公共语言运行时 (CLR)

我知道,这一点其实显而易见。毕竟没有它,后续操作根本无从谈起,对吧!言归正传,CLR 的工作机制及其底层运行逻辑本身就足够单独写一篇博客详解,因此本文仅从极高层面概述这款 BOF 在通过非托管代码加载 CLR 时所用到的核心逻辑。

加载 CLR 的屏幕截图

加载 CLR

如上方简化屏幕截图所示,BOF 加载 CLR 的核心步骤如下:

  1. 调用 CLRCreateInstance 函数,通过此函数获取 ICLRMetaHost 接口。
  2. 随后通过 ICLRMetaHost -> GetRuntime 方法获取我们指定版本的.NET 运行时信息。若待执行的程序集基于.NET 3.5 及以下版本构建,需指定请求 v2.0.50727 版本运行时;若基于.NET 4.0 及以上版本构建,则需指定请求 v4.0.30319 版本运行时。事实上,BOF 中内置了一个函数,可自动识别.NET 程序集所依赖的版本,这一点我们稍后再详细说明。
  3. 获取到运行时信息后,我们会调用 ICLRRuntimeInfo->IsLoadable 方法检查该版本运行时是否可加载至当前进程。该方法还会考量进程中是否已加载其他版本的.NET 运行时:若目标运行时可被加载,会将布尔值 fLoadable 设为 1(即 true)。
  4. 若上述检查全部通过,我们会调用 ICLRRuntimeInfo->GetInterface 方法将 CLR 加载至当前进程,并获取 ICorRuntimeHost 接口实例。
  5. 最后,调用 ICorRuntimeHost->Start 方法启动 CLR 运行时。

至此,CLR 已完成初始化,但要真正执行我们常用的.NET 程序集,还需完成后续几步操作。首先要创建 AppDomain 实例,Microsoft 将其定义为“应用程序运行的隔离环境”。换句话说,我们将借助这个隔离环境来加载并执行后渗透阶段的.NET 程序集。

屏幕截图:AppDomain 的创建以及程序集的加载与执行

AppDomain 的创建以及程序集的加载与执行

如上方简化屏幕截图所示,BOF 加载并调用.NET 程序集的核心步骤如下:

  1. 使用 ICorRuntimeHost->StartDomain 创建我们唯一的 AppDomain
  2. 使用 IUnknow->QueryInterface (pAppDomainThunk) 获取指向 AppDomain 接口的指针
  3. 创建 SafeArray 数组并将.NET 程序集字节数据拷贝至该数组中
  4. 通过 AppDomain->Load_3 加载程序集
  5. 通过 Assembly->EntryPoint 获取程序集中的入口点
  6. 最后,通过 MethodInfo->Invoke_3 调用程序集

希望现在你已对通过非托管代码执行.NET 程序有了整体认知,但仅靠这些还远远达不到实战工具的要求。因此,我们接下来会聊聊这款 BOF 中实现的几项关键功能,正是这些功能让它从“勉强能用”变得“完全适配实战”。

将控制台标准输出 (STDOUT) 重定向至命名管道或邮槽:无需修改工具本身

你可能会疑惑这一功能的重要性何在。其实道理很简单:如果你和我一样珍惜时间,就绝不会想把精力耗费在修改几乎所有.NET 程序集上,只为了让这些程序集的入口点返回一个包含所有数据的字符串(而这些数据原本只需直接输出到控制台标准输出流即可),对吧?我猜也是。为了避免这种情况,我们需要将标准输出流重定向至命名管道或邮槽,待输出内容写入后读取数据,再将输出流恢复至原始状态。通过这种方式,我们就能直接运行未经修改的.NET 程序集,操作体验与在 cmd.exe 或 powershell.exe 中执行完全一致。不过在展开代码讲解前,我必须先感谢 @N4k3dTurtl3,以及其发布的一篇关于进程内执行程序集与邮槽的博客文章。早在该技术刚问世时,我正是受这篇文章启发,将此方法集成到了自己的私有 C 语言植入程序中;数月后,我又把这一功能移植到了 BOF 中。好了,感谢的话就说到这里,下面我们来看一个简化示例,说明如何通过将标准输出重定向至命名管道实现这一功能:

屏幕截图:将控制台标准输出重定向至命名管道并恢复原始状态

将控制台标准输出重定向至命名管道并恢复原始状态

识别.NET 程序集的版本信息

还记得吗?通过 ICLRMetaHost -> GetRuntime 加载 CLR 时,我们必须指定所需的.NET Framework 版本。而这个版本又取决于.NET 程序集的编译版本。如果每次都要手动指定所需版本,那也太麻烦了,对吧?好在 @b4rtik 在其为 Metasploit 框架开发的 execute-assembly 模块中实现了一个实用的函数来处理这个问题,我们可以轻松地将该函数集成到自己的工具中,具体实现如下:

屏幕截图:读取.NET 程序集并辅助判定加载 CLR 所需.NET 版本的函数

读取.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 版本变量所用的 If/else 语句

修补反恶意软件扫描接口 (AMSI)

聊到.NET 攻击技巧,就绝对绕不开 AMSI。虽然我们不会深入讲解 AMSI 的定义以及各类绕过方法,相关内容早已被大量剖析过,但仍需简单说明:为何根据 BOF 中要执行的内容,修补 AMSI 可能是必要的操作。比如,若你尝试运行未做任何混淆处理的 Seatbelt,很快会发现不仅没有任何输出,就连信标也彻底失效了。没错,是彻底、完全失效。究其原因,是 AMSI 检测到了你的恶意程序集,判定其具有恶意特征,然后像取缔噪音过大的家庭派对一样,直接终止了你的操作。这显然不是我们想要的结果,对吧?针对 AMSI 检测,我们有两种可行方案:一是借助 ConfuserXInvisibility Cloak 这类工具对.NET 攻击工具进行混淆处理;二是通过多种技术手段禁用 AMSI。在本工具的实现中,我们采用了 RastaMouse 提出的一种方法——在内存中修补 amsi.dll,使其返回 E_INVALIDARG 错误码,并将扫描结果置为 0。正如其博客中所指出的,该值通常会被系统解读为AMSI_RESULT_CLEAN。以下是适用于 x64 进程的简化版实现代码:

屏幕截图:AmsiScanBuffer 的内存补丁

AmsiScanBuffer 的内存补丁

从上方屏幕截图中可清晰看到,我们只需执行以下操作:

  1. 加载 amsi.dll 并获取 AmsiScanBuffer 函数的指针
  2. 更改内存保护
  3. 在 amsiPatch [] 字节中打补丁
  4. 将内存保护恢复到初始状态

将该逻辑集成到工具后,我们即可通过添加 --amsi 参数绕过 AMSI 检测,运行未经修改的 Seatbelt.exe 原版程序,具体效果如下所示:

屏幕截图:InlineExecute-Assembly AMSI 绕过示例

InlineExecute-Assembly AMSI 绕过示例

修补 Event Tracing for Windows (ETW)

对于防御方而言,幸运的是,除了 AMSI 之外,还可借助 ETW 来检测恶意.NET 攻击行为。但遗憾的是,与 AMSI 类似,攻击者也能相当轻松地绕过 ETW 监控,@xpn 就针对 ETW 的绕过方法开展了极具价值的研究。以下是通过修补 ETW 实现其完全禁用的简化示例:

屏幕截图:EtwEventWrite 内存修补

EtwEventWrite 内存修补

从上方屏幕截图中能清楚看到,这些步骤与我们修补 AMSI 的方式几乎完全一致,因此这里就不再赘述。你可参考下方屏幕截图,查看添加 --etw 标志前后的运行效果对比:

屏幕截图:在使用 --etw 标志运行 inlineExecute-Assembly 之前,通过 Process Hacker 查看 PowerShell.exe 的属性

在使用 --etw 标志运行 inlineExecute-Assembly 之前,通过 Process Hacker 查看 PowerShell.exe 的属性

屏幕截图:使用 –etw 标志运行 inline-Execute-Assembly

使用 –etw 标志运行 inline-Execute-Assembly

屏幕截图:在运行 inlineExecute-Assembly 之后,通过 Process Hacker 查看同一个 PowerShell.exe 进程的属性

在运行 inlineExecute-Assembly 之后,通过 Process Hacker 查看同一个 PowerShell.exe 进程的属性

独立 AppDomain、命名管道、邮槽

默认情况下,创建 AppDomain、命名管道或邮槽会使用默认值 “totesLegit”。你可根据测试环境的实际情况修改这些值,以更好地隐藏踪迹,既可以在提供的 aggressor 脚本中修改,也能通过命令行参数实时调整。以下是通过命令行修改这些值的示例:

终端屏幕截图:展示在 Beacon 中执行命令 inlineExecute-Assembly --dotnetassembly /root/Desktop/MessageBoxCS.exe 的过程输出内容包含以下状态信息:执行 inlineExecute-Assembly 命令、主机回连(已发送 16319 字节数据)、接收到输出内容“Hello From .NET!”,以及完成提示“inlineExecute-Assembly Finished”。

InlineExecute-Assembly 使用示例:配置独立的 AppDomain 名称与自定义命名管道名称

屏幕截图:独立的 AppDomain 名称 ChangedMe 示例

独立的 AppDomain 名称 ChangedMe 示例

屏幕截图:独立的命名管道 LookAtMe 示例

独立的命名管道 LookAtMe 示例

屏幕截图:AppDomain 在执行成功后被移除的示例

AppDomain 在执行成功后被移除的示例

屏幕截图:命名管道在执行成功后被移除的示例

命名管道在执行成功后被移除的示例

注意事项

本部分内容基本与我在 GitHub 代码仓库中的说明一致,但我认为有必要重申使用本工具时需要注意的几点事项:

  1. 尽管我已尽可能确保工具的稳定性,但无法保证程序永远不会崩溃、信标也不会失效。我们无法实现 fork and run 机制的优势,即当程序运行出错时,信标仍能保持存活状态。这是使用 BOF 技术需要付出的代价。基于此,我必须强调,提前测试程序集以确保其能够正常运行,这一点至关重要。
  2. 由于 BOF 是在进程内执行的,且运行期间会占用你的信标,因此在用于执行长时间运行的程序集前,需充分考虑这一点。若你选择运行需要耗时很久才能返回结果的程序集,那么在结果返回、程序集执行完毕前,你的信标将无法响应新的命令执行请求。此外,该操作也不受休眠时间的限制。例如,即便你将信标休眠时间设置为 10 分钟,一旦运行该 BOF,结果也会在 BOF 执行完成后立即返回。
  3. 除非对那些在内存中加载 PE 文件的工具(例如 SafetyKatz)做针对性修改,否则这类工具几乎都会导致你的信标失效。这类工具在配合 execute assembly 使用时往往能正常运行,原因是它们可在退出前,从“牺牲进程”中发送控制台输出内容。但当它们通过我们的进程内 BOF 执行并退出时,会直接终止宿主进程,进而导致信标失效。虽然这类工具可通过修改适配 BOF 运行,但我仍建议通过 execute assembly 来运行此类程序集;因为若使用 BOF,一些不符合 OPSEC 规范的内容可能会被加载到进程中,且执行后无法被清理。
  4. 若你的程序集中调用了 Environment.Exit 方法,必须将其移除,该方法会直接终止进程并导致信标失效。
  5. 此外,命名管道和邮槽的名称需保证唯一性。如果你的信标仍处于存活状态,但未接收到返回数据,那么问题大概率出在命名管道/邮槽名称重复,此时只需更换一个不同的名称即可。

防御注意事项

以下是一些防御性注意事项:

  1. 本工具在对 AMSI 和 ETW 进行内存修补时,会使用PAGE_EXECUTE_READWRITE内存保护属性。这一设计是刻意为之的,但该属性也应被视作一个明显的警报信号,因为极少有合法程序会将内存区域的保护属性设置为 PAGE_EXECUTE_READWRITE。
  2. 创建的命名管道默认名称为 totesLegit。这一命名是刻意设定的,但安全设备可通过特征检测将其标记为异常。
  3. 创建的邮槽默认名称为 totesLegit。这一命名是刻意设定的,但安全设备可通过特征检测将其标记为异常。
  4. 加载的 AppDomain 默认名称为 totesLegit。这一命名是刻意设定的,但安全设备可通过特征检测将其标记为异常。
  5. 关于检测.NET 恶意滥用行为的实用技巧可参考以下资源:(由 @bohops 分享)点击此处、(由 F-Secure 发布)点击此处,以及点击此处
  6. 重点监测是否有.NET CLR 被加载到可疑进程中,例如,那些本不应加载 CLR 的非托管进程。
  7. 关于事件跟踪的更多信息。
  8. 排查其他已知的 Cobalt Strike 信标入侵指标或 C2 横向渗透/通信入侵指标。