0x00 前言
从现在杀软对抗的角度和技术来讲,syscall 可以说是 loader 中一个不可缺少的技术。为什么 syscall 逐渐成为主流? 很早之前杀软其实只会对 kernel32 中(R3)一些函数进行 hook,所以恶意程序开发者使用 ntdll 中的函数去实现 loader 的免杀效果是远高于直接或者间接使用 kernel32 中的函数,比如 VirtualAlloc 之类的函数。 我们又不能直接通过动态调用的方式的去加载 ntdll 中的函数,原因是调用链比较明显(使用 GetModuleHandle,GetProcAddress)。
在windows系统中,调用syscall充当程序与系统交互的接口,让程序能请求各类服务,例如读取或者写入文件,分配内存,开辟进程等。 例如:当调用 WinAPIs 函数时会触发 NtAllocateVirtualMemory 系统调用。然后,此 syscall 将用户在上一个函数调用中提供的参数移动到 Windows内核,执行请求的操作并将结果返回给程序。 所有系统调用都会返回一个指示代码的NTSTATUS值,如果系统调用成功执行操作,则返回(零)STATUS_SUCCESS。
0x001 Syscall的优点
优势就笔者个人的经验来说,使用某特别的方式间接调用nt函数,杀软hook的R3层是无法监管到你R0层的操作的。
0x002 Syscall的缺点
不unhookR3层,你调用nt函数的缺点就是调用链的不够完整。 我们在使用 VirtualAlloc 函数的时候,它的调用链是从kernel32到ntdll,最后使用 NtAllocateVirtualMemory,简而言之就是加载的底层函数是 NtAllocateVirtualMemory。 现在一些项目调用会直接走到ntdll当中,不走kernel32。这样杀软会认为你的函数调用链有问题。当然并不是所有的杀软都会对调用链进行监控。(当然这都是后话,先暂时学习syscall入门项目Hellsgate体会一下这种神奇的手法吧~)
0x01 Syscall-Hellsgate
0x011 分析NtAllocateVirtualMemory
对于一个基础的上线代码,我们只需要三步,分配地址,复制过去,指针执行上线。
#include<stdio.h>
#pragma comment (linker,"/subsystem:\"windows\" /entry:\"mainCRTStartup\"")
unsigned char buf[] = "\x48\x81\xEC\x00\x01\x00\x00\x65\x48\x8B\x04\x25\x60\x00\x00\x00\x48\x8B\x40\x18\x48\x8B\x40\x30\x48\x8B\x70\x10\x48\x8B\x58\x40\x48\x8B\x00\x81\x7B\x0C\x33\x00\x32\x00\x75\xEC\x48\x8B\xCE\x48\xC7\xC2\x32\x74\x91\x0C\xE8\xC0\x00\x00\x00\x4C\x8B\xF0\x48\xC7\xC3\x6C\x6C\x00\x00\x53\x48\xBB\x75\x73\x65\x72\x33\x32\x2E\x64\x53\x48\x8B\xCC\x48\x83\xEC\x18\x41\xFF\xD6\x48\x8B\xD8\x48\x8B\xCB\x48\xC7\xC2\x6A\x0A\x38\x1E\xE8\x8E\x00\x00\x00\x4C\x8B\xF0\x4D\x33\xC9\x4D\x33\xC0\x48\x33\xD2\x48\x33\xC9\x41\xFF\xD6\x48\x8B\xCE\x48\xC7\xC2\x51\x2F\xA2\x01\xE8\x6D\x00\x00\x00\x4C\x8B\xF0\x48\x33\xC0\x50\x48\xB8\x63\x61\x6C\x63\x2E\x65\x78\x65\x50\x48\x8B\xCC\x48\x83\xEC\x20\x48\xC7\xC2\x01\x00\x00\x00\x41\xFF\xD6\x48\x8B\xCE\x48\xBA\x85\xDF\xAF\xBB\x00\x00\x00\x00\xE8\x38\x00\x00\x00\x4C\x8B\xF0\x48\xC7\xC0\x61\x64\x00\x00\x50\x48\xB8\x45\x78\x69\x74\x54\x68\x72\x65\x50\x48\x8B\xCE\x48\x8B\xD4\x48\x83\xEC\x20\x41\xFF\xD6\x4C\x8B\xF0\x48\x81\xC4\x88\x01\x00\x00\x48\x83\xEC\x18\x48\x33\xC9\x41\xFF\xD6\xC3\x48\x83\xEC\x40\x56\x48\x8B\xFA\x48\x8B\xD9\x48\x8B\x73\x3C\x48\x8B\xC6\x48\xC1\xE0\x36\x48\xC1\xE8\x36\x48\x8B\xB4\x03\x88\x00\x00\x00\x48\xC1\xE6\x20\x48\xC1\xEE\x20\x48\x03\xF3\x56\x8B\x76\x20\x48\x03\xF3\x48\x33\xC9\xFF\xC9\xFF\xC1\xAD\x48\x03\xC3\x33\xD2\x80\x38\x00\x74\x0F\xC1\xCA\x07\x51\x0F\xBE\x08\x03\xD1\x59\x48\xFF\xC0\xEB\xEC\x3B\xD7\x75\xE0\x5E\x8B\x56\x24\x48\x03\xD3\x0F\xBF\x0C\x4A\x8B\x56\x1C\x48\x03\xD3\x8B\x04\x8A\x48\x03\xC3\x5E\x48\x83\xC4\x40\xC3";
void main() {
LPVOID Memory = VirtualAlloc(NULL, sizeof(buf), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(Memory,buf,sizeof(buf));
((void(*)())Memory)();
}
把这个放进x64dbg看看,很清楚的看到了当用户使用VirtualAlloc函数,会间接调用NtAllocateVirtualMemory函数。
再往下走,调用了NtProtectVirtualMemory
如果杀软对系统的ntdll.dll做了hook操作,此次调用的virtualalloc函数就会被杀软hook,然后分析出敏感行为最后被杀。
#include <windows.h>
using namespace std;
int main()
{
HMODULE hModule = GetModuleHandle(L"ntdll.dll");
FARPROC rinima = GetProcAddress(hModule, "NtAllocateVirtualMemory");
}
当然杀软也不是傻子,这么明显的调用链基本上用出去就是秒杀。而且在.rdata节还是可以清晰看到NtAllocateVirtualMemory函数的调用。
这个时候回到之前的VirtualAlloc程序去分析一下ntdll.dll的NtAllocateVirtualMemory函数。
movr10,rcx
moveax,0x18
这里的0x18叫做系统调用号,0x18对应在ntdll中的函数就是NtAllocateVirtualMemory,对于不同版本不同位数的windows系统来说,这个调用号可能都不相同。
0x012 缘起Hellsgate
针对上面提到的方式,我们总结出一个方法去绕过杀软在R3的监控—即直达R0。
所以为了绕过HOOK,我们可以使用Syscall。使用前提为:
不使用GetModuleHandle找到ntdll 的基址。 解析DLL的导出表。 查找syscall number。 执行syscall。
一.寻找ntdll基址
此类操作我们需要用到PEB_LDR_DATA中的InMemoryOrderModuleList。
这里引入了一个新概念PEB_LDR_DATA,为了介绍这个知识点,我们要额外引入两个概念,TEB与PEB。
1. TEB (Thread Environment Block)
- 定义: TEB 是每个线程独有的数据结构,存储了与线程相关的信息。
- 作用: 包含线程的执行环境信息,如线程的栈、异常处理信息、线程本地存储等。
2. PEB (Process Environment Block) - 定义: PEB 是每个进程独有的数据结构,存储了与进程相关的信息。
- 作用: 包含进程的加载模块信息、环境变量、命令行参数、内存管理信息等。
3. PEB_LDR_DATA - 定义:
PEB_LDR_DATA
是 PEB 的一部分,专门用于管理进程加载的模块(如 DLL 和 EXE)。 - 作用: 包含一个链表,记录了进程中加载的所有模块的信息,如模块的基地址、大小、名称等。
总结一下就是:TEB->PEB->PEB_LDR_DATA。
Windbg分析一下: 1.TEB+0x60->PEB 2.PEB+0x18->PEB_LDR_DATA
经过查询我们可以看出来Ldr结构体内部0x10,0x20,0x30处分别有三个双向链表,都与驱动有关。其中InLoadOrderModuleList是本次的重点,它与dll模块加载顺序有关,偏移是0x10。 查看链表InLoadOrderModuleList链表结构。0x30偏移处DllBase就是我们要的dll基址,这就是从内核层面进行GetMoudleHandle(L"ntdll")。
寻址图示过程如下图:
结构中的每个指针,指向了一个 LDR_DATA_TABLE_ENTRY
的结构。
#include <windows.h>
using namespace std;
int main() {
DWORD64 PEB = __readgsqword(0x60);
DWORD64 PEB_LDR_DATA = *(DWORD64*)(PEB + 0x18);
PLIST_ENTRY InLoadOrderMoudleList = *(PLIST_ENTRY*)(PEB_LDR_DATA + 0x10);
//PLIST_ENTRY Dllbase = *(PLIST_ENTRY*)(InLoadOrderMoudleList->Flink + 0x30);
void* Dllbase = *(void**)((ULONG64)InLoadOrderMoudleList->Flink + 0x30);
cout << "Dllbase: " << Dllbase << endl;
cout << GetModuleHandleA("ntdll.dll") << endl;
}
2.寻找PE导出表
在上面我们提到了,获取完Dll基址之后我们就要获取ntdll的导出表去寻找我们要利用的nt函数。大概将其分为四个步骤:
1.定位导出表流程2.找到扩展PE头的最后一个成员DataDirectory。
3.获取DataDirectory[0]。 4.通过DataDirectory[0].VirtualAddress得到导出表的RVA。 将导出表的RVA转换为FOA,在文件中定位到导出表。
接下来我们学习一下PE文件结构,先附上一张大佬的神图,一图看明白PE文件格式分布。
PE文件结构:
1.IMAGE_DOS_HEADER
所有的PE文件都是以一个64字节的DOS头(MZ文件头)开始。这个DOS头只是为了兼容早期的DOS操作系统。
在IMAGE_DOS_HEADER头需要掌握的字段只有两个,分别是第一个字段e_magic和最后一个字段e_lfanew。
2.IMAGE_NT_HEADERS
e_magic字段
DOS可执行文件的标识符,占用2字节。e_lfanew字段
保存着PE头的起始位置,一般用于定位PE文件开头位置和PE文件合法性检测。IMAGE_NT_HEADERS由三个部分组成。
1.字串“PE\0\0”(Signature),以此识别给定文件是否为有效PE文件。
2.映像文件头(IMAGE_FILE_HEADER),结构域包含了关于PE文件物理分布的信息。
3.可选映像头(IMAGE_OPTIONAL_HEADER)。
虽然很多地方把它称为可选头,但实际上这是一个必选的头。只是因为该头的数据目录数组中,有的数据目录项是可有可无的,数据目录项部分是可选的,因此称为“可选头”。它定义了PE文件的很多关键信息。
看到这应该能明白了,我们大费周章的了解TEB,PEB,PE文件结构就是为了获取到这个导出表的地址走R0直接调用nt函数隐藏自己。
根据上面说的思路写一个获取导出表函数与函数地址的代码。
3.获取系统调用号
Hellsgate系列引入djb2hash的目的就是使用哈希值作为函数名称的表示,可以一定程度上避免直接暴露敏感的函数名字符串,增加一定的模糊性,但其本质就可以理解为一个函数对应一个hash,用hash就是用函数。
这个时候引入Hellsgate中写的两个结构体,其中_VX_TABLE_ENTRY的pAddress,dwHash,wSystemCall分别对应函数地址,函数djb2hash,函数系统调用号。
VX_TABLE用于管理上线需要的Nt系列函数。
结合Hellsgate的代码来看看,这里举例NtAllocateVirtualMemory。
首先就是利用djb2算法对NtAllocateVirtualMemory函数进行转换得到 0xf5bd373480a6b89b。
跟进GetVxTableEntry函数:
发现跟我们上面写的代码简直没区别,都是获取导出表的各个成员。只是引入了djb2算法,代码看着更加复杂了一些。
接下来进入for循环,获取各个函数每次与函数地址。
然后就是判断,如果循环遍历到的函数对应的djb2hash相同,就进入while循环。
进入while循环之后会取地址判断是否是syscall还是ret,如果是的话就退出,反之开始判断对应的机器特征码0x4c 0x8b 0xb8 这种mov等特征码,返回对应的系统调用号。
这一块我们详细的讲解一下,笔者将把他分为四部分。
Part1:
这一部分检测是否为syscall指令, 0x0f 0x05 是Intel x86-64 架构上用于发起系统调用的指令 syscall 的机器码表示。 0x0f 是 syscall 指令的前缀字节。 0x05 是 syscall 指令本身的操作码。 如果当前指令是 syscall ,则返回 FALSE ,表示该函数可能是一个系统调用的函数,并且它可能已经被替换为其他系统调 用实现,因此我们认为“太远了”或已经失效,跳过分析。
Part2:
这一部分检测是否为ret指令, 0xc3 是 ret 指令的机器码,表示函数的返回操作。 如果遇到 ret 指令,表示该函数可能已经执行完并返回了,或者被篡改为简单的返回操作,也跳过分析,返回 FALSE 。
Part3:
这一段代码看着比较复杂,但其实原理和上面的都是相近的。这里这样写是为了检查当前函数是否符合一定的模式。
还记得 我们一开始分析过NtAllocateVirtualMemory吗,它的调用方式就是方框内所示。
Part4:
4.执行syscall
我们来观察Hellsgate中上线的部分,shellcode数组就是你的payload。
作者这里没用memcpy而是自己重写了一个。
HellsGate函数与HellDescent函数在汇编asm文件中已定义。
首先定义了一个wSystemCall全局变量用于存储系统调用号,HellsGate函数设置系统调用号,将调用者提供的值存储到wSystemCall。HellDescent使用 wSystemCall指定的系统调用号,并根据 Windows 的 x64 系统调用约定准备参数,最终通过 syscall 指令执行系统调用。
最后wmain调用了Payload函数,开始执行shellcode。
执行过程如下:
1.NtAllocateVirtualMemory
2.VxMoveMemory(memcpy)
3.NtProtectVirtualMemory
4.NtCreateThreadEx
5.NtWaitForSingleObject(挂住线程保持存活)
0x02 总结
学完Hellsgate,你会发现你掌握了部分TEB、PEB、PE文件结构、汇编等知识,同时你对内核函数有了更深层次的理解。
它的实现原理其实很简单:
获取ntdll基址,解析pe结构找到导出表,遍历导出表找到对应函数,利用syscall调用函数。
我们在学习的过程中要注重过程本身带来的作用,而不是单纯为了免杀而囫囵吞枣看完代码,拿来即用。很多师傅拿开源项目改改过了火绒wdf就说自己会免杀了,其实不然,授人以鱼不如授人以渔,开源项目总有被标记的一天,希望大家一起加油学学学卷卷卷,避免被优化。
最后在这里谢谢大家的观看,希望对大家有所帮助!
【微信群聊&技术交流&安全证书咨询&考证需求】