调用栈欺骗(Callstack Spoofing)
2026-03-27 18:49:09 # 防御规避

一、前言

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

PixPin_2026-03-22_17-40-47.png

又比如boku7/BokuLoader

PixPin_2026-03-19_22-13-03.png

调用栈欺骗的种类分为

调用栈欺骗,欺骗什么?又或者说想隐藏什么呢?

就如Elastic Security这篇文章所说的那样 Call Stacks: No More Free Passes For Malware — Elastic Security Labs

Elastic EDR将调用栈作为对Windows端点的重要遥测数据,通过调用栈,能得到“谁在做什么事”信息,这种信息赋予了它无与伦比的能力来揭露恶意活动。

PixPin_2026-03-25_21-27-32.png

我举几个例子让各位师傅感受一下。

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

PixPin_2026-03-20_00-54-12.png

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

PixPin_2026-03-20_00-57-32.png

PixPin_2026-03-21_15-14-59.png

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

PixPin_2026-03-21_14-23-41.png

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

PixPin_2026-03-21_14-37-16.png

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

PixPin_2026-03-19_23-58-38.png

当然并不是说出现unbacked memory就会被EDR查杀,具体情况具体分析。

调用栈欺骗的核心:是让AV/或EDR等检查工具看到的调用来源合法的系统代码。

二、被动欺骗

2.1 ThreadStackSpoofer

项目地址: mgeeky/ThreadStackSpoofer

当shellcode(如 Cobalt Strike Beacon)调用Sleep进入休眠时,会被Hook重定向到MySleep,在MySleep实现栈欺骗。

PixPin_2026-03-21_16-40-38.png

1. main

main函数的主流程就3个:

  • 读shellcode文件
  • HookSleep函数
  • 注入shellcode并执行,由于sleep被hook了,所以会重定向到自定义的MySleep,在MySleep完成调用栈欺骗。

PixPin_2026-03-21_15-43-33.png

2. hookSleep

重点分析一下hookSleep函数

PixPin_2026-03-21_15-48-22.png

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

PixPin_2026-03-21_15-51-59.png

3. 进入fastTrampoline

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

PixPin_2026-03-21_15-58-19.png

installHook == truebuffers != NULL,说明需要备份Sleep原始字节。

1
2
3
memcpy(buffers->previousBytes,   // → g_hookedSleep.sleepStub
addressToHook, // → kernel32!Sleep 起始地址
buffers->previousBytesSize); // 16 字节

备份好Sleep的前16个字节后,修改内存保护并写入跳板Trampoline。

PixPin_2026-03-21_16-04-54.png

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

PixPin_2026-03-21_16-11-12.png

Hook前

PixPin_2026-03-21_16-16-27.png

Hook后

PixPin_2026-03-21_16-19-02.png

4. 执行shellcode,重定向到我们的MySleep

怎么执行shellcode,代码的注释给出了详细的原因,最终使用创建线程的方式执行shellcode

PixPin_2026-03-21_16-28-28.png

翻译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//
// ThreadStackSpoofer 早期版本提供的示例:
// https://github.com/mgeeky/ThreadStackSpoofer/blob/ec0237c5f8b1acd052d57562a43f40a20752b5ca/ThreadStackSpoofer/main.cpp#L417
// 展示了如何从临时挂钩的 ntdll!RtlUserThreadStart+0x21 位置启动我们的 shellcode。
//
// 那种方法存在一定缺陷,原因是:一旦我们在模块内引入挂钩,
// 即使立即卸载挂钩,系统仍会在共享库的内存分配区域(由 MEM_IMAGE/MEM_MAPPED 内存池组成)
// 中分配一页(4096字节)类型为 MEM_PRIVATE 的内存。
//
// 像 Moneta 这样的内存扫描器对扫描内存映射的 PE DLL 非常敏感,
// 当发现这些区域中存在大量标记为 MEM_PRIVATE 的内存时,
// 会将其(正确地!)识别为"代码被修改"的异常特征。
//
// 对于 kernel32!Sleep 我们无法规避这种检测,但对于 ntdll 可以。
// 与其从合法的用户线程回调函数中运行 shellcode,
// 不如直接创建一个指向我们方法的线程,然后从该方法跳转到 shellcode。
//
// 经过与 @waldoirc 的讨论,我们得出结论:为了不引入新的 IOC(失陷指标),
// 最好从 EXE 自身的代码空间启动 shellcode,
// 从而避免基于 `ntdll!RtlUserThreadStart+0x21` 在某些环境中成为突出异常点的检测。
// 感谢 @waldoirc 与我们进行的长时间深入讨论!
//

