一、前言
调用栈欺骗(Call Stack Spoofing),也常被称为堆栈欺骗,是一种通过伪造栈帧与返回链来干扰栈回溯结果的技术。虽然并不新鲜,但最近受到了更多关注,在安全研究、对抗测试以及一些开源加载器项目中,这类手法经常被提及。如果各位师傅关注github上各种顶级黑客发布的项目,就经常会看到这个技术,比如说最近很火的 SysWhispers4


调用栈欺骗的种类分为
- 被动欺骗:它只能在植入程序处于休眠状态时发生,详见:mgeeky/ThreadStackSpoofer 和Cobalt-Strike/CallStackMasker
- 主动欺骗:在调用敏感API前就主动构造一条假的可回溯调用链。详见:klezVirus/SilentMoonwalk、susMdT/LoudSunRun
调用栈欺骗,欺骗什么?又或者说想隐藏什么呢?
就如Elastic Security这篇文章所说的那样 Call Stacks: No More Free Passes For Malware — Elastic Security Labs
Elastic EDR将调用栈作为对Windows端点的重要遥测数据,通过调用栈,能得到“谁在做什么事”信息,这种信息赋予了它无与伦比的能力来揭露恶意活动。

我举几个例子让各位师傅感受一下。
beacon.dll通过反射dll加载上线,这意味着RDI内部会使用到一些unbacked memory。可以看到下图发起sleep调用的来自unbacked memory,这些内存并没关联到磁盘上文件,看起来非常的可疑。

所以一些项目就会使用到被动欺骗,在植入物程序处于休眠状态时发生,伪造其调用栈,让调用栈不出现unbacked memory。


也有一些项目,直接从URDI入手,消除可疑的调用栈。就比如 boku7/BokuLoader 在RDI内部实现主动合成帧(synthetic frames)技术。

堆栈欺骗的核心实现, spoof_synthetic_callstack 汇编函数,代码没截全。BokuLoader使用的堆栈欺骗技术主要来源于LoudSunRun和SilentMoonwalk,下文会重点分析这两个项目。

又比如这样一种情况,unbacked memory -> KernelBase!LoadLibraryA,从未备份的内存中调用LoadLibraryA这样的敏感API,对于部分EDR(如Elastic EDR)直接kill。下图来自cen4encen师傅的 Elastic EDR 规则检测下的对抗-先知社区

当然并不是说出现unbacked memory就会被EDR查杀,具体情况具体分析。
调用栈欺骗的核心:是让AV/或EDR等检查工具看到的调用来源是合法的系统代码。
二、被动欺骗
2.1 ThreadStackSpoofer
项目地址: mgeeky/ThreadStackSpoofer
当shellcode(如 Cobalt Strike Beacon)调用Sleep进入休眠时,会被Hook重定向到MySleep,在MySleep实现栈欺骗。

1. main
main函数的主流程就3个:
- 读shellcode文件
- HookSleep函数
- 注入shellcode并执行,由于sleep被hook了,所以会重定向到自定义的MySleep,在MySleep完成调用栈欺骗。

2. hookSleep
重点分析一下hookSleep函数

- previousBytes用来接收 Sleep 被覆盖前的原始字节
- previousBytesSize 备份长度。
- origSleep保留Sleep原函数地址,供后续直接调用(实际上代码没有用到)

3. 进入fastTrampoline
构造跳转指令,其中addr等待后续填充为MySleep的地址。

