前言
虽说也不是什么新技术,但第一次见感觉还是很厉害,之前有看过关于这种系统调用的方法,今天来学习学习。
Hell's Gate(地狱之门)这个名称来自于一篇论文:https://vxug.fakedoma.in/papers/VXUG/Exclusive/HellsGate.pdf
通过地狱之门进行系统调用,能够实现绕过内存hook监控调用系统API。
本文学习内容基于这篇博客:Implementing Direct Syscalls Using Hell’s Gate – Team Hydra 这篇博客的代码写的很棒,为了学习这种代码风格,本文多数可以看到我都是在抄他的代码(毕竟学习都是从模仿开始的嘛)
实验环境&工具
- Windows 10 x64 21H1
- Visual Studio 2019
- x64dbg
- API Monitor
原理介绍
在Ring3调用的系统API,基本上都是在系统动态链接库里的函数,例如kernel32.dll
;
通过动态链接库,使用调试器一层一层追下去,会发现在执行的最后一层,会进入ntdll.dll
,通过执行指令syscall
,进入系统Ring0执行函数的内核部分,例如API:OpenProcess
:
在使用syscall
之前,会给eax一个系统调用号,这个调用号不同版本的Windows是不一样的,可通过:Microsoft Windows System Call Table (XP/2003/Vista/2008/7/2012/8/10) (vexillium.org)网站进行查询
一个比较省事的办法就是,直接读取
ntdll.dll
,然后通过遍历导出表,找到目标函数对应的地址,然后从地址里来动态获取调用号。
地狱之门实现系统调用的方法就很简单粗暴,直接读取ntdll.dll
文件,解析PE结构遍历导出表,然后根据函数名找到函数地址,把整个函数取出来(21个字节),在代码里使用函数指针来进行调用,从而绕过了内存上的监控,从我们自己的程序中执行了ntdll.dll
里面的流程并得到返回值
实现
本次实验的目的是:通过地狱之门实现系统调用,并使用API monitor进行监控测试,故本次只使用NtOpenProcess
这一个函数。
定义函数指针及其相关结构体
Nt开头的都是内核函数,内核函数用到的结构体都是用户层没有的,需要我们自行定义一下
查内核函数的参数可以走MSDN或NTAPI Undocumented Functions (ntinternals.net)这个网站进行查询
关于内核结构体,则可以通过网站Vergilius Project | Home来进行查询
这里定义的函数结构体和函数指针如下:
typedef struct _UNICODE_STRING
{
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, * PUNICODE_STRING;
typedef struct _OBJECT_ATTRIBUTES
{
ULONG Length;
HANDLE RootDirectory;
PUNICODE_STRING ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor;
PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES, * POBJECT_ATTRIBUTES;
typedef struct _CLIENT_ID
{
HANDLE UniqueProcess;
HANDLE UniqueThread;
} CLIENT_ID, * PCLIENT_ID;
using MyNtOpenProcess = NTSTATUS(NTAPI*)(
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK AccessMask,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId
);
遍历导出表找指定函数
这里的难点在于对PE结构的解析,如果不了解PE结构,这一块应该就看不懂了,本人虽有些PE基础,看了大佬的代码,我认识到这一块的知识需要巩固巩固了哈哈哈哈
BOOL GetSysCall() {
LPVOID fileData = NULL;
HANDLE file = NULL;
DWORD fileSize = NULL;
DWORD bytesRead = NULL;
BOOL status = TRUE;
// 读取ntdll.dll到缓冲区
file = CreateFileA("c:\\windows\\system32\\ntdll.dll",GENERIC_READ,FILE_SHARE_READ,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,NULL);
fileSize = GetFileSize(file,NULL);
fileData = HeapAlloc(GetProcessHeap(), 0, fileSize);
if (!ReadFile(file, fileData, fileSize, &bytesRead, NULL))
return FALSE;
// 解析PE找到导出表
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)fileData;
PIMAGE_NT_HEADERS ntHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)fileData + dosHeader->e_lfanew);
DWORD exportDirRVA = ntHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeader); // ??
PIMAGE_SECTION_HEADER textSection = section;
PIMAGE_SECTION_HEADER rdataSection = section;
for (int i = 0; i < ntHeader->FileHeader.NumberOfSections; i++) {
if (strcmp((CHAR*)section->Name, (CHAR*)".rdata") == 0) {
rdataSection = section;
break;
}
section++;
}
PIMAGE_EXPORT_DIRECTORY exportDirectory = (PIMAGE_EXPORT_DIRECTORY)RVAtoRawOffset((DWORD_PTR)fileData + exportDirRVA, rdataSection);
if (!FindOpenProc(exportDirectory, fileData, textSection, rdataSection))
status = FALSE;
if (file) {
CloseHandle(file);
file = NULL;
}
if (status)return status;
return FALSE;
}
这里是把文件读取到缓冲区中,进行PE结构的解析工作,目标是找到.rdata
和.text
两个Section的头位置
接下来根据这两个头的地址去寻找函数:
MyNtOpenProcess _NtOpenProcess = NULL;
char OpenProcStub[SYSCALL_STUB_SIZE] = {};
BOOL FindOpenProc(PIMAGE_EXPORT_DIRECTORY exportDirectory, LPVOID fileData, PIMAGE_SECTION_HEADER textSection, PIMAGE_SECTION_HEADER rdataSection) {
DWORD oldProtection;
_NtOpenProcess = (MyNtOpenProcess)(LPVOID)OpenProcStub;
BOOL status = VirtualProtect(OpenProcStub, SYSCALL_STUB_SIZE, PAGE_EXECUTE_READWRITE, &oldProtection);
if (MapSyscall("NtOpenProcess", exportDirectory, fileData, textSection, rdataSection, OpenProcStub)) {
return TRUE;
}
return FALSE;
}
这里定义了全局变量来保存函数信息,通过这个函数去修改函数所在内存的执行属性,接下来调用另一个函数去填充Nt函数的内容:
// 遍历导出表函数,找到指定名称的函数,返回对应的字节码
BOOL MapSyscall(LPCSTR functionName, PIMAGE_EXPORT_DIRECTORY exportDirectory, LPVOID fileData, PIMAGE_SECTION_HEADER textSection, PIMAGE_SECTION_HEADER rdataSection, LPVOID syscallStub) {
// 解析导出函数的名称/地址
PDWORD addressOfNames = (PDWORD)RVAtoRawOffset((DWORD_PTR)fileData + *(&exportDirectory->AddressOfNames), rdataSection);
PDWORD addressOfFunctions = (PDWORD)RVAtoRawOffset((DWORD_PTR)fileData + *(&exportDirectory->AddressOfFunctions), rdataSection);
for (size_t i = 0; i < exportDirectory->NumberOfFunctions; i++){
DWORD_PTR functionNameVA = (DWORD_PTR)RVAtoRawOffset((DWORD_PTR)fileData + addressOfNames[i], rdataSection);
DWORD_PTR functionaddressVA = (DWORD_PTR)RVAtoRawOffset((DWORD_PTR)fileData + addressOfFunctions[i+1], textSection);
LPCSTR functionNameResolved = (LPCSTR)functionNameVA;
if (strcmp(functionNameResolved, functionName) == 0) {
memcpy(syscallStub, (LPVOID)functionaddressVA, SYSCALL_STUB_SIZE);
return TRUE;
}
}
return FALSE;
}
这里再次解析PE结构,这里通过获得的导出表,和那两个区段头,来解析导出表中的函数名称和函数地址的RVA,通过转换将其转换成在文件中的偏移来获取到能找到名称和函数的地址
然后通过循环来进行遍历比对寻找我们要找的函数,当找到后,就从函数地址那里复制21个字节到之前我们准备的自定义函数的数组里
到这里为止,函数已经构造完成了,接下来就是调用函数了
函数调用
HANDLE CallOpenProc(DWORD pid) {
HANDLE hProcess = NULL;
OBJECT_ATTRIBUTES oa = {0};
oa.Length = sizeof(OBJECT_ATTRIBUTES);
CLIENT_ID targetid = { 0 };
targetid.UniqueProcess = (VOID*)pid;
NTSTATUS status = NULL;
status = _NtOpenProcess(&hProcess, PROCESS_ALL_ACCESS, &oa, &targetid);
if (status != 0)return NULL;
return hProcess;
}
这里就是填充参数用到的结构体,然后来进行函数调用了,返回窗口句柄。
到此整个流程已经实现完成了,接下来来测试一下
测试
测试代码:
测试代码比较简陋,将就看看吧,这里我把普通的OpenProcess
函数的调用也拿出来了,用来进行对比效果
HANDLE NormalOpenProc(DWORD pid) {
HANDLE process = OpenProcess(PROCESS_ALL_ACCESS,NULL,pid);
std::cout << process << std::endl;
return process;
}
int main() {
GetSysCall();
DWORD pid = 0;
std::cout << "请输入Pid:";
std::cin >> pid;
HANDLE process = NULL;
while (1) {
int select = 0;
std::cout << "请选择打开进程的方式:1.普通方式打开,2.HellsGate方式打开:";
std::cin >> select;
switch (select)
{
case 1:
process = NormalOpenProc(pid);
std::cout << process << std::endl;
CloseHandle(process);
break;
case 2:
process = CallOpenProc(pid);
std::cout << process << std::endl;
CloseHandle(process);
break;
}
}
return 0;
}
API Monitor 测试
编译完成之后,运行起来,把API Monitor也开起来,随便输入个pid,然后进行测试,先允许普通方式的调用:
API Monitor检测到了函数调用行为
接下来用地狱之门调用:
可以看到,这里获取到了进程句柄,函数执行成功了,但API Monitor检测不到这次API调用
实验成功!
总结
今天的实验过程还算顺利,基本都在抄代码(尴尬),不过真的挺让人感叹这PE解析的操作,还有分成多个函数来实现,挺值得学习的,所以就抄了,那篇博客的作者使用小驼峰命名法给变量命名,感觉这样也不错,之前我都是匈牙利命名法
下次好好研究一下这套PE解析操作,挖个坑,到时候再来分享哈哈哈哈
参考资料
API Monitor简介(API监控工具) - 缘生梦 - 博客园 (cnblogs.com)
NtOpenProcess function (ntddk.h) - Windows drivers | Microsoft Docs
Microsoft Windows System Call Table (XP/2003/Vista/2008/7/2012/8/10) (vexillium.org)