John Uhlmann

Get- InjectedThreadEx – 检测线程创建 跳板

在本博客中,我们将演示如何检测四种类型的进程跳板,并发布更新后的 PowerShell 检测脚本 – Get-InjectedThreadEx

19 分钟阅读安全研究
Get-InjectedThreadEx – Detecting Thread Creation Trampolines

内存驻留恶意软件的普遍性仍然非常高。防御者已经对基于文件的技术施加了巨大的成本,恶意软件通常必须利用内存技术来避免检测。在 Elastic 最近发布的全球威胁报告中,规避防御是我们观察到的最多样化的策略,代表了一个快速、持续创新的领域。

对于内存驻留恶意软件来说,在其代理进程中创建自己的线程是方便的,有时也是必要的。通过识别那些启动地址没有磁盘上的可移植可执行文件 (PE) 映像支持的线程,可以相对低噪声地检测到许多此类线程。这种检测技术最初由 Elastic 的 Gabriel Landau 和 Nicholas Fritts 为 Elastic Endgame 产品构思。此后不久,它以 PowerShell 脚本的形式发布,以社区的利益为出发点,形式为 Get-InjectedThread,并由 Jared Atkinson 和 Elastic 的 Joe Desimone2017 年 SANS 威胁搜寻和 IR 峰会上发布。

在较高层面上,这种方法会检测到在未支持的可执行内存中使用用户启动地址创建的线程。未支持的可执行内存本身在许多进程中很正常,例如那些对 .NET 或 javascript 等字节码或脚本进行即时 (JIT) 编译的进程。但是,JIT 代码很少管理自己的线程——通常由运行时或引擎处理。

然而,攻击者通常有足够的控制权来创建一个具有映像支持启动地址的线程,该线程随后会将执行转移到其未支持的内存中。当这种转移立即完成时,它被称为“跳板”,因为你会很快被抛到其他地方。

跳板有四大类——你可以从头开始构建自己的跳板,你可以使用虚幻的跳板,你可以将其他东西重新用作跳板,或者你可以简单地找到一个现有的跳板。

换句话说 - 挂钩、劫持、小工具和函数。

这些都将绕过我们最初的未支持可执行内存启发式方法。

我强烈推荐这两篇优秀的博客作为背景知识

在本博客中,我们将演示如何检测每种此类绕过方法,并发布更新后的 PowerShell 检测脚本 – Get-InjectedThreadEx

CreateThread() 概述

简单回顾一下,Win32 CreateThread() API 允许你指定指向所需 StartAddress 的指针,该指针将用作函数的入口点,该函数恰好接受一个用户提供的参数。

因此,CreateThread() 实际上是一个简单的 shellcode 运行器。

而它的兄弟 CreateRemoteThread() 实际上是远程进程注入。

lpStartAddress 参数的值由内核存储在该线程的 ETHREAD 结构中的 Win32StartAddress 字段中。

可以使用记录在案的 NtQueryInformationThread() 系统调用和 ThreadQuerySetWin32StartAddress 信息类从用户模式查询此值。随后调用 VirtualQueryEx() 可用于发出第二个系统调用,从内核请求该虚拟地址的基本内存信息。这包括一个枚举,指示内存是映射的 PE 映像、映射的文件还是只是私有内存。

虽然原始脚本是时间点追溯检测实现,但在 创建线程通知内核回调期间,相同的可用信息是内联的。所有有效的端点检测和响应 (EDR) 产品都应提供可疑线程创建的遥测数据。

所有有效的端点保护平台 (EPP) 产品都应默认拒绝可疑的线程创建,并提供一种机制来为展示此行为的合法软件添加允许列表条目。

在野外,你会看到这种行为的“合法”实例,例如来自其他安全产品、反作弊软件、旧的复制保护软件和一些已被调整为在 Windows 上工作的 Unix 产品。虽然在每个实例中,这种安全代码异味可能表明你可能不想在企业环境中使用的软件。这些方法的使用可能是一个领先指标,表明其他安全最佳实践尚未得到遵循。即使使用这组有限的例外情况来处理,这种检测和/或预防方法在今天仍然具有高度相关性和成功性。