5. MySleep(具体栈欺骗实现)

定位当前栈帧的返回地址

PixPin_2026-03-21_16-44-44.png

PixPin_2026-03-21_16-45-25.png

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

PixPin_2026-03-21_16-46-30.png

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

PixPin_2026-03-21_16-54-50.png

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

PixPin_2026-03-21_17-15-01.png

为什么用SleepEx? 因为Sleep已经被Trampoline覆盖,再调用会无限递归回到MySleep。直接调用SleepEx(未被Hook)是安全的。

恢复返回地址,SleepEx返回后,栈仍然完好(我们只改了返回地址槽的值,没动rsp)

PixPin_2026-03-21_17-17-49.png

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

PixPin_2026-03-21_16-56-41.png

其中在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. 初始化伪造调用栈

首先分析静态模式

PixPin_2026-03-21_22-08-34.png

InitialiseStaticCallStackSpoofing 简要步骤:

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

硬编码的4帧伪造调用栈,如下图

PixPin_2026-03-21_22-10-52.png

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

PixPin_2026-03-21_22-28-18.png

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

PixPin_2026-03-21_22-48-19.png

PixPin_2026-03-21_22-34-51.png

下面分析动态模式

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

PixPin_2026-03-21_22-55-52.png

2. 以伪造的startAddr为起始地址,挂起状态创建新线程

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

PixPin_2026-03-21_23-02-14.png

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

PixPin_2026-03-21_23-07-07.png

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

PixPin_2026-03-21_23-07-20.png

5. MaskCallStack(具体栈欺骗实现)

该睡眠技术来自:Cracked5pider/Ekko,既然能实现睡眠 + 混淆,那也可以实现睡眠 + 调用栈欺骗,当然也能够实现睡眠 + 混淆 + 调用栈欺骗,只不过没有本项目实现混淆这一功能而已。

  • pNtContinue:Timer ROP链的”执行跳板”
  • hTimerQueue:承载4个定时任务的容器
  • hEvent:用于阻塞主线程 + 定时唤醒
  • pCopyOfStack:堆上栈备份缓冲区,大小 = totalRequiredStackSize

ChildSP,AI的解释是:被调用函数(子函数)视角下的栈帧起始位置

按我的理解是:ChildSP表示当前栈帧RSP的值,也就是当前栈帧的栈顶地址。

看下图,执行 add rsp, 38h 指令前,当前栈帧ntdll!LdrpDoDebuggerBreakChildSP = RSP = 000000D8149BF260

PixPin_2026-03-22_01-52-37.png

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

PixPin_2026-03-22_01-54-22.png

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

PixPin_2026-03-22_01-55-08.png

回归正文,MaskCallStack调用GetChildSP获取当前MaskCallStack栈帧的RSP的值,GetChildSP的具体原理是:

  • MaskCallStack函数 call GetChildSP函数会在 RSP - 8 的位置留下返回地址
  • GetChildSP函数通过 _AddressOfReturnAddress() 拿到这个位置,加8跳过这个槽位,即获得当前MaskCallStack栈帧的RSP的值

可能这难以理解,我画一个解释一下

Drawing 2026-03-21 23.43.31.excalidraw.png

或者调试验证一下

PixPin_2026-03-22_00-28-21.png

定位NtWaitForSingleObject系统调用时的RSP,即执行完 call NtWaitForSingleObject 后,RSP的值

