John Uhlmann

调用堆栈:恶意软件不再逍遥法外

我们探讨了调用堆栈为恶意软件检测带来的巨大价值,以及为什么 Elastic 尽管存在架构限制,仍将其视为重要的 Windows 端点遥测。

阅读时间:19 分钟安全研究
调用堆栈:恶意软件不再逍遥法外

调用堆栈提供谁

Elastic 的 Windows 端点遥测关键差异化因素之一是调用堆栈

大多数检测依赖于正在发生的事情——但这往往是不够的,因为大多数行为都是双重目的。通过调用堆栈,我们添加了细粒度的能力来确定在执行活动。这种结合使我们拥有了无与伦比的发现恶意活动的能力。通过将这些深度遥测数据提供给Elastic Defend的主机规则引擎,我们可以快速应对新出现的威胁。

调用堆栈是一个美丽的谎言

在计算机科学中,堆栈是一种后进先出的数据结构。类似于一堆实物,只能添加或删除顶部元素。调用堆栈是包含有关当前活动子例程调用的信息的堆栈。

在 x64 主机上,只能使用 CPU 上的执行跟踪功能(例如Intel LBR 、Intel BTS、Intel AET、 Intel IPTx64 Architectural LBR )才能准确生成此调用堆栈。这些跟踪功能是为性能分析和调试目的而设计的,但也可以在某些安全场景中使用。然而,更普遍可用的是通过称为 堆栈遍历 的机制从线程的数据堆栈中恢复的 近似 调用堆栈。

x64 架构中,“堆栈指针寄存器”( rsp )不出所料地指向一个堆栈数据结构,并且有高效的指令来读取和写入该堆栈上的数据。此外, call指令将控制权转移到新的子程序,但还将返回地址保存在堆栈指针引用的内存地址处。ret指令稍后将检索此保存的地址,以便执行可以返回到中断的地方。大多数编程语言中的函数通常使用这两条指令来实现,并且函数参数和局部函数变量通常都会分配在这个堆栈上以提高性能。与单个函数相关的堆栈部分称为堆栈框架。

堆栈遍历只是从存储在线程堆栈上的异构数据中恢复返回地址。返回地址需要存储在某个地方以用于控制流 - 因此堆栈遍历会选择这些现有数据来近似调用堆栈。这完全适合大多数调试和性能分析场景,但对于安全审计的帮助稍小。主要问题是您不能反向拆卸。您始终可以确定给定调用站点的返回地址,但反之则不行。您可以采取的最佳方法是检查每个 15 可能的前置指令长度,并查看哪些指令可以反汇编为恰好一条调用指令。即使如此,您所恢复的也只是一个调用站点 — — 不一定是确切的前一个调用站点。这是因为大多数编译器使用尾调用优化来省略不必要的堆栈帧。这会给安全性带来令人烦恼的情况,例如,即使调用了 Win32StartAddress 函数,也无法保证该函数位于堆栈上。

所以我们通常所说的调用堆栈实际上就是返回地址堆栈。

恶意软件作者利用这种模糊性来撒谎。他们要么通过合法模块制作蹦床堆栈框架来隐藏来自恶意代码的调用,要么强制堆栈遍历预测与 CPU 将执行的返回地址不同的返回地址。当然,恶意软件始终只是一种谎言的尝试,而反恶意软件只是揭露谎言的过程。

“……但最终真相会大白。”

  • 威廉·莎士比亚,《威尼斯商人》,第二幕,第二场

让调用堆栈更美观

到目前为止,堆栈遍历只是一个数字内存地址的列表。为了使它们对分析有用,我们需要用上下文来丰富它们。(注意:我们目前不包括内核堆栈框架。)

最小有用的充实是将这些地址转换为模块内的偏移量(例如ntdll.dll+0x15c9c4 )。但这只能捕获最恶劣的恶意软件——我们可以更深入地研究。Windows 上最重要的模块是那些实现 Native 和 Win32 API 的模块。这些 API 的应用程序二进制接口要求每个函数的名称都包含在包含模块的导出目录中。这是 Elastic 当前用来丰富其端点调用堆栈的信息。

通过使用供应商基础设施(尤其是 Microsoft)上托管的公共符号(如果可用),可以实现更准确的丰富。虽然这种方法提供了更深的保真度,但它带来了更高的运营成本,并且对于我们的隔离客户来说是不可行的。

