在现代高级威胁防御环境下,攻击者为了规避EDR/AV对进程内存的扫描,经常采用“睡眠混淆”技术,将恶意模块在休眠期间加密,并在适当时机恢复执行。本文聚焦四种代表性的睡眠混淆实现:Ekko、ZILEAN、FOLIAGE 和 Cronos。它们虽然具体实现方式各异,但核心目标一致:通过改写内存权限、动态加密/解密模块以及精密调度机制,使恶意模块在休眠窗口期难以被检测。本文将从调度方式、上下文捕获、执行链及内存处理等角度详细分析这四种技术的原理与流程,帮助读者理解高级恶意程序如何利用Windows内核与线程机制实现隐蔽睡眠。
一、Ekko
项目地址:Cracked5pider/Ekko: Sleep Obfuscation
相信各位师傅对Ekko技术非常熟悉了,我就长话短说,Ekko利用 CreateTimerQueueTimer + NtContinue,把改权限(RX->RW),加密内存,睡眠,解密,恢复权限(RW->RX)做成一条ROP的上下文链,让Timer线程执行,主线程在等待的过程中,整个进程内存被加密/解密。
来看Ekko的主流程:
CreateTimerQueue创建一个定时器队列。用
CreateTimerQueueTimer(..., RtlCaptureContext, ...)捕获Timer线程的CONTEXT。复制出多份
1
CONTEXT
,分别代表:
RopProtRW:调用VirtualProtect(ImageBase, Size, PAGE_READWRITE, &OldProtect)RopMemEnc:调用SystemFunction032(&Key, &Img)(RC4加密当前进程映像)RopDelay:调用WaitForSingleObject(NtCurrentProcess(), SleepTime)RopMemDec:再次调用SystemFunction032(解密)RopProtRX:VirtualProtect(..., PAGE_EXECUTE_READWRITE, ...)RopSetEvt:SetEvent(hEvent)通知主线程可以继续
这里需要理解的就是 CreateTimerQueueTimer(..., RtlCaptureContext, ...) 捕获的是Timer线程的 CONTEXT ,我在《调用栈欺骗(Callstack Spoofing)》文章就说过了,这里我们再进一步验证。
我们来到微软的官方文档:createTimerQueueTimer 函数 (threadpoollegacyapiset.h) - Win32 apps | Microsoft Learn,其中Flags参数为WT_EXECUTEINTIMERTHREAD时表示回调函数由计时器线程本身调用。

如果捕获的是主线程的上下文,RopMemEnc都加密了整个进程映像,此后 .text 是一堆密文,怎么可能执行会执行到RopDelay呢,所以是捕获的是Timer线程的上下文。
还有一点需要明白的是主线程执行下图这块代码所需的时间是非常短的,远远快于计时器发出信号之前必须经过的当前时间的时间量,即 主线程跑完Setup的时间 << 200ms (以执行到 RopMemEnc 作为评判标准)。

我们可以简单地调试验证一下,在 CreateTimerQueueTimer(……,RopSetEvt,) 下一个断点,如果能够正常停在断点处,没有出现异常,则说明 主线程跑完Setup的时间 << 计时器设置的时间量(DueTime)。

如果我们在 CreateTimerQueueTimer(……,RopDelay,) 下一个断点,并停留大概1s的时间后往下走,则发生异常,因为我们停留的1s内,Timer线程执行了RopMemEnc,对进程映射加密了,主线程执行密文崩溃。

最后一点为什么 CreateTimerQueueTimer + NtContinue 能够间接执行目标函数,我不想再这里过多赘述,请阅读我的另一篇文章:《调用栈欺骗(Callstack Spoofing)》
根据上文的分析,主线程创建计时器队列计时器可以看作是一瞬间的事,所以Timer的时间片可以看作是100ms执行一个操作。Timer线程每个时间片执行的操作:
| DueTime | 计划时刻 | 实际执行时刻 | 操作 |
|---|---|---|---|
| 100 | T0+100ms | T0+100ms | VirtualProtect RW |
| 200 | T0+200ms | T0+200ms | SystemFunction032(加密) |
| 300 | T0+300ms | T0+300ms | WaitForSingleObject(Timer阻塞SleepTime秒时间) |
| 400 | T0+400ms | T0+400ms+SleepTime | SystemFunction032(解密) |
| 500 | T0+500ms | T0+500ms+SleepTime | VirtualProtect RX |
| 600 | T0+600ms | T0+600ms+SleepTime | SetEvent |
T0可以近似地看作最后一个 CreateTimerQueueTimer 执行完后的时间。
注意:以上全凭个人主观猜测,不保证正确性!
主线程和Timer线程各自时间轴上的状态:

Timer执行执行加解密的的时间(忽略执行CreateTimerQueueTimer的时间,因为这个时间非常短)相对于Sleeptime来说是非常非常短的,这也就是说大部分时间EDR抓不到Beacon的内存特征,除非它时时刻刻盯着这块内存的变化,我想这也不太可能吧?
二、ZILEAN
代码:Havoc/payloads/Demon/src/core/Obf.c at main · HavocFramework/Havoc
使用 RtlRegisterWait 注册一组延迟触发的wait callback,然后让这些callback调用 NtContinue 恢复伪造好的 CONTEXT,从而按顺序执行 VirtualProtect → 加密 → Sleep → 解密 → 恢复权限 → SetEvent。
其整体的执行顺序跟Ekko技术一致,只是调度方式和执行线程有所不同,具体来说就是:
- EKKO使用RtlCreateTimerQueue/RtlCreateTimer创建定时器队列和定时器进行API调度,使用定时器线程(Timer thread)执行目标函数。
- ZILEAN使用RtlRegisterWait给等待对象wait_event注册一个等待任务(call back),如果wait_event被触发,或者等待超时delay_ms到达,就由wait thread执行等待任务。
ZILEAN依赖的关键API如下:
- RtlRegisterWait:注册wait callback
- RtlDeregisterWait:清理wait handle
- RtlCaptureContext:捕获线程上下文,作为CONTEXT模板
- NtContinue:恢复伪造上下文,从而跳转执行指定函数
- SystemFunction032:对内存区域做加密/解密
第一阶段:创建wait_event对象

第二阶段:捕获Wait 线程上下文作为模板

RtlCaptureContext(&TimerCtx) 不是主线程直接调用,而是wait callback线程里执行,所以TimerCtx保存的是 wait callback线程的上下文。
第三阶段:通知主线程已完成捕获

Delay += 100,延迟100ms后,让wait callback线程执行 EventSet ,通知主线程上下文已捕获,所以 EvntTimer 是一个同步信号。
SysNtWaitForSingleObject的,其Alertable = FALSE表示无限等待,也就是说主线程会一直卡在这里,直到EvntTimer被设置。
第四步:初始化ROP CONTEXT
等待EvntStart。使用WaitForSingleObjectEx,阻塞wait callback线程执行ROP链,直到主线程发出开始信号。

把Beacon模块区域改成可读写,方便加密。

加密。

这里是用当前进程句柄做等待对象,因为当前进程在运行期间不会signaled,所以基本等价于:Sleep(Delay + TimeOut);

解密。

恢复内存权限。需要关注的是:加密前把整个ImgBase/ImgSize改成PAGE_READWRITE,但恢复时只恢复 TxtBase/TxtSize。
通知主线程:整条ROP睡眠混淆链结束。
第五阶段:ROP链执行与结束
每个 Rop[i] 都会被注册成一个未来执行的callback。callback函数是NtContinue。callback参数是&Rop[i]。这些调用不是同步线性调用,而是由按时间触发。

首先wait callback线程执行WaitForSingleObjectEx的ROP,线程阻塞,直到主线程发出EvntStart开始信号。
主线程使用SysNtSignalAndWaitForSingleObject先设置 EvntStart 信号,wait callback线程收到信号,继续往下执行剩余ROP链,然后主线程等待 EvntDelay 信号,直到ROP链结束。

三、FOLIAGE
代码:Havoc/payloads/Demon/src/core/Obf.c at main · HavocFramework/Havoc
FOLIAGE与前面EKKO/ZILEAN 不同:它不是用Timer/Wait回调触发 NtContinue,而是创建一个挂起线程,然后向它连续投递APC,让APC依次执行一组 NtContinue(CONTEXT),完成加密、睡眠、解密和恢复。
主要流程如下:
- 创建事件hEvent
- 创建挂起线程hThread
- 获取hThread初始CONTEXT
- 基于这个CONTEXT构造多组ROP CONTEXT
- 向hThread 队列投递多个NtContinue APC
- AlertResumeThread恢复线程并触发 APC
- ROP 链依次执行:
- 等待 hEvent
- 改内存 RW
- 加密模块
- 获取当前线程上下文
- 设置伪造上下文
- sleep
- 解密模块
- 恢复 .text 权限
- 恢复原上下文
- 退出线程
第一阶段:创建事件hEvent。hEvent 用来控制ROP链开始执行,他与ZILEAN的EvntStart作用相同。

