MemFiles 是一套用于 CobaltStrike 的工具包,它使操作员能够将 Beacon 进程产生的文件写入内存,而不是写入目标系统的磁盘。它已成功在 Windows 7、10 和 11 上测试通过;相应的服务器版本应该也可以正常工作。MemFiles 仅支持 x64 Beacon。
它是通过 Hook NTDLL.dll 中的多个不同 NtAPI,并将对这些 API 的调用重定向到已注入 Beacon 进程内存空间的函数来实现的。
MemFiles 假定 Beacon 进程中有一份干净/未被 Hook 的 NTDLL 副本。如果 Beacon 进程中仍存在 EDR Hook,则不对 MemFiles 的可行性做任何保证。使用 MemFiles 之前,请修复/刷新 NTDLL!
MemFiles 工具包中定义了一个“特殊的”、不存在的目录;任何写入此特殊目录的文件都会被 MemFiles 捕获并写入内存,之后可以下载到 Teamserver。
MemFiles 与大多数(并非全部)在 Beacon 进程内运行、并且可以指定输出目录的工具兼容。它不需要提升权限即可工作。
这包括: -BOF -.NET 程序集(使用类似 inline-executeAssembly 的工具内联运行) -PE(使用类似 Inline-Execute-PE 的工具内联运行)
所有这些工具都是兼容的,因为它们在 Beacon 进程内运行,而相关的 NtAPI 已经被 Hook。
MemFiles 不适用于以下情况: -execute-assembly -shell -run
这些都不兼容,因为它们都会生成其他进程,而这些进程的 NtAPI 没有被 Hook。
当 Rubeus、SharpHound、Procdump 和 Powershell 等工具在 Beacon 进程内运行时,MemFiles 已成功测试通过。

克隆仓库,并可选地修改 /PIC/Source/NtCreateFile.c 和 /PIC/Source/NtOpenFile.c 中第 56 行定义的 hookdir 变量。这个变量是“特殊”目录,用于告诉 MemFiles 应该拦截正在创建的文件。hookdir 变量默认设置为“redteam”。确保该变量不是目标系统上的真实目录,并且在两个文件中保持一致!

运行 'make all' 来编译所需的 BOF 和 PIC 函数。
将 MemFiles.cna 加载到 CobaltStrike 客户端中。确保 CobaltStrike 运行的目录对你的用户可写;MemFiles 会在那里创建一个文本文件(memfiles.txt),以确保 MemFiles 正常运行所需的数据可用。
MemFiles 可以配置为在每个连入 Teamserver 的新 Beacon 中自动安装;这是通过使用 MemFiles->Config 菜单项实现的。默认情况下,MemFiles 不会在新 Beacon 中自动安装。请注意,这是一个全局设置;如果有两个客户端连接到 Teamserver 并且都加载了 MemFiles.cna,那么如果客户端 A 切换了“Install on beacon initial”设置,该更改也会对客户端 B 生效!

MemFiles 由 4 个面向目标的命令(运行 BOF)和 1 个内部命令(操作项目数据结构)组成。
面向目标的命令:
内部数据结构:
meminit 负责在 Beacon 进程中安装 MemFiles。
MemFiles 所 Hook 的 NtAPI 列表如下:
meminit 执行以下主要操作:
memlist 用于显示 MemFiles 在给定 Beacon 中当前存储的所有文件。
显示多个字段,其中与用户最相关、最关心的是文件名和存储数据的长度。
memfetch 用于实际获取 MemFiles 在给定 Beacon 中存储在内存中的文件。
默认情况下,memfetch 会获取 MemFiles 存储的所有“句柄”已被关闭的文件。这个设计选择是为了避免尝试下载程序/应用程序尚未写入完成的文件而出现的问题。
这意味着,如果程序/应用程序没有关闭它打开的文件句柄,memfetch 将不会下载该文件。 可以通过在 memfetch 中使用“force”参数来缓解,即 'memfetch force',以获取内存中的所有文件,而不管其句柄状态如何。
memfetch 从内存中获取的文件会作为下载项发送回 Teamserver,并可以通过 CobaltStrike 中的 Downloads 选项卡将文件从 Teamserver 同步到客户端。
一旦文件被 Teamserver 下载,它就会从 Beacon 进程的内存中清除,并且其通过 memlist 显示的条目也会被删除。
memclean 负责从 Beacon 进程中清理和移除 MemFiles。
MemFiles 的标准用法是安装它并让它在 Beacon 的整个生命周期内保持安装状态;但是,如果你想将 MemFiles 与某个工具结合使用来捕获和获取文件输出,然后卸载 MemFiles 以便它的产物不留在内存中,可以使用 memclean 将 Beacon 进程恢复到运行 meminit 之前的状态。
这包括:
请注意,在 memclean 执行这些操作之前,它会强制下载 MemFiles 存储在内存中的任何文件。如果你打算将 MemFiles 与单个工具一起使用然后移除它,可以跳过 memfetch,直接使用 memclean 一次性获取文件并从 Beacon 进程中移除 MemFiles。
memtable 用于显示和跟踪当前安装了 MemFiles 的 Beacon 的信息。它还显示全局配置信息。
每个 CobaltStrike 客户端都有自己的 memtable;MemFiles 尽最大努力确保其数据在所有连接的 CobaltStrike 客户端之间保持同步,以便所有操作员可以在所有 Beacon 中使用 MemFiles。更多信息请参阅“Design Considerations and Commentary”。