对于 Microsoft 内核和本机符号的经验法则是,每个组件的导出接口都有一个大写的前缀,例如 Ldr、Tp 或 Rtl。私有函数使用 p 扩展此前缀。默认情况下,具有外部链接的私有函数包含在公共符号表中。非常大的偏移量可能表示非常大的函数,但也可能仅表示没有符号的未命名函数。一般准则是将导出函数中的任何三位数和更大的偏移量视为可能属于另一个函数。

调用堆栈堆栈遍历Stack Walk 模块Stack Walk 导出(弹性方法)Stack Walk 公共符号
0x7ffb8eb9c9c2 0x12d383f0046 0x7ffb8eb1a9d8 0x7ffb8eb1aaf4 0x7ffb8ea535ff 0x7ffb8da5e8cf 0x7ffb8eaf14eb0x7ffb8eb9c9c4 0x7ffb8c3c71d6 0x7ffb8eb1a9ed 0x7ffb8eb1aaf9 0x7ffb8ea53604 0x7ffb8da5e8d4 0x7ffb8eaf14f1ntdll.dll+0x15c9c4 内核库.dll+0xc71d6ntdll.dll+0xda9edntdll.dll+0xdaaf9ntdll.dll+0x13604kernel32.dll+0x2e8d4ntdll.dll+0xb14f1ntdll.dll!NtProtectVirtualMemory+0x14 kernelbase.dll!VirtualProtect+0x36 ntdll.dll!RtlAddRefActivationContext+0x40d ntdll.dll!RtlAddRefActivationContext+0x519 ntdll.dll!RtlAcquireSRWLockExclusive+0x974 kernel32.dll!BaseThreadInitThunk+0x14 ntdll.dll!RtlUserThreadStart+0x21ntdll.dll!NtProtectVirtualMemory+0x14 kernelbase.dll!VirtualProtect+0x36 ntdll.dll!RtlTpTimerCallback+0x7d ntdll.dll!TppTimerpExecuteCallback+0xa9 ntdll.dll!TppWorkerThread+0x644 kernel32.dll!BaseThreadInitThunk+0x14 ntdll.dll!RtlUserThreadStart+0x21

调用堆栈丰富级别的比较

在上面的例子中,位于 0x12d383f0000 的 shellcode 故意使用了尾部调用,以便其地址不会出现在堆栈遍历中。即使只是跟踪行走,这种隐瞒事实的行为也是显而易见的。Elastic 使用proxy_call启发式方法报告此问题,因为恶意软件注册了一个计时器回调函数来代理从不同线程对VirtualProtect的调用。

让调用堆栈更强大

我们使用Windows 事件跟踪(ETW) 监控的系统调用的调用堆栈具有预期的结构。堆栈底部是线程 StartAddress - 通常是 ntdll.dll!RtlUserThreadStart。接下来是 Win32 API 线程入口 - kernel32.dll!BaseThreadInitThunk,然后是第一个用户模块。用户模块是不属于 Win32(或 Native)API 的应用程序代码。第一个用户模块应该与线程的 Win32StartAddress 匹配(除非该函数使用尾调用)。随后会有更多的用户模块跟进,直到最终的用户模块调用 Win32 API,进而调用 Native API,最终导致对内核的系统调用。

从检测的角度来看,此调用堆栈中最重要的模块是最终的用户模块。Elastic 显示该模块,包括其哈希值和任何代码签名。这些细节有助于警报分类,但更重要的是,它们大大提高了我们对有时表现得像恶意软件的合法软件的行为进行基准测试的粒度。我们越能准确地确定正常的基线,恶意软件就越难混入。

{
  "process.thread.Ext": {
    "call_stack_summary": "ntdll.dll|kernelbase.dll|file.dll|rundll32.exe|kernel32.dll|ntdll.dll",
    "call_stack": [
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!NtAllocateVirtualMemory+0x14" }, /* Native API */
      { "symbol_info": "c:\\windows\\system32\\kernelbase.dll!VirtualAllocExNuma+0x62" }, /* Win32 API */
      { "symbol_info": "c:\\windows\\system32\\kernelbase.dll!VirtualAllocEx+0x16" }, /* Win32 API */
      {
        "symbol_info": "c:\\users\\user\\desktop\\file.dll+0x160d8b", /* final user module */
        "callsite_trailing_bytes": "488bf0488d4d88e8197ee2ff488bc64883c4685b5e5f415c415d415e415f5dc390909090905541574156415541545756534883ec58488dac2490000000488b71",
        "callsite_leading_bytes": "088b4d38894c2420488bca48894db8498bd0488955b0458bc1448945c4448b4d3044894dc0488d4d88e8e77de2ff488b4db8488b55b0448b45c4448b4dc0ffd6"
      },
      { "symbol_info": "c:\\users\\user\\desktop\\file.dll+0x7b429" },
      { "symbol_info": "c:\\users\\user\\desktop\\file.dll+0x44a9" },
      { "symbol_info": "c:\\users\\user\\desktop\\file.dll+0x5f58" },
      { "symbol_info": "c:\\windows\\system32\\rundll32.exe+0x3bcf" },
      { "symbol_info": "c:\\windows\\system32\\rundll32.exe+0x6309" }, /* first user module - typically the ETHREAD.Win32StartAddress module */
      { "symbol_info": "c:\\windows\\system32\\kernel32.dll!BaseThreadInitThunk+0x14" }, /* Win32 API */
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!RtlUserThreadStart+0x21" /* Native API - the ETHREAD.StartAddress module */
      }
    ],
    "call_stack_final_user_module": {
      "path": "c:\\users\\user\\desktop\\file.dll",
      "code_signature": [ { "exists": false } ],
      "name": "file.dll",
      "hash": { "sha256": "0240cc89d4a76bafa9dcdccd831a263bf715af53e46cac0b0abca8116122d242" }
    }
  }
}

