本文系翻译自Applying Test-Driven Development to Detection Engineering[1],文中的“我”均指代原作者。在翻译过程中,为了更好地帮助国内安全从业人员理解,我们对部分内容进行了适度的增删处理,力求保持原意的同时,提升阅读的流畅性和理解度。
检测工程,可以概括为确保在定义的条件发生时执行某个操作的过程,与传统软件工程,特别是测试驱动开发(TDD)有着显著的相似性。两者都依赖于以最终逻辑为导向引导开发工作,定义一组在函数完成(或检测)时预期的状态,考虑可能导致失败状态的不同条件,并定期测试代码以验证我们的实现是否符合期望的最终状态。
这篇博客文章旨在展示如何将测试驱动开发(TDD)工作流程应用于检测工程,使我们能够确切地知道我们的检测措施能否保护我们免受目标威胁。
什么是测试驱动开发(TDD)
在测试驱动开发(TDD)中占主导地位的测试设计模式是 Arrange-Act-Assert(AAA)(另一种是 Given-When-Then),其步骤定义为:
安排 — 通过变量声明、对象实例化和初始化设置特定状态 行动 — 通过触发某些状态变化来激活待测试的逻辑 断言 — 验证在给定更改的情况下是否满足了对逻辑的预期
在传统的软件开发中,我们经常在应用内部测试功能。在下面的假设示例中,我们有一个函数,用于找到列表中最小和最大数字之间的差异。我们的单元测试创建了一个包含四个数字的集合,调用我们的函数来找到最大差异,并确保结果值与我们的预期相符。
pub fn find_max_difference(list: &[i32]) -> Option<i32> { if list.len() < 2 { return None; } let mut max_diff = 0; for i in 0..list.len() { for j in i+1..list.len() { let diff = list[j] - list[i]; if diff > max_diff { max_diff = diff; } } } Some(max_diff) } #[cfg(test)] mod tests { use super::*; #[test] fn test_find_max_difference_sorted_asc() { // Arrange let list = vec![1, 2, 3, 10]; // Act let result = find_max_difference(&list); // Assert assert_eq!(result, Some(9)); // Largest between 1 and 10 } }
你能发现我们的功能中有什么缺陷吗?如果我们引入以下单元测试会发生什么?
#[test] fn test_find_max_difference_sorted_desc() { // Arrange let list = vec![10, 9, 5, 3, 1]; // Act let result = find_max_difference(&list); // Assert assert_eq!(result, Some(9)); // Largest between 10 and 1 }
我们对不同情况下逻辑如何工作的假设在设计我们的功能时是错误的。只有当一个较大的数字出现在一个较小的数字之后,我们的功能才能正常工作。它无法处理严格递减的列表,因为一对数字之间的差始终是负数,小于我们起始值 0。
在检测工程中,通常在以下情况下会诱发类似的故障状态:
我们做出必要的让步以使检测(例如,排除)实现工业化。 我们的控件本身以某种方式失效(例如,没有收集适当的遥测数据) 我们对技术手法的理解不足以涵盖我们检测目标的所有变体。
在所有情况下,采用测试驱动开发(TDD)方法可以帮助我们识别差距并理解可能触发我们检测逻辑失败的各个状态。
将 TDD 应用于检测工程
现在我们已经看到了测试驱动开发(TDD)的力量,让我们将其整合到我们的检测工程工作流程中。第一步是在检测威胁的背景下定义 AAA 模式:
安排 将我们的检测逻辑部署到相关控件上,在相关系统上安装测试运行程序 执行 一个测试/工具/恶意软件样本,该样本引入我们希望检测的攻击向量 断言 评估我们的控件以确定其是否执行了期望的行为(收集遥测数据、产生检测或积极阻止已知不良行为)
让我们逐个查看这些。
安排
TDD 工作流程的第一步是准备我们的系统进行测试。在检测工程的语境中,这意味着:
安装某种测试运行程序 确保我们的检测逻辑在我们的控件中实现
接下来,您必须确保他们有一种在主机上引入测试输入。在许多情况下,团队选择使用现成的 C2 框架,如 Cobalt Strike、Mythic 或 Sliver 来执行测试。虽然根据我自己的经验,这些都是可行的选择,但它们都有严重的局限性。
这些工具需要自己设置和维护基础设施(C2 服务器、重定向器/监听站、用于将代理部署到主机的有效载荷),两用工具的感知风险(蓝队/紫队是否会使用红队的核心工具包导致检测重点在于框架细节而非通过框架无关地执行的技术?),以及在理想情况下是在生产系统上部署恶意软件所固有的摩擦。
我们发现,后者,实际上是对许多团队来说最难解决的问题。这是因为我们需要在一个具有代表性的系统样本集上进行测试,以真正评估我们的测试覆盖率,这可能比我们团队完全控制的少数终端要多。这导致团队在实验室中部署测试运行程序,这些实验室只是他们生产环境的粗略近似,并不真正代表他们的攻击面,而只是一组有些相似的操作系统构建。
量化一个具有代表性的样本集是未来一篇博客的主题,但可以这么说,这不仅仅是少数样本的问题,而且对于许多组织而言,在这些样本上部署代理(或进行监测/管理)是一个真正的难题。
Prelude 通过基础设施管理和提供“真正的软件”解决方案来帮助解决上述问题,以及其他之前提到的挑战。例如,在 Detect 中,没有需要明确管理的 C2 服务器、重定向器或域名——我们通过我们的平台处理所有这些。用户无需担心为了在端点上部署测试运行器而消耗操作负载,因为Prelude 的探针是一个简单的、单功能的、有签名的应用程序,满足我们的需求,并且可以在反病毒/XDR 中被允许列表,而不会引入新的风险。最后,部署我们的探针非常容易,因为它可以通过现有的安全控制(如 Crowdstrike Falcon)部署,或者通过 MSI/RPM/DEB/PKG 使用你选择的软件部署工具,甚至可以作为 Windows、macOS 或 Linux 上的临时进程运行。
在执行测试之前,我们要完成的最后一项任务是确保我们的检测规则/查询/策略得到应用。这些可以是像 CrowdStrike IOAs 这样的东西,这将涉及到确保 IOAs 已启用并分配给已应用于我们识别主机的策略。它们也可以是 SIEM 查询,这需要添加到计划搜索中。这里的目的是确保我们不会因为没有应用我们的工作而引起故障。如果我们确实在我们的逻辑中检测到故障,我们也可能需要快速迭代,因此了解我们检测的位置、内容和状态将非常重要。
执行
工作流程的下一步是执行我们的测试,以刺激防御控制并评估其性能是否符合我们的预期。这可能涉及在终端上执行本地工具、进攻性安全工具或特定恶意软件等操作。要测试什么主要取决于你团队的检测理念。
关于应该测试和防御的内容存在多种相互竞争的意识形态。这些方法各有侧重,涵盖了从基于威胁情报驱动测试到攻击路径分析,再到技术穷尽性测试等多种策略。每种方法都有其独特的优势和适用场景,组织应根据自身的安全需求、资源和技术能力来选择最适合的方法。
无论您选择哪种测试方法,最终都会有一系列具体的测试用例,您期望这些测试用例能够被防御控制观察、检测或阻止。根据您构建的检测类型不同,目标可能是检测工具的调用(精确检测)或检测多个实例化技术中共享的基础行为(鲁棒检测)。这两种检测方式各有特点和适用场景。
假设我想要确保自己免受“创建或修改系统进程:Windows 服务”(T1543.003)的威胁。我的测试程序将包括以下内容:
sc.exe(创建和配置) services.msc SharpSC PsExec smbexec.py (Impacket) 直接注册表修改
执行这些具体的测试程序也是一个复杂的话题。我们可以利用许多不同的执行方式来向终端引入恶意测试程序,范围从简单地运行一个命令到复杂的shellcode注入技术。例如,使用comsvcs minidump 技术在 LSASS 中提取凭证的安全测试可能如下所示:
//go:build windows // +build windows package main import ( Endpoint "github.com/preludeorg/libraries/go/tests/endpoint") func test() { Rundll32LsassDump() } func Rundll32LsassDump() { Endpoint.Say("Attempting to dump credentials") powershellCmd := "gps lsass | % { & rundll32 C:\windows\System32\comsvcs.dll, MiniDump $_.Id prelude.dmp full }" _, err := Endpoint.Shell([]string{"powershell.exe", "-c", powershellCmd}) if err != nil { Endpoint.Say("Credentials dump attempt failed") Endpoint.Stop(126) return } Endpoint.Say("Credentials dumped successfully") Endpoint.Stop(101) } func main() { Endpoint.Start(test) }
评估一个“良好”的测试外观在很大程度上取决于你的测试理念。无论你是想简单地引入期望的攻击向量到系统中,还是想全面评估攻击向量可以以多少种方式被引入系统,这一步的目标都是相同的——让“引爆”发生,以便你的防御控制可以采取行动。
断言
此工作流程的最后阶段是我们对防御控制的实际评估;我们期望发生的事情是否发生了?当测试攻击向量出现时,我们可以预期防御控制会表现出以下三类行为:
观察 控件生成并收集与攻击向量相关的遥测数据,以便后续进行威胁狩猎和检测规则的开发。 检测 控件生成了一个警报,明确识别测试刺激为恶意行为,并理想情况下将其归类为特定的技术或威胁。 阻止 控件在识别到恶意条件后,主动阻止了测试的执行,并理想情况下生成了一个警报,通知人类操作员已经采取了自动化的行动。
这三种防御行为构成了一个三层金字塔。检测和预防都基于这样一个事实:控件能够看到恶意攻击向量,因此“观察”构成了这个金字塔的底部基础。接下来,检测基于通过观察产生的遥测数据说:“这种行为模式具有恶意的迹象,我需要通知相关人员。”
最后, 阻止位于金字塔的顶端,代表了预期中数量最少的行动。 阻止基于这样的概念:我们已经识别出了一些被确认为恶意的遥测数据,而且这些数据要么足够严重,要么我们对其性质有足够的信心,以至于我们愿意采取自主行动来阻止其来源。可以假设,如果我们有数据做出检测决策,我们也应该能够阻止所讨论的行为。虽然完全有可能,但我们必须记住合法软件有时会进行一些表面看起来像是恶意的行为,比如浏览器在更新时会打开与LSASS(本地安全认证子系统服务)的句柄……
这些误报正是我们无法将每次检测转化为阻止的原因。由于各种原因,我们没有足够的确定性来推广它们。
为了评估防御控制对攻击向量的响应,我们通常会手动查询该控制本身。如果我们期望该控制已经观察到测试,我们会查询事件源以获取与测试相关的遥测数据。如果我们期望它被检测到,我们会检查警报源以查看是否产生了响应我们测试的警报。如果我们期望它阻止了测试,我们可以再次检查警报源,寻找阻止类型的警报,并检查测试本身以确定在执行过程中是否有早期或意外的错误。
我们已经在检测中自动化了这个过程,我们称之为 ODP(代表观察、检测或阻止),它允许您将 EDR 与我们的平台集成,定义测试的预期结果,执行测试,然后自动评估你的控制机制的响应。这大大加快了了解您的控制机制在暴露于已知恶意攻击向量时的表现的能力,并通过调整检测、策略或其他控制手段来迭代改进您的安全态势。
一个例子
在这个时候,给出一个例子来展示基于测试驱动开发(TDD)的方法可能是有帮助的。虽然不完整,但这个例子将展示最重要的部分。在这个虚构的例子中,我们有一个函数detect_service_creation()
,它模拟了在您的 SIEM、EDR或其他控制中进行的上游检测,而我们希望对其进行测试。
我们的两个测试用例首先检查检测和测试运行程序(探针、代理等)是否存在。然后它们将执行核心测试攻击向量。每个测试根据其使用的工具的性质以不同的方式执行测试攻击向量。最后,测试用例将验证测试是否完成运行,并且满足了“已检测”的预期结果。后者不仅检查是否发生了一些检测,而且还将其与特定的测试用例相关联。
/// Function that we will testpub fn detect_service_creation(event: &Event) -> DetectionResult { iflet Event::RegistryValueSet(registry_value_set) = event { if registry_value_set .key .starts_with("SYSTEM\CurrentControlSet\Services\") && registry_value_set.value == "ImagePath" { return DetectionResult::ServiceCreation(registry_value_set.key.clone()); } } DetectionResult::Negative } #[cfg(test)] mod tests { use super::*; /// The tests themselves #[test] fn test_detect_service_creation_sc() { // Arrange assert!(control.is_detection_live("ServiceCreation")); assert!(test_runner.is_present()); // Act let (result, timestamp) = test_runner.shell("sc.exe create Prelude binpath= C:\Windows\System32\Prelude.exe"); // Assert assert!(result.is_ok()); let detections = control.get_detections("ServiceCreation"); assert_eq!(detections.count(), 1); assert_eq!( detections.latest().key, "SYSTEM\CurrentControlSet\Services\Prelude" ); // make sure the the timestamp is within 5 milliseconds assert!(detections.latest().timestamp = timestamp); } #[test] fn test_detect_service_creation_sharpsc() { // Arrange assert!(control.is_detection_live("ServiceCreation")); assert!(test_runner.is_present()); // Act let (result, timestamp) = test_runner.exeute_assembly( "SharpSC.exe action=create service=Prelude binpath=C:\Windows\System32\Prelude.exe", ); // Assert assert!(result.is_ok()); let detections = control.get_detections("ServiceCreation"); assert_eq!(detections.count(), 1); assert_eq!( detections.latest().key, "SYSTEM\CurrentControlSet\Services\Prelude" ); // make sure the the timestamp is within 5 milliseconds assert!(detections.latest().timestamp = timestamp); } }
当测试失败时
这个过程的自然部分是看到你的期望从一开始就未能满足。请记住,我们之所以进行测试,正是为了应对这种情况。我们不能总是信任供应商关于他们工具性能的说法;我们必须亲自验证。当控制无法满足我们的期望时,应该采取什么行动,这主要取决于预期的结果是什么。
如果我们的期望是控制能够阻止威胁,那么解决方法可能很简单,比如调整控制内的策略、升级触发检测后的动作(即使这意味着触发外部的某些操作,如SOAR剧本),或者通过实施像Crowdstrike IOA这样的设置为“阻止”的规则来填补漏洞。
在检测失败的情况下,这是检测工程师介入的时候。假设已经收集了足够的遥测数据,我们的任务就是找到提取相关信息的方法,并将其与其他数据点相关联,生成一个分析,如果匹配成功,将在控件或集中式系统(例如您的 SIEM)中生成警报。这种检测的性质因团队的策略而异。例如,如果他们需要快速产生大量检测结果,他们可能会选择创建精确的检测,针对测试攻击向量的非常具体的属性。如果他们有一些缓冲时间,他们可能会利用控制系统中可用的最佳遥测资源来创建健壮的检测,以便广泛检测测试攻击向量的核心部分,而不是只针对每次实例创建检测。
例如,在没有观察到测试结果的情况下,事情会变得更加复杂。可能存在终端或控件本身存在问题,阻止数据到达收集器服务的情况,但在许多情况下,我们发现的是,我们期望的遥测数据根本没有被捕获。在这些情况下,我们唯一真正的选择是从补充控制(例如 Sysmon)获取这些遥测数据,或者礼貌地要求供应商在他们的传感器/代理中实现它。这可能发生也可能不发生,且速度是否符合需求(或者根本无法实现),这就是为什么这种失败状态让检测工程师感到如此沮丧。我们无法改善我们的状况,只能将其标记为盲点,同时我们试图使用次优数据提供某种覆盖。
结语
根据您对给定测试的预期结果,从这里开始的流程是执行、评估、迭代,然后再进行,直到您对覆盖范围感到满意,然后继续下一个目标。使用预期结果来对抗一组充分代表您所需覆盖范围的攻击向量,以提高您防御控制的有效性,这种过程感觉就像一种超能力。每次练习结束后,您都可以确信您的控制措施能够保护您免受与您的业务最相关的威胁。
附
传统开发模式与TDD开发模式对比
传统开发模式流程: 项目代码开发 -> 编写测试用例 –> 运行测试用例 -> 修复代码BUG
2. TDD开发模式流程 编写测试用例 -> 运行测试用例 –> 编写项目代码 -> 运行测试用例 -> 重构代码
Applying Test-Driven Development to Detection Engineering:https://www.preludesecurity.com/blog/test-driven-development-detection-engineering
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...