回调地狱:用回调链与代理栈帧混淆调用栈

admin 2025-12-25 03:05:44 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文探讨利用回调链与代理栈帧混淆调用栈,旨在解决线程池回调中隐藏栈帧与获取返回值的矛盾。作者提出了帧交换技术,通过特定Gadget保存返回值,并设计了回调链以混淆执行流。文章分析了代理栈帧对IntelCET的兼容性,提供了检测思路与POC代码,为绕过EDR调用栈检测提供了实用的技术原语。 综合评分: 96 文章分类: 免杀,红队,逆向分析,二进制安全,漏洞POC


cover_image

回调地狱:用回调链与代理栈帧混淆调用栈

klezVirus

securitainment

2025年12月23日 10:24 中国香港

前言

有一次,我的朋友 Athanasios Tserpelis(又名 trickster0) 打电话给我,手上正碰到一个很棘手的问题:

我在用 TpAllocWork + TpPostWork 执行一个任意函数,但不太确定该怎么取回返回值。有什么思路吗?

这个问题让我想起我以前做过的一些实验,只是因为懒一直搁置。我决定重新捡起来再试一次。你在这篇博文里看到的,就是这次重启后的成果:我尝试解决这个问题,并在后来将其整合进我的 stack spoofing 研究。

这项具体工作最终没有进入我的最终演讲,原因有很多,主要是我不认为它在实质上推进了研究,或提供了有意义的额外实用价值。

不过,为了完整起见,我还是在这里发布这些结果。

定义

在正式“跳”进主题之前,我们先给出一些定义,方便阅读。

Tail calls (尾调用)

尾调用 (tail call) 指的是:在某个函数的最后一步执行一次子例程调用。如果一个函数把“调用另一个函数”作为自己的最后一个操作 (也就是直接返回该调用的结果),那么这就是尾调用。

在汇编层面,尾调用通常用 jmp指令实现,而不是 call

在普通的函数调用中,call指令会把返回地址压入栈中,并把控制流转移到目标函数。

但在尾调用里,由于当前函数已经结束、返回地址也不再需要,我们可以复用当前的栈帧。jmp指令正好满足这一点:它不会压入新的返回地址,而是直接把控制流转交给被调用者 (callee)。

这样就避免了深度递归调用中原本会出现的栈增长。

Callbacks (回调)

回调 (callback) 是作为参数传递给另一个函数的函数指针,目的是在稍后某个时刻被调用。通常,回调会在某个事件触发或某个操作完成后执行。

  • 回调是异步编程、事件驱动系统和各类 API 的基础。
  • 它把调用方与被调用方解耦,从而带来更好的扩展性与控制反转。

ROP Gadget

ROP gadget 是一段短小的、已存在的机器指令序列,通常以 ret(或类似的控制流转移指令) 结尾。攻击者把多个 gadget 串联起来,在不注入代码的情况下完成任意操作,从而绕过诸如 DEP/NX 等防护。

JOP/COP Gadget

JOP gadget (Jump-Oriented Programming) 或 COP gadget (Call-Oriented Programming) 也是一段由“合法指令”组成的短序列,但它以间接 jmp或 call结尾 (而不是像 ROP 那样以 ret结尾)。攻击者通过可控指针把这些序列串起来,以实现任意执行,并绕过依赖返回地址的防护机制,例如 shadow stacks 或 control-flow integrity。

临时定义

下面这些定义是“随手定”的:仅为本文的讨论服务,并不一定反映广泛接受的共识。

Forward Proxy Frame (前向代理栈帧)

前向代理栈帧 (Forward Proxy Frame) 本质上是一个 JOP 或 COP gadget:它使用自己构造的栈帧执行,并且控制流通过 forward edge 转移到该 gadget。也就是说,它是通过一次间接 jump 或 call 直接进入,而不是通过 return 指令触发。

一个符合该定义的栈帧示例如下:

call&nbsp;REG &nbsp;<-- we place RIP here via CONTEXT&nbsp;or&nbsp;reach it via JMP REG
poprbx
addrsp,&nbsp;20
ret

这类栈帧一般并不容易找到,因此我们会把该定义扩展到其他也能充当 proxy 的函数 (例如 NdrClientCall3NdrServerCall2等)。

Backward Proxy Frame (后向代理栈帧)