1
2
pRsp	= 当前MaskCallStack的栈顶 - KERNELBASE!WaitForSingleObjectEx栈帧的大小 - NtWaitForSingleObject的返回地址
= (PCHAR)pChildSP - spoofedCallStack.front().totalStackSize - 0x8;

PixPin_2026-03-22_15-22-54.png

画个图吧,有点难以理解

Drawing 2026-03-22 15.09.51.excalidraw.png

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

PixPin_2026-03-22_15-34-03.png

基于捕获的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

PixPin_2026-03-22_15-52-49.png

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

ok,口说无凭,我们来看看RtlCaptureContext的汇编代码,只用关注我框红的地方。

PixPin_2026-03-23_12-43-26.png

PixPin_2026-03-23_12-44-27.png

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

Drawing 2026-03-23 12.45.38.excalidraw.png

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

PixPin_2026-03-22_16-57-16.png

所以借用的是RtlCaptureContext的返回地址,让目标函数执行完后能回到Timer线程的原始执行流,Timer再触发下一个NtContinue。

当然这一切的前提是所有回调函数都共享同一个返回地址,且Timer线程的栈内容没有被破坏(实际上我们也没破坏)

利用CreateTimerQueueTimer + NtContinue组合实现间接执行目标函数的功能,本来Timer线程在执行NtContinue,不过我们将预设好的上下文作为参数给NtContinue,其中Rip被设置成memcpy或SetEvent等目标函数的地址,实际上形成了这样一种执行关系:

1
Timer -> 回调NtContinue -> 恢复执行目标函数

PixPin_2026-03-22_17-10-01.png

伪造前

PixPin_2026-03-22_17-14-37.png

伪造后

PixPin_2026-03-22_17-15-56.png

我还能说什么呢(what can i say),这就是顶级黑客的艺术,确实能让我研究学习一阵子,膜拜就完事了。

三、主动欺骗

我会重点分析LoudSunRun的实现,毕竟未来我会将其融入到我的SRDI项目中,又或者其他项目?

3.1 LoudSunRun

LoudSunRun基于SilentMoonWalk和CallStackSpoofer(也称VulcanRaven)实现了合成栈帧(synthetic frames)

因为LoudSunRun实现比较简单,但该有的技术都包含进去了,而且也比较好调试,所以我就只分析LoudSunRun,其余的SilentMoonWalk和VulcanRaven我就不再这里分析。

项目地址: susMdT/LoudSunRun

1. 初始化局部变量

PixPin_2026-03-18_19-03-58.png

其中最最重要的就是这两个变量

  • ReturnAddress:临时保存伪造栈帧要用的返回地址。
  • PRM p:最核心的参数结构,后面会传给Spoof。

PRM p是这个项目里最关键的上下文,作用是把C代码准备好的数据传给汇编里的Spoof,同时也让Spoof 在执行前后有地方保存和恢复现场。下图是它的结构体定义。

PixPin_2026-03-18_19-07-33.png

2. 查找跳板gadget

PixPin_2026-03-21_14-14-40.png

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

PixPin_2026-03-18_19-12-26.png

PixPin_2026-03-18_19-15-20.png

3. 计算BaseThreadInitThunk、RtlUserThreadStart和trampoline伪造栈帧所需大小

首先我们将 BaseThreadInitThunk + 0x14RtlUserThreadStart + 0x21 这两个地址当作“回溯时希望看到的返回点”,然后调用 CalculateFunctionStackSizeWrapper 去解析它对应函数的unwind信息,得到该帧需要占用的栈空间,分别存进:p.BTIT_ssp.BTIT_retaddrp.RUTS_ssp.RUTS_retaddrp.Gadget_ss

PixPin_2026-03-18_19-22-57.png

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

PixPin_2026-03-18_19-24-42.png

上图代码的流程:

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

PixPin_2026-03-18_19-33-57.png

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

PixPin_2026-03-18_19-34-57.png

  • 最后交给 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。这样才能跳到下一条真正的新记录。

PixPin_2026-03-18_20-01-47.png

PixPin_2026-03-18_20-02-07.png

链式unwind信息

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

PixPin_2026-03-18_20-03-21.png

最后为什么还要 +8