第二阶段:创建挂起线程hThread

第三阶段:获取hThread初始CONTEXT
SysNtDuplicateObject的作用是复制当前线程的句柄,得到一个可以被后续 NtGetContextThread / NtSetContextThread 使用的线程句柄。
NtGetContextThread的作用是获取暂停线程的上下文。

第四阶段:基于这个CONTEXT构造多组ROP CONTEXT。这个构造过程与上文介绍的ZILEAN完全一致,我就不过多介绍了。
第五阶段:APC投递执行链。FOLIAGE的调度方式就是创建一个自建线程,然后通过APC队列把多组 NtContinue(CONTEXT) 投递给这个线程,由这个线程依次执行整条sleep obfuscation链。

第六阶段:AlertResumeThread恢复线程并触发 APC
NtAlertResumeThread的作用是先alert这个线程,再resume这个线程,也就是说线程变成可告警状态,即等待期间可以被APC打断,并在当前线程中执行异步过程调用。

第七阶段:ROP链执行与结束
首先自建线程执行WaitForSingleObjectEx的ROP,线程阻塞,直到主线程发出hEvent开始信号。
主线程使用SysNtSignalAndWaitForSingleObject先设置hEvent信号,自建线程收到信号,继续往下执行剩余ROP链,然后主线程等待hThread结束信号,直到ROP链结束。

四、Cronos
项目地址:GitHub - Idov31/Cronos:一种利用等待定时器规避内存扫描的睡眠混淆技术的PoC。·GitHub
博文:timeout /t 31 && start evil.exe
一种基于Ekko思路的新型睡眠混淆技术PoC。Cronos利用可等待定时器对当前主EXE 的PE映像范围进行加密,并在加密期间将该映像权限从 PAGE_EXECUTE_READWRITE 改为 PAGE_READWRITE,睡眠结束后再恢复为 PAGE_EXECUTE_READWRITE,以降低内存扫描器在睡眠窗口中直接扫描有效代码的概率。
Ekko使用 CreateTimerQueueTimer 创建定时器,并设置等待时间,然后放入 TimerQueue 队列中。时间到期后,系统会自动分配工作线程执行回调。
Cronos使用 CreateWaitableTimerW 创建内核定时器对象,获取对象句柄,然后通过 SetWaitableTimer 配置定时器参数,包括到期时间、APC回调、周期等。主线程执行 SleepEx 后进入可警告等待状态,即等待期间可以被APC打断,并在当前线程中执行异步过程调用。
Cronos和Ekko的整体流程大致一致。区别在于:Cronos 在当前线程的可警告等待期间执行APC回调,而Ekko通过独立工作线程执行TimerQueue回调。
- 创建可等待定时器

- APC 回调中捕获上下文
通过一个dummy timer触发 RtlCaptureContext,在APC回调执行路径中捕获当前线程的寄存器上下文,并保存到 ctxDummyThread 结构中。这个上下文不是普通业务代码中刚进入 SleepEx 时的现场,而是APC分发路径中的上下文,后续会以它作为模板构造多个 NtContinue 上下文。

- 创建四个上下文
以捕获到的APC上下文为模板,克隆4个副本,分别构造:
VirtualProtect(ImageBase, ImageSize, PAGE_READWRITE, &oldProtect)SystemFunction032(&Image, &Key)加密SystemFunction032(&Image, &Key)解密VirtualProtect(ImageBase, ImageSize, PAGE_EXECUTE_READWRITE, &oldProtect)

- 时间线编排
时间线以线程进入可警告等待状态后,也就是执行 SleepEx(..., TRUE) 后作为起始点。
需要注意:源码中的宏名是 InitializeTimerMs,但实际换算逻辑是 sec * 1000 * 10 * 1000,也就是按“秒”转换成 100ns 单位的负相对时间。因此 CronosSleep(10) 对应的时间线大致如下:

| 时间 | 操作 |
|---|---|
| 立即 | VirtualProtect(PAGE_READWRITE) |
| 1 秒 | SystemFunction032 加密 |
| sleeptime - 1 秒 | SystemFunction032 解密 |
| sleeptime 秒 | VirtualProtect(PAGE_EXECUTE_READWRITE) |
- 为SleepEx调用链寻找 gadgets

- 全部设置为NtContinue 作为 APC 回调,并传入对应上下文

- 偏移计算
在正式介绍 SleepEx ROP链的汇编代码之前,需要先解释一个问题:构造上下文时,为什么会有下面这些看起来比较奇怪的偏移?
1 | ctxProtectionRW.Rsp -= (8 + 0x150); |
Ekko使用NtContinue恢复上下文执行目标函数时,如果希望目标函数能够正常返回,ctx.Rsp必须指向目标函数的返回地址。通常情况下,RtlCaptureContext捕获后的Rsp可以理解为位于返回地址之后(栈高地址),因此需要ctx.Rsp -= 8,让Rsp指向返回地址。
但Cronos这里额外多出了 0x150、0xF0、0x90、0x30。原因在于Cronos并不是直接从主代码调用目标函数,而是通过:
SleepEx alertable wait -> APC dispatch -> NtContinue -> VirtualProtect / SystemFunction032
这种路径间接执行目标函数。
更准确地说:在执行到call ntdll!KiUserCallForwarder之前,APC分发逻辑已经在当前线程栈上消耗了一部分栈空间。Cronos需要让NtContinue恢复后的目标函数返回地址,正好落在APCdispatcher已经布置好的返回路径上。
此外,由于QuadSleep ROP链的存在,每一次调用SleepEx时的Rsp都不同于主代码中直接调用SleepEx时的Rsp。下面通过调试观察。
在主代码中调用SleepEx后,RSP = 00000065202FDA98

进入到ROP链后,第一次调用SleepEx,RSP = 00000065202FD948

两者差值:
1 | 00000065202FDA98 - 00000065202FD948 = 0x150 |

进一步探究推到
1 | 注:Base_RSP以主代码中调用SleepEx后的RSP为基准。 |
其中depth_i就是每一次SleepEx入口相对于Base_RSP的栈深度差:
1 | SleepEx #1: depth = 0x150 -> offset = -(8 + 0x150) |

这样做的目的,是让所有通过APC + NtContinue执行的目标函数都能统一返回到KiUserCallForwarder后续的APC分发路径中,再由系统恢复被APC打断的SleepEx现场。
调试验证一下。
0x6cca2fcf38

VirtualProtect(PAGE_READWRITE) 的返回地址就是ntdll!KiUserCallForwarder 的返回地址,函数执行完后会返回到call ntdll!KiUserCallForwarder 的下一条指令。

call ntdll!KiUserCallForwarder 之后,在0x6cca2fcf38处留下返回地址作为VirtualProtect的返回地址。

第二次调用VirtualProtect,此时rsp = 0x000000147CCFD0A8,正常来说是指向返回地址,但是不知道为什么并没有写入正确的返回地址,导致VirtualProtect指向ret指令后,触发c0000005异常。

这一切的原因我不得而知,我只能确定是写了返回地址,目标函数执行完后能正常返回。我再次声明,以上全是我个人的主观推测,可能有误导性! 我也尝试使用动态调试的方法验证,可能是水平有限实在无法验证,如果各位师傅有验证的思路可以告诉我,谢谢!
最终结论:指向目标函数返回地址的 RSP = capturedRSP - depth_i - 8
也就是:
ctxX.Rsp = capturedRSP - depth_i - 8
这个结论的实际意义是:如果修改了QuadSleep的汇编代码,只要新增或删除了会改变栈深度的指令,比如push、pop、sub rsp, xx、add rsp, xx 等,就必须重新计算每一次SleepEx的depth_i,不能继续沿用原来的0x150、0xF0、0x90、0x30。
例如,我像正常函数一样保存非易失寄存器:


这里多了两次push,栈深度增加0x10,所以CronosSleep中4个上下文的offset都需要相应增加 0x10。

正常执行

反之,出错

总的来说,凡是在汇编QuadSleep中改变每次进入SleepEx前栈深度的操作,都会影响这4个上下文的Rsp偏移,必须重新调试或计算对应的depth_i。
- 执行SleepEx ROP链
因为当线程调用SleepEx进入可警告等待状态,触发执行了一个APC函数,就会返回到正常的执行状态。详见: https://learn.microsoft.com/zh-cn/windows/win32/api/synchapi/nf-synchapi-sleepex


调用链
1 | QuadSleep jmp SleepEx #1 |
在这里我还想讲一下:Cronos 技术中,ROP链是在主线程执行的,而不是在新建线程或者线程池线程中。当主线程进入 alertable 等待时,已经排队的APC就会依次执行,ROP链在主线程上下文中运行。
Cronos看起来像是在主线程中加密当前进程映像,因此很容易产生一个疑问:既然主线程所在的 EXE .text 已经被加密,那它继续执行时为什么不会崩溃?
关键在于,Cronos并不是让主线程在加密后继续执行自身代码,而是让主线程进入 SleepEx(alertable) 的系统等待路径;加密、解密和权限恢复都通过 APC + NtContinue 在这个等待路径中完成。只有当解密和权限恢复完成后,主线程才从SleepEx /ROP 链返回到原始代码继续执行。也就是说,加密窗口内真正运行的是APC dispatcher、NtContinue、VirtualProtect、SystemFunction032、SleepEx 等系统路径,而不是已经被加密的主 EXE .text。
五、总结
| 技术名称 | 调度方式 | 核心流程 | 特点与优势 |
|---|---|---|---|
| Ekko | TimerQueue Timer + NtContinue | 1. 创建 TimerQueue 2. 捕获 Timer 线程上下文 3. 构造 ROP CONTEXT 链 4. 执行 VirtualProtect → 加密 → Sleep → 解密 → 恢复权限 → SetEvent |
使用独立 Timer 线程,主线程仅等待事件完成;ROP链串联执行,模块在休眠期间加密,难被扫描 |
| ZILEAN | RtlRegisterWait callback + NtContinue | 1. 创建 wait_event 对象 2. 捕获 Wait 线程上下文 3. 构造 ROP CONTEXT 4. 等待主线程触发 EvntStart 5. 执行 VirtualProtect → 加密 → Sleep → 解密 → 恢复权限 → SetEvent |
依赖 Wait 线程执行回调,异步触发;主线程与 Wait 线程协调信号完成 ROP 链 |
| FOLIAGE | APC 队列 + 自建挂起线程 | 1. 创建事件 hEvent 2. 创建挂起线程 hThread 3. 获取初始 CONTEXT 4. 构造多组 ROP CONTEXT 5. 投递 NtContinue APC 6. AlertResumeThread 唤醒线程 7. ROP 链执行模块权限修改、加密、Sleep、解密、恢复 | 利用 APC 异步执行,线程自身可被 alertable 调用打断,链式执行更隐蔽;支持上下文/栈伪装 |
| Cronos | WaitableTimer + SleepEx(alertable) + NtContinue | 1. 创建可等待定时器 2. 捕获 APC 执行路径上下文 3. 构造四个 CONTEXT(改权限/加密/解密/恢复) 4. SleepEx 期间触发 APC 5. 执行上下文链 | 在主线程 SleepEx alertable 等待期间执行 APC;对当前主 EXE 的 PE 映像加密,简化调度流程 |
睡眠混淆是C2框架中一项重要的防御规避技术,它的核心目标是在Beacon休眠期间隐藏内存特征,降低被 EDR/AV扫描命中的概率。
出于研究目的,我在自研C2中实现了 Ekko 和 ZILEAN。但从工程角度看,睡眠混淆并不是一项必须实现的能力。它本质上是一把双刃剑:一方面可以在休眠期间隐藏Beacon的内存特征,另一方面也会带来更复杂的执行链和更明显的行为特征。对于自研C2而言,是否引入这类机制,应该取决于实际研究目标和对抗场景,而不是单纯追求“高级功能”的数量。
本月最后一篇文章如约而至。下一篇文章的主题我也不卖关子了,就是大名鼎鼎的——间接系统调用。
我相信各位师傅对这项技术并不陌生。无论是Hell’s Gate、Halo’s Gate,还是后续各种syscall stub、syscall number动态解析与绕过思路,间接系统调用一直都是Windows攻防研究中绕不开的话题。下一篇文章我会结合自己的理解,聊一聊这项技术。
当然我还有其他的主题可以写,比如最近在某公众号闹得沸沸扬扬的绕过核晶添加计划任务和添加用户,不过转念一想我可没这么厉害,能够一直绕过核晶,之前绕过了几天,还是被杀掉了,所以还是不丢人显眼。
真的最近几个月真是高强度更新文章和自研C2,耗费了大量精力,等7月一过,先消停一段时间,不定时更新博客。
画师B站:玫瑰不必长高,但是可以长大_哔哩哔哩_bilibili