1 - 自带跳板

最简单的跳板是一个小挂钩。攻击者只需要将必要的跳转指令写入现有的映像支持的内存中即可。这就是 Filip Olszak 用于通过 DripLoader 绕过 Get-InjectedThread 的方法。

这些字节甚至可以在线程创建后立即恢复到其原始值。这有助于避免追溯检测,例如我们的脚本 – 但请记住,你的端点安全产品应该执行内联检测,并且能够在执行时仔细检查挂钩的线程入口点,并在必要时拒绝执行。

上面的概念验证挂钩 ntdll!DbgUiRemoteBreakin,这是一个合法的远程线程启动地址,尽管在生产环境中很少见到。实际上,该挂钩可以放置在正常操作中不太可能调用的任何函数字节上,甚至可以放置在函数之间的空白区域或 PE 部分的末尾。

另请注意,使用了 WriteProcessMemory() 而不是简单的 memcpy()。MEM_IMAGE 页面通常是只读的,前者为我们处理将页面保护切换为可写,然后再切换回去的过程。

我们可以很容易地检测到挂钩的启动地址,因为我们可以很容易地检测到持久的内联挂钩。为了节省内存,共享库的分配使用相同的后备物理内存页,并在每个进程的地址空间中标记为 COPY_ON_WRITE。因此,一旦插入挂钩,整个页面就无法再共享。相反,会在进程的工作集中创建一个副本。

使用 QueryWorkingSetEx() API,我们可以查询内核以确定包含启动地址的页面是否可共享或是否位于私有工作集中。

现在我们知道页面上的某些内容已修改,但我们不知道我们的地址是否已挂钩。并且,对于我们更新的 PowerShell 脚本,这就是我们所做的一切。请记住,字节可以在线程启动后取消挂钩 - 因此对已运行线程的任何进一步检查都可能导致误报。

但是,如果存在“合法”挂钩或其他修改,这也可能是一个误报。

特别是,许多安全产品仍然会钩住 ntdll.dll。这在 2007 年 Vista 发布时是一种完全合法的技术方法:它允许将基于内核系统调用钩子的现有 x86 功能快速移植到使用用户模式系统调用钩子的新兴 x64 架构。自 2015 年 Windows 10 发布以来,这种方法的有效性受到了更多的质疑。大约在这个时候,x64 被确立为主要的 Windows 架构,我们可以坚定地将安全性较低的 x86 Windows 降级为遗留状态。在 2017 年 Windows 10 创意者更新添加了额外的内核模式检测功能,为某些被滥用的系统调用的恶意使用提供了更强大的检测方法之后,用户模式钩子的价值主张进一步降低。

作为参考,我们最初的 Elastic Endgame 产品具有使用用户模式钩子实现的功能,而我们较新的 Elastic Endpoint 尚未确定需要使用用户模式钩子来获得与 Endgame 相当甚至更好的保护。这意味着 Elastic Endgame 必须防御这些钩子免受篡改,而 Elastic Endpoint 目前不受执行 ntdll.dll 解钩的各种所谓的“通用 EDR 绕过”的影响。

除了较旧的安全产品之外,还有许多产品通过钩子扩展其他产品的功能,或者可能在运行时解包其代码等。因此,如果该 4KB 页面是私有的,那么安全产品还需要将起始地址字节与原始的原始副本进行比较,并在它们不同时发出警报。

并且,为了大规模部署,它们还需要维护一个允许列表,以用于那些罕见的合法用途。

2 - 移动跳板

从技术上讲,安全产品只能在线程通知回调时看到字节,这略早于线程执行。恶意软件可以创建一个挂起的线程,让线程回调执行,然后再钩住起始字节,最后恢复线程。不过,不用担心 - 有效的安全产品也可以检测到这种内联行为。但这是另一个话题了。