使用 meminit 命令在 Beacon 中初始化 MemFiles。这可以通过切换 MemFiles->Config 菜单中的选项来配置为自动发生。

初始化 MemFiles 后,你现在可以使用你最喜欢的工具将文件写入内存!具体方式取决于具体工具;有些工具允许你指定一个目录来输出多个文件,而其他工具则允许你为工具创建的单个文件指定绝对路径。下面是一些示例:
这里我们指定 SharpHound 应将所有生成的文件输出到 c:\redteam\ 目录(我们的特殊 MemFiles 目录),并且不要压缩文件;MemFiles 不支持程序从内存读取文件,只支持写入,因此 SharpHound 中的 zip 功能不起作用。

对 Rubeus 使用“dump”命令,并指示它将所有控制台输出发送到一个文件(位于我们的特殊目录中)

在这个例子中,使用 Inline-Execute-PE 将 powershell.exe 加载到 Beacon 进程中,并运行 'Get-ADUser' 来获取域用户列表。通过管道和 'out-file',数据可以被写入内存,然后被获取。

当你想要获取文件时,运行 memfetch:

当你使用完 MemFiles 和/或不想让它继续安装在 Beacon 进程中时,运行 memclean:

请注意,在上面的示例中,有一个文件尚未下载;memclean 会先下载该文件并将其从内存中清除,然后再卸载 MemFiles。
使用 memtable 查询 MemFiles 的状态和配置。在进行长时间操作时,从 memtable 中清除死/旧 Beacon 的条目,以避免混乱。