当 installHook == true 且 buffers != NULL,说明需要备份Sleep原始字节。
1 | memcpy(buffers->previousBytes, // → g_hookedSleep.sleepStub |
备份好Sleep的前16个字节后,修改内存保护并写入跳板Trampoline。

刷新指令缓存,让我们的Hook立即生效,最后恢复原内存保护。

Hook前

Hook后

4. 执行shellcode,重定向到我们的MySleep
怎么执行shellcode,代码的注释给出了详细的原因,最终使用创建线程的方式执行shellcode

翻译
1 | // |
5. MySleep(具体栈欺骗实现)
定位当前栈帧的返回地址


清零返回地址,即截断调用栈,具体来说就是Windows 调用栈展开(stack unwinding)依赖RtlVirtualUnwind遍历帧链,当它尝试用返回地址0x0去查找对应的RUNTIME_FUNCTION(异常处理表)时,找不到任何合法记录,展开算法就此终止,不再继续往上追溯。后面的主动欺骗的项目也会用到这个性质。

截断后的调用栈,很明显,我们的shellcode栈帧被隐藏了

执行真正的休眠后的调用栈。

为什么用SleepEx? 因为Sleep已经被Trampoline覆盖,再调用会无限递归回到MySleep。直接调用SleepEx(未被Hook)是安全的。
恢复返回地址,SleepEx返回后,栈仍然完好(我们只改了返回地址槽的值,没动rsp)

Hook Sleep的作用不止调用栈欺骗,比如就像fdx师傅在这篇文章初探堆栈欺骗之静态欺骗-先知社区所说的那样,我们还做非常多的事情:>

其中在Sleep期间更改shellcode内存属性,以绕过卡巴斯基的内存扫描。具体做法是Beacon进入睡眠就取消它内存的可执行属性,等Beacon线程醒来时触发异常交由VEH异常处理函数恢复内存的可执行属性,然后Beacon执行完成后又进入睡眠一直重复上述过程,具体实现请看WBGlIl师傅的这篇文章cs bypass卡巴斯基内存查杀 2-先知社区
2.2 CallStackMasker
项目地址:Cobalt-Strike/CallStackMasker
官方博客:Behind the Mask: Spoofing Call Stacks Dynamically with Timers | Cobalt Strike Blog
核心目标是让线程在休眠时伪装成系统进程的调用栈,项目有静态模式和动态模式。
1. 初始化伪造调用栈
首先分析静态模式

InitialiseStaticCallStackSpoofing 简要步骤:
InitialiseSpoofedCallstack:对每个栈帧(共4帧):加载/获取DLL基址、计算伪造返回地址(函数地址 + offset)、解析UNWIND_INFO得到帧栈大小(什么是UNWIND_INFO,下文分析LoudSunRun的时候会解释或者让AI解释也可以)CalculateStaticStackSize:累加所有帧的栈大小 + 8(返回地址的大小),得到总缓冲区大小- 分配对应大小的堆内存(无可执行属性)
CreateFakeStackInBuffer:按栈布局将伪造返回地址逐帧写入堆缓冲区,末尾写0x0终止栈展开
硬编码的4帧伪造调用栈,如下图

最终产出得到一块模拟真实栈布局的内存,供后续MaskCallStack用memcpy覆盖线程真实栈。

我们将线程的起始地址设置为 localspl.dll!InitializePrintMonitor2 + 0xb20,在线程创建事件中的起始地址是合法的地址。我也不懂为什么在Process Hacker里看到的线程起始地址是 localspl.dll!DeviceObject::DeviceCreateCallback+0x190,反正线程的起始地址看起来是合法的


下面分析动态模式
InitialiseDynamicCallStackSpoofing在系统中找到一个真实的、正在WaitForSingleObjectEx中休眠的线程,克隆其调用栈 + 起始地址,填充 thread.{dwPid, dwTid, totalRequiredStackSize, pFakeStackBuffer}。说实话,InitialiseDynamicCallStackSpoofing的代码太过复杂,我不太想在本文展开,有需要的就去看源代码了解。

2. 以伪造的startAddr为起始地址,挂起状态创建新线程
创建一个线程起始点为 localspl.dll!InitializePrintMonitor2 + 0xb20 线程,随后取线程上下文,将RIP修改为真正执行的函数go,写回上下文,线程将从go函数开始执行。在系统进程监测工具来看起始地址合法的,但起始地址被我们重定向到go函数了

3. ResumeThread 恢复线程执行,线程开始跑go

4. 退出主线程,工作由新线程接管

5. MaskCallStack(具体栈欺骗实现)
该睡眠技术来自:Cracked5pider/Ekko,既然能实现睡眠 + 混淆,那也可以实现睡眠 + 调用栈欺骗,当然也能够实现睡眠 + 混淆 + 调用栈欺骗,只不过没有本项目实现混淆这一功能而已。
- pNtContinue:Timer ROP链的”执行跳板”
- hTimerQueue:承载4个定时任务的容器
- hEvent:用于阻塞主线程 + 定时唤醒
- pCopyOfStack:堆上栈备份缓冲区,大小 = totalRequiredStackSize
ChildSP,AI的解释是:被调用函数(子函数)视角下的栈帧起始位置。
按我的理解是:ChildSP表示当前栈帧RSP的值,也就是当前栈帧的栈顶地址。
看下图,执行 add rsp, 38h 指令前,当前栈帧ntdll!LdrpDoDebuggerBreak的ChildSP = RSP = 000000D8149BF260

执行 add rsp, 38h 指令后,当前栈帧 ntdll!LdrpDoDebuggerBreak 的ChildSP = RSP = 000000D8149BF298

执行ret指令后,返回到 LdrpInitializeProcess ,当前栈帧 ntdll!LdrpInitializeProcess 的ChildSP = RSP = 000000D8149BF2A0

回归正文,MaskCallStack调用GetChildSP获取当前MaskCallStack栈帧的RSP的值,GetChildSP的具体原理是:
- MaskCallStack函数 call GetChildSP函数会在 RSP - 8 的位置留下返回地址
- GetChildSP函数通过
_AddressOfReturnAddress()拿到这个位置,加8跳过这个槽位,即获得当前MaskCallStack栈帧的RSP的值
可能这难以理解,我画一个解释一下

或者调试验证一下

定位NtWaitForSingleObject系统调用时的RSP,即执行完 call NtWaitForSingleObject 后,RSP的值
1 | pRsp = 当前MaskCallStack的栈顶 - KERNELBASE!WaitForSingleObjectEx栈帧的大小 - NtWaitForSingleObject的返回地址 |

画个图吧,有点难以理解

利用 CreateTimerQueueTimer 创建一个定时器,在指定的延迟时间后Timer线程首次触发回调函数RtlCaptureContext采集当前Timer线程的CONTEXT,主要是为了下文ROP链的构造。WaitForSingleObject(hEvent, 0x32) 等待上下文捕获完成。

基于捕获的CONTEXT备份四份
- ropBackUpStack:备份真实栈
- Rip = memcpy
- Rcx = pCopyOfStack (dst)
- Rdx = pRsp(src: 真实栈起始)
- R8 = totalRequiredStackSize
- ropSpoofStack:写入伪造栈
- Rip = memcpy
- Rcx = pRsp(dst: 覆盖真实栈)
- Rdx = pFakeStackBuffer(src: 预制好的假栈)
- R8 = totalRequiredStackSize
- ropRestoreStack:恢复真实栈
- Rip = memcpy
- Rcx = pRsp(dst)
- Rdx = pCopyOfStack (src: 之前备份的真实栈)
- ropSetEvent:发出完成信号
- Rip = SetEvent
- Rcx = hEvent

为什么会有rop.Rsp -= 8 ? NtContinue不是通过call的方式调用目标函数,而是直接设置上下文rip = 目标函数地址的方式劫持线程执行流程,故目标函数执行完后没有返回地址(因为不是正常的call func)。回顾一下下,我们捕获的上下文是RtlCaptureContext执行瞬间的Timer线程状态,也就是call RtlCaptureContext之前的上下文,此时ctx.rsp指向的是RtlCaptureContext返回地址的上方,不 -8 字节,程序不能正常返回,直接奔溃。
ok,口说无凭,我们来看看RtlCaptureContext的汇编代码,只用关注我框红的地方。


执行 lea rax, [rsp+8+arg_0] 前,经过了一次pushfq,最终RSP如下图所示。执行 lea rax, [rsp+8+arg_0] 后,RSP指向原始调用者的栈顶,其中arg_0的值是8。

回调函数返回地址返回到哪里?这里我也不同懂,应该是返回到调用者内部了吧?

所以借用的是RtlCaptureContext的返回地址,让目标函数执行完后能回到Timer线程的原始执行流,Timer再触发下一个NtContinue。
当然这一切的前提是所有回调函数都共享同一个返回地址,且Timer线程的栈内容没有被破坏(实际上我们也没破坏)
利用CreateTimerQueueTimer + NtContinue组合实现间接执行目标函数的功能,本来Timer线程在执行NtContinue,不过我们将预设好的上下文作为参数给NtContinue,其中Rip被设置成memcpy或SetEvent等目标函数的地址,实际上形成了这样一种执行关系:
1 | Timer -> 回调NtContinue -> 恢复执行目标函数 |

伪造前

伪造后

我还能说什么呢(what can i say),这就是顶级黑客的艺术,确实能让我研究学习一阵子,膜拜就完事了。
三、主动欺骗
我会重点分析LoudSunRun的实现,毕竟未来我会将其融入到我的SRDI项目中,又或者其他项目?
3.1 LoudSunRun
LoudSunRun基于SilentMoonWalk和CallStackSpoofer(也称VulcanRaven)实现了合成栈帧(synthetic frames)。
因为LoudSunRun实现比较简单,但该有的技术都包含进去了,而且也比较好调试,所以我就只分析LoudSunRun,其余的SilentMoonWalk和VulcanRaven我就不再这里分析。
项目地址: susMdT/LoudSunRun
1. 初始化局部变量

其中最最重要的就是这两个变量
ReturnAddress:临时保存伪造栈帧要用的返回地址。PRM p:最核心的参数结构,后面会传给Spoof。
PRM p是这个项目里最关键的上下文,作用是把C代码准备好的数据传给汇编里的Spoof,同时也让Spoof 在执行前后有地方保存和恢复现场。下图是它的结构体定义。

2. 查找跳板gadget

调用 FindGadget 在 kernel32.dll 的前0x200000字节里搜索字节序列 FF 23,即jmp [rbx]。
这个gadget地址保存到 p.trampoline,在后续的Spoof中会使用到。


3. 计算BaseThreadInitThunk、RtlUserThreadStart和trampoline伪造栈帧所需大小
首先我们将 BaseThreadInitThunk + 0x14 和 RtlUserThreadStart + 0x21 这两个地址当作“回溯时希望看到的返回点”,然后调用 CalculateFunctionStackSizeWrapper 去解析它对应函数的unwind信息,得到该帧需要占用的栈空间,分别存进:p.BTIT_ss 、p.BTIT_retaddr、p.RUTS_ss、p.RUTS_retaddr、p.Gadget_ss。

在这里重点分析一下 CalculateFunctionStackSizeWrapper 函数的流程,它的作用其实很简单但也很关键:把某个返回地址转换成这个函数栈帧大概需要多大,方便我们在Spoof中伪造相同大小的栈帧。

上图代码的流程:
- 检查传入地址是否为空
- 用
RtlLookupFunctionEntry查这个地址属于哪个函数,根据传入的代码地址,去PE文件的.pdata节中找到对应的RUNTIME_FUNCTION记录。

RUNTIME_FUNCTION 记录中的 UnwindInfo 字段是我们栈伪造所需的关键信息。

- 最后交给
CalculateFunctionStackSize解析目标函数栈所需要的大小。
CalculateFunctionStackSize的核心作用是:根据某个函数的RUNTIME_FUNCTION和对应的UNWIND_INFO,推导这个函数在栈上总共占了多少空间。
Windows x64 下,很多函数的栈展开信息都记录在 .pdata里。这个函数就是在读这些unwind元数据,而不是反汇编函数体。算出来的totalStackSize后面会被Spoof用来伪造对应大小的栈帧。
每个unwind->UnwindCode中都含有unwindOperation和operationInfo字段共同描述这个函数prolog里做过什么保存/分配动作,unwindOperation有下面的几种取值:
UWOP_PUSH_NONVOL:表示push了一个非易失寄存器,比如rbx/rbp/rdi。每次push占8字节。如果 OpInfo == 5,也就是推入的是rbp,它还会额外记录。UWOP_SAVE_NONVOL:这类不是push,而是把寄存器保存到“已经分配好的栈空间偏移处”,所以它本身不新增栈大小。UWOP_ALLOC_SMALL:表示函数执行过小块栈分配,比如sub rsp, xx。UWOP_ALLOC_LARGE:表示大块栈分配,代码先前移取出后续slot(即unwind->UnwindCode的下一个元素),再把结果加到totalStackSize。它分两种编码格式:- operationInfo == 0:大小在下一个slot,单位是8字节,所以要 * 8
- operationInfo == 1:大小在后两个slot,组合成更大的值
UWOP_SET_FPREG:这表示函数建立了帧指针,比如把rbp设成栈帧基址。这里没有直接增加栈大小,只做了stackFrame.setsFramePointer = true;。
涉及到栈大小变化的操作,就直接累加到totalStackSize。
循环里的index为什么有时会多加?
有些unwind code不是只占 1 个slot。
比如UWOP_SAVE_NONVOL、UWOP_ALLOC_LARGE会占后续额外slot,所以分支内部会先index += 1,循环结尾又统一再 index += 1。这样才能跳到下一条真正的新记录。


链式unwind信息
某些函数的展开信息不是一份独立描述完的,需要沿链继续累加。

最后为什么还要 +8
这是把“返回地址”那 8 字节也算进去。因为从栈展开视角看,一个完整帧不仅有局部变量和保存寄存器,还要包含栈上的返回地址槽位。
总结:CalculateFunctionStackSize 最终返回的是“函数自己的栈分配 + 保存寄存器到栈上占用 + 返回地址”
4. Spoof(具体栈欺骗实现)
1 | pop rax ; Real return address in rax |
注意 pop rax 之后,rsp不再指向返回地址,而是影子空间,rsp ~ rsp + 20h 这一部空间是属于影子空间,故Spoof栈参数如下
- [rsp + 20h] = 第5个参数,即
PRM p的地址 - [rsp + 28h] = 第6个参数,pPrintf指针
- [rsp + 30h] = 第7个参数,在本例中为0
spoof的第1个参数到第4个参数分别存放在rcx,rdx,r8,r9中,第5至第10被称为栈参数。栈参数需要按从右往左的顺序依次压入栈中。这一部是windows x64调用约定的内容,感兴趣的可以去微软官方文档看看。
从第1至第4,第8至第10是间接调用的函数的参数,即bruh函数的参数,后面还需要将brush的栈参数复制到伪造栈中,下文会解释。


保存部分非易失性寄存器,此时rdi = PRM p 的地址,根据偏移将这些部分非易失性寄存器保存起来,等到fixup的时候再恢复。
1 | ; --------------------------------------------------------------------- |

注意最后一个指令是:mov r12, rax,所以r12存储返回地址(返回到main函数的地址)。
1 | ; --------------------------------------------------------------------- |
首先我们看下面这一部分

这一部分汇编指令的目的是计算未来目标函数栈参数位置相对当前 rsp 的偏移量。
1 | r14 = 200h // 320字节的工作空间 |
这一部分可能有点难以理解,我画一个图来辅助解释

在本例中,通过 Spoof 间接调用 bruh,而 bruh 拥有7个参数,这意味着前4个参数存储在rcx,rdx,r8,r9中,而从第5个参数开始需要将参数存放到栈上,所以这些参数也称栈参数。

bruh函数的第5至第7个参数即Spoof函数的第8至第10个参数。

执行完下面的指令后,r10指向spoof的第7个参数。

- r15指向目标函数当前这次要写入的栈参数位置
- r11表示已经复制了多少个栈参数
- r13表示目标函数总共有多少个额外栈参数
- r10初始先指向Spoof的第 7 个参数,循环里add r10, 8后才依次指向第8、第9、第10个参数,也就是目标函数的栈参数。
接下来就是通过一个循环,预先搬运目标函数的额外栈参数到指定位置上

执行 sub rsp, 200h 前的调用栈

执行后的调用栈

push 0 的作用是给伪造调用链的最顶层放一个“终止标记”,让栈回溯走到这里就停下来。执行 push 0 后的调用栈

伪造RtlUserThreadStart + 0x14 frame后

伪造BaseThreadInitThunk + 0x21 frame后

伪造Gadget frame后

调用目标函数后的调用栈(已经伪造三层栈帧了)

目标函数结束,恢复原先的调用栈


3.2 BYOUD
项目地址:klezVirus/BYOUD
博客: Fantastic unwind information and where to find them | klezVirus
PS:看完感叹神乎其技。
核心思想:通过修改/注入PE的.pdata,欺骗基于调用栈回溯的EDR,使其认为代码在合法的调用链中执行,从而绕过检测。
该项目一共有7种欺骗技术,以后有机会我再把BYOUD所有欺骗技术再研究一遍,这里不想把文章写的太长影响观感。
UNWIND_DATA_TAMPER

UNWIND_DATA_HIJACK

剩余的5种欺骗就不演示,感兴趣的可以自己运行,并观察调用栈。如果想了解这7种调用栈欺骗原理,我更推荐阅读klezVirus写的那篇博客,如果各位师傅觉得我写的还行,那未来我也会出篇文章一起学习(感觉像挖坑)
总结
ThreadStackSpoofer通过截断shellcode栈帧的方式隐藏调用栈。
CallStackMasker通过睡眠 + 合成栈帧的方式伪造调用栈
LoudSunRun通过合成栈帧的方式在调用敏感函数前伪造调用栈。
BYOUD(很新的一个项目,刚发布不到一个星期):通过修改/注入PE的.pdata,欺骗基于调用栈回溯的EDR,使其认为代码在合法的调用链中执行,从而绕过检测。
这篇文章写的一般,感觉像流水账,计划是在4月初发的,不过写都写了就发出来,给师傅当个乐子看。
现代红队主流的防御规避技术我已经学了差不多了,还剩几个没写,我还能写啥呢?下篇文章的主题各位师傅猜一下呗。
参考资料
- boku7/BokuLoader: A proof-of-concept Cobalt Strike Reflective Loader which aims to recreate, integrate, and enhance Cobalt Strike’s evasion features!
- klezVirus/ThreadPoolExecChain:一个简单的POC,展示如何通过尾部调用串联多个回调,人工构建调用栈
- kyleavery/AceLdr: Cobalt Strike UDRL for memory scanner evasion.
- JoasASantos/SysWhispers4:通过直接和间接系统调用Windows NT 3.1 24H2的AV/EDR规避x64 ·x86 ·魔兽世界64 ·ARM64
- klezVirus/SilentMoonwalk: PoC Implementation of a fully dynamic call stack spoofer
- klezVirus/Moonwalk–: Moonwalk++: Simple POC Combining StackMoonwalking and Memory Encryption
- kapla0011/KaplaStrike: A Cobalt Strike RL built with Crystal Palace — module overloading, NtContinue entry transfer, call stack spoofing, sleep masking, and static signature removal.
- 0xNinjaCyclone/AsmLdr:具备复杂规避功能的动态壳码加载器
- klezVirus/BYOUD:带上你自己的 Unwind 数据框架
- 栈欺骗入门
- 再探堆栈欺骗之动态欺骗-先知社区
- 三探堆栈欺骗之Custom Call Stacks-先知社区
- mgeeky/ThreadStackSpoofer
- Cobalt-Strike/CallStackMasker
- klezVirus/BYOUD
- susMdT/LoudSunRun