示例丰富的调用堆栈

调用堆栈最终用户模块丰富:

namecall_stack_final_user_module 的文件名。也可以是“Unbacked”,表示私有可执行内存,或“Undetermined”,表示可疑的调用堆栈。
路径call_stack_final_user_module 的文件路径。
哈希.sha256call_stack_final_user_module 或 protection_provenance 模块(如果有)的 sha256。
代码签名call_stack_final_user_module 或 protection_provenance 模块(如果有)的代码签名。
分配私有字节此内存区域中 +X 且不可共享的字节数。非零值可能表示代码挂钩、修补或挖空。
protection如果不是 RX,则包含页面活动区域的内存保护。对应于MEMORY_BASIC_INFORMATION.Protect。
protection_provenance导致此页面保护的最后修改的内存区域的名称。“Unbacked”可能表示shellcode。
保护来源路径导致此页面保护的最后修改的模块的路径。
原因异常的 call_stack_summary 导致“未确定”的 protection_provenance。

快速调用堆栈词汇表

检查调用堆栈时,熟悉一些 Native API 函数会很有帮助。现就职于微软的 Ken Johnson 为我们提供了NTDLL 内核模式到用户模式回调的目录,以帮助我们入门。说真的,你应该在这里停下来,先去读一下。

我们之前见过RtlUserThreadStart。它和它的兄弟 RtlUserFiberStart 都应该只出现在调用堆栈的底部。这些分别是用户线程和纤程的入口点。然而,每个线程上的第一条指令实际上是 LdrInitializeThunk。在执行线程初始化(和进程,如果需要)的用户模式组件之后,此函数通过 NtContinue 将控制权转移到入口点,从而直接更新指令指针。这意味着它不会出现在任何未来的堆栈审核中。

因此,如果您看到包含 LdrInitializeThunk 的调用堆栈,则意味着您正处于线程执行的开始阶段。这是应用程序兼容性Shim Engine运行的地方,基于钩子的安全产品倾向于自行安装,恶意软件试图在其他安全产品之前执行的地方。Marcus HutchinsGuido Miggelenbrink都就此主题撰写了精彩的博客。对于利用内核 ETW进行遥测的安全产品来说,这种创业竞赛并不存在。