这使我们想到了第二种跳板方法:在入口点被调用之前劫持执行流。当我们可以通过使用 SetThreadContext() 直接修改其指令指针(或等效的上下文操作),或者通过排队一个“早鸟”异步过程调用 (APC) 来篡夺执行时,为什么要明显地钩住我们挂起线程的线程入口点?

像这样创建合法入口点的幻觉的问题在于,它经不起任何严格的检查。

在普通线程中,用户模式起始地址通常是线程堆栈中的第三个函数调用 - 在 ntdll!RtlUserThreadStart 和 kernel32!BaseThreadInitThunk 之后。因此,当线程被劫持时,这在调用堆栈中将是显而易见的。

对于指令指针操作,第一个帧将属于注入的代码。

对于“早鸟”APC 注入,调用堆栈的底部将是 ntdll!LdrInitializeThunk、ntdll!NtTestAlert、ntdll!KiUserApcDispatcher,然后是注入的代码。

更新后的脚本会检测各种异常的调用堆栈基址。

如果合法软件发现有必要修改 Windows 进程或线程初始化,则可能会出现误报。例如,在 MSYS2 Linux 环境中观察到了这种情况。还有一种边缘情况,即函数可能已使用尾调用优化 (TCO) 生成,这可以消除不必要的堆栈帧以提高性能。但是,这些情况都可以通过一个小型的例外列表轻松处理。

3 - 如果它走起来像跳板,说起来也像跳板...

第三种跳板方法是在映像支持的内存中找到合适的 gadget,这样就不需要修改代码。这是 Adam Chester 在他的博客中采用的方法之一。

我们之前的钩子是 12 个字节,并且在实践中不太可能找到精确的 12 字节 gadget。

但是,在 x64 Windows 上,函数默认使用四寄存器快速调用约定。因此,当操作系统调用我们的 gadget 时,我们将控制 RCX 寄存器,该寄存器将包含我们传递给 CreateThread() 的参数。

最简单的 x64 gadget 是两个字节的 JMP RCX 指令 “ff e1” - 这很容易找到。

Gadget 甚至不需要本身就是指令 - 它们可能在代码段中的操作数或其他数据中。例如,ntdll.dll 中的上述 “ff e1” gadget 是 GUID 的相对地址的一部分。

我们也可以检测到这一点 - 因为它目前还不能通用地工作。

在所有现代 Windows 软件中,线程起始地址都受到控制流保护 (CFG) 的保护,该保护具有在编译时计算的有效间接调用目标的位图。为了使用此 gadget,恶意软件必须首先禁用 CFG,或调用 SetProcessValidCallTargets() 函数,以请求内核动态设置 CFG 位图中与此 gadget 对应的位。

需要明确的是:这不是 CFG 绕过。这是 CFG 功能,用于支持合法软件执行奇怪的操作。请记住,CFG 是一种漏洞利用保护 - 并且能够调用 SetProcessCallTargets() 以调用 CreateThread() 对于漏洞利用开发者来说是一个先有鸡还是先有蛋的问题。

与之前一样,为了节省内存,DLL 的 CFG 位图页面也在进程之间共享。这次我们可以检测起始地址的 CFG 位图条目是在可共享页面上还是在私有工作集中 - 如果是私有的,则发出警报。

控制流保护在其他地方进行了详细描述,但这里对 CFG 的高层概述有助于理解我们的检测方法。CFG 位图中的每两位对应 16 个地址。两位给了我们四种状态。具体来说,在 Microsoft 的一个非常巧妙的优化中,两种状态仅对应于 16 字节对齐的地址(允许和导出抑制),而两种状态对应于所有 16 个地址(允许和拒绝)。

现代 CPU 以 16 字节的行提取指令,因此现代编译器通常将绝大多数函数入口点对齐到 16 字节。绝大多数 CFG 条目仅将单个地址设置为有效的间接调用目标,并且很少有条目会将整个 16 个地址的块指定为有效的调用目标。这意味着 CFG 位图的大小可以缩小到八分之一,而不会因过于宽松的位图而明显增加有效 gadget 的风险。

