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、实现方式不同