这是把“返回地址”那 8 字节也算进去。因为从栈展开视角看,一个完整帧不仅有局部变量和保存寄存器,还要包含栈上的返回地址槽位。

总结CalculateFunctionStackSize 最终返回的是“函数自己的栈分配 + 保存寄存器到栈上占用 + 返回地址”

4. Spoof(具体栈欺骗实现)

1
2
3
4
5
6
7
pop    rax                         ; Real return address in rax

mov r10, rdi ; Store OG rdi in r10
mov r11, rsi ; Store OG rsi in r11

mov rdi, [rsp + 32] ; Storing struct in the rdi
mov rsi, [rsp + 40] ; Storing function to call

注意 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的栈参数复制到伪造栈中,下文会解释。

PixPin_2026-03-19_19-38-53.png

PixPin_2026-03-19_19-37-22.png

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

1
2
3
4
5
6
7
8
9
10
11
12
; ---------------------------------------------------------------------
; Storing our original registers
; ---------------------------------------------------------------------

mov [rdi + 24], r10 ; Storing OG rdi into param
mov [rdi + 88], r11 ; Storing OG rsi into param
mov [rdi + 96], r12 ; Storing OG r12 into param
mov [rdi + 104], r13 ; Storing OG r13 into param
mov [rdi + 112], r14 ; Storing OG r14 into param
mov [rdi + 120], r15 ; Storing OG r15 into param

mov r12, rax ; OG code used r12 for ret addr

PixPin_2026-03-18_23-09-28.png

注意最后一个指令是:mov r12, rax,所以r12存储返回地址(返回到main函数的地址)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
; ---------------------------------------------------------------------
; Prepping to move stack args
; ---------------------------------------------------------------------

xor r11, r11 ; r11 will hold the # of args that have been "pushed"
mov r13, [rsp + 30h] ; r13 will hold the # of args total that will be pushed

mov r14, 200h ; r14 will hold the offset we need to push stuff
add r14, 8
add r14, [rdi + 56] ; stack size of RUTS
add r14, [rdi + 48] ; stack size of BTIT
add r14, [rdi + 32] ; stack size of our gadget frame
sub r14, 20h ; first stack arg is located at +0x28 from rsp, so we sub 0x20 from the offset. Loop will sub 0x8 each time

mov r10, rsp
add r10, 30h ; offset of stack arg added to rsp

looping:

xor r15, r15 ; r15 will hold the offset + rsp base
cmp r11, r13 ; comparing # of stack args added vs # of stack args we need to add
je finish

; ---------------------------------------------------------------------
; Getting location to move the stack arg to
; ---------------------------------------------------------------------

sub r14, 8 ; 1 arg means r11 is 0, r14 already 0x28 offset.
mov r15, rsp ; get current stack base
sub r15, r14 ; subtract offset

; ---------------------------------------------------------------------
; Procuring the stack arg
; ---------------------------------------------------------------------

add r10, 8
push [r10]
pop [r15] ; move the stack arg into the right location

; ---------------------------------------------------------------------
; Increment the counter and loop back in case we need more args
; ---------------------------------------------------------------------
add r11, 1
jmp looping

首先我们看下面这一部分

PixPin_2026-03-19_18-48-06.png

这一部分汇编指令的目的是计算未来目标函数栈参数位置相对当前 rsp 的偏移量

1
2
3
4
5
6
r14 = 200h					// 320字节的工作空间
+= 8 // push 0
+= RUTS_ss // RtlUserThreadStart 假帧的大小
+= BTIT_ss // BaseThreadInitThunk 假帧的大小
+= Gadget_ss // gadget 假帧的大小
- 0x20 // 减去32字节的影子空间

这一部分可能有点难以理解,我画一个图来辅助解释

Drawing 2026-03-19 18.55.08.excalidraw.png

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

PixPin_2026-03-19_19-25-20.png

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

PixPin_2026-03-19_19-38-53.png

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

PixPin_2026-03-19_19-54-53.png

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

接下来就是通过一个循环,预先搬运目标函数的额外栈参数到指定位置上