{
  "process.thread.Ext": {
    "call_stack_summary": "ntdll.dll|file.exe|ntdll.dll",
    "call_stack": [
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!ZwProtectVirtualMemory+0x14" },
      { "symbol_info": "c:\\users\\user\\desktop\\file.exe+0x1bac8" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!RtlAnsiStringToUnicodeString+0x3cb" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!LdrInitShimEngineDynamic+0x394d" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!LdrInitializeThunk+0x1db" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!LdrInitializeThunk+0x63" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!LdrInitializeThunk+0xe" }
    ],
    "call_stack_final_user_module": {
      "path": "c:\\users\\user\\desktop\\file.exe",
      "code_signature": [ { "exists": false } ],
      "name": "file.exe",
      "hash": { "sha256": "a59a7b56f695845ce185ddc5210bcabce1fff909bac3842c2fb325c60db15df7" }
    }
  }
}

入口点前执行示例

下一对是 KiUserExceptionDispatcher 和 KiRaiseUserExceptionDispatcher。当用户模式异常情况发生后,内核使用前者将执行传递给已注册的用户模式结构化异常处理程序。后者也会引发异常,但是代表的是内核。第二种变体通常只能由调试器(包括应用程序验证器)捕获,并有助于识别用户模式代码何时没有充分检查系统调用的返回代码。这些函数通常会出现在与应用程序特定的崩溃处理或Windows 错误报告相关的调用堆栈中。然而,有时恶意软件会将其用作伪断点 - 例如,如果他们想要在进行系统调用后立即波动内存保护以重新隐藏其 shellcode。

{
  "process.thread.Ext": {
    "call_stack_summary": "ntdll.dll|file.exe|ntdll.dll|file.exe|kernel32.dll|ntdll.dll",
    "call_stack": [
      {
        "symbol_info": "c:\\windows\\system32\\ntdll.dll!ZwProtectVirtualMemory+0x14",
        "protection_provenance": "file.exe", /* another vendor's hooks were unhooked */
        "allocation_private_bytes": 8192
      },
      { "symbol_info": "c:\\users\\user\\desktop\\file.exe+0xd99c" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!RtlInitializeCriticalSectionAndSpinCount+0x1c6" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!RtlWalkFrameChain+0x1119" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!KiUserExceptionDispatcher+0x2e" },
      { "symbol_info": "c:\\users\\user\\desktop\\file.exe+0x12612" },
      { "symbol_info": "c:\\windows\\system32\\kernel32.dll!BaseThreadInitThunk+0x14" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!RtlUserThreadStart+0x21" }
    ],
    "call_stack_final_user_module": {
      "name": "file.exe",
      "path": "c:\\users\\user\\desktop\\file.exe",
      "code_signature": [ { "exists": false }],
      "hash":   { "sha256": "0e5a62c0bd9f4596501032700bb528646d6810b16d785498f23ef81c18683c74" }
    }
  }
}

通过异常处理程序示例保护波动

接下来是 KiUserApcDispatcher,用于传递用户 APC 。这是恶意软件作者最喜欢的工具之一,因为微软只提供了对其使用有限的可见性。

{
  "process.thread.Ext": {
    "call_stack_summary": "ntdll.dll|kernelbase.dll|ntdll.dll|kernelbase.dll|cronos.exe",
    "call_stack": [
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!NtProtectVirtualMemory+0x14" },
      { "symbol_info": "c:\\windows\\system32\\kernelbase.dll!VirtualProtect+0x36" }, /* tail call */
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!KiUserApcDispatcher+0x2e" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!ZwDelayExecution+0x14" },
      { "symbol_info": "c:\\windows\\system32\\kernelbase.dll!SleepEx+0x9e" },
      {
        "symbol_info": "c:\\users\\user\\desktop\\file.exe+0x107d",
        "allocation_private_bytes": 147456, /* stomped */
        "protection": "RW-", /* fluctuation */
        "protection_provenance": "Undetermined", /* proxied call */
        "callsite_leading_bytes": "010000004152524c8d520141524883ec284150415141baffffffff41525141ba010000004152524c8d520141524883ec284150b9ffffffffba0100000041ffe1",
        "callsite_trailing_bytes": "4883c428c3cccccccccccccccccccccccccccc894c240857b820190000e8a10c0000482be0488b052fd101004833c44889842410190000488d84243014000048"
      }
    ],
    "call_stack_final_user_module": {
      "name": "Undetermined",
      "reason": "ntdll.dll|kernelbase.dll|ntdll.dll|kernelbase.dll|file.exe"
    }
  }
}

通过 APC 示例进行保护波动

Windows 窗口管理器是在内核模式设备驱动程序(win32k.sys)中实现的。大多。有时窗口管理器需要从用户模式执行某些操作,而 KiUserCallbackDispatcher 就是实现该操作的机制。它基本上是一个针对 user32.dll 函数的反向系统调用。覆盖进程的KernelCallbackTable中的条目是劫持 GUI 线程的简单方法,因此跟随此调用的任何其他模块都是可疑的。

了解每个内核模式到用户模式入口点的用途,有助于确定给定的调用堆栈是否自然,或者是否被盗用以实现其他目标。

使调用堆栈易于理解

为了帮助理解,我们还使用我们识别的各种 process.Ext.api.behaviors 来标记事件。这些行为不一定是恶意的,但它们突出了与警报分类或威胁搜寻相关的方面。对于调用堆栈,这些包括:

native_api直接调用了 Native API,而不是 Win32 API。
direct_syscallA syscall instruction originated outside of the Native API layer.
代理调用调用堆栈可能指示代理 API 调用以掩盖真实来源。
Shellcode第二代可执行非图像内存称为敏感 API。
图像间接调用An entry in the call stack was preceded by a call to a dynamically resolved function.
图像_rop调用堆栈中的条目之前没有调用指令。
图像读取调用堆栈中的条目是可写的。代码应该是只读的。
unbacked_rwx调用堆栈中的条目是非图像且可写的。甚至 JIT 代码也应该是只读的。
截断堆栈The call stack seems to be unexpectedly truncated. This may be due to malicious tampering.

在某些情况下,仅这些行为就足以检测到恶意软件。

欺骗——绕过还是承担责任?

多年来,返回地址欺骗一直是一种主要的游戏黑客恶意软件技术。这个简单的技巧可以让注入的代码借用合法模块的声誉,而几乎不会产生任何后果。深度调用堆栈检查和行为基线的目标是阻止恶意软件获得这种免费通行证。

攻击研究人员一直在通过研究完整调用堆栈欺骗的方法来协助这项工作。最值得注意的是:

SilentMoonwalk不仅是极好的攻击性研究,还是一个绝佳的例子,说明撒谎如何让你陷入双倍的麻烦 — — 但只有当你被抓住的时候。许多防御规避技术依赖于隐蔽性安全——一旦被研究人员揭露,它们就会成为一种负担。在这种情况下,研究包括对逃避尝试所带来的检测机会的建议。

{
  "process.thread.Ext": {
    "call_stack_summary": "ntdll.dll|kernelbase.dll|kernel32.dll|ntdll.dll",
    "call_stack": [
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!NtAllocateVirtualMemory+0x14" },
      { "symbol_info": "c:\\windows\\system32\\kernelbase.dll!VirtualAlloc+0x48" },
      {
        "symbol_info": "c:\\windows\\system32\\kernelbase.dll!CreatePrivateObjectSecurity+0x31",
        /* 4883c438 stack desync gadget - add rsp 0x38 */
        "callsite_trailing_bytes": "4883c438c3cccccccccccccccccccc48895c241057498bd8448bd2488bf94885c90f84660609004885db0f845d060900418bd14585c97411418bc14803c383ea",
        "callsite_leading_bytes": "cccccccccccccccccccccccccccccc4883ec38488b4424684889442428488b442460488944242048ff15d9b21b000f1f44000085c00f8830300900b801000000"
      },
      { "symbol_info": "c:\\windows\\system32\\kernelbase.dll!Internal_EnumSystemLocales+0x406" },
      { "symbol_info": "c:\\windows\\system32\\kernelbase.dll!SystemTimeToTzSpecificLocalTimeEx+0x2d1" },
      { "symbol_info": "c:\\windows\\system32\\kernelbase.dll!WaitForMultipleObjectsEx+0x982" },
      { "symbol_info": "c:\\windows\\system32\\kernel32.dll!BaseThreadInitThunk+0x14" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!RtlUserThreadStart+0x21" }
    ],
    "call_stack_final_user_module": {
      "name": "Undetermined", /* gadget module resulted in suspicious call stack */
      "reason": "ntdll.dll|kernelbase.dll|kernel32.dll|ntdll.dll"
    }
  }
}

SilentMoonwalk 调用堆栈示例

发掘隐藏文物的标准技术是使用多种技术对其进行枚举,然后比较结果是否存在差异。这就是RootkitRevealer 的工作原理Get-InjectedThreadEx.exe中也使用了这种方法,它可以在线程堆栈中向上移动,也可以向下移动。

在某些情况下,我们可以通过两种方式恢复调用堆栈。如果存在差异,那么您将看到以 call_stack_summary_original 形式发出的可靠性较低的调用堆栈。

{
  "process.thread.Ext": {
    "call_stack_summary": "ntdll.dll",
    "call_stack_summary_original": "ntdll.dll|kernelbase.dll|version.dll|kernel32.dll|ntdll.dll",
    "call_stack": [
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!NtContinue+0x12" },
      { "symbol_info": "c:\\windows\\system32\\ntdll.dll!LdrInitializeThunk+0x13" }
    ],
    "call_stack_final_user_module": {
      "name": "Undetermined",
      "reason": "ntdll.dll"
    }
  }
}

调用堆栈摘要原始示例

调用堆栈适合所有人

默认情况下,您只会在我们的警报中找到调用堆栈,但这可以通过高级策略进行配置。

events.callstacks.emit_in_events如果设置,调用堆栈将包含在收集它们的常规事件中。否则,它们仅包含在触发行为保护规则的事件中。请注意,设置此项可能会显著增加数据量。默认值:false

如需进一步了解 Windows 调用堆栈,请参阅以下 Elastic 安全实验室文章: