1.基础知识
而我们的** syscall** 是一个计算机操作系统中的指令,用于向操作系统内核发起系统调用。系统调用是 用户空间程序与操作系统内核进行交互的方式之一 ,用于请求操作系统执行特定的功能,如文件操作、进程管理、网络通信等。
在用户态运行的系统要控制系统时,或者要运行系统代码就必须取得R0权限。用户从R3到R0需要借助 ntdll.dll中的函数,这些函数分别以“Nt”和“Zw”开头,这种函数叫做Native API,下图是调用过程:
比如当我们调用类似 kernel32.dll中 CreateThread() 时,最终会进入 0 环(即 R0)调用 ntdll.dll中的ZwCreateThreadEx(),接下来我们来逆向一下该函数的实现。
movr10,rcx moveax,xxh syscall |
Fence1-1
2. Syscall 是如何绕过EDR 的?
要回答这个问题之前,我们要先明白 EDR 是如何工作的。
EDR 通常会对恶意软件常用的 api 进行 hook,即在调用 api 之前先进入 EDR 进行检查,检查通过之后 才可以继续调用 api。下面是没有被 hook 时 NtReadVirtualMemory 的样子:
可以看到在 NtReadVirtualMemory 的开头就被插入了一条 jmp 指令,跳转到了内存中的其他地方。
我们现在知道 syscall的通用模板,那么我们就自己想办法获取 SSN,然后自己 syscall一下,那么不就绕过了 EDR的 hook,直接去调用内核层的一些东西了。
Syscall 有很多使用的项目都值得我们学习,不同的项目也都是了作者和 EDR 厂商的对抗,下面是我对于
几个经典项目的一些理解,大家不要只看文章, 一定要去看看源码,这样思路才会清晰。
3.1 Hell's Gate 地狱之门
我们先一起看一下经典的地狱之门,项目地址: https://github.com/am0nsec/HellsGate/首先看 main.c 中的 main 函数:
先调用了 RtlGetThreadEnvironmentBlock函数
这个函数先是获取到导出表的相关结构,然后循环比较,并使用 djb2 这个哈希函数来计算当前函数名称 的 hash,如果一样则证明找到了该函数,并且将地址存储到了pVxTableEntry->pAddress
然后判断了下所在位置是不是已经超过了寻找的范围还没有找到,推荐一个汇编和 hex 互相转换的网 站: https://defuse.ca/online-x86-assembler.htm。cw的作用是方便逐个字节比较
先在数据段定义了 wSystemCall,用来存放 SSN。
HellsGate 的参数就是我们的 SSN,参数会根据调用约定放到了 ecx 中,所以 wSystemCall 就获取了正 确的 SSN。
HellDescent 则直接仿照我们上面的格式进行 syscall,参数正常传参即可,这样就在被 hook 的情况下 绕过 hook 完成一次对内核的操作,所以 HellsGate 和 HellDescent 成对出现即可,用法还是比较简单 的。
至此我们来总结一下地狱之门项目:
-
从内存中已经加载的ntdll.dll模块中通过遍历解析导出表,定位函数地址,再获取系统调用号 -
实现了动态获取 SSN -
需要一块干净的内存 ntdll 模块,否则无法正常获取 SSN -
直接系统调用
3.2 Halo's Gate 光环之门
项目地址: https://github.com/trickster0/TartarusGate/
地狱之门实现了动态获取 SSN,但是有一个缺点,那就是 ntdll 的内存必须是干净的,否则无法获取 SSN ,这个时候我们就需要光环之门了。
原理如下:当我们所需要的 Nt 函数被 hook 时,它相邻的 Nt 函数可能没有被 hook,因为 EDR 不可能 hook 所有的 Nt 函数,总有一些不敏感的 Nt 函数没有被 hook,这样我们从我们需要的 Nt 函数出发, 向上或者向下寻找,找到没有被 hook 的 Nt 函数,然后它的 SSN 加上或减去步数就得到了我们需要的 SSN。
看下图,ZwMapViewOfSection 显然被 hook 了,因为它开头是jmp <offset>指令,而不是 movr10, rcx,但是相邻的 ZwSetInformationFile 和 NtAccessCheckAndAuditAlarm 却是干净的,他们的 系统调用号分别是0x27和0x29。因此,确定 ZwMapViewOfSection 编号非常简单 ,只需查看邻居编号 并相应地进行调整即可。如果邻居也被 hook 了,那么检查邻居的邻居,依此类推:
我们可以观察到代码中有一处硬编码,其实是对应 Nt函数的 rawhex格式,我们通过下图可以看到前四个字节是4c8bd1b8 ,由于是小端格式存储,在内存中就变成了00B8D18B4Ch
-
该项目在地狱之门的基础上增加了检查前四个字节确定是否被 hook 的步骤,并且如果被 hook 尝 试查找邻居是否被 hook 来获取 SSN
3.3 TartarusGate
根据作者描述,这个项目是光环之门的进化版,因为 EDR的 hook不一定就是在第一条指令,在第二条指令中也可能出现 jmp指令,比如下图就是这样:
所以说这个项目对这种情况进行了判断,在 GetVxTableEntry()里我们可以看到进行了两次 if 判断,同 时我们也看到了光环之门那两个过程对应的 c++实现。
3.4 GetSSN
这是一个获取 SSN 的思路,方法比较简单,不需要unhook,不需要手动从代码存根中读取,也不需要 加载NTDLL新副本。
我们知道 SSN 是递增的,所以我们遍历 ntdll 所有导出函数,然后按照地址升序排序,从 0 开始,不就 得到了所有导出函数的 SSN 了。
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:%stFunction Address:%pn", 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;
}
}
Fence3-1
3.5 SysWhispers
项目地址:https://github.com/jthuraisamy/SysWhispers
这是老外写的一个直接系统调用的框架,这个框架现在有三个版本,我们先一起来看第一个版本。
我们知道 SSN 在不同版本下是不一样的,因此直接系统调用要适配多版本可能会有些麻烦,而这个项目 就可以帮助我们解决这个问题。在不指定版本的情况下, Syswhispers 会导出指定函数的所有已知版本 的系统调用号,根据操作系统版本的不同再进行指定调用。
不同操作系统间调用号的不同详情可参考
-
https://j00ru.vexillium.org/syscalls/nt/32/
-
https://j00ru.vexillium.org/syscalls/nt/64/
我们以 NtCreateProcess 为例看一下用法,命令如下:
python .syswhispers.py -f NtCreateProcess -o syscall
然后我们得到了一个 asm 和一个.h 文件,通过包含头文件就可以进行 syscall。
将两个文件包含到头文件中,然后按照博客中配置一下:
https://blog.csdn.net/qq_29176323/article/details/129145326
.h 文件中声明了 NtCreateProcess 原型
看 asm 文件,其实就是不断的比较系统版本然后跳转
然后可以在 cpp 里面写一个简单的小 demo:
int main() {
// 准备创建进程的参数
OBJECT_ATTRIBUTES objAttr;
InitializeObjectAttributes(&objAttr, NULL, 0, NULL, NULL);
// 使用 NtCreateProcess 创建一个新的进程
HANDLE hProcess;
NTSTATUS status = NtCreateProcess(&hProcess, PROCESS_ALL_ACCESS, &objAttr, GetCurrentProcess(), FALSE, NULL, NULL, NULL);
}
Fence3-2
但是一版本的项目特征太多,很容易被 AV/EDR 针对,所以出现了二版本。
3.6 SysWhispers2
项目地址:https://github.com/jthuraisamy/SysWhispers2
用法与 Syswhispers 大致相同,但是会生成很多文件:
-
有 x64 的,有 x86 的,这个区别应该就不用介绍了;
-
有.nasm 的,有.asm 的,这个是因为 SysWhispers2 为了适配 mingw-gcc 而做出的改变,后缀 nasm 的文件可以被 gcc 直接编译,而 vs 中无法直接编译,并且mingw-gcc 支持 x64 的内联汇 编,这点就十分方便;
-
有std的,有rnd的,这个是std是基础的syscall 方法,rnd 则是使用了RandomSyscallJumps 的方法,下面会有具体介绍。
我们以 NtCreateThreadEx 为例,分析一下调用过程,我们在 syscall.h 文件中找到了 NtCreateThreadEx,是通过外部导入的
去看 asm 文件,找到了该函数(过程),先是将计算出的 hash 值赋给 currentHash,每次使用时 hash 都不一样,然后再调用WhisperMain 过程。
看一下 WhisperMain 过程
3.6.1 SW2_GetSyscallNumber
我们接下来看一下几个关键函数的实现,首先是 SW2_GetSyscallNumber:
里面首先调用了 SW2_PopulateSyscallList,跟进看一下:
然后遍历导出表,定位所有 Zw 开头函数,并且将其 hash 和地址存入 Entries
然后就是一个冒泡排序,根据地址升序排序,对应的序号就是 SSN
分析完之后再看 SW2_GetSyscallNumber 就很简单了,剩下的部分就是循环匹配 hash,如果匹配到了 就返回 SSN,找不到就返回 -1。
3.6.2 SW2_GetRandomSyscallAddress
再看 SW2_GetRandomSyscallAddress,我们需要先 #define RANDSYSCALL 声明宏才能开启
如上代码, Zw 函数起始偏移 0x12 的位置即是 syscall 指令,其对应的第一个字节是 0x0F。然后通过随 机数的方式随机找到一处 ntdll 里面的 syscall 来进行间接系统调用。
3.7 间接/直接系统调用
在继续学习三版本的项目之前,我们先对比一下直接系统调用和间接系统调用,直接系统调用就行上面 的地狱之门等项目一样,直接在汇编中写出来 syscall,没有进入 ntdll 中 syscall,而间接 syscall 就像 SysWhispers2 一样,进入到 ntdll 里面随便找一个 syscall 进行 call。
我们借助https://redops.at/en/blog/direct-syscalls-vs-indirect-syscalls里面的一张图片来说明直接系统调用和间接系统调用在堆栈上的区别。
对于直接系统调用,系统调用本身及其返回执行发生在执行进程的.exe文件的内存空间中,这会导致调用 堆栈的顶帧来自.exe 内存,而不是ntdll.dll内存,这个特征可能会导致程序被杀掉,但是间接系统调用的 表现就更合法。系统调用的执行和返回指令都发生在ntdll.dll的内存中,这是正常应用程序进程中的预期 行为。
再补充两张图,正常程序的调用顺序如下:
直接系统调用的如下:
我们可以观察到 RIP 指向不同,因此很容易被查杀。
3.8 SysWhispers3
项目地址:https://github.com/klezVirus/SysWhispers3
它的主要提升是支持使用 egg_hunter , 以及使用 jumper & jumper_randomized 来进行间接 syscall。 py .syswhispers.py --preset common -o syscalls_common -m jumper -c mingw
3.8.1 egg_hunter
它的作用是在内存中先用一些垃圾字符占位,然后运行时再从内存中找出来替换成 syscall。
下面是一个简单的 demo,放置一个已知字节序列(egg)作为 syscall 指令的占位符,并在运行时替换 它,这个字节序列时 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
Fence3-3
这样直接运行当然会报错,为了可用,我们需要使用必要的操作码修改内存中的“w00tw00t”,在这种情况下0f 05c3909090cccc,这转换为syscall;nop;nop;ret;nop;int3;int3
以下是作者给出的FindAndReplace函数的demo:
HMODULE GetMainModule(HANDLE);
BOOL GetMainModuleInformation(PULONG64, PULONG64);
void FindAndReplace(unsigned char[], unsigned char[]);
HMODULE GetMainModule(HANDLE hProcess)
{
HMODULE mainModule = NULL;
HMODULE* lphModule;
LPBYTE lphModuleBytes;
DWORD lpcbNeeded;
// First call needed to know the space (bytes) required to store the modules' handles
BOOL success = EnumProcessModules(hProcess, NULL, 0, &lpcbNeeded);
// We already know that lpcbNeeded is always > 0
if (!success || lpcbNeeded == 0)
{
printf("[-] Error enumerating process modulesn");
// At this point, we already know we won't be able to dyncamically // place the syscall instruction, so we can exit
exit(1);
}
// Once we got the number of bytes required to store all the handles for // the process' modules, we can allocate space for them
lphModuleBytes = (LPBYTE)LocalAlloc(LPTR, lpcbNeeded);
if (lphModuleBytes == NULL)
{
printf("[-] Error allocating memory to store process modules handlesn"); exit(1);
}
unsigned int moduleCount;
moduleCount = lpcbNeeded / sizeof(HMODULE);
lphModule = (HMODULE*)lphModuleBytes;
success = EnumProcessModules(hProcess, lphModule, lpcbNeeded, &lpcbNeeded);
if (!success)
{
printf("[-] Error enumerating process modulesn");
exit(1);
}
// Finally storing the main module
mainModule = lphModule[0];
// Avoid memory leak
LocalFree(lphModuleBytes);
// Return main module
return mainModule;
}
BOOL GetMainModuleInformation(PULONG64 startAddress, PULONG64 length) {
HANDLE hProcess = GetCurrentProcess();
HMODULE hModule = GetMainModule(hProcess);
MODULEINFO mi;
GetModuleInformation(hProcess, hModule, &mi, sizeof(mi));
printf("Base Address: 0x%llun", (ULONG64)mi.lpBaseOfDll);
printf("Image Size: %un", (ULONG)mi.SizeOfImage);
printf("Entry Point: 0x%llun", (ULONG64)mi.EntryPoint);
printf("n");
*startAddress = (ULONG64)mi.lpBaseOfDll;
*length = (ULONG64)mi.SizeOfImage;
DWORD oldProtect;
VirtualProtect(mi.lpBaseOfDll, mi.SizeOfImage, PAGE_EXECUTE_READWRITE, &oldProtect);
return 0;
}
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%llun", (ULONG64)startAddress + currentOffset);
while (currentOffset < size - 8)
{
currentOffset++;
LPVOID currentAddress = (LPVOID)(startAddress + currentOffset); if(DEBUG > 0){
printf("Searching at 0x%llun", (ULONG64)currentAddress); }
if (!ReadProcessMemory((HANDLE)((int)-1), currentAddress, current, 8, &nBytesRead)) {
printf("[-] Error reading from memoryn");
exit(1);
}
if (nBytesRead != 8) {
printf("[-] Error reading from memoryn");
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 %llun", (ULONG64)currentAddress);
WriteProcessMemory((HANDLE)((int)-1), currentAddress, replace, 8, &nBytesRead);
}
}
printf("Ended search at: 0x%llun", (ULONG64)startAddress + currentOffset);
free(current);
}
Fence3-4
然后是主函数中:
int main(int argc, char** argv) {
unsigned char egg[] = { 0x77, 0x00, 0x00, 0x74, 0x77, 0x00, 0x00, 0x74 }; // w00tw00t
unsigned char replace[] = { 0x0f, 0x05, 0x90, 0x90, 0xC3, 0x90, 0xCC, 0xCC };
// syscall; nop; nop; ret; nop; int3; int3
//####SELF_TAMPERING####
(egg, replace);
Inject();
return 0;
}
Fence3-5
3.8.2jumper &jumper_randomized
再来看 jumper_randomized,这项技术和和 SysWhisper2 非常相似,使用SW3_GetRandomSyscallAddress函数先获取一个随机的syscall地址,实现和二版本的项目几乎一样,放到r11中,然后再jmpr11即可
4. 总结
广告
原文始发于微信公众号(影域实验室):Syscall笔记
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论