初探堆栈欺骗之静态欺骗
本文首发先知社区:https://xz.aliyun.com/t/14487
首先介绍一下堆栈欺骗的场景,当我们用一个基本的 shellcode loader 加载 cs 的 shellcode,在没有对堆栈做任何事情时,我们的堆栈是不干净的,我们去看一下堆栈时会发现有很多没有被解析的地址在其中,这显然是不正常的,因此 av/edr 会重点扫描这部分内存区域,就可能会导致我们的 loader gg。
或者说当我们直接系统调用时,和正常程序也是有区别的,如下:
- 正常程序:主程序模块->kernel32.dll->ntdll.dll->syscall,这样当0环执行结束返回3环的时候,这个返回地址应该是在ntdll所在的地址范围之内
- 直接进行系统调用:此时当ring0返回的时候,rip将会是你的主程序模块内,而并不是在ntdll所在的范围内。
因此我们需要堆栈欺骗来帮我们隐藏堆栈。
我们先需要 32 位/64 位下堆栈的知识,推荐阅读:https://cloud.tencent.com/developer/article/2149944
https://pyroxenites.github.io/post/diao-yong-zhan-qi-pian/
https://mp.weixin.qq.com/s/_Cr6Ds0vaeGF7DShlq_XJg
https://codemachine.com/articles/x64_deep_dive.html
我们也来简单的说一下,在 32 位下,是通过rbp 来指向堆栈的开始位置,并且每次移动 rbp 时会 push rbp,然后再 mov rbp,rsp,因此我们只需要不断回溯 rbp 就可以回溯完整个堆栈。
在 64 位下,ebp 不再有这样的功能,它现在是一个通用寄存器,下面上两张图简单解释一下吧,这篇文章涉及到的技术为被动欺骗,不需要很深的理解也能看懂大部分。
x64 PE 文件中存在一个名为 .pdata的区段,区别于x32其属于x64独有区段,值的注意的是.pdata的RVA和异常目录表的RVA是相同。pdata中的数据由 多个 _IMAGE_RUNTIME_FUNCTION_ENTRY 结构体组成,具体的声明如下:
typedef struct _IMAGE_RUNTIME_FUNCTION_ENTRY {
DWORD BeginAddress;
DWORD EndAddress;
union {
DWORD UnwindInfoAddress;
DWORD UnwindData;
} DUMMYUNIONNAME;
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION, _IMAGE_RUNTIME_FUNCTION_ENTRY, *_PIMAGE_RUNTIME_FUNCTION_ENTRY;
从每个字段类型位DWORD可以看出,其表示的都是RVA,所以在使用时都需要加上模块基地址,BeginAddress代表函数的起始地址RVA,EndAddress代表函数的结束地址RVA,UnwindInfoAddress指向 _UNWIND_INFO结构体,其声明如下:
typedef struct _UNWIND_INFO {
UBYTE Version : 3;
UBYTE Flags : 5;
UBYTE SizeOfProlog;
UBYTE CountOfCodes;
UBYTE FrameRegister : 4;
UBYTE FrameOffset : 4;
UNWIND_CODE UnwindCode[1];
/* UNWIND_CODE MoreUnwindCode[((CountOfCodes + 1) & ~1) - 1];
* union {
* OPTIONAL ULONG ExceptionHandler;
* OPTIONAL ULONG FunctionEntry;
* };
* OPTIONAL ULONG ExceptionData[]; */
} UNWIND_INFO, *PUNWIND_INFO;
Version默认为1,Flags总共包含四个值,UNW_FLAG_NHANDLER,UNW_FLAG_EHANDLER ,UNW_FLAG_UHANDLER,UNW_FLAG_CHAININFO,SizeOfProlog表示序言大小(字节),CountOfCodes代表序言操作中所有指令总共占用的”槽“数量,FrameRegister用到的帧寄存器,FrameOffset帧寄存器距离栈顶的偏移。
UnwindCode表示的是 _UNWIND_CODE联合体,大小为两个字节,其声明如下:
typedef union _UNWIND_CODE {
struct {
UBYTE CodeOffset;
UBYTE UnwindOp : 4;
UBYTE OpInfo : 4;
};
USHORT FrameOffset;
} UNWIND_CODE, *PUNWIND_CODE;
CodeOffset紧跟序言的代码起始偏移,UnwindOp操作码,Opinfo对应操作码的附加操作信息。
然后就根据UnwindOp 对应不同操作码对栈的影响,即可计算某个函数的栈帧大小了。
下面上两张图帮大家理解一下:
我们在这篇文章中先介绍被动欺骗,或者说是静态欺骗,它是关于 sleep 的欺骗,或者说是睡眠时间混淆,并不能说是真正意义的堆栈欺骗,但是对于 beacon 来说也是有一定意义的,而主动欺骗,支持任何函数的堆栈欺骗,将在下一篇文章进行介绍。下面我们一起来看几个项目。
threadStackSpoofer
第一个方式的项目地址在https://github.com/mgeeky/ThreadStackSpoofer。
首先是处理参数和读取 shellcode 的部分,我们不关心。
然后又调用了 hookSleep 函数,我们跟进去
在 hookSleep 函数里面,他先准备了一个结构体,结构体里面包含了要 hook 的字段以及将 hook 的函数改写到哪里的字段,然后将 sleep,自实现的 MySleep,buffers 一并传给 fastTrampoline 函数。
在接下来构造了一个 trampoline 用于跳转
调试一个,可以看到 addr 的地址其实就是我们自实现的 MySleep 里面
然后保存一下原始的 addressToHook 字节,再将我们 trampoline 重写到 addressToHook 的位置,这样调用 Sleep 的时候其实会跳转到我们自实现的 MySleep 里面。
然后这部分代码相当于对当前进程刷新一下缓存,使得我们修改生效
然后就是注入 shellcode 的过程,然后当我们的 beacon sleep 时,就会调用到我们的 MySleep 函数,我们接下来再看看 MySleep 是如何处理我们的堆栈的。
_AddressOfReturnAddress 是编译器提供的一个函数,作用是返回当前函数返回地址的内存地址,给到 overwrite
然后关键就来了,我们将overwrite 直接改写为 0,这样停止继续回溯栈,然后我们就可以隐藏剩余的栈帧,即我们的 shellcode 栈帧就会被隐藏,当 sleep 结束之后再将栈帧改写回去。
这是调用堆栈未被欺骗时的样子:
当启用线程堆栈欺骗时:
此时帧栈展开到我们的 MySleep 函数,往后 shellcode 的帧栈就被隐藏了,当然我们还可以做更多有趣的事情,比如在 sleep 期间更改 shellcode 内存属性,对 shellcode 内存区域进行加密,或者解除我们对 etw/amsi 的 hook,在 sleep 之后再重新 hook,或者等等等等可以由大家自由发挥。
但是这里还是会有一些问题的,我们将调用堆栈设为不可展开,这意味着它看起来异常,因为系统将无法正确遍历整个调用堆栈帧链。当一个专业的恶意软件分析师在分析时自然会发觉异常,但是那些内存扫描工具就不一定了,它总不能遍历每个线程的堆栈来验证其是否不可展开。
CallStackMasker
这个项目的地址在https://github.com/Cobalt-Strike/CallStackMasker ,cs 官方也写了博客来介绍这个技术https://www.cobaltstrike.com/blog/behind-the-mask-spoofing-call-stacks-dynamically-with-timers
这个项目是计时器欺骗调用堆栈的 PoC ,在 beacon 休眠之前,我们可以对计时器进行排队,用假的调用堆栈覆盖其调用堆栈,然后在恢复执行之前恢复原始调用堆栈。因此,就像我们可以在睡眠期间欺骗属于我们的植入物的内存一样,我们也可以欺骗主线程的调用堆栈。这种方式是比较简单的复制堆栈,避免了主动堆栈欺骗的复杂性。
如果我们考虑一个正在执行任何类型等待的通用线程(waitforsingleobject),它在等待满足之前无法修改自己的堆栈。此外,它的堆栈始终是可读写的。因此,我们可以使用定时器来:
- 创建当前线程堆栈的备份
- 用假线程堆栈覆盖它
- 在恢复执行之前恢复原始线程堆栈
这就是这个技术的核心,PoC 以两种模式运行:静态和动态。静态模式模仿 spoolsv.exe 硬编码调用堆栈。该线程如下所示,通过 KERNELBASE!WaitForSingleObjectEx 可以看到处于‘Wait:UserRequest’ 状态:
我们的线程的起始地址和调用堆栈与上面 spoolsv.exe 中标识的线程相同:
静态模式的明显缺点是我们仍然依赖硬编码的调用堆栈。为了解决这个问题,PoC 还实现了动态调用堆栈欺骗。在此模式下,它将枚举主机上所有可访问的线程,并找到一个处于所需目标状态的线程(即通过 WaitForSingleObjectEx 的 UserRequest)。一旦找到合适的线程堆栈,它将复制它并使用它来休眠线程的克隆。同样,PoC 将再次复制克隆线程的起始地址,以确保我们的线程看起来合法。
好的,接下来让我们看看代码:
关于堆栈计算大小等等代码我们先略过,这并不会影响我们理解这项技术,并且解释起来显得太啰嗦。
我们看关键地方,这里创建一个新的线程,并且将 rip 指针指向 go 函数,也就是说要执行我们的 go 函数。
我们来看MaskCallStack,先是初始化上下文和句柄,方便后续操作,然后获取 NtContinue 函数地址:通过 GetProcAddress 函数获取 Ntdll 模块中的 NtContinue 函数的地址。这个函数通常用于继续执行线程
设置定时器,创建定时器,并设置回调函数,以执行一系列操作:备份堆栈、覆盖堆栈、恢复堆栈和设置事件,
当等待事件对象被定时器触发,此时调用堆栈将被遮蔽,然后定时器结束之后又触发事件,堆栈又恢复。
纤程
纤程是一种用户级线程,它允许在一个线程内部进行上下文切换。纤程的切换完全由程序控制,不需要内核的参与,因此效率非常高。纤程的上下文包括寄存器状态和堆栈,当切换纤程时,当前纤程的上下文会被保存,然后加载新纤程的上下文。这意味着,通过纤程切换,可以改变当前线程的堆栈。
一个线程可以创建多个纤程,并通过调用 SwitchToFiber 函数根据需要在它们之间切换。在此之前,当前线程本身必须通过调用 ConvertThreadToFiber 成为纤程,因为只有一个纤程可以创建其他纤程。
所以当我们进行 sleep 时可以切换到新的纤程里面进行 sleep,从而隐藏我们 shellcode 堆栈,当调用返回时,它将再次切换到 shellcode 的纤程,以便可以继续执行。
重要的 api 使用如下:
// 创建纤程
LPVOID lpFiber = CreateFiber(0, FiberFunc, NULL);
// 将当前线程转换为纤程
ConvertThreadToFiber(NULL);
// 切换到新创建的纤程
SwitchToFiber(lpFiber);
项目参考:https://github.com/Kudaes/Fiber
代码实现的话第一个项目改改就可以实现,先 hook sleep 函数,然后调用 sleep 函数的时候就可以将上下文转换到一个新的纤程中,然后 sleep 结束之后,再转回 shellcode 执行的纤程中即可,这里不再分析代码。
但是当我们在执行 shellcode 相关功能时如果被检测到了会直接 gg。