后向代理栈帧 (Backward Proxy Frame) 是一个使用专用栈帧执行的 ROP gadget:控制流通过 backward edge 到达该 gadget——也就是通过 return 指令触发,符合传统 return-oriented programming 的语义。

简单来说,就是我们为该 gadget 分配对应的栈帧,然后让原本的 epilogue 负责将其 unwind。

对读过任何 Moonwalk 系列博文的读者来说,很容易看出:任何 CONCEAL gadget 确实都是一个合法的后向代理栈帧。

一个符合该定义的栈帧示例如下:

call&nbsp;0xaddress
poprbx&nbsp; <-- we&nbsp;push&nbsp;the address of this instruction as return address&nbsp;and&nbsp;let it execute on return
addrsp,&nbsp;20
ret

很明显:前向栈帧兼容 CET (因为 CALL会完成返回地址的合法设置),而后向栈帧不兼容,因为其返回地址是人工构造的。

回调在现实中的呈现方式

在调用栈 (call stack) 检测以及一般的用户自定义回调场景里,一个长期存在的限制是:线程 worker 要执行的内存区域必须真实存在于内存中——要么嵌在某个 module 里 (例如通过 stomping),要么位于专门的 RX/RWX 私有内存区域。结果就是,回调地址不可避免地会出现在调用栈里,从而更容易被检查与检测。

我们可以在下方看到:回调地址在调用栈中清晰可见。

一种可能的缓解方式是:把回调改造成一个无栈帧函数 (no-frame function),使用类似 tail call optimization 的模式。我们可以用纯 MASM 来实现该回调:先 pop 掉紧邻的返回地址,准备参数,使其能接收超过 Thread Worker Callback 通常支持的标准 3 个参数,然后把 CALL指令替换为跳转到真实目标函数的 JMP

滥用回调与尾调用,在调用栈里隐藏原始调用者并不是什么新概念;围绕这个主题已有不少公开研究。最有代表性的例子之一是 Brute Ratel C2 的作者 Chetan Nayak 发布的博文:Hiding In PlainSight – Indirect Syscall is Dead! Long Live Custom Call Stacks

简而言之:我们已知可以构造一个无栈帧回调 (frameless callback),让它把真正要调用的函数以尾调用的形式触发,大致如下:

section.text
global&nbsp;WorkCallback
WorkCallback:
movrbx,&nbsp;rdx; backing up the struct as we are going to stomp rdx
movrax, [rbx] &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;; NtAllocateVirtualMemory
movrcx, [rbx&nbsp;+&nbsp;0x8] &nbsp;&nbsp;; HANDLE ProcessHandle
movrdx, [rbx&nbsp;+&nbsp;0x10] &nbsp;; PVOID *BaseAddress
xor&nbsp;r8, r8 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;; ULONG_PTR ZeroBits
mov&nbsp;r9, [rbx&nbsp;+&nbsp;0x18] &nbsp;&nbsp;; PSIZE_T RegionSize
mov&nbsp;r10, [rbx&nbsp;+&nbsp;0x20] &nbsp;; ULONG Protect
mov&nbsp;[rsp+0x30], r10 &nbsp; &nbsp;; stack pointer for 6th arg
mov&nbsp;r10,&nbsp;0x3000; ULONG AllocationType
mov&nbsp;[rsp+0x28], r10 &nbsp; &nbsp;; stack pointer for 5th arg
jmprax

虽然这确实能把回调栈帧从调用栈里“消掉”,但代价是:你会丢失被调用函数的返回值。在很多场景里,这种取舍是不可接受的——尤其是当回调 (这里指线程池) 在 worker 执行完成后,并不原生支持返回值的取回。

初版栈帧交换设计

在开发 stack moonwalking 技术期间,我们考虑过该算法对人工检查的抗性。为了增强对人类审阅的对抗性,我们首先为主可执行文件内部的调用设计了一套更不透明的架构,由一个条件跳板 (conditional trampoline) 和一个任意函数调用器 (arbitrary function invoker) 组成。条件跳板的目的,是在函数被调用时“欺骗”调用栈检查者,让其误判到底走了哪个分支。由于不可能得知某个过去时刻 T的变量值,因此分支只能通过检查调用栈中后续栈帧来推断,而这些后续栈帧是可控的。