PixPin_2026-03-19_19-57-01.png

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

PixPin_2026-03-19_20-14-33.png

执行后的调用栈

PixPin_2026-03-19_20-14-54.png

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

PixPin_2026-03-19_20-15-30.png

伪造RtlUserThreadStart + 0x14 frame后

PixPin_2026-03-19_20-16-02.png

伪造BaseThreadInitThunk + 0x21 frame后

PixPin_2026-03-19_20-16-32.png

伪造Gadget frame后

PixPin_2026-03-19_20-17-09.png

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

PixPin_2026-03-19_20-17-56.png

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

PixPin_2026-03-19_21-57-11.png

PixPin_2026-03-19_21-58-09.png

3.2 BYOUD

项目地址:klezVirus/BYOUD

博客: Fantastic unwind information and where to find them | klezVirus

PS:看完感叹神乎其技。

核心思想:通过修改/注入PE的.pdata,欺骗基于调用栈回溯的EDR,使其认为代码在合法的调用链中执行,从而绕过检测。

该项目一共有7种欺骗技术,以后有机会我再把BYOUD所有欺骗技术再研究一遍,这里不想把文章写的太长影响观感。

UNWIND_DATA_TAMPER

PixPin_2026-03-22_19-56-29.png

UNWIND_DATA_HIJACK

PixPin_2026-03-22_19-57-34.png

剩余的5种欺骗就不演示,感兴趣的可以自己运行,并观察调用栈。如果想了解这7种调用栈欺骗原理,我更推荐阅读klezVirus写的那篇博客,如果各位师傅觉得我写的还行,那未来我也会出篇文章一起学习(感觉像挖坑)

总结

ThreadStackSpoofer通过截断shellcode栈帧的方式隐藏调用栈。

CallStackMasker通过睡眠 + 合成栈帧的方式伪造调用栈

LoudSunRun通过合成栈帧的方式在调用敏感函数前伪造调用栈。

BYOUD(很新的一个项目,刚发布不到一个星期):通过修改/注入PE的.pdata,欺骗基于调用栈回溯的EDR,使其认为代码在合法的调用链中执行,从而绕过检测。

这篇文章写的一般,感觉像流水账,计划是在4月初发的,不过写都写了就发出来,给师傅当个乐子看。

现代红队主流的防御规避技术我已经学了差不多了,还剩几个没写,我还能写啥呢?下篇文章的主题各位师傅猜一下呗。

参考资料

  1. boku7/BokuLoader: A proof-of-concept Cobalt Strike Reflective Loader which aims to recreate, integrate, and enhance Cobalt Strike’s evasion features!
  2. klezVirus/ThreadPoolExecChain:一个简单的POC,展示如何通过尾部调用串联多个回调,人工构建调用栈
  3. kyleavery/AceLdr: Cobalt Strike UDRL for memory scanner evasion.
  4. JoasASantos/SysWhispers4:通过直接和间接系统调用Windows NT 3.1 24H2的AV/EDR规避x64 ·x86 ·魔兽世界64 ·ARM64
  5. klezVirus/SilentMoonwalk: PoC Implementation of a fully dynamic call stack spoofer
  6. klezVirus/Moonwalk–: Moonwalk++: Simple POC Combining StackMoonwalking and Memory Encryption
  7. kapla0011/KaplaStrike: A Cobalt Strike RL built with Crystal Palace — module overloading, NtContinue entry transfer, call stack spoofing, sleep masking, and static signature removal.
  8. 0xNinjaCyclone/AsmLdr:具备复杂规避功能的动态壳码加载器
  9. klezVirus/BYOUD:带上你自己的 Unwind 数据框架
  10. 栈欺骗入门
  11. 再探堆栈欺骗之动态欺骗-先知社区
  12. 三探堆栈欺骗之Custom Call Stacks-先知社区
  13. mgeeky/ThreadStackSpoofer
  14. Cobalt-Strike/CallStackMasker
  15. klezVirus/BYOUD
  16. susMdT/LoudSunRun
Prev
2026-03-27 18:49:09 # 防御规避