但是,如果每两位对应 16 个地址,则 4K 私有 CFG 位页面对应 256KB 的代码。这真是个误报的潜在可能!

因此,我们只能希望合法代码永远不会这样做……没关系。你永远不应该希望合法代码不会做晦涩的事情。到目前为止,我们已经确定了三种当代情况

  • 旧版 Edge 浏览器会通过取消设置某些可滥用函数的 CFG 位来强化其 javascript 主机进程
  • user32.dll 似乎对旧版软件过于友善 - 如果它们注册为回调函数,则将取消抑制导出地址
  • 某些安全产品也会在离合法模块太近的地方放置一页钩子跳板,并且私有可执行内存始终具有私有位图条目(实际上,它们通常会将其放置在模块的首选加载地址 - 这会阻止操作系统共享该模块的内存)

因此,我们需要通过与预期的 CFG 位图值进行比较来排除误报。我们可以从磁盘上的 PE 文件中读取此值,但 x64 位图已作为共享 CFG 位图的一部分映射到我们的进程中。

我们发布的 PowerShell 脚本实现会针对两种情况发出警报:修改后的 CFG 页面和具有非原始 CFG 值的起始地址。

在给定时间点,可能存在非常少量的 CFG 兼容 gadget,但仅在非常特定的 DLL 中,这些 DLL 在代理进程中很可能看起来异常。

4 - 它实际上已经是一个跳板

第三种绕过类别是找到一个执行我们想要的操作的现有函数,并且有很多这样的函数。例如,Christopher Paschen 强调的一个是 Microsoft 的 C 运行时 (CRT)。此 C 标准库的实现充当位于 Win32 之上的 API 层,并且它包括线程创建 API。

这些 API 通过将内部 CRT 线程入口点传递给 CreateThread(),并将用户入口点传递给随后作为 CreateThread() 参数指向的结构的一部分调用,在线程创建/销毁时执行一些额外的 CRT 簿记操作。

因此,在这种情况下,观察到的 Win32StartAddress 将是非导出的 msvcrt!_startthread(ex)。Shellcode 地址将在线程创建期间与线程参数有一个特定的偏移量(Microsoft CRT 源代码可用),并且 shellcode 将是 CRT 之后的调用堆栈中的下一帧。

注意:如果没有其他技巧,这只能用于创建进程内线程,并且没有等效的 CreateRemoteThread()。但是,这些技巧确实存在,你不应该期望此模块作为远程线程中的起始地址。

不幸的是,没有操作系统簿记会告诉你事后是否远程创建了线程。因此,我们无法使用脚本扫描它 - 但安全产品使用的内联回调可以区分这一点。

目前,该脚本只是自下而上遍历堆栈,并通过查看候选返回地址来推断前几个帧。可以通过反汇编或使用展开信息来改进此代码,但在 PowerShell 中实现这些信息的回报较少。当前方法对于演示目的来说足够可靠

更新后的脚本除了检测此研究中描述的四类绕过方法外,还会检测原始的可疑线程。

查找可疑的线程创建

除了检测四类已知的线程起始地址跳板之外,更新后的脚本还包括一些额外的启发式方法。其中一些具有中等误报率,并且隐藏在 -Aggressive 标志后面。但是,它们在查找场景中可能仍然有用。