另一方面,任意函数调用器会利用一个函数指针变量来“伪装”主程序调用的函数确实是 High-Level API。在实现上,这通过两类函数配合完成:一个非法的无栈帧函数 (illegal frameless function) 和一个标准的有栈帧函数 (standard framed function)。

无栈帧函数是程序实际执行的部分,它负责准备栈空间,以便容纳“看起来合法”的有栈帧函数。有栈帧函数并不会完整执行;它的主要任务是:验证非法函数创建的栈空间、验证返回地址、验证到 high-level API 的调用流,并在返回到主程序代码之前执行一次 restore 例程。

在该方案中,emulate_system_call充当不透明跳板 (opaque trampoline,或称 dispatcher),而 emulate_system_call_w充当无栈帧函数。restore 函数会被调用栈中的 restore 函数替换,并把返回地址设置为指令 restore+19h,即 call functionAddress之后的那条指令。变量 functionAddress 最终保存的是指向 High-Level API 的伪造函数指针,与原始架构一致。

最终的调用栈可以在图中观察到,结合程序的执行流,这有助于理解无栈帧函数与有栈帧函数各自的角色。无栈帧函数本质上扮演了有栈帧函数的 prologue:创建栈帧并放置正确的返回地址。另一方面,restore 函数只执行它的 epilogue:恢复所有被保存的寄存器,并释放之前分配的栈空间。

在我们的人工场景里这点不算重要;但镜像 prologue 的一个额外好处是:如果函数内部抛出异常,可能可以避免一些奇怪的副作用。

这套新架构确实为程序代码增加了一层混淆,使使用中的 stack spoofing 技术更难被分析和检测。dispatcher 可以扩展为包含多个条件跳转,进一步复杂化执行流,让真实路径更难被确定。此外,无栈帧函数还可以在不影响有栈帧函数的情况下被拆分与混淆,从而在隐藏程序真实行为上提供更大的灵活性。

不过,由于 half moonwalk 技术只涉及部分 stack spoofing,这种额外复杂度最终被认为与其有限收益不成比例。

使用线程池进行栈帧交换代理

虽然整体架构相对于直接收益可能显得过于复杂,但它引入了一个更具普适性的原语 (primitive):frame swapping (也就是“给我们从 ret address spoofing 发明以来一直在做的事,再贴一个尴尬的新名字”)。

当开发者希望用 callback-style 机制来代理任意函数调用时,这项技术会非常灵活。在一些线程池执行模型中也能看到类似范式,例如 SafeBreach Labs 最早在 这里 探索过的模型。

我们的小实验发现:frame swapping 能非常有效地解决这一两难。它既能把回调栈帧隐藏在调用栈中,又保留从被代理函数取回返回值的能力,为基于回调的执行模型提供了一个强力的规避与控制流原语。

要让这项技术按预期工作 (即隐藏回调栈帧,同时保留返回值),我们必须识别出这样一种函数模式:在函数 epilogue 之前,先把返回值 (通常在 RAX) 通过另一个寄存器 (作为指针) 存入某个内存位置。

这种模式在多个系统 DLL 中都能找到不少例子。但为了最大化调用栈的“可信度”,我们需要直接从我们注入代码的那个程序的 image base 中挑选一个栈帧。

为了举例,下面是 wininet中函数 GlobalGetUserAndPassW的 EPILOG:

180152087488903&nbsp; &nbsp; &nbsp;MOV&nbsp;qword&nbsp;ptr [RBX],RAX
18015208a4883c420ADD&nbsp;RSP,0x20
18015208e5b&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;POP RBX
18015208fc3&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;RET

从这段片段可以看出:该函数期望拿到一个 64-bit 变量的引用,用来保存前一个 CALL产生的返回值。基于该 epilogue,总栈帧大小也很容易计算:ADD RSP, 0x20释放 0x20字节,再加上 POP指令对应的 8 字节,总计 0x28 字节。

沿用上一节建立的模式,我们只需要在自定义回调中复刻 prologue 的行为:具体来说,准备好栈,使得执行流能直接返回到负责保存返回值的那条 MOV指令地址。

do_call:
mov&nbsp;r10, [r10 +&nbsp;08h] &nbsp;; addressToPush

;; Pretending we are in a frame proc
;; Note: compile this as a frame proc is absolutely not necessary
;; in this case, it is just to show we are mirroring the prolog of GlobalGetUserAndPassW
&nbsp; .pushreg&nbsp;rbx
pushrbx
&nbsp; .allocstack&nbsp;20h
subrsp,&nbsp;20h; this is for the spoofed/swapped frame
&nbsp; .endprolog

; This is the address after the call in GlobalGetUserAndPassW
push&nbsp;r10

; GCONTEXT is the address of a user-controlled Work Item structure
movrbx, GCONTEXT &nbsp; &nbsp; &nbsp;; we need this structure in RBX
addrbx,&nbsp;8; we use the address of the return value, unused for generic callbacks

; Finally we just to the target function
jmprax

得到的调用栈会如下所示:

这样我们也能取回回调的返回值。另一个值得注意的点是:这类 gadget 也能通过 Eclipse-based inspection 的检查,因为该函数的完整模式允许我们把返回地址精确放在 CALL指令之后,如下所示:

180152075e8eeaf0000&nbsp; CALL internal_function
18015207a488903&nbsp; &nbsp; &nbsp; &nbsp; MOV&nbsp;qword&nbsp;ptr [RBX],RAX &nbsp;<--- We can set the return address here
18015207db801000000&nbsp; MOV EAX,0x1
180152082eb06&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;JMP PROCEDURE_END

SAVE_RBX_RDX:
180152084488902&nbsp; &nbsp; &nbsp; &nbsp; MOV&nbsp;qword&nbsp;ptr [RDX],RAX
180152087488903&nbsp; &nbsp; &nbsp; &nbsp; MOV&nbsp;qword&nbsp;ptr [RBX],RAX

PROCEDURE_END:
18015208a4883c420ADD&nbsp;RSP,0x20
18015208e5b&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; POP RBX
18015208fc3&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; RET
180152090cc&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ??&nbsp;CCh

限制

这项技术本身没有太多固有的限制,但当然,具体能达到什么效果取决于你选择的 save gadget。

返回值

一般来说,只要具备 MOV [REG], RAX这种形态,就可以实现上述行为:保存一次普通调用的返回值。

当然,如果某个函数是通过参数返回结果 (而不是通过寄存器返回),那你就不需要这些手段了。

参数数量

最后那个 gadget 对“你能往栈上放多少参数”至关重要。函数 GlobalGetUserAndPassW在调用内部函数之前只分配了 shadow stack 并保存了 RBX,这意味着前一个栈帧的返回地址只相距 0x28 字节。结合 64-bit 下参数传递的方式,读者应该能看出:在这种情况下,即便你去覆盖压到栈上的 RBX 原值 (这属于非法操作),在覆盖到返回地址之前,你也最多只有一个 stack 参数的空间 (否则在 return 时会崩溃)。

因此,如果你希望支持更多参数,就需要找到与之类似、但分配更大的 proxy frame (要支持 nargs个参数,你至少需要 0x20 + (nargs*8)的空间)。

我和 Alex Reid 讨论后也认为:另一种办法是复用 Moonwalk++ 里的同类模式,再额外加一个足够大的 proxy frame (conceal-gadget),以支持所需的参数数量。

一个、没有与十万次

我记得不久前看过一篇博文:它描述了加载一个名字很奇怪的 DLL,通过其中一个函数充当前向代理栈帧 (forward proxy frame),在 ThreadPool worker callback 之间“插入一帧”。 我指的那篇文章在这里:LINK

不过,基于这种模式,我们还可以探索多种方式:不仅仅是“加一帧”,而是在不依赖加载奇怪 DLL 的前提下,几乎构造出完全“自定义”的调用栈。当然,每种方法都有各自的取舍。

  1. 例如,构造一条由“简单”后向代理栈帧 (backward proxy frames) 组成的链,以最后一个栈帧负责保存目标调用的返回值。
  • 合法的 caller-callee 关系未必能稳定构造出来

  • 并非所有 epilogue 都紧跟在某条 call指令之后执行,因此更容易被检测

  • 主要优势:调用栈可以完全自定义

  • 劣势:

  1. 另一种方案是使用自然存在的前向代理栈帧 (forward proxy frames),也就是各种 callbacks
  • 我不认为任何理智的人会去用这种链

  • 我们是在把 callbacks 叠在…… callbacks 之上?

  • 主要优势:forward edges 全部是合法的,并且兼容 CET

  • 劣势:

  1. ……我并没有认真想过第三种方案……或者说,其实想过?

第一种方案需要类似 stack moonwalk 的准备工作:识别栈帧大小、收集合法的后向代理栈帧 (backward proxy frames),然后把它们压到栈上并设置正确的返回地址。就我个人而言,我之前文章和项目里提供的代码与示例已经足够让你复现,不需要再额外解释。

第二种方案则释放了我很“友好”地称之为“callback hell”的东西:我们可以把多个回调机制串在一起,用来混淆调用栈。这个策略非常简单,甚至比上一种更简单;并且能充当回调触发器 (callback invokers) 的函数并不稀缺。如果你不想自己去挖掘,AlternativeShellcodeExec 这个项目基本覆盖了你需要的大部分内容。

为了实现链式调用逻辑,我们会定义一个通用的结构体数组 (Work Items):每个 work item 定义指向链中下一个调用的指针、它的参数,以及可选的返回参数。

typedefstruct&nbsp;WorkItemContext {
&nbsp; &nbsp; FARPROC func; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// Function pointer
void* retAddress; &nbsp; &nbsp; &nbsp;// Return address to simulate stack return
uint64_t&nbsp;argc; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Argument count
void* args[MAX_ARGC]; &nbsp;// Arguments (up to MAX_ARGC)
} WorkItemContext;

至于具体逻辑,我们会构建三个主要的汇编函数*:

  • FirstCallback

    :初始化在回调链中会用到的一些变量

  • GenericCallback

    :对下一个通用回调触发器执行尾调用 (tail call)

  • LastCallback

    :设置 save 到 RBX_SAVE 的后向代理栈帧 (backward proxy frame),并对目标执行实际调用

*注:这种实现方式完全是随意的

最终的程序流程大致如下:

好吧,但为什么?

显然,尽管听起来很离谱,这种奇怪的调用栈构造方式确实能绕过当前的 call stack 检测逻辑。

理论上,你可以用 n!种方式把 n个 callbacks 串起来,这让它在“人工构造调用栈”方面显得很有意思。另一方面,这些回调里多数都是已知的,因此也可能为它们建立特征 (至少对有文档记录的那部分而言)。

POC||GTFO

最终的 POC 在这里:ThreadPoolExecChain。和往常一样,拼图里最关键的一块还是汇编部分。它并不复杂,应该可以作为更复杂设计的基础。

下面是一段展示该技术效果的小视频。如果嵌入版本无法播放,这里是 LINK。

检测视角

注意:只有在禁用 Intel-CET 的进程中才需要做检测。后向代理栈帧与 CET 不兼容。

对于使用后向代理栈帧 (backward proxy frames) 的 frame swapping,检测视角与最初的 stack moonwalking 技术基本一致。最可靠的检测策略仍然是监控调用栈中的异常,尤其是识别那些“前面没有合法 CALL指令”的返回地址。

事实上,这项技术对成功执行提出了相当严格的约束:我们必须找到这样一条指令——它既能把 RAX写入一个指针指向的位置,又能在返回时不破坏关键寄存器。这组条件会显著减少可用 gadget 的数量。不过,要找到合适 gadget 也远非不可能;我们在本文中使用的那个就证明了这一点。

至于回调链呢?坦白说,我不知道为什么这种行为至今还没有一个特征签名,因为从“正当需求”的角度看,几乎没有任何理由要实现这种东西。

其他检测思路:

  • 检查进程 IAT,以检测动态调用
  • 使用 CFG/XFG bitmap 验证被调用函数 (若启用 CFG)
  • 验证 CALL地址
  • 代码模拟 (emulation)

结语

归根结底,这一切折腾都源于我去追一个朋友提出的、非常偶发的小问题;讽刺的是,我甚至还没把结果告诉他。

另外值得注意的是:即使在启用 CET 的情况下,回调链仍能正常工作,但“取回返回值”的技巧却不行。这意味着未来我们得想出另一套做法。在那之前,就先这样吧。

参考

  • Hiding In PlainSight – Indirect Syscall is Dead! Long Live Custom Call Stacks
  • Windows x64 Calling Convention
  • AlternativeShellcodeExec
  • PoolParty
  • Evading Elastic EDR’s call stack signatures with call gadgets

Callback hell: abusing callbacks, tail-calls, and proxy frames to obfuscate the stack

免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:securitainment klezVirus《回调地狱:用回调链与代理栈帧混淆调用栈》

评论:0   参与:  4