Syscall笔记
本文首发:https://xz.aliyun.com/t/13687
基础知识
我们知道,系统核心态指的是R0,用户态指的是R3,系统代码在核心态下运行,用户代码在用户态下运行。系统中一共有四个权限级别,R1和R2运行设备驱动,R0到R3权限依次降低,R0和R3的权限分别为最高和最低。
而我们的** syscall** 是一个计算机操作系统中的指令,用于向操作系统内核发起系统调用。系统调用是用户空间程序与操作系统内核进行交互的方式之一,用于请求操作系统执行特定的功能,如文件操作、进程管理、网络通信等。
在用户态运行的系统要控制系统时,或者要运行系统代码就必须取得R0权限。用户从R3到R0需要借助ntdll.dll中的函数,这些函数分别以“Nt”和“Zw”开头,这种函数叫做Native API,下图是调用过程:
比如当我们调用类似 kernel32.dll 中 CreateThread() 时,最终会进入 0 环(即 R0)调用 ntdll.dll 中的 ZwCreateThreadEx(),接下来我们来逆向一下该函数的实现。
可以看到先向 eax 里面存储了一个值,即系统调用号 SSN,然后再进行 syscall。
我们再看一下相邻函数的 SSN,会发现该值是递增的,且大致格式都如下:
mov r10,rcx
mov eax,xxh
syscall
Syscall 是如何绕过 EDR 的?
要回答这个问题之前,我们要先明白 EDR 是如何工作的。
EDR 通常会对恶意软件常用的 api 进行 hook,即在调用 api 之前先进入 EDR 进行检查,检查通过之后才可以继续调用 api。下面是没有被 hook 时 NtReadVirtualMemory 的样子:
下面是被 hook 时的样子:
可以看到在 NtReadVirtualMemory 的开头就被插入了一条 jmp 指令,跳转到了内存中的其他地方。
我们现在知道 syscall 的通用模板,那么我们就自己想办法获取 SSN,然后自己 syscall 一下,那么不就绕过了 EDR 的 hook,直接去调用内核层的一些东西了。
项目学习
Syscall 有很多使用的项目都值得我们学习,不同的项目也都是了作者和 EDR 厂商的对抗,下面是我对于几个经典项目的一些理解,大家不要只看文章,一定要去看看源码,这样思路才会清晰。
Hell's Gate 地狱之门
我们先一起看一下经典的地狱之门,项目地址:https://github.com/am0nsec/HellsGate/
首先看 main.c 中的 main 函数:
先调用了 RtlGetThreadEnvironmentBlock 函数
这个函数是获取 TEB 的,因为这个项目涉及到了一些 PEB 的知识,如果对 PEB 和 TEB 不太熟悉可以看一下https://xz.aliyun.com/t/13556
接下来两句代码就是先获取 PEB,然后判断系统版本是不是 Win10,如果不是的话就直接退出。
然后通过 PEB 的相关知识获取到 ntdll 的 module,然后紧接着看一下导出表的相关信息,如果没有则证明前面的步骤发生了错误,直接退出。其中 GetImageExportDirectory 函数需要一些 PE 的知识才可以看懂。
然后就进入代码的关键部分了,先给出 Table 中需要 syscall 的函数名的 hash 值,然后调用 GetVxTableEntry 去获取 SSN,去看一下函数的实现
这个函数先是获取到导出表的相关结构,然后循环比较,并使用 djb2 这个哈希函数来计算当前函数名称的 hash,如果一样则证明找到了该函数,并且将地址存储到了pVxTableEntry->pAddress
然后判断了下所在位置是不是已经超过了寻找的范围还没有找到,推荐一个汇编和 hex 互相转换的网站:https://defuse.ca/online-x86-assembler.htm。cw 的作用是方便逐个字节比较
然后就是寻找 SSN,这里通过判断上面 syscall 格式来获取 SSN,然后分别获取高位和低位,高位左移 8 位再和低位或操作得到正确的 SSN,并存储到pVxTableEntry->wSystemCall
。
然后我们回到 main 函数,又调用了 payload 函数,这个函数就是执行我们 payload 的地方
我们重点看红框框出来的函数
这两个函数是从别的文件导入的
看一下 asm 文件里面就是这两个函数
先在数据段定义了 wSystemCall,用来存放 SSN。
HellsGate 的参数就是我们的 SSN,参数会根据调用约定放到了 ecx 中,所以 wSystemCall 就获取了正确的 SSN。
HellDescent 则直接仿照我们上面的格式进行 syscall,参数正常传参即可,这样就在被 hook 的情况下绕过 hook 完成一次对内核的操作,所以 HellsGate 和 HellDescent 成对出现即可,用法还是比较简单的。
至此我们来总结一下地狱之门项目:
- 从内存中已经加载的ntdll.dll模块中通过遍历解析导出表,定位函数地址,再获取系统调用号
- 实现了动态获取 SSN
- 需要一块干净的内存 ntdll 模块,否则无法正常获取 SSN
- 直接系统调用
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>
指令,而不是 mov r10, rcx
,但是相邻的 ZwSetInformationFile 和 NtAccessCheckAndAuditAlarm 却是干净的,他们的系统调用号分别是0x27和0x29。因此,确定 ZwMapViewOfSection 编号非常简单 ,只需查看邻居编号并相应地进行调整即可。如果邻居也被 hook 了,那么检查邻居的邻居,依此类推:
这个项目和地狱之门的大致思路是一样的,但是函数是用汇编实现的,我们重点看下面三个过程,findSyscallNumber,halosGateUp 和 halosGateDown,分别是直接获取 SSN 以及向上和向下获取 SSN 的过程。
我们可以观察到代码中有一处硬编码,其实是对应 Nt 函数的 raw hex 格式,我们通过下图可以看到前四个字节是4c8bd1b8
,由于是小端格式存储,在内存中就变成了00B8D18B4Ch
上面第一个名为 findSyscallNumber
的过程很简单,就是比较硬编码和在内存中获取的是不是一样,如果不一样我们就认为它被 hook 了,并且跳转到 error 过程,如果一样就直接获取 SSN 返回。
而 halosGateUp
和 halosGateDown
两个过程是在上面的基础上加了一个寻找 rdx 偏移的步骤,思路还是一样的。如果感到汇编比较难懂的话可以看下面的项目,是对该项目的补充版且用 c++实现。
项目总结:
- 该项目在地狱之门的基础上增加了检查前四个字节确定是否被 hook 的步骤,并且如果被 hook 尝试查找邻居是否被 hook 来获取 SSN
TartarusGate
根据作者描述,这个项目是光环之门的进化版,因为 EDR 的 hook 不一定就是在第一条指令,在第二条指令中也可能出现 jmp 指令,比如下图就是这样:
所以说这个项目对这种情况进行了判断,在GetVxTableEntry()
里我们可以看到进行了两次 if 判断,同时我们也看到了光环之门那两个过程对应的 c++实现。
除此之外,这个项目在 asm 文件中增加了一些 nop 指令来混淆
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:%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
项目地址:https://github.com/jthuraisamy/SysWhispers
这是老外写的一个直接系统调用的框架,这个框架现在有三个版本,我们先一起来看第一个版本。
我们知道 SSN 在不同版本下是不一样的,因此直接系统调用要适配多版本可能会有些麻烦,而这个项目就可以帮助我们解决这个问题。在不指定版本的情况下,Syswhispers 会导出指定函数的所有已知版本的系统调用号,根据操作系统版本的不同再进行指定调用。
不同操作系统间调用号的不同详情可参考
我们以 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:
#include "syscall.h"
#include <stdio.h>
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);
}
但是一版本的项目特征太多,很容易被 AV/EDR 针对,所以出现了二版本。
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 则是使用了 Random Syscall Jumps 的方法,下面会有具体介绍。
我们以 NtCreateThreadEx 为例,分析一下调用过程,我们在 syscall.h 文件中找到了 NtCreateThreadEx,是通过外部导入的
去看 asm 文件,找到了该函数(过程),先是将计算出的 hash 值赋给 currentHash,每次使用时 hash 都不一样,然后再调用WhisperMain 过程。
看一下 WhisperMain 过程
首先是一些保护寄存器的操作,不用关心,然后将上个过程的 currentHash 给 ecx,作为参数调用 SW2_GetSyscallNumber,获取系统调用号,然后将结果存储到 syscallNumber 变量中。然后再调用SW2_GetRandomSyscallAddress,随机获取一个 ntdll 导出函数中的一个 syscall 指令的地址,SysWhispers2 并没有直接在主程序中调用 syscall 指令,而是采用了间接系统调用,随机获取一个 syscall 指令的地址后,跳转到该地址执行 syscall 指令,这样就规避了在主程序中直接系统调用的特征。然后就是一些回复寄存器的操作,也不用关心,紧接着后面就是 call 随机的 syscall 地址。
SW2_GetSyscallNumber
我们接下来看一下几个关键函数的实现,首先是 SW2_GetSyscallNumber:
里面首先调用了 SW2_PopulateSyscallList,跟进看一下:
代码有点长,我们慢慢来分析,首先先判断 SW2_SyscallList 是否被填充,如果被填充直接返回即可,如果没有被填充就继续接下来的填充操作,先通过 PEB 得到 ntdll
然后遍历导出表,定位所有 Zw 开头函数,并且将其 hash 和地址存入 Entries
然后就是一个冒泡排序,根据地址升序排序,对应的序号就是 SSN
分析完之后再看 SW2_GetSyscallNumber 就很简单了,剩下的部分就是循环匹配 hash,如果匹配到了就返回 SSN,找不到就返回 -1。
SW2_GetRandomSyscallAddress
再看 SW2_GetRandomSyscallAddress,我们需要先#define RANDSYSCALL
声明宏才能开启
如上代码,Zw 函数起始偏移 0x12 的位置即是 syscall 指令,其对应的第一个字节是 0x0F。然后通过随机数的方式随机找到一处 ntdll 里面的 syscall 来进行间接系统调用。
间接/直接系统调用
在继续学习三版本的项目之前,我们先对比一下直接系统调用和间接系统调用,直接系统调用就行上面的地狱之门等项目一样,直接在汇编中写出来 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 指向不同,因此很容易被查杀。
SysWhispers3
项目地址:https://github.com/klezVirus/SysWhispers3
它的主要提升是支持使用 egg_hunter, 以及使用 jumper & jumper_randomized 来进行间接 syscall。
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
这样直接运行当然会报错,为了可用,我们需要使用必要的操作码修改内存中的“w00tw00t”,在这种情况下 0f 05 c3 90 90 90 cc cc ,这转换为 syscall; nop; nop; ret; nop; int3; int3
以下是作者给出的 FindAndReplace 函数的 demo:
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>
#include <psapi.h>
#define DEBUG 0
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 modules\n");
// 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 handles\n");
exit(1);
}
unsigned int moduleCount;
moduleCount = lpcbNeeded / sizeof(HMODULE);
lphModule = (HMODULE*)lphModuleBytes;
success = EnumProcessModules(hProcess, lphModule, lpcbNeeded, &lpcbNeeded);
if (!success)
{
printf("[-] Error enumerating process modules\n");
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%llu\n", (ULONG64)mi.lpBaseOfDll);
printf("Image Size: %u\n", (ULONG)mi.SizeOfImage);
printf("Entry Point: 0x%llu\n", (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%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);
}
然后是主函数中:
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;
}
比较简单就不再详细解释了。
但是这种方法还是属于直接系统调用,EDR 只要检测 RIP 的指向即可检测到异常,就有了接下来要介绍技术。
jumper & jumper_randomized
先来看 jumper,使用命令python syswhispers.py -f NtCreateThreadEx -m jumper -o jumper
来生成一个采用该技术的 demo
看 asm 文件,可以看到先将 syscall 的地址放到 r15,再 jump r15
实现的
再来看 jumper_randomized,这项技术和和 SysWhisper2 非常相似,使用 SW3_GetRandomSyscallAddress 函数先获取一个随机的 syscall 地址,实现和二版本的项目几乎一样,放到 r11 中,然后再 jmp r11
即可
总结
看到这里相信大家对 syscall 都有了一定的了解了,我们再简单的总结一下。
首先,为了防止 api 被 hook,提出了 syscall 函数,这产生了地狱之门的项目,然而,当 ntdll 被 hook 时,这种方法就失效了,因此出现了更高级的技术,如“光环之门”,试图通过邻居来获取系统调用号(SSN)。然而,即使获取了 SSN,仍然有可能被安全软件检测到,因为系统调用的签名(sysall)可能会被查杀。为了解决这个问题,出现了“egg_hunter”等技术。但是堆栈的问题还没有解决,我们需要合法的堆栈, SysWhispers2 和 SysWhispers3,它们提出了间接系统调用的方案,进一步提高了对系统调用的隐藏性和逃避性,使得安全工具更难检测到和拦截这些调用。