首页
社区
课程
招聘
[翻译]伪造调用堆栈以混淆EDR
2023-9-3 22:02 9032

[翻译]伪造调用堆栈以混淆EDR

2023-9-3 22:02
9032

原文标题:Spoofing Call Stacks To Confuse EDRs
原文地址:https://labs.withsecure.com/publications/spoofing-call-stacks-to-confuse-edrs

目录

调用堆栈是EDR产品中被低估但常常重要的遥测来源。它们可以为事件提供重要的上下文,并在确定误报和真实事件(特别是与凭据窃取事件相关的事件,如对lsass的句柄访问)时成为一种非常强大的工具。

攻击者通常会通过注入的代码驻留在内存中。当进行API调用时,这些未支持或浮动的内存将出现在调用堆栈中,并显得非常异常。

已经有一些公开的研究关于伪造调用堆栈(尤其是https://github.com/mgeeky/ThreadStackSpoofer和https://github.com/Cracked5pider/Ekko ),然而这些似乎主要集中在为了避开AV/EDR检测而隐藏睡眠线程的调用堆栈(例如Cobalt Strike的睡眠遮罩)。

这与主动欺骗EDR(或ETW提供程序)记录来自内核驱动程序的虚假调用堆栈以达到特定的技术战术目的形成了对比,例如为了准备转储凭据而打开lsass的句柄。本文将演示一种PoC技术,实现能够通过调用NtOpenProcess来伪造具有任意调用堆栈的情况(即真实的调用堆栈伪造器)。

技术介绍

Windows内核提供了许多回调函数供AV/EDR驱动程序订阅,以便接收有关系统事件的通知。例如,这包括进程创建/删除事件(PsSetCreateProcessNotifyRoutineEx)、线程创建/删除事件(PsSetCreateThreadNotifyRoutine)和对象访问(ObRegisterCallbacks)等。

其中许多回调在触发操作的线程上下文中运行。因此,当内核驱动程序的进程通知例程被调用时,它在触发回调的进程上下文中运行(例如通过调用CreateProcess),并解释该用户进程上下文中的用户模式虚拟地址。此外,回调将内联运行;操作系统在回调返回之前等待,才能完成目标操作,例如创建进程或新线程。

下面是从通过内核调试在windbg中获得的虚构内核调用堆栈的示例。其中显示了在自定义的ObRegisterCallback例程上设置的断点(在本例中是一个处理进程句柄操作),该例程通过Outflank的dumpert工具触发了调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1: kd> k
00 ffff9387`368011f0 fffff806`2e0a78cc exampleAVDriver!ObjectCallback+0x50
01 ffff9387`36801b70 fffff806`2e0a7a3a nt!ObpCallPreOperationCallbacks+0x10c
02 ffff9387`36801bf0 fffff806`2e015e13 nt!ObpPreInterceptHandleCreate+0xaa
03 ffff9387`36801c60 fffff806`2e086ca9 nt!ObpCreateHandle+0xce3
04 ffff9387`36801e70 fffff806`2e09a60f nt!ObOpenObjectByPointer+0x1b9
05 ffff9387`368020f0 fffff806`2e0f27b3 nt!PsOpenProcess+0x3af
06 ffff9387`36802480 fffff806`2de272b5 nt!NtOpenProcess+0x23
07 ffff9387`368024c0 00007ff7`ef821d42 nt!KiSystemServiceCopyEnd+0x25
08 0000000f`f4aff1e8 00007ff7`ef8219b2 Outflank_Dumpert+0x1d42
09 0000000f`f4aff1f0 00007ff7`ef821fb0 Outflank_Dumpert+0x19b2
0a 0000000f`f4aff890 00007ffd`6c317034 Outflank_Dumpert+0x1fb0
0b 0000000f`f4aff8d0 00007ffd`6d862651 KERNEL32!BaseThreadInitThunk+0x14
0c 0000000f`f4aff900 00000000`00000000 ntdll!RtlUserThreadStart+0x21

通过这个回调,AV/EDR驱动程序可以检查对象访问请求并采取直接行动,例如根据需要从请求的句柄中去除权限位。同样,从进程或线程回调的角度来看,AV/EDR可以检查新的进程/线程并采取预防性措施,例如基于某种检测逻辑/启发式来阻止其执行(线程是否指向可疑内存等)。

此外,为了支持调用堆栈收集的有用性论点,上面的示例清楚地展示了直接系统调用的使用,因为在nt!KiSystemServiceCopyEnd之前,在调用堆栈中没有列出ntdll。

需要注意的是,ObjectCallback实际上不能保证在触发操作的线程上下文中运行;它在所谓的任意线程上下文中运行(因此当前上下文可能不是触发回调的实际进程)。然而,大多数情况下,您可以可靠地假设是这种情况。

从上面的示例中可以清楚地看到,AV/EDR可以在内核回调中执行的一个操作是遍历调用堆栈。事实上,这正是SysMon在处理进程访问事件(事件ID 10:进程访问)时所做的。

在下面的屏幕截图中,我们可以看到SysMon生成的一个进程访问事件,显示svchost获取了一个对lsass的句柄:
图片描述
图1:一个示例的SysMon进程访问事件,其中lsass是目标映像。

我们可以看到事件包含一个"CallTrace"字段;这显示了用户模式的调用堆栈,并且基本上揭示了导致句柄请求的进程内事件链(尽管没有完全的符号解析)。这个特定的事件是在安装SysMon几分钟后生成的,并在此后以固定的频率发生。由于调用堆栈不包含任何异常的内存区域,应该清楚地表明这是一个明显的误报。

如果我们将SysMon驱动程序(SysmonDrv.sys)加载到IDA中,我们可以确定SysMon是如何收集调用堆栈的。要查找的关键函数是RtlWalkFrameChain,并从那里进行交叉引用。SysMonDrv为进程句柄操作注册了一个回调(下面的ObjectHandleCallback),在每次调用时将调用RtlWalkFrameChain来收集用户模式的调用堆栈(通过StackWalkWrapper函数):
图片描述
图2:IDA生成的SysMonDrv对象回调的反编译代码。

请注意,SysMon在调用RtlWalkFrameChain时使用了标志位1('mov r8d, 1'),表示它只想收集用户模式的调用堆栈。
RtlWalkFrameChain是由ntoskrnl导出的函数,其工作原理(在非常高的层面上)如下:

  • 调用RtlCaptureContext来捕获当前线程的ContextRecord / CONTEXT结构。
  • 调用RtlpxVirtualUnwind,它将使用CONTEXT结构开始对堆栈进行展开(基于CONTEXT结构中记录的当前执行状态,如Rip/Rsp等)。

RtlVirtualUnwind的实现示例可以在此处找到:https://github.com/hzqst/unicorn_pe/blob/master/unicorn_pe/except.cpp#L773https://doxygen.reactos.org/d8/d2f/unwind_8c.html#a03c91b6c437066272ebc2c2fff051a4c

此外,ETW也可以配置以收集调用堆栈(参见:https://github.com/microsoft/krabsetw/pull/191 )。对于许多提供程序来说,这对于确定异常活动非常有用(例如,应用于Microsoft TI Feed或寻找未备份的wininet调用)。需要注意的是,ETW以与上述典型的内联内核回调方法稍有不同的方式收集调用堆栈。它首先将APC排队到目标线程,然后调用RtlWalkFrameChain。这可能是因为某些ETW提供程序在任意线程上下文中执行。

快速查看RtlVirtualUnwind的实现会揭示出(相当复杂的)解析X64展开码的过程。因此,为了理解通过RtlVirtualUnwind如何遍历调用堆栈,首先需要对X64上的代码生成/执行方式有一定的了解。全面概述超出了本博客文章的范围,但这篇优秀的CodeMachine博文包含了理解本博客文章中使用的技术所需的全部内容:https://codemachine.com/articles/x64_deep_dive.html

简单回顾一下,在CPU中,没有函数的概念,而是一种更高级的语言抽象。在x86中,函数通过使用帧指针寄存器(Ebp)在CPU级别实现,帧指针可以用作访问局部变量和通过堆栈传递的参数的引用。通过跟随这些Ebp指针(或函数帧)的链,可以找到上一个堆栈帧,从而遍历x86堆栈。

在X64中,情况更加复杂,因为不再使用Rbp作为帧指针,因此上述方法将无法工作。需要理解的关键区别是,X64可执行文件包含一个名为“.pdata”的新部分。该部分实质上是一个数据库,包含可执行文件中的每个函数以及如何在异常发生时“展开”给定函数的指令(称为UNWIND_CODE)。在这里,“展开”实际上是指反转函数序言中对堆栈进行的任何修改操作(例如为局部变量分配空间,将任何非易失性寄存器推送到堆栈等)。在X64上,一旦函数完成了序言(因此堆栈修改),它在回到序言前不会修改堆栈指针,因此Rsp在整个函数体中保持不变。

一些典型的UNWIND_CODE包括:

  • ALLOC_SMALL/LARGE(为局部参数分配小/大内存,例如sub rsp, 80h)
  • PUSH_NONVOL(将非易失性寄存器推送到堆栈,例如push rdi)

在WinDbg中,'.fnent'命令将解析指定函数的这些信息并显示其展开信息,下面是对kernelbase!OpenProcess的演示:

1
2
3
4
5
6
7
8
9
10
11
0:000> .fnent kernelbase!OpenProcess
Debugger function entry 000001e2`92241720 for:
(00007ff8`7a3bc0f0) KERNELBASE!OpenProcess | (00007ff8`7a3bc170) KERNELBASE!SetWaitableTimer
Exact matches:
BeginAddress = 00000000`0002c0f0
EndAddress = 00000000`0002c160
UnwindInfoAddress = 00000000`00266838
 
Unwind info at 00007ff8`7a5f6838, 6 bytes
version 1, flags 0, prolog 7, codes 1
00: offs 7, unwind op 2, op info c UWOP_ALLOC_SMALL.

这表明OpenProcess只有一个展开码,即它在堆栈上分配了一小块内存区域。'UWOP_ALLOC_SMALL'的总大小通过将操作信息值乘以8并加上8来计算(0xc * 8 + 8 = 0x68)。这可以通过反汇编kernelbase!OpenProcess的前几个字节来确认(sub rsp, 68h):

1
2
3
4
0:000> uf kernelbase!OpenProcess
KERNELBASE!OpenProcess:
00007ff8`7a3bc0f0 4c8bdc mov r11,rsp
00007ff8`7a3bc0f3 4883ec68 sub rsp,68h

可以在此处找到一个已记录的所有可用UNWIND_CODES(以及如何解析它们)的列表:https://docs.microsoft.com/en-us/cpp/build/exception-handling-x64?view=msvc-170

为了遍历堆栈,Windbg将通过累加以下内容来计算每个找到的函数的总堆栈大小:

  • 任何局部变量
  • 任何基于堆栈的参数
  • 返回地址(8字节)
  • 调整空间
  • 非易失性寄存器占用的堆栈空间

以调用OpenProcess为例:

1
2
3
4
5
6
7
8
9
10
11
0:000> knf
# Memory Child-SP RetAddr Call Site
00 000000df`7d8fef88 00007ffd`b1bdc13e ntdll!NtOpenProcess
01 8 000000df`7d8fef90 00007ff7`f10c087d KERNELBASE!OpenProcess+0x4e
02 70 000000df`7d8ff000 00007ff7`f10c24b9 VulcanRaven!main+0x5d [C:\Users\wb\source\repos\VulcanRaven\VulcanRaven\VulcanRaven.cpp @ 641]
03 9e0 000000df`7d8ff9e0 00007ff7`f10c239e VulcanRaven!invoke_main+0x39 [d:\a01\_work\43\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 79]
04 50 000000df`7d8ffa30 00007ff7`f10c225e VulcanRaven!__scrt_common_main_seh+0x12e [d:\a01\_work\43\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
05 70 000000df`7d8ffaa0 00007ff7`f10c254e VulcanRaven!__scrt_common_main+0xe [d:\a01\_work\43\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 331]
06 30 000000df`7d8ffad0 00007ffd`b2237034 VulcanRaven!mainCRTStartup+0xe [d:\a01\_work\43\s\src\vctools\crt\vcstartup\src\startup\exe_main.cpp @ 17]
07 30 000000df`7d8ffb00 00007ffd`b3e82651 KERNEL32!BaseThreadInitThunk+0x14
08 30 000000df`7d8ffb30 00000000`00000000 ntdll!RtlUserThreadStart+0x21

顶部的条目,ntdll!NtOpenProcess(#00),是当前的堆栈帧。000000df`7d8fef88的Child-SP值是NtOpenProcess完成其函数序言后的Rsp值(即,在NtOpenProcess执行任何需要进行的堆栈修改后的堆栈指针的值)。在当前帧下方的行中,“Memory”列中的值8是NtOpenProcess使用的总堆栈大小。因此,为了计算下一帧的Child-SP,我们可以将当前帧的总堆栈大小(8)加上当前的Child-SP:

1
2
0:000> ? 000000df`7d8fef88 + 8
Evaluate expression: 959884291984 = 000000df`7d8fef90

请注意,NtOpenProcess没有解旋操作码(它不修改堆栈),因此下一个Child-SP可以通过跳过前一个调用者(KERNELBASE!OpenProcess)推送的返回地址来找到。这就是为什么其总堆栈大小列为8字节(例如,仅为返回地址)。

这个新的Child-SP(000000df`7d8fef90)是KERNELBASE!OpenProcess完成其函数序言后的Rsp值。当KERNELBASE!OpenProcess调用ntdll!NtOpenProcess时,它会将其返回地址压入堆栈;因此,这个返回地址将是Child-SP指向的位置之后堆栈上的下一个值,如图3中Child-SP 01所示。

对于下一个帧,这个过程可以再次重复。Kernelbase!OpenProcess的Child-SP为000000df`7d8fef90,其总堆栈利用率为0x70字节。再次将它们相加,我们就可以得到VulcanRaven!main的下一个Child-SP:

1
2
0:000> ? 000000df`7d8fef90 + 70
Evaluate expression: 959884292096 = 000000df`7d8ff000

这个过程会一直重复,直到调试器完全遍历了堆栈。因此,从高层次上看,堆栈遍历的过程如下:
图片描述
图3:显示X64堆栈遍历过程的图表

与这篇博文相关的关键信息是,通过知道一个函数的总堆栈大小,可以(无需符号)跟踪这个子堆栈指针链并遍历调用堆栈。当涉及欺骗调用堆栈时,这个过程将反向模拟。

在讨论了调用堆栈遥测的有用性并对x64上调用堆栈展开的工作原理进行了简要概述后,我们现在可以转向这篇博文要回答的问题:是否可能欺骗调用堆栈,以便在行内进行此收集(例如在内核驱动程序回调例程内部)时记录一个虚假的调用堆栈?

方法

这篇博文中的PoC采用了以下方法:

  1. 确定要欺骗的目标调用堆栈。为此,使用了SysMon,并选择了一个事件类型为10的任意条目,该条目打开了一个句柄到lsass,例如以下示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CallTrace:
C:\Windows\SYSTEM32\ntdll.dll + 9d204 (ntdll!NtOpenProcess)
C:\Windows\System32\KERNELBASE.dll + 32ea6 (KERNELBASE!OpenProcess)
C:\Windows\System32\lsm.dll + e959
C:\Windows\System32\RPCRT4.dll + 79633
C:\Windows\System32\RPCRT4.dll + 13711
C:\Windows\System32\RPCRT4.dll + dd77b
C:\Windows\System32\RPCRT4.dll + 5d2ac
C:\Windows\System32\RPCRT4.dll + 5a408
C:\Windows\System32\RPCRT4.dll + 3a266
C:\Windows\System32\RPCRT4.dll + 39bb8
C:\Windows\System32\RPCRT4.dll + 48a0f
C:\Windows\System32\RPCRT4.dll + 47e18
C:\Windows\System32\RPCRT4.dll + 47401
C:\Windows\System32\RPCRT4.dll + 46e6e
C:\Windows\System32\RPCRT4.dll + 4b542
C:\Windows\SYSTEM32\ntdll.dll + 20330
C:\Windows\SYSTEM32\ntdll.dll + 52f26
C:\Windows\System32\KERNEL32.DLL + 17034
C:\Windows\SYSTEM32\ntdll.dll + 52651
  1. 对于上述目标调用堆栈中的每个返回地址,解析其展开代码并计算所需的总堆栈空间,以便我们可以定位下一个子级SP帧。

  2. 创建一个挂起的线程并修改CONTEXT结构,使得堆栈/栈指针的轮廓与要欺骗的目标调用堆栈完全匹配(而不包含任何实际数据)。因此,我们通过推送虚假的返回地址并减去正确的子级SP偏移量(例如,反向堆栈展开)来初始化线程的状态,以“适应”另一个线程的配置文件。在处理某些展开代码(UWOP_SET_FPREG)时需要注意,因为这会重置rsp == rbp。

  3. 修改CONTEXT结构,使Rip指向我们的目标函数(在本例中为ntdll!NtOpenProcess),并设置x64调用约定所需的适当参数(例如,通过设置Rcx/Rdx/R8/R9)。

  4. 恢复线程并通过向量异常处理程序处理不可避免的错误(因为它返回到了一个虚假的调用堆栈)。从这个异常处理程序中,我们可以重新将线程重定向到RtlExitUserThread(通过重新设置Rip),让它正常退出。

对于第5点,这种方法显然有限;更好的方法是使用VEH异常处理,类似于这种无补丁AMSI绕过的方式:https://gist.github.com/CCob/fe3b63d80890fafeca982f76c8a3efdf

采用这种方法,我们可以在NtOpenProcess系统调用返回后的ret指令(如下所示的00007ff8`7ca6d204)处设置断点:

1
2
3
4
5
6
7
8
9
0:000> uf ntdll!NtOpenProcess
ntdll!NtOpenProcess:
00007ff8`7ca6d1f0 4c8bd1 mov r10,rcx
00007ff8`7ca6d1f3 b826000000 mov eax,26h
00007ff8`7ca6d1f8 f604250803fe7f01 test byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ff8`7ca6d200 7503 jne ntdll!NtOpenProcess+0x15 (00007ff8`7ca6d205) Branch
ntdll!NtOpenProcess+0x12:
00007ff8`7ca6d202 0f05 syscall
00007ff8`7ca6d204 c3 ret

一旦生成断点异常(并且在线程返回并崩溃之前),我们可以按照之前讨论的方式处理错误。此外,恢复虚拟线程的状态并能够重复使用它将是一个改进,这样就不需要反复创建“牺牲线程”。

此外,这种方法还可以潜在地应用于睡眠混淆问题;可以初始化一个具有合法调用堆栈的虚拟线程来调用ntdll!NtDelayExecution(或WaitForSingleObject等),并使用VEH异常来在睡眠时间结束后将流重定向到主要的beacon函数。

Poc || GTFO

PoC代码可以在这里找到:https://github.com/countercept/CallStackSpoofer

这个PoC附带了三个示例调用堆栈(wmi/rpc/svchost)可以模拟,这些调用堆栈是通过观察对lsass的进程句柄访问的SysMon日志随机获取的。可以通过使用'--wmi'、'--rpc'和'--svchost'标志来选择这些调用堆栈配置,如下所示:
图片描述
图4:显示VulcanRaven获取lsass的句柄并伪造调用堆栈以模拟RPC活动的屏幕截图。

上面的屏幕截图展示了一个假的调用堆栈被SysMon记录下来(与预期的OpenProcess的使用相对应,这将有一个调用堆栈:VulcanRaven.exe -> kernelbase!OpenProcess -> ntdll!NtOpenProcess)。只是强调一下,这个PoC中的示例是为了模拟通过SysMon发现的事件,但调用堆栈不必有意义,可以是任何内容,如下所示:
图片描述
图5:WinDbg的屏幕截图显示在调用NtOpenProcess时伪造的完全无意义的调用堆栈

这种技术对攻击者具有吸引力的最明显的例子是,大多数远程访问木马(如beacon等)仍然倾向于在浮动/无后备内存上运行。因此,当攻击者直接将mimikatz注入内存时,从这段注入代码进行的后续句柄访问的调用堆栈将看起来非常异常。

以下是一个示例,显示了使用无后备内存调用OpenProcess的SysMon事件:
图片描述
图6:一个SysMon事件显示对lsass的句柄访问,源自未备份的内存

这是通过使用修改过的Stephen Fewer的ReflectiveDLLInjection(https://github.com/stephenfewer/ReflectiveDLLInjection )代码库生成的。

在这个示例中,一个反射DLL已被注入到cmd.exe中,并随后以PROCESS_ALL_ACCESS权限获得了对lsass的句柄。由于调用源自未备份的内存,SysMon将记录调用堆栈的最后一个条目为"UNKNOWN"(例如,堆栈回溯的最后一个返回地址属于浮动/未备份的代码,而不是合法加载的模块),因此显然是可疑的。

然而,如果我们将上面的VulcanRaven PoC修改为作为反射DLL运行,我们会生成以下事件:
图片描述
图7:显示Vulcan Raven修改为作为反射DLL运行的屏幕截图。即使它是从未备份的内存中运行,对lsass的句柄访问的调用堆栈仍然被伪装成看起来合法

调用堆栈("CallTrace")被伪装成从SysMon中获取的任意值,正如预期的那样。从这个调用堆栈中无法判断NtOpenProcess/OpenProcess的调用实际上是从未备份的内存中运行的代码发起的,表面上看这个线程是真实的(尽管cmd.exe是虚构的,显然可疑)。还要注意Figure 1中的GrantedAccess与本例中的不同,本例中的GrantedAccess是PROCESS_ALL_ACCESS/0x1FFFFF。

然而,很明显,攻击者可以对其选择的调用堆栈进行配置,使其与他们选择的注入进程(如wmi、procexp、svchost等)相融合。这些进程经常获取lsass的句柄。


[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

最后于 2023-9-4 08:14 被Max_hhg编辑 ,原因:
收藏
点赞2
打赏
分享
最新回复 (1)
雪    币: 19349
活跃值: (28971)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2023-9-3 23:08
2
1
感谢分享
游客
登录 | 注册 方可回帖
返回