![prolog byte regex](/assets/images/get-injectedthreadex-detection-thread-creation-trampolines/image14.png

第一个查看线程用户入口点的起始字节。函数序言具有结构 - 除非它们没有结构。据我们所知,PowerShell 中没有反编译器 - 因此我们改为使用字节模式正则表达式进行近似。识别不遵循约定的代码很有用,但很容易存在于我们尚未测试过的编译器中。

有趣的是,我们不得不考虑对应于 DOS 可执行文件的“MZ”魔术字节,这是一个据称有效的线程入口点。对于 .NET 等公共语言运行时 (CLR) 可执行文件,Windows 加载器忽略 PE 标头中 AddressOfEntry 字段的值。

相反,执行总是从 CLR 运行时中的 MsCorEE!_CorExeMain() 开始,它从 CLR 元数据中确定实际的进程入口点。这是合理的,因为 CLR 程序集可能只包含字节码,需要运行时 JIT 编译后才能调用。但是,此字段的值仍然会传递给 CreateThread(),而且通常为零 - 这会导致意外的 MZ 入口点字节。

第二个启发式方法检查用户入口点紧前面的字节。这通常是一个返回、一个跳转或一个填充字节。常见的填充字节是零、nop 和 int 3。但是,这只是一种约定。

特别是,较旧的编译器经常将数据与代码并排放置 - 据推测是为了通过数据局部性来实现性能。例如,我们之前分析了 Microsoft 符号服务器上的 x64 二进制文件,并注意到这种代码和数据的混合在 Visual Studio 2012 中很常见,在 VS2013 中得到了很大程度的修复,并且似乎在 VS2015 Update 2 中最终得到了解决。

第三个启发式方法是另一种编译器约定。如前所述,编译器喜欢输出最大化指令缓存性能的函数,这些函数通常使用 16 字节的提取。但是,编译器似乎也喜欢节省空间 - 因此它们通常只确保第一个基本块适合最小数量的 16 字节行,而不是严格的 16 字节对齐。换句话说,如果一个基本块是 20 字节,那么它总是需要至少两次提取,但我们希望确保它不需要三次。

许多常见的 Win32 模块根本没有有效的线程入口点 - 因此请检查这些。

此列表绝对不详尽。

Kernel32.dll 是一个特例。LoadLibrary 在技术上不是一个有效的线程入口点 - 但 CreateRemoteThread(kernel32!LoadLibraryA, “signed.dll”) 实际上是大多数安全产品在必要时首选将代码注入到正在运行的进程中的方式。也就是说,注入的代码是已签名的,并加载到只读的映像支持内存中。据我们所知,我们认为这种方法最初是由 Jeffrey Richter 在 1994 年 5 月的 Microsoft System Journal 上的一篇文章中提出的,后来被收录在他的 Advanced Windows 一书中。因此,将 LoadLibrary 视为可疑的 - 但不一定是有恶意的。

ntdll.dll 在所有地方都加载,因此通常是 gadget 或 hook 的首选。我们知道只有四个有效的 ntdll 入口点,并且脚本会显式检查这些入口点。

其中两个函数没有导出,并且脚本没有使用 P/Invoke 下载公共符号并查找 PDB 中的偏移量,而是动态查询其自身线程的起始地址以查找它们的起始地址。PowerShell 已经使用了工作线程,并且该脚本启动了一个私有的 ETW 日志记录会话,以强制生成一个具有最终地址的线程。

侧加载的 DLL 仍然是一种非常流行的技术 - 而且仍然主要未签名。

这不是一个线程启动启发式方法 - 但它太简单了,不容忽视。合法的线程可能会短暂地模拟 SYSTEM,但是(懒惰的)恶意软件作者(或操作员)倾向于在初始时提升权限并无限期地保持这些权限。

总结

正如上次标记的那样,安全领域没有什么是万能的。你不应该期望仅从可疑的线程创建中获得 100% 的检测。

例如,攻击者可以修改他们的工具,使其根本不创建任何新线程,而是将其执行限制为仅劫持的线程。这种区别可能很微妙,但 Get-InjectedThreadEx 仅尝试检测异常的线程创建地址 - 而不是更广泛的合法线程随后被劫持的情况。这就是为什么,除了在线程创建时增加成本之外,Elastic Security 还采用了其他防御层,包括内存签名行为检测防御逃避检测

虽然在创建后劫持单个线程相对容易(确保你的恶意软件的所有线程,包括任何第三方有效负载,都使用已安装的安全产品的正确版本的正确检测绕过),但这对于攻击者来说是一种维护成本,而且会犯错误。

让我们继续提高标准。我们很乐意听到有关线程创建绕过 - 以及可扩展的检测方法的信息。我们团结在一起更强大。