标签 Brute Ratel C4 下的文章

0x01.前言 在上一篇文章中,分析了BRC4如何利用APC来进行睡眠混淆的。本篇文章中将通过逆向出来的代码作为参考,但可能会有差异,一步步实现APC睡眠混淆加密整个可执行程序。以及如何在C2中集成APC睡眠混淆。 0x02.实现APC睡眠混淆 通过之前的分析我们知道Badger中创建了一个线程(TpReleaseCleanupGroupMembers + 0x450),然后利用了这个线程来获取正常的CONTEXT结构。实现代码如下,实现时为了简单起见,有些地方不使用Nt*Zw*的函数,实际中为了opsec是有必要写的,但这里主要起到演示作用。

C

复制代码
LPVOID TpReleaseCleanupGroupMembers_450 = (UINT_PTR)GetProcAddress(GetModuleHandleA("ntdll.dll"), "TpReleaseCleanupGroupMembers") + 0x450;

DWORD dwThreadId = 0;
HANDLE hThread = CreateThread(NULL, 0, TpReleaseCleanupGroupMembers_450, NULL, CREATE_SUSPENDED, &dwThreadId);

然后根据这个线程句柄,获取CONTEXT,并复制到所有的CONTEXT中。

C

复制代码
if (!GetThreadContext(ThreadHandle, &CtxThread)) {
printf("GetThreadContext failed With Error:%lu\n", GetLastError());
return FALSE;
}

memcpy(&RopWaitFor, &CtxThread, sizeof(CONTEXT));
memcpy(&RopProtRW, &CtxThread, sizeof(CONTEXT));
memcpy(&RopMemEnc, &CtxThread, sizeof(CONTEXT));
memcpy(&RopSleep, &CtxThread, sizeof(CONTEXT));
memcpy(&RopMemDec, &CtxThread, sizeof(CONTEXT));
memcpy(&RopProtRX, &CtxThread, sizeof(CONTEXT));
memcpy(&RopRtlEtTd, &CtxThread, sizeof(CONTEXT));

现在就可以构造ROP链了,在每一个CONTEXT结构的返回地址写NtTestAlert函数的地址,也就是Rsp寄存器。这样做为了确保能够执行完所有的APC队列回调。 ROP链执行的函数依次为WaitForSingleObject、VirtualProtect、SystemFunction032、WaitForSingleObjectEx、SystemFunction032、VirtualProtect、RtlExitUserThread。我们逆向badger的这个ROP链会发现还会执行ZwGetContextThread -> ZwSetContextThread -> ... -> ZwSetContextThread。之前说过了,这里就是备份一份当前线程(构造ROP链的线程)的CONTEXT结构,然后设置获取fake CONTEXT,主要是为了进行堆栈欺骗,睡眠完成后,在将备份的CONTEXT还原。

C

复制代码
RopWaitFor.Rcx = StartEventHandle;
RopWaitFor.Rdx = INFINITE;
*(PULONG64)RopWaitFor.Rsp = (ULONG64)pNtTestAlert;
RopWaitFor.Rip = WaitForSingleObject;

RopProtRW.Rcx = ImageBase;
RopProtRW.Rdx = ImageSize;
RopProtRW.R8 = PAGE_READWRITE;
RopProtRW.R9 = &oldProtect;
*(PULONG64)RopProtRW.Rsp = (ULONG64)pNtTestAlert;
RopProtRW.Rip = VirtualProtect;

RopMemEnc.Rcx = &Image;
RopMemEnc.Rdx = &Key;
RopMemEnc.Rip = SystemFunction032;
*(PULONG64)RopMemEnc.Rsp = (ULONG64)pNtTestAlert;

RopSleep.Rcx = (HANDLE)-1;
RopSleep.Rdx = SleepTimes * 1000;
RopSleep.R8 = FALSE;
*(PULONG64)RopSleep.Rsp = (ULONG64)pNtTestAlert;
RopSleep.Rip = WaitForSingleObjectEx;

RopMemDec.Rcx = &Image;
RopMemDec.Rdx = &Key;
*(PULONG64)RopMemDec.Rsp = (ULONG64)pNtTestAlert;
RopMemDec.Rip = SystemFunction032;

RopProtRX.Rcx = ImageBase;
RopProtRX.Rdx = ImageSize;
RopProtRX.R8 = PAGE_EXECUTE_READWRITE;
RopProtRX.R9 = &oldProtect;
*(PULONG64)RopProtRX.Rsp = (ULONG64)pNtTestAlert;
RopProtRX.Rip = VirtualProtect;

QueueUserAPC将这些CONTEXT依次插入TpReleaseCleanupGroupMembers + 0x450入口点线程的APC队列,NtAlertResumeThread准备执行APC,NtSignalAndWaitForSingleObject信号StartEventHandle开始执行APC,并等待TpReleaseCleanupGroupMembers + 0x450入口点线程退出。

实现的代码和逆向Brc4的badger睡眠混淆代码基本一致,效果如下:

image-20260113103244238.png

采用固定密钥的话使用SystemFunction032每次加密的内容均相同,我们加密可以使用SystemFunction040,在msdn中被描述为RtlEncryptMemory。

image-20260113103757964.png

解密可以使用SystemFunction041,在msdn中被描述为RtlDecryptMemory。

image-20260113104310662.png

关键是加密的使用使用的是系统内部派生密钥,每次随机密钥加密,使用起来更加方便安全。 0x03.CFG Bypass 这样的代码注入到开启CFG的系统进程中还是会引发崩溃,需要Bypass CFG,在编译属性中开启/guard:cf

image-20260113105028177.png

重新编译后再次允许程序会崩溃,查看异常代码对应0xC0000409(STATUS_STACK_BUFFER_OVERRUN),正是由CFG引起的。在APC队列中回调函数为NtContinue,在开启CFG的情况下,它在CFG的无法间接调用的函数列表,所以会引发错误。

image-20260113105128374.png

我们需要绕过CFG,Brc4 Badger是利用的SetProcessValidCallTargets。

image-20260113114858654.png

SetProcessValidCallTargets在msdn上的定义如下。第一个参数为当前进程句柄。第二参数为目标标记为有效的虚拟内存区域的开始,调试发现传入的地址为ntdll的起始(qword_1003C7C8),这也符合Badger添加到CFG允许列表都是位于ntdll中的函数。第三个参数为虚拟内存区域的大小,需要做按页对齐操作。第四个参数表示添加到CFG允许列表个数为1。最后一个参数为相对于虚拟内存范围的偏移量和标志的列表,指向CFG_CALL_TARGET_INFO结构。此结构的第一个参数函数地址减去ntdll起始地址。第二个参数很重要,描述要对地址执行的操作的标志。 如果设置了CFG_CALL_TARGET_VALID(1),则地址将标记为对CFG有效,从而绕过CFG保护。

image-20260113145013617.png


image-20260113121049401.png

image-20260113145748993.png

整个过程很清楚了,但还需最后一步,判断当前进程是否开启CFG。一种通用的方法是根据PE OptionalHeader的DllCharacteristics来判断编译的时候是否根据CFG来编译的。

image-20260113150817207.png

判断是否开启CFG,以及添加CFG允许列表的相关代码如下,对应Kernel32中也需要实现一个相同的函数来添加VritualProtect和WaitForSingleObject、WaitForSingleObjectEx。执行SetProcessValidCallTargets出现的87错误代码我们直接跳过,这个错误表示目标地址没有受到CFG的保护。

完整的代码我放在github上了,参考 https://github.com/CDipper/SleepMaskingByAPC 查看SetProcessValidCallTargets不难发现,内部就是调用了NtSetInformationVirtualMemory,国外有老哥根据此API二次开发了Ekko(利用计时器队列进行睡眠混淆,很常用,容易被杀),参考 https://github.com/Crypt0s/Ekko_CFG_Bypass/blob/main/Ekko_CFG_Bypass/CFG.c

image-20260114103903645.png

0x04.C2中使用Sleeping Mask C2中使用Sleeping Masking就是在我们的马中每次睡眠的时候把马全部加密不就行了吗?其实并非如此,如果全部加密,对于一些持久化任务(例如keylogger等)执行就会崩溃,这是显然的。在Cobalt Strike中睡眠混淆是在arsenal-kit中实现,其当然也不是将beacon的内存全部加密,Brc4中也是如此。 Cobalt Strike 4.4中首次引入Sleeping Mask的概念。一开始只是简单对一些特征进行加密,后面随着版本的更新逐渐支持对beacon堆内存的加密,以及对更多的内存进行加密。Ekko项目出现了之后,Cobalt Strike也进行了支持,能够加密Sleep Mask的代码,也就是解决了自己不能加密自己的问题。默认的Sleep Mask相关加密代码是能够被类似Elastic等优秀yara规则检测到的,所以加密Sleep Mask的代码很有必要。

image-20241126195748278.png


后面又逐步支持堆栈欺骗,基于LLVM的代码变异技术实现动态生成Sleep Mask,BeaconGate...... 所以在写自己的马时,需要考虑到哪部分内存我能够加密的,那部分内存我不能够加密,往往加密的可能是一块敏感字符串等,以及那块加密代码。


0x1.前言

Brute Ratel C4(以下简称BRC4)是一位印度老哥开发的C2,用于对抗EDR,也是一款极其优秀的C2了。本文使用的BRC4版本为1.2.2(早已经有更新的破解版本了,但载荷都大差不差)。生成马被称为Badger,类似CobaltStrike的Beacon。如下图是它支持的一些规避方法。在第一篇文章后分析其实现方式,第二篇文章实现此睡眠混淆方式,以及如何在自己开发的马中实现Sleeping Masking。

什么是睡眠混淆,简单的来说就是你的马运行后,为了opsec,需要Sleep,CobaltStrike等C2就是默认Sleep 60s,这就是睡眠的概念。混淆就是指为了规避AV/EDR的内存扫描,需要在内存中对我们的马进行加密,这样就绕过了内存扫描。

在本文中分析BRC4的睡眠混淆方法中的利用APC来达到Sleeping Masking。什么是APC参考MSDN: https://learn.microsoft.com/en-us/windows/win32/sync/asynchronous-procedure-calls

image-20260109111047796.png



0x02.利用APC进行Sleeping Masking分析

搭建BRC4这些就不说了,创建的监听器的时候,设置Sleep Mask为APC。

image-20260109150623146.png



然后生成马子进行分析,Stageless即可。

image-20260109150850812.png



写个简单的加载器,直接开始调试就行。开头有大量push操作,猜测是核心载荷被压入栈,直接跳过这些。

image-20260109152541979.png



需要先绕过NtGlobalFlag的反调试,把ZF标志位改了就行,接着往下。

image-20260109153108824.png



在通过Hash的方式获取到一些API的地址后,RC4解密出了一个被抹掉MZ头的PE文件,带解密的内容很显然是前面push压入的数据。得到的是核心载荷,后面可能被反射式注入执行。

image-20260109153249913.png



我们将这个PE文件从内存中Dump出来。

image-20260109153413118.png



然后很显然接下来的这个call,开始反射式注入此解密出PE文件了。

image-20260109154412958.png



然后call rax,跳到修复后DLL执行DllMain开始执行。这大概就是Badger的执行上线流程。

image-20260109154716516.png



上线后,我们关注点是Sleeping Masking。直接定位到睡眠混淆的地方。

image-20260109165852761.png



可以根据动态函数地址以及前面Dump出的PE,在IDA中修改函数名(其中调用很多函数都是通过Syscall),进行分析。首先会在堆上分配很多CONTEXT结构,这里的0x4D0代表CONTEXT结构体大小。

image-20260109170053749.png



往下判断是否开启CFG,开启的话,使用SetProcessValidCallTargets扩展CFG允许集合。这是为了后续构造ROP链执行做准备。

image-20260109171734879.png



image-20260109172111715.png



为什么这里判断是否开启CFG?在实际中,一般shellcode都是注入到别的系统进程中,而不是自己写的Loader,系统进程例如RunTimerBorker.exe基本都是开启CF Guard的。所以这里的判断很合理也必要。

image-20260109172406849.png



继续往下看。第一个if块这里不太明白是在干嘛,像是获取当前PE的VA,调试发现不会进入这一块,先跳过。第二个if块是最关键的位置,显然能够进入此if块执行,第一个条件是创建了一个Event对象,第二个是创建了一个挂起的线程,入口点是TpReleaseCleanupGroupMembers + 0x450的地方。这个线程主要是为了后续调用ZwGetThreadContext,为分配的CONTEXT结构体,提供一个正常的CONTEXT和执行APC回调的地方。

image-20260109173644610.png



往下进入第二个if块内容。首先给这几个CONTEXT结构体赋值,表明对什么寄存器感兴趣。然后通过ZwDuplicateObject赋值了当前线程的句柄。然后调用ZwGetThreadContext获取前面TpReleaseCleanupGroupMembers + 0x450入口线程的CONTEXT。并依次赋值到所有的CONTEXT中去。

image-20260109182034372.png



sub_10019490函数中,之所以没有rename是因为找不到合适的名字,这个函数获取了一个CONTEXT。这个CONTEXT是从当前进程但不是当前执行线程的CONTEXT,在ZwGetThreadContext后,在对这个CONTEXT的Rsp值和Rip值进行处理。

image-20260109182323048.png



奇怪的是该函数返回后并不会进入下面的if分支。不过这里像是在做堆栈欺骗类似的动作?先不管吧,继续往下。

image-20260109182658006.png



开始构造ROP链,对前面分配的每个CONTEXT进行赋值。Rcx、Rdx、R8、R9分别是前四个参数,Rip是要执行的API,返回地址为NtTestAlert,作用就是为了触发APC队列。ROP链执行函数的顺序为ZwWaitForSingleObject、NtProtectVirtualMemory、SystemFunction032、ZwGetContextThread、ZwSetContextThread、WaitForSingleObjectEx、SystemFunction032、NtProtectVirtualMemory、ZwSetContextThread、RtlExitUserThread。

image-20260112100700434.png



image-20260112102159224.png



其中SystemFunction032可能会让我们感到疑惑,这是微软未公开的函数,其实就是一个RC4加密算法,ReactOS可以看到定义,在advapi32.dll模块中实现。前后各调用一次,即是对在内存的载荷加解密。

image-20260112160004248.png



然后到了最关键的地方,通过NtQueueApcThread插入到指定线程的APC队列,回调函数为ZwContinue,其作用是恢复CONTEXT下文,参数即为前面分配赋值的CONTEXT。插入完成后,NtAlertResumeThread恢复ThreadHandle,这里的Thread就是前面的TpReleaseCleanupGroupMembers + 0x450,然后设置Alerted状态,开始执行APC队列。

首先执行ZwWaiteForSingleObject,这里是为了等待NtSignalAndWaitForSingleObject Signal EventHandle,然后Waite ThreadHandle。

image-20260112104428693.png



执行NtProtectVirtualMemory将内存属性修改为RW,这样极大减少了被扫描的可能性,很多AV内存扫描的目标仅仅是带有X属性的内存。

image-20260112113705851.png



SystemFunction032 RC4加密相关内存。

image-20260112113947107.png



然后调用ZwGetThreadContext,获取前面通过ZwDuplicateObject复制的TargetHandle句柄(badger执行的主线程)的CONTEXT结构,调用ZwSetThreadContext设置TargetHandle的CONTEXT为sub_10019490获取的CONTEXT,这里像是在做调用堆栈的伪装,但是前面没进入那个IF块,又不像堆栈欺骗。这里可能是在睡眠期间,将调用堆栈也做了混淆,目的是看不出在进行了APC等睡眠混淆的操作。

调用WaitForSingleObjectEx模拟正常睡眠,这个时间就是Listener或Profile中设置的Sleep时长(0x4E20 == 20000)。

image-20260112115746030.png



睡眠完成后开始恢复,首先是再次调用SystemFunction032 RC4解密内存。

image-20260112120012634.png



然后NtProtectVirtualMemory恢复内存属性。

image-20260112120056230.png



调用ZwSetThreadContext还原TargetHandle的CONTEXT上下文。最后RtlExitUserThread退出TpReleaseCleanupGroupMembers + 0x450线程。

至此一个完成睡眠混淆流程结束。对于一个存活时间长的马,睡眠时间栈大多数,执行任务的时间很短。

可能有人要问,为什么我不能直接对马改内存属性和加密呢?还要写APC回调?这样写其实解决了加密代码不能加密自己的问题,以及修改了内存属性如何改回来的问题,万一有些AV就专门盯着你写的那块加密代码,这样马就被杀了。

下一篇文章中,根据分析结果实现APC Sleeping Masking