Syscall入门之Hellsgate分析

文摘   2024-12-29 11:39   江苏  

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<windows.h>#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,然后分析出敏感行为最后被杀。

对于这样的情况,我们也存在解决办法,就是GetMoudleHandle->GetProcAddress。
#include <iostream>#include <windows.h>using namespace std;
int main(){ HMODULE hModule = GetModuleHandle(L"ntdll.dll"); FARPROC rinima = GetProcAddress(hModule, "NtAllocateVirtualMemory");}
这个时候可以发现,现在已经没有NtAllocateVirtualMemory函数了,取而代之的是我们调用的GetModuleHandle与GetProcAddress。

当然杀软也不是傻子,这么明显的调用链基本上用出去就是秒杀。而且在.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
3.进入Ldr看看内部结构
 (附上大佬的神图,一图弄明白结构之间的关系)
  • 经过查询我们可以看出来Ldr结构体内部0x10,0x20,0x30处分别有三个双向链表,都与驱动有关。其中InLoadOrderModuleList是本次的重点,它与dll模块加载顺序有关,偏移是0x10。
  • 查看链表InLoadOrderModuleList链表结构。0x30偏移处DllBase就是我们要的dll基址,这就是从内核层面进行GetMoudleHandle(L"ntdll")。
  • 寻址图示过程如下图:

  • 结构中的每个指针,指向了一个LDR_DATA_TABLE_ENTRY的结构。
现在大概的寻址过程我们已经知道了,写个代码来验证一下
#include <iostream>#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;}
这里笔者注释了一段,在获取Dllbase的时候,我们想获取的是普通类型的基址,再借助相对偏移量来看。而注释的用的是PLIST_ENTRY,操作的时候使用的是链表节点地址,可能会导致一些问题(这里Debug了好久)。
  • 2.寻找PE导出表

  • 在上面我们提到了,获取完Dll基址之后我们就要获取ntdll的导出表去寻找我们要利用的nt函数。大概将其分为四个步骤:
    1.定位导出表流程

    2.找到扩展PE头的最后一个成员DataDirectory。

  1. 3.获取DataDirectory[0]。
  2. 4.通过DataDirectory[0].VirtualAddress得到导出表的RVA。
  3. 将导出表的RVA转换为FOA,在文件中定位到导出表。
    接下来我们学习一下PE文件结构,先附上一张大佬的神图,一图看明白PE文件格式分布。
  • 用PEview打开之前的代码所生成的程序,红色框框内就是一个64位PE文件的大体结构(32位同理)。
    • 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文件的很多关键信息。
    可选头的定位:
    起始位置:可选头的定位有一定的技巧性,起始位置的定位相对比较容易找到,按照 PE 标识开始寻找是非常简单的。结束位置:可选头的结尾后面跟的是第一项节表的名称,所以可选头的结束位置就在.text节的前一个位置。
    其中我们重点关注的是:IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];也就是我们所说的数据目录。数据目录数组包含多个重要的数据目录项,如导出表、导入表、资源表等。
    DataDirectory是IMAGE_OPTIONAL_HEADR中的一个数组成员,它是一个IMAGE_DATA_DIRECTORY结构体的数组。
    通过将基地址 ntDllbase与导出表的 RVA 相加,我们就可以计算出导出表在内存中的实际地址。
    接下来就是遍历红框内的东西NumberOfFunctions,NumberOfNames不用过多介绍,它们的含义跟字面意思相同,重点介绍一下AddressOfFuctions与AddressOfNameOrdinals。
    • 看到这应该能明白了,我们大费周章的了解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就说自己会免杀了,其实不然,授人以鱼不如授人以渔,开源项目总有被标记的一天,希望大家一起加油学学学卷卷卷,避免被优化。

      最后在这里谢谢大家的观看,希望对大家有所帮助!

    • 【微信群聊&技术交流&安全证书咨询&考证需求】





    湘安无事
    湘安无事团队欢迎各位同学了解安全,也欢迎各位大佬加入和指点。深情哥wx:azz_789 乱聊技术