正如简介中强调的,MemFiles 需要 Beacon 进程中有一份干净的 NTDLL 副本才能正常工作。这是必要的,因为它会读取 NtFunction 中的原始字节,并将其中一些字节复制到 trampoline,trampoline 之后用于完成 MemFiles 不应干预的 NtFunction 正常调用。这个主题在“Technical Details, Design Considerations, and Commentary”部分中有更详细的讨论。
MemFiles 为每个文件初始分配 1048576 字节;随着数据写入内存,它可以并且将会根据需要扩展此分配以容纳更大的文件。
存储在 MemFiles 结构体中的文件名是从传递给替换后的 NtCreateFile 函数的参数中解析出来的。MemFiles 以一种相当简单的方式做到这一点:在文件路径参数中定位“特殊”目录,将指针移动到其末尾,然后将其递增 1 以考虑分隔“特殊”目录和文件名的 '\' 字符。例如,在路径 'C:\users\tom\redteam\myfile.txt' 中,MemFiles 定位到 'redteam',考虑反斜杠字符,然后选择 'myfile.txt' 作为文件名。
MemFiles 不关心文件路径中的任何前置目录;就 MemFiles 而言,'C:\redteam\myfile.txt' 和 'c:\users\tom\appdata\local\redteam\myfile.txt' 都是同样有效的路径。
鉴于 MemFiles 从文件路径中解析文件名的方式,应该注意 MemFiles 不支持创建子目录;这意味着 MemFiles 无法与某些工具正常配合,例如试图创建 c:\redteam\mynewdir\file1.txt、c:\redteam\mynewdir\file2.txt、c:\redteam\mysecondir\file3.txt 等的工具。
MemFiles 所 Hook 的 NtAPI 已被确定为各种程序用于 I/O 操作的 API。如前所述,已成功与 MemFiles/这组 Hook 的 NtAPI 一起测试的工具/功能包括:SharpHound、Rubeus、Powershell、Procdump、BOF、执行文件写入操作的通用 C 程序,以及 CobaltStrike 的 bupload_raw 命令(该命令允许操作员指定远程文件位置)。当然还有其他工具可以开箱即用地与 MemFiles 配合使用;其他工具则不兼容。
乍一看,Windows 文件创建过程似乎很简单:NtCreateFile->NtWriteFile->NtClose。但在深入研究这个项目后,我很快就发现还涉及许多其他 API,而且有趣的是,这些其他 API 因程序而异。有些程序在其 I/O 过程中调用 Win32 API SetFilePointerEx,后者又会调用 NtAPI NtSetInformationFile。其他程序,如包括 SharpHound 在内的 .NET 程序,最终会调用 NtFlushBuffersFile。
由于创建和写入文件缺乏单一通用的 API 调用链,因此根据 MemFiles 所使用的工具,存在不兼容的空间。程序可能会调用 MemFiles 尚未 Hook 的另一个 NtAPI,将 MemFiles 在替换的 NtCreateFile 函数中创建的假句柄传递给它,从而导致 "Invalid Handle" 错误并停止执行。其他程序会执行 MemFiles 无法充分伪装/替换的更复杂操作。已经发现的一个此类不兼容性是 ADExplorer.exe。
ADExplorer.exe 是微软签名的二进制文件,用于活动目录枚举。在运行时,ADExplorer 将数据写入指定的输出文件,然后稍后返回并引用它,最后在执行结束时将最终输出写入该文件。因为它将输出文件用作某种缓存,所以它必须能够读取已经写入文件的数据,而它极不可能以任何简单、可预测的方式这样做。
MemFiles 目前不支持程序或应用程序读取内存中的文件,但通过进一步充实自定义 NtReadFile 函数并向 MemFiles 结构体添加一些额外的变量/数据跟踪,这应该是可能的。
ADExplorer 还带来了其生成文件大小的另一个挑战。在大型企业环境中,输出文件可能超过 1GB;虽然 MemFiles 在程序上应该能够处理这个问题,但它肯定不适合这种用例。
毫无疑问,社区会发现 MemFiles 无法正常使用的工具;我鼓励你开一个 issue,详细说明不兼容的程序/工具以及你运行它的环境(例如它是 BOF、通过 inline-executeAssembly、Inline-Execute-PE 等),以便我看看能否扩展 MemFiles 并让它正常工作。
与 MemFiles 相关的 IOC 包括但不限于:
使用 VirtualAlloc 分配内存 使用 WriteProcessMemory 写入数据 更改已分配内存的内存保护属性,在 RW 和 RX 之间切换 覆盖 NTDLL.dll 中的内存
MemFiles 没有针对正规 EDR 进行开发或测试;当时可用的是 Microsoft Defender。话虽如此,我敢说,与 MemFiles 捕获或存储文件到内存相比,Beacon 运行产生文件的任何工具/程序更有可能被警报。覆盖 NTDLL 中的内存/Hook NtAPI 在我看来是某些产品可能会提出异议的行为,但我没有任何证据可以证实这一点。对于不涉及 MemFiles 应该捕获的文件的 Hook NtFunction 调用,syscall 仍然从 NTDLL.dll 地址空间内发出,因为安全产品确实会检测并对从该区域外部发出的 syscall 发出警报。
应该注意的是,MemFiles 存储在内存中的文件没有被编码或加密;如果发现 AV/EDR 对内存中生成的文件发出警报的真实用例/实例,可以添加此功能。
几个月前,在 KFiveFour 的 Tradecraftcon 会议上,我第一次接触内存文件系统的概念,当时一位演讲者(@DexterGerig)演示了一个 POC,该 POC 使用客户端-服务器模型创建了一个内存文件系统。我所设想的功能中有一半由我上一个主要版本 Inline-Execute-PE 覆盖。另一半——捕获工具产生的文件并将其存储在内存中而不是磁盘上的想法——在该项目中没有得到实现,并且出于显而易见的原因,它仍然是一个梦寐以求的功能。
MemFiles 对我来说是一个极具挑战性的项目,因为在这个项目之前,我很少使用调试器,不了解汇编语言,也不理解 API Hook。在这个项目期间,我遇到了几个 10-20 小时的障碍,值得庆幸的是,通过坚持不懈,我能够克服它们。虽然这可能不是最有效的方式,但我在调试器方面获得了大量熟悉,并且对计算机在汇编、寄存器、栈和调用约定方面的底层工作原理有了更深入的理解。
下面是对 MemFiles 中一些更重要技术细节和设计考虑的技术深入探讨。
Windows 上的文件创建从 NtCreateFile 开始,向其传递所需文件的路径,作为回报,Windows 在该位置创建文件并提供句柄。返回的句柄用于之后涉及该文件的所有调用,例如 NtWriteFile 和 NtClose。
在考虑如何将这些 API 的调用区分为我们想要拦截和篡改的调用,以及我们想要忽略的调用时,我决定在 NtCreateFile 调用中查找关键字。这是通过在 NtCreateFile 调用的文件路径中指定一个唯一的、不存在的目录来实现的。当我们的 Hook 将执行重定向到替换的 NtCreateFile 函数时,会检查作为参数传递给 NtCreateFile 的文件路径,看是否存在该唯一“关键字”;如果找到,MemFiles 就知道这个 NtCreateFile 调用与应该放入内存而不是磁盘的文件有关。发生这种情况时,MemFiles 会初始化几个变量并为该文件分配初始 1MB 内存,但最重要的是,它将一个假句柄与 MemFiles 结构体中指定的文件名关联起来,并将该假句柄返回给调用者。
对于 MemFiles Hook 的所有其他 NtAPI,相应的替换 NtFunction 会查看作为参数传入的句柄,并查看它是否存在于 MemFiles 结构体中;如果该句柄确实存在于 MemFiles 结构体中(MemFiles 生成的假句柄足够假,它们不应该与真实句柄交叉重叠),MemFiles 就会将此调用识别为与内存中文件有关的调用并相应处理。### Hook 理论与替换函数 为了将原本要写入磁盘的文件写入内存,MemFiles 需要拦截程序在尝试创建文件时对某些 API 的调用。API Hook 已经存在了很长时间,并且被许多 EDR 产品作为其核心功能的一部分积极使用;对某些 API 的调用会被重定向到 EDR 的地址空间,在那里对 API 调用及其传入变量进行分析。如果 EDR 判定该调用是恶意的(例如属于攻击工具或杀伤链的一部分),它会阻止调用完成并发出警报。如果 EDR 判定该调用是良性的,它会将执行流修补回被重定向的位置,并让 API 调用按原始意图正常完成。一个简单的类比是:你给朋友寄了一封信,但在朋友收到之前,第三方拆开了信并阅读,然后判断信中是否含有非法内容;如果有,你的朋友永远不会收到信,并且警方会被惊动。
MemFiles 遵循同样的理论,只不过没有警报(也没有理论上的警方介入)。API Hook 通常实现在用户态尽可能低的层级,即 NTDLL.dll 中的 NtFunctions。让我们来看一下 NtCreateFile,在尚未进行任何 Hook 之前:
所有 NtFunction 都是相同的,唯一的区别是系统调用号,在本例中为 55。系统调用号在不同 NtFunction 之间不同,还应注意,该数字在不同 Windows 版本之间也可能不同;此操作系统(Windows 11)上 NtCreateFile 的系统调用号为 55,但在 Windows 10 上可能不同(在 Windows 7 上当然也不同)。
值得注意的是 TEST 和 JNE 指令。它们的存在是为了确定 NtFunction 应该使用正常的 syscall 指令,还是使用传统的 INT 2E 指令。我将引用 klezvirus 的文章 SysWhispers is dead, long live SysWhispers!:
现在有趣的部分是,函数会检查 SharedUserData[0x308](BYTE PTR DS:[7FFE0308])是否被设置为 1。SharedUserData 是一个符号,指代内核模式结构 KUSER_SHARED_DATA。
KUSER_SHARED_DATA 结构定义了一个固定(或预定义)的内存空间,用于与用户态软件共享信息。当然,这样做是为了让某些全局系统信息可以被用户态代码直接使用,而无需在用户态与内核态执行之间反复切换的开销。
索引 0x308 处的值代表 syscall 指令,从 Windows 1511 开始的所有 Windows 版本都支持该指令。你可以想象,在 Windows 1511 之前的所有版本中,执行系统调用的标准方式是通过调用中断 int 2Eh。
...
如果你在疑惑为什么这个 int 2Eh 仍然存在,即使 Windows 现在已经远高于 1511 版本,那是因为该指令仍在使用。确实,当 HVCI(Hypervisor-protected Code Integrity,受虚拟机监控程序保护的代码完整性)启用时,SharedUserData[0x308] 会被设置为 0,此时会使用 int 2Eh 而不是 syscall 指令。这主要是出于性能原因,取决于使用哪一种指令来执行 Ring3 到 Ring0 的切换。
我在 Twitter 上就这个问题请求进一步澄清,@yarden_shafir 回复如下:

简而言之,NtFunction 中的每条指令都可能在某时被需要(也许末尾那个看起来不可达的多字节 NOP 除外),如果我们覆盖了 NtFunction 中的指令,就需要确保保存它们,并在最终发出 syscall(或视情况使用 INT 2E)之前的某个时刻执行它们。
值得注意的是,在发出 syscall 之前,系统调用号会被移入 RAX(在截图中显示为 EAX)。由于我们没有看到在此之前 RAX 被压入栈,我(也许天真地)认为在系统调用号被移入之前 RAX 中所保存的值并不重要,也不需要在 syscall 发出后继续使用。这是个好消息,因为这意味着我们可以自由使用 RAX 寄存器,只要确保在 syscall 发出前它包含系统调用号即可。
为了将执行重定向到我们的自定义代码/替换 NtFunction,我们会覆盖原始 NtAPI 的一部分,将替换 NtFunction 的地址移入 RAX,然后使用 JMP 指令跳转到该代码:
MOV 和 JMP 指令需要 12 个字节;由于我们通过覆盖 NtAPI 的前 12 个字节破坏了其他指令,这些指令已被替换为 NOP,以保持 NtAPI 的正确间距和对齐。
当程序现在调用 NtCreateFile 时,它将跳转到我们的替换 NtFunction 执行。
MemFiles 在 Hook 的 API 所重定向到的替换函数的存放位置上,与 EDR 执行 Hook 的方式有所不同。许多 EDR 会将它们自己的 DLL 加载到进程中。被 Hook 的 API 会被重定向到该已加载 DLL 的地址空间,在那里进行分析。由于这个项目的全部意义就在于避免将文件写入磁盘,因此将 DLL 放到磁盘上并让我们的 Beacon 进程加载它以便访问我们的替换 NtFunction,似乎并不是一个好方法。有一个几年前发布的 POC 支持从内存加载 DLL,这能满足我们的需求,但该项目已不再维护,而且看起来存在几个问题。此外,它有 1200 行代码,转换为 BOF 格式将是一项艰巨的工作。
顺便提一下,我们的替换 NtFunction 不能驻留在 BOF 中;CobaltStrike 会加载、运行 BOF,并在完成后将其从进程内存中清除。由于我们需要内存中持久存在的函数,以便在进程调用任何被 Hook 的 NtAPI 时被调用,因此 BOF 无法满足需求。
我想到的答案是位置无关代码(PIC)。顾名思义,与需要在特定位置加载/各部分之间保持特定关系的普通可执行文件不同,PIC 可以放置在内存中的任何位置并运行。这为我们打开了大门:将替换 NtFunction 编写为 PIC 可执行文件,将它们注入 Beacon 进程,并在调用被 Hook 的 NTAPI 时让我们的 Hook 将执行重定向到它们。
这些 PIC NtFunction 的模板来自 Cracked5pider 的 ShellcodeTemplate 项目。
与基础项目的一个显著不同之处在于,基础项目是围绕创建一个完整的 PIC exe 设计的;也就是说,它包含了用于保存栈指针、在栈上分配空间、调用 exe 内指定的替换 NtFunction,并在该函数返回后恢复栈指针的 ASM。PIC exe 发出的 call 指令会带来一个问题,因为这样做会将调用发生处(在 PIC exe ASM 中)的返回地址压入栈,这会导致后面的 ret 指令将执行返回到 PIC exe 中,而不是返回到原始 NtAPI 的调用者。
为了缓解这个问题,我们对 ShellcodeTemplate 项目中的 ASM 文件进行了编辑,移除了与设置栈、调用函数以及在函数执行完成后恢复栈指针相关的 ASM。结果是,放置在 NtAPI 中的 Hook 现在将执行直接跳转到替换 NtFunction,栈和寄存器的状态与程序调用原始 NtAPI 时保持一致(除了被我们用于 JMP 的 RAX)。
原始 ShellcodeTemplate ASM:

MemFiles ASM:

每个被 Hook 的 NtAPI 都有自己对应的 PIC NtFunction,其中包含必要的逻辑,用于执行以下任一操作:
A. 执行 MemFiles 特有的操作,例如创建伪句柄、将数据写入内存、修改 MemFiles 结构体中的变量等
或
B. 将执行引导到 trampoline(蹦床),使 API 调用回到正轨,并修补回 NTDLL 以便发出 syscall
有些替换 NtFunction 比其他更复杂;当对某个被 Hook 的 NtAPI 的调用与 MemFiles 相关时,像 NtCreateFile 和 NtQueryVolumeInformationFile 这类函数会根据 MSDN 文档、测试结果以及一些猜测/常识来修改作为参数传递给 NtAPI 的变量。其他函数,如 NtClose 和 NtReadFile,则只是向原始调用者返回 STATUS_SUCCESS,以避免因传入 MemFiles 创建的伪句柄而必然出现的“无效句柄”错误。
当对被 Hook 的 NtAPI 的调用与 MemFiles 无关时,我们需要将执行引导到 trampoline,以使一切回到正轨:

蹦床负责执行由于 API 被 Hook 而未在原始 NtAPI 中执行的任何/所有指令;这包括被原始 Hook 部分或全部覆盖的任何指令。Hook API 很容易导致我们没有足够的空间来执行所有需要的指令。蹦床也有助于缓解这个问题,因为我们可以在跳回原始 NtAPI 之前执行任意数量的操作来设置寄存器/栈。下面可以看到 NtCreateFile 的蹦床:

最明显的是,被我们初始 Hook 覆盖的三条指令可以在蹦床中看到,它们就是前三条指令:
MOV R10, RCX
MOV EAX, 55
TEST BYTE PTR DS:[7FFE0308], 1
如前所述,所有这些操作中的一个重要要求是,在发出 syscall 之前,系统调用号(上例中的 55)必须存在于 RAX(EAX)中。然而我们有一个问题:我们仍然需要使用 JMP 指令将执行引导回原始 NtAPI。虽然可能有另一个寄存器没有保存重要信息,可以用来完成这件事,但考虑到我们正在 Hook 的 NtAPI 数量,以及每个 NtAPI 可能以不同方式使用寄存器,我没有找到一个始终安全的选择。安全的选择是继续使用 RAX;我们可以先将 RAX 中保存的系统调用号压入栈,然后将想要跳转回的原始 NtAPI 中的地址移入 RAX,并使用 JMP 指令回到 NTDLL:

安装在 NtAPI 中的 Hook 包含一条 POP RAX 指令,这正是我们使用蹦床跳转到的位置。执行这条指令会从栈顶将系统调用号恢复回 RAX,并让我们准备好发出 syscall。注意,原始未 Hook 的 NtAPI 中的 JNE 指令仍然存在;对应的 TEST 指令(用于设置 ZF 标志并决定是否执行 JNE,也就是跳过 syscall 转到 INT 2E)已经在蹦床中执行了。这样安排既能让我们成功 Hook NtAPI 并将执行重定向到替换的 PIC NtFunction,又能确保我们不会跳过任何内容,也不会因为 Hook 而丢失任何功能。
简要总结一下到目前为止所涵盖的内容:当 MemFiles 被初始化时,会在 Beacon 进程内存中创建一个结构体,其中包含对 MemFiles 功能至关重要的信息。在 MemFiles 的整个生命周期中,这个结构体中的信息会被持续引用,包括被每个替换的 PIC NtFunction 引用,以及被用于查询和获取 MemFiles 存储在内存中的文件的 BOF 引用。为此,当结构体创建时,结构体所在的内存地址会被传回 Teamserver:

在将该地址存储到 memtable 之后,后续的 MemFiles 命令(memlist、memfetch、memclean)会将这个地址作为参数发送给 BOF,以便定位和引用该结构体。但是 PIC NtFunction 是如何定位这个结构体的呢?
最明显的问题是,PIC NtFunction 需要 MemFiles 结构体的内存地址,但它们在结构体创建时已经编译完成。MemFiles 的一个早期实现通过将 MemFiles 的初始化拆分为两个独立的 BOF 来解决这个问题。第一个 BOF 创建结构体并将地址发送回 Teamserver,Teamserver 通过某种 Aggressor 脚本魔法解析出该地址,将其插入每个 NtFunction 的源代码文件中,然后重新编译成最终的 PIC NtFunction。第二个 BOF 随后传输完成后的 PIC NtFunction,并实际执行 NtAPI 的注入和 Hook。
除了难看且耗时之外,在多个 Beacon 同时尝试初始化 MemFiles 的情况下,可能会产生真正的问题。如果 Beacon 2 在 Beacon 1 正在修补并重新编译 NtFunction 源代码文件的过程中回传它的 MemFiles 结构体地址,事情可能会变得一团糟。
解决这个问题的优雅方案是对 PIC NtFunction 执行二进制补丁,将 MemFiles 结构体地址修补到编译后的 NtFunction 中,并在运行时访问。为了便于实现这一点,每个 NtFunction 中都写入了一个占位符字符串:

使用 xxd 之类的工具可以在编译后的代码中看到这个变量:

当运行 meminit 命令时,每个 PIC NtFunction 都会与 InstallHooks BOF 一起发送到 Beacon。该 BOF 负责创建 MemFiles 结构体;创建完成后,它会对每个 PIC NtFunction 调用 patchAddr 函数。patchAddr 负责定位那些 A 字符串,并将其替换为结构体地址的字符串表示。实际上,使用前面截图中的内存地址,pFileInfoStr 变量现在看起来像这样:
char* pFileInfoStr = GET_SYMBOL( "00000178BC106860" );
然后可以将这个字符串表示转换为实际的十六进制值,也就是我们的内存地址。BOF 使用 _strtoi64 API 非常简单地完成了这一点:

当然,对于 PIC NtFunction 来说,事情不可能这么简单。
这段简短的汇编代码展示了一个 PIC NtFunction 的结尾。注意 JMP RAX 指令,这是 PIC NtFunction 在调用蹦床(意味着这是 MemFiles 没有伪造/干预的调用):

出于未知的原因,当我尝试使用 _strtoi64(或其同类 API,如 stroull 或 atoll)时,这条 JMP RAX 指令变成了 CALL RAX 指令。我确信这背后有一个涉及“计算机如何工作”某些深层次原理的正当理由,但这个看似微不足道的变化破坏了不少东西。在花了 15 个多小时试图找到一种方法,将 MemFiles 结构体地址的字符串表示转换为实际的十六进制值,以便利用其中存储的值之后,我遇到了这个 StackOverflow 帖子,其中一位评论者提供了一个专为微控制器设计的自定义例程,用于将字符串转换为 uint32;幸运的是,它无需修改也能用于 uint64,最重要的是,它保留了 PIC NtFunction 后面的 JMP RAX 指令,而不会将其改为 CALL。最终的片段:

为了简单起见,之前没有提到,但 NtAPI 的格式这些年已经发生了变化。早期版本(例如 Windows 7 使用的版本)比现代版本短得多,只有 16 字节,而不是 32 字节:

这要求 MemFiles 对被 Hook 的 NtAPI 的 Hook 方式以及蹦床的构建方式都要做出改变。为了让 MemFiles 识别它正在处理的 NtAPI 版本,InstallHooks BOF 首先解析相关 NtAPI 的地址,然后从该位置读取 32 个字节。不同的格式导致 syscall 指令在不同版本的 NtAPI 中位于不同的位置;通过检查某个字节偏移处是否存在 syscall,MemFiles 能够判断它正在处理的是现代还是传统 NtAPI 实现,并采取相应的行动:

Hook 之后,传统的 NtCreateFile API 看起来像这样:

以及用于设置寄存器然后跳回 NtCreateFile 的蹦床:

总体而言,该技术与之前非常相似,但空间更加紧张,几乎没有回旋余地。值得注意的是,为了让它全部适配并正常工作,syscall 指令不得不被移动到 NtCreateFile 内部;它仍然位于 NTDLL 中 NtCreateFile API 的内存空间内,但已经从该 API 的第 9 和第 10 个字节移动到了第 14 和第 15 个字节,多字节 NOP 被牺牲掉以将所有内容合适地塞进去。
某些与文件 I/O 操作相关的 API 比较直观,因此很容易识别和 Hook;其他的则难以琢磨,需要花费大量时间在 WinDbg 和 x64dbg 中单步调试汇编,试图识别正在调用哪些 API。我不得不相信有更高效的方法来完成这项任务,但我是在实践中不断学习的。As a quick extra, I thought I would detail the, what in hindsight should have been very quick, process to identify the NtAPI that was preventing SharpHound from working for 20 hours. Being a .NET program, SharpHound spit out a very ugly call stack all relating to the fact that it had determined that "The handle is invalid". While .NET is a programmer-friendly language, most(all?) functionality gets translated and ultimately passes through the Win32 API (and as a result the NtAPI) where we can observe and hook it just like anything else.

The "Invalid Handle" error was a dead giveaway that there was an NtAPI used by SharpHound that I wasn't hooking (as opposed to one of my existing PIC NtFunctions not working properly), so I set out to try and figure out what it was. The technique I had used with other tools up to this point was to first set a breakpoint on NtCreateFile and locate the call that concerned my "special" directory. From there I would step through the program (usually several times because I would get lost), and see what functions the program called next. Following this methodology is how I discovered that NtQueryVolumeInformationFile, NtQueryInformationFile, and NtSetInformationFile were called and needed to be hooked.
SharpHound throws some extra curveballs in that it performs many of it's tasks asynchronously. This makes it much more difficult to trace the linear steps that a single file takes through API calls because there are several files going through the process simultaneously. In addition, I found by stepping through the program after the NtCreateFile call that eventually the thread in which the NtCreateFile call was made terminates; the eventual NtWriteFile calls (and whatever other unknown API calls that are the subject of this search) happen in a different thread, which further confounds the search process.
Having only briefly dealt with .NET I wasn't well versed in parsing call stacks produced by errors, doubly so for those that are made twice as ugly by the use of async. Over the course of the 20 hour problem, in the absence of progress using my earlier strategy, I kept returning to it and slowly but surely made more sense of it. About the third "chunk" from the top as separated by the "--- End of stack trace..." lines, a line can be seen reading "at Sharphound.Writers.JsonDataWriter...". This gave me a relative place to start in the actual SharpHound code, which is open source and available on Github. As the name suggested, the SharpHound function dealt with writing JSON output out to file; I was already aware that my data wasn't writing out to file successfully so this wasn't news. Tracing up the call stack one level, the next relevant line was "at System.IO.Streamwriter.". The System.IO prefix told me this was a .NET inherent, as opposed to a SharpHound specific function. Looking at the very top section of the call stack, the line that jumped out at me was "at System.IO.FileStream.FlushOSBuffer()". I decided to google FlushOSBuffer and see what I could find.
Doing so led me to Microsoft's .NET documentation for filestream.cs. There I found the definition for FlushOSBuffer:

It appears to call a Win32 API, FlushFileBuffers. The definition for Win32Native.FlushFileBuffers can be found by looking at the Win32Native documentation:

Anyone who has worked in .NET with P/Invoke will recognize the format. I now had a Win32 API that I knew System.IO.Filestream.FlushOSBuffer(), my problem .NET function, calls. Setting a breakpoint on KERNEL32!FlushFileBuffers and running SharpHound confirmed this, and by stepping through I quickly saw that under the hood FlushFileBuffers calls NtFlushBuffersFile. Hooking this API alleviated the issues SharpHound was having and enabled it to run successfully, writing it's output files to memory.
A critical part of this project is the ability to actually download the files to the CobaltStrike Teamserver once they are in memory. Predictably the normal CobaltStrike download command won't work with a filepath that doesn't really exist. Knowing what I know now, a solution probably lies in fleshing out the replacement PIC NtReadFile code to facilitate the in-memory files being able to be read instead of just written. Not having that knowledge beforehand, being able to actually retrieve the files was a major blocker.
By accident I came accross a BOF written by EspressoCake that contained a function that jumped out at me:

Looking through the code it appears to use an undocumented Beacon CALLBACK option:

The function enables a BOF to initiate the download of a file to the Teamserver from the target, as opposed to from the Client. This capability (which I later learned was a combined effort of several other people to include @Cr0Eax, @EthicalChaos, and @anthemtotheego) removed the major blocker that previously existed, as I now had a way to initiate a file transfer from the target system for the in-memory file. Big thanks to all involved for this code snippet which I forsee being useful in the future as well.
A challenging part of this project was ensuring the availability of MemFiles functionality to all CobaltStrike Clients connected to the Team Server. MemFiles's data is stored in structures created by MemFiles.cna, which must be loaded into each Client that wishes to use the tool; as a result, these data structures live within each Client, not on the Team Server. If this data did live in a single central location (TS) it would be trivial to retrieve it from each Client and this whole thing would be a non-issue; were the CobaltStrike Team to formally integrate a capability like MemFiles into CobaltStrike I am positive this is the direction they would go. But being that this is a community add-on, we make do with what we have.
There are a couple different scenarios we have to worry about when it comes to ensuring that each CobaltStrike Client has the latest, accurate data concerning MemFiles status within Beacons and configuration:
New Clients connecting to the TS and needing the current memtable
Instances where only a single Client is connected to the TS and restarts CobaltStrike (thus losing the memtable stored in the Client memory)
Client A making a change to MemFiles data which must be communicated to Client B
A multi-pronged approach was taken to address these scenarios. To handle the case where only a single CobaltStrike Client is connected to the TS (and thus is the only entity that has the memtable data), each time the Client alters the memtable (meminit, memclean) it also writes the contents of it's memtable out to a local text file located in the CobaltStrike directory. Should the Client exit/restart, or when MemFiles.cna is reloaded, it will first attempt to read from the local memtable.txt file in order to populate it's in-memory memtable.
When multiple Clients are connected to a TS and a new Client joins (as per the Event Log), each Client fetches a list of all users connected to the TS and sorts it alphabetically. The Client that is first on that list is selected as the "Broadcast" Client, and after waiting 5 seconds (to allow the new Client to initialize and read it's local memtable.txt) will send messages (Actions) in the Event Log for each entry in it's memtable. All Clients (aside from the Broadcasting one) will read these messages and update their memtable with the broadcast information; this includes updating existing entries as well as adding any additional ones that their respective memtable does not contain.
Normal operations involving MemFiles also rely on sending messages in the Event Log. When Client A runs meminit, a message is broadcast containing all of the pertinent memtable information; ALL Clients update their respective memtable by parsing these broadcasted Event Log messages using the "on Event_Action" hook. Changes are also made to MemFiles data when meminit finishes executing it's BOF; these changes are communicated back by Beacon (e.g. after running meminit, Beacon calls back with the memory location of the pMemAddrs struct) and as such are visible to all connected Clients, which update their respective memtable using the "on Beacon_Output" hook.
These separate efforts combined result in MemFiles being able to efficiently and reliably synchronize critical data between multiple Clients.
This project would not be possible without the contributions of the following